从 scp 传文件到 git 自动部署
我最早的部署方式是 scp:本地打包、传上去、解压、覆盖。这套流程用了两年,直到有次凌晨改了一个模板文件,传上去发现线上白屏——本地文件是改了一半的状态,我传的时候没注意。回滚更麻烦,因为我不知道上一个版本长什么样。
后来改成了 git 部署。不是因为它时髦,而是它顺手解决了三个问题:线上跑的是哪个版本可以查,回滚就是一条命令,改了什么可以和线上对账。这篇写的是我这套单人、单机、无 CI 的部署流程,重点是能跑通、能回滚,不追求多环境多分支那套企业级复杂度。
先想清楚:哪些东西该进仓库
这一步决定了后面所有事情。我的分法:
进仓库的:主题模板文件、自定义 PHP 逻辑、配置文件模板(.env.example、nginx.conf.tpl)、部署脚本本身、数据库迁移 SQL。
不进仓库的:上传的图片附件、用户生成的内容数据、真正的 .env(含密码)、日志、缓存目录、依赖包(vendor/ 或 node_modules/,除非项目很小)。
判断标准不是"重要不重要",而是"能不能由别的东西重新生成"。日志能重新生成吗?能,但那不算——重点是它不该被版本管理,因为它每天变、体量大、没有回滚价值。配置文件里含密码的部分不进仓库,但结构要用模板提交上去,否则新机器上你根本不知道需要哪些配置项。
# .gitignore 的实用版本
*.log
logs/
cache/
tmp/
uploads/
.env
.env.*
!.env.example
vendor/
node_modules/
*.sql.gz
.DS_Store!.env.example 那一行是白名单,用来把被通配符排除的文件重新加回来。第一次配的时候我漏了这行,结果 .env.example 一直没进仓库,换机器时踩了个坑。
服务端的仓库布局
最省事的做法是裸仓库 + 工作目录分开:
/srv/git/site.git # 裸仓库,作为 push 目标
/var/www/site/ # 工作目录,被 nginx 读取
# 创建
mkdir -p /srv/git
git init --bare /srv/git/site.git为什么不直接在工作目录里 git init 然后 git pull?因为工作目录里会多出一个 .git 文件夹,nginx 能访问到它——/.git/config 是可读的,里面有仓库路径,严重时能顺着拿到源码历史。这是很常见的低级漏洞,扫描器一直在找。用裸仓库分离之后,工作目录是 git --work-tree 检出的纯净文件,没有 .git。
裸仓库上的 hooks 用 pre-receive 或 post-receive:
#!/bin/bash
# /srv/git/site.git/hooks/post-receive
set -euo pipefail
WORK=/var/www/site
GIT_DIR=/srv/git/site.git
BRANCH=main
while read -r oldrev newrev refname; do
if [ "$refname" != "refs/heads/$BRANCH" ]; then
echo "忽略分支 $refname"
continue
fi
echo "收到 $BRANCH: ${oldrev:0:7} -> ${newrev:0:7}"
# 检出到工作目录(--force 覆盖本地改动,.gitignore 的目录不受影响)
git --git-dir="$GIT_DIR" --work-tree="$WORK" checkout -f "$newrev"
# 依赖更新:只在依赖文件真的变了时才跑
if git --git-dir="$GIT_DIR" diff --name-only "$oldrev" "$newrev" | grep -qx 'composer.lock'; then
echo "composer.lock 有变更,安装依赖"
cd "$WORK" && composer install --no-dev --optimize-autoloader --no-interaction
fi
# 清缓存、重载 PHP
if command -v php >/dev/null; then
php -r 'opcache_reset();' 2>/dev/null || true
fi
systemctl reload php-fpm 2>/dev/null || true
echo "部署完成:$(git --git-dir="$GIT_DIR" log -1 --format='%h %s' "$newrev")"
done几个细节值得说:
checkout -f $newrev 而不是 checkout $BRANCH。用具体 commit 能保证工作目录和收到的那个版本一一对应;如果 checkout 分支名,钩子里又有其他 push 进来,可能检出到更新的版本,出现"部署了但部署的不是这个"的困惑。
set -euo pipefail 在钩子里很重要。-e 让某一步失败立即退出,避免后续步骤在错误状态下继续跑。-u 防止变量为空时误操作(比如 WORK 拼错变成空字符串,rm -rf "$WORK/"* 就成了灾难)。pipefail 让管道里的失败也能被捕获——没有它,git diff | grep 中 git 失败但 grep 成功返回 0,你会在错误的分支判断上继续跑。
依赖只在 lock 文件变化时装。composer install 每次跑要几十秒,加上 opcache_reset 会让站点短暂变慢。用 diff 判断能省掉绝大多数无谓的安装。
本地推送的配置
# 本地仓库加一个 remote
git remote add deploy server:/srv/git/site.git
# 推送即部署
git push deploy main如果 SSH 走的是非标准端口或者跳板机,在 ~/.ssh/config 里配好别名,remote 就写别名:
Host myserver
HostName 95.216.247.102
Port 40049
User deploy
IdentityFile ~/.ssh/deploy_key
ProxyJump vp2注意用于部署的账号权限。我的 deploy 用户能写 /var/www/site,但不能 sudo、不能读其他站的文件、不能用 shell 之外的交互。post-receive 钩子里需要重启 php-fpm 的那行,用 sudoers 精确授权单条命令:
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php-fpm
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx只授权 reload,不授权 restart、不授权 systemctl edit。这类细节单独看不重要,但部署账号是自动化流程的入口,权限过大等于把 root 拱手让人。
回滚:三条命令之内
这是我改用 git 部署的最大收益。线上出问题时:
# 1. 看线上是哪个版本
git --git-dir=/srv/git/site.git --work-tree=/var/www/site log -1 --oneline
# 2. 找上一个版本
git --git-dir=/srv/git/site.git log --oneline -10
# 3. 退回上一个版本(注意是检出具体 commit)
git --git-dir=/srv/git/site.git --work-tree=/var/www/site checkout -f HEAD~1
php -r 'opcache_reset();'回滚之后本地仓库会落后于线上,需要 git reset --hard HEAD~1 && git push --force deploy main 让两边一致。或者更规范的做法是 git revert 生成一个反向提交再推——好处是历史线性、不会出现本地和服务端分叉,代价是多一次部署。紧急情况下我先用 checkout 止血,事后再补 revert,两件事分开做。
我这里犯过一次错:回滚时忘了 opcache_reset,页面还是旧的错误版本,我一度以为回滚没生效,又多退了两个版本,结果把本来没问题的东西也退了。PHP 的 opcache 有独立的缓存生命周期,文件变了不一定立即生效,这跟 git 一点关系都没有,但会让人误判 git 部署出了问题。所以在钩子和回滚脚本里都显式加上 reset。
如果不想用钩子:拉模式
有些环境不允许从外部 push 进来(防火墙只开 80/443),那就反过来:服务端定时 pull。
# /opt/scripts/deploy-pull.sh
#!/bin/bash
set -euo pipefail
export TZ=Asia/Shanghai
cd /var/www/site || exit 1
git fetch --quiet origin main
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" = "$REMOTE" ]; then
exit 0
fi
echo "$(date -Iseconds) 部署 $LOCAL -> $REMOTE"
git reset --hard "$REMOTE"
composer install --no-dev --optimize-autoloader --no-interaction
php -r 'opcache_reset();' || true
systemctl reload php-fpmcron 每五分钟跑一次。拉模式的优点是不用在防火墙上开口;缺点是延迟(最多五分钟)、且工作目录里有 .git(可配合 nginx 的 location ~ /\. 拒绝访问)。小站够用,发布要秒级生效的话还是 push 更直接。
上线后必须验证的三件事
部署成功不等于站点正常。我固定验证三项:
一是首页状态码:curl -o /dev/null -s -w '%{http_code}\n' https://example.com/,返回 200 才算过,500 直接回滚。二是关键页面内容:不只是状态码,还要 grep 一个必然存在的字符串,确认渲染正确、没有出现白屏但状态码 200 的情况。三是错误日志:tail -50 /var/log/nginx/error.log 和 PHP 错误日志,看有没有新冒出来的 fatal。
#!/bin/bash
# 部署后自检,跑完打印结论
set -uo pipefail
fail=0
code=$(curl -o /tmp/home.html -s -w '%{http_code}' https://example.com/)
[ "$code" = "200" ] || { echo "首页状态码 $code"; fail=1; }
grep -q '站点名称' /tmp/home.html || { echo "首页内容异常"; fail=1; }
if tail -100 /var/log/nginx/error.log | grep -q "PHP message: PHP Fatal"; then
echo "发现新的 fatal 错误"
fail=1
fi
[ "$fail" = "0" ] && echo "部署自检通过" || echo "部署自检失败,考虑回滚"
exit $fail把这段挂到 post-receive 末尾,失败时直接输出回滚命令提示。自动化部署的价值不在于省下那几分钟手动操作,而在于把"上线后检查什么"这件事固化下来——人是会忘的,脚本不会。
多站点共用一套流程的做法
我手上不止一个站。如果每个站各建一套裸仓库和钩子,重复劳动太多,维护起来也容易漏改。我的做法是一个通用钩子脚本,站点信息放在配置里:
# /srv/git/site-b.git/hooks/post-receive → 软链接到通用脚本
ln -sf /opt/deploy/hooks/post-receive /srv/git/site-b.git/hooks/post-receive
# /opt/deploy/sites.d/site-b.conf
NAME=site-b
WORK=/var/www/site-b
BRANCH=main
RUN_COMPOSER=1
RELOAD_SERVICES="php-fpm nginx"
POST_CHECK=https://b.example.com/通用脚本用 $(basename "$GIT_DIR" .git) 推导站点名,再 source 对应配置文件。好处是新加一个站只需要三件事:git init --bare、建软链、写一个十几行的 conf。坏处是所有站共用一个脚本,改脚本等于改全部站——所以这个脚本要格外谨慎,我改完之后会在一台测试机上先推一次空提交验证。
共用的还有一件事:部署日志。每个钩子的输出都 append 到 /var/log/deploy/<site>.log,格式统一成 时间 站点 旧版本 新版本 操作人。事后追溯"上周三那次改动是什么时候上的"时,这份日志比 git log 直接——git log 告诉你提交时间,部署日志告诉你上线时间,两者经常差好几个小时甚至几天。
# 钩子里的日志行
echo "$(date -Iseconds) $NAME ${oldrev:0:7} -> ${newrev:0:7} ${USER:-unknown}" \
>> /var/log/deploy/$NAME.log几个容易忽略的坑
文件权限。git 检出时用当前运行钩子的用户,如果 nginx 跑在 www-data、钩子跑在 deploy,检出的文件属主是 deploy,权限取决于 umask。我遇到过上传目录写不进去的问题,原因就是检出后目录权限变成 755、属主是 deploy。解决办法是在钩子里显式 chown -R www-data:www-data "$WORK/uploads" 并 chmod -R 755,别依赖 umask。
换行符。core.autocrlf 在 Windows 上编辑过的文件会带 CRLF,传到 Linux 上脚本直接报 bad interpreter。仓库根目录加 .gitattributes:* text=auto eol=lf,一劳永逸。
钩子里的 PATH。git 的钩子运行环境 PATH 可能不含 /usr/local/bin,如果 composer、php 装在那边,就会报 command not found。钩子开头显式 export PATH=/usr/local/bin:/usr/bin:/bin。
大文件。不小心提交了几百兆的备份包,仓库体积就再也降不下来。.gitignore 要在第一次提交前就写好,事后清理需要 filter-repo 重写历史,代价不小。
并发推送。两个人(或两个自动化任务)同时推,钩子可能并行执行。单人站很少遇到,但如果你有定时任务自动提交(比如自动生成 sitemap),要么加锁(flock),要么就让自动任务直接改文件不走 git。
小结
这套流程的核心就三句话:裸仓库分离,工作目录没有 .git;钩子里检出具体 commit,不是分支名;上线后跑自检,失败就回滚。至于 CI、多环境、蓝绿发布,一个人维护几个站用不上——把"能回滚"和"上线后知道检查什么"做到位,就已经比 scp 时代安全太多了。