<h2>为什么你需要一套可回滚的部署流程</h2>
<p>个人站长最常见的上线方式是这样的:用 FTP 或者 SFTP 客户端连上服务器,把修改好的文件拖上去,覆盖旧的,然后刷新浏览器看看有没有报错。这套流程在小站、单人、低频更新的阶段确实够用。但它有一个致命缺陷——<strong>没有回滚能力</strong>。</p>
<p>当你在半夜改了一个 CSS 或者一个 PHP 文件,把首页改崩了,你能做什么?凭记忆把文件改回来?如果你的编辑器没有本地备份呢?如果你同时改了七个文件、记不清改了哪几个呢?</p>
<p>这篇文章讲的是个人站长也能轻松落地的方案:用 Git 管理代码 + Git Hook 实现 <code>push 即上线</code>,并且每次发布都自动留一个可一键回滚的快照。整套方案不依赖任何 CI 服务、不需要服务器之外的账号,服务器上只要装了 git 就能跑。</p>
<h2>整体架构</h2>
<p>核心思路是「<strong>裸仓库 + 检出目录</strong>」:</p>
<pre><code>服务器上的目录结构:
/opt/repo/site.git ← 裸仓库(bare),只存版本历史,不存工作文件
你本地 push 的目标
/www/site ← 检出目录(work tree),真正被 Nginx 指向的目录
由 post-receive 钩子自动更新
/opt/backups/site ← 每次发布前自动打的 tar 快照,用于快速回滚</code></pre>
<p>本地 push 到 <code>site.git</code>,钩子脚本自动把内容检出到 <code>/www/site</code>,并更新权限、清理缓存。整个过程一两秒完成,比 FTP 上传还快,而且<strong>每一次上线都有完整的 Git 记录和物理快照</strong>。</p>
<h2>第一步:服务器上初始化裸仓库</h2>
<pre><code># 在服务器上执行
mkdir -p /opt/repo /opt/backups /www
cd /opt/repo
git init --bare site.git
关键:让裸仓库知道工作目录在哪
git --git-dir=/opt/repo/site.git config core.bare false
git --git-dir=/opt/repo/site.git config core.worktree /www/site
建好检出目录
mkdir -p /www/site
推荐:设置接收后不自动合并(我们靠钩子手动 checkout)
git --git-dir=/opt/repo/site.git config receive.denyCurrentBranch ignore</code></pre>
<p>关于 <code>core.bare false</code> 和 <code>core.worktree</code>:<code>git init --bare</code> 创建的仓库默认是「没有工作区」的,直接 <code>checkout</code> 会报 <code>fatal: this operation must be run in a work tree</code>。把 <code>core.bare</code> 改成 <code>false</code> 并指定 <code>core.worktree</code>,就等于把这个裸仓库变成「工作区在别处的普通仓库」,钩子里就能直接 <code>git checkout -f</code> 了。</p>
<h2>第二步:写 post-receive 钩子</h2>
<p><code>post-receive</code> 是 Git 在裸仓库收到推送之后自动执行的脚本。它从 stdin 接收推送信息,每一行格式是 <code>旧SHA 新SHA 引用名</code>。</p>
<p>这里有个 Git 的经典陷阱:<strong>钩子脚本的工作目录是 <code>$GIT_DIR</code>(也就是裸仓库本身),而 <code>GIT_DIR</code> 环境变量在钩子里是被设置好的</strong>。如果你直接在里面执行 <code>git checkout</code>,它会尝试在裸仓库里操作,报错或者干出奇怪的事。正确做法是显式用 <code>--git-dir</code> 和 <code>--work-tree</code> 参数,或者先把 <code>GIT_DIR</code> 清空。</p>
<pre><code>#!/bin/bash
/opt/repo/site.git/hooks/post-receive
set -e
GIT_DIR_REAL="/opt/repo/site.git"
WORK_TREE="/www/site"
BACKUP_DIR="/opt/backups/site"
LOG_FILE="/var/log/deploy.log"
KEEP_BACKUPS=10
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"}
读取推送信息
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
log "接收到推送: 分支=$BRANCH 旧=$oldrev 新=$newrev"
# 只处理主分支
if [ "$BRANCH" != "main" ] &amp;&amp; [ "$BRANCH" != "master" ]; then
log "跳过非主分支: $BRANCH"
continue
fi
# ---------- 发布前快照 ----------
mkdir -p "$BACKUP_DIR"
if [ -n "$(ls -A "$WORK_TREE" 2>/dev/null)" ]; then
SNAP="$BACKUP_DIR/$(date '+%Y%m%d-%H%M%S')-${oldrev:0:8}.tar.gz"
tar -czf "$SNAP" -C "$WORK_TREE" . 2&gt;/dev/null || true
log "已创建快照: $SNAP"
fi
# ---------- 检出新版本 ----------
git --git-dir="$GIT_DIR_REAL" --work-tree="$WORK_TREE" \
checkout -f "$BRANCH" 2&gt;&amp;1 | tee -a "$LOG_FILE"
# ---------- 修正权限 ----------
# 注意:不要无脑 chown -R,几万个文件会很慢
chown -R www-data:www-data "$WORK_TREE" 2&gt;/dev/null || true
find "$WORK_TREE" -type d -exec chmod 755 {} \; 2&gt;/dev/null || true
find "$WORK_TREE" -type f -exec chmod 644 {} \; 2&gt;/dev/null || true
# ---------- 清理旧快照,只保留最近 N 个 ----------
ls -1t "$BACKUP_DIR"/*.tar.gz 2&gt;/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
log "发布完成: $newrev"done
exit 0</code></pre>
<p>授予执行权限,这一步不能忘:</p>
<pre><code>chmod +x /opt/repo/site.git/hooks/post-receive
touch /var/log/deploy.log && chmod 664 /var/log/deploy.log</code></pre>
<h2>第三步:本地配置与首次推送</h2>
<p>在服务器上为部署创建一个专用 SSH key(比用 root 密码推送安全得多):</p>
<pre><code># 服务器上:为部署建一个受限用户(可选但推荐)
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
把你本地的公钥写进去
vim /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
让 deploy 用户有权限写入仓库和站点目录
chown -R deploy:deploy /opt/repo/site.git /opt/backups/site
/www/site 需要 www-data 读、deploy 写
chown -R deploy:www-data /www/site
chmod -R 775 /www/site
允许 deploy 执行 chown(钩子里用到了)
echo "deploy ALL=(ALL) NOPASSWD: /bin/chown, /usr/bin/find" \
> /etc/sudoers.d/deploy-deploy
chmod 440 /etc/sudoers.d/deploy-deploy</code></pre>
<p>本地仓库配置远程地址:</p>
<pre><code># 本地执行
cd ~/projects/mysite
git init
git remote add deploy ssh://deploy@your-server-ip/opt/repo/site.git
首次推送
git add -A
git commit -m "初始化站点"
git push deploy main</code></pre>
<p>推送完成后看服务器上的 <code>/var/log/deploy.log</code>,应该能看到完整的「接收 → 快照 → 检出 → 权限 → 完成」流程。同时 <code>ls /www/site</code> 应该已经是你的站点文件了。</p>
<h2>回滚:三种粒度</h2>
<p>有了上面这套结构,回滚变得非常轻松。按场景分三种:</p>
<p><strong>粒度一:回滚到上一个 commit(最常用)。</strong> 在服务器上执行:</p>
<pre><code>cd /opt/repo/site.git
git --git-dir=/opt/repo/site.git --work-tree=/www/site \
checkout -f HEAD~1
但这样是「游离 HEAD」状态,下次 push 会被覆盖
更规范的做法是在本地操作:
git revert HEAD (生成一个反向提交,历史可追溯)
git reset --hard HEAD~1 && git push -f deploy main (丢弃提交)</code></pre>
<p><strong>推荐用 <code>git revert</code> 而不是 <code>reset --hard</code></strong>,因为 revert 会生成一个新的提交记录「撤销了某某改动」,历史完整可查;而 reset + force push 会丢失提交,万一你第二天又想把改动捡回来就很麻烦。</p>
<p><strong>粒度二:从物理快照恢复(改动没进 Git 时的救命稻草)。</strong> 比如你直接在服务器上改了文件、忘了提交,或者某个文件被误删:</p>
<pre><code># 看有哪些快照
ls -lt /opt/backups/site/
先解压到临时目录预览,别直接覆盖
mkdir -p /tmp/snapshot-check
tar -xzf /opt/backups/site/20260920-031500-a1b2c3d4.tar.gz -C /tmp/snapshot-check
确认无误后,只恢复需要的文件
cp /tmp/snapshot-check/wp-config.php /www/site/
或者全量恢复
rsync -av --delete /tmp/snapshot-check/ /www/site/</code></pre>
<p><strong>粒度三:单文件回滚。</strong> 只想把某一个文件恢复到某个历史版本:</p>
<pre><code># 查看这个文件的修改历史
git --git-dir=/opt/repo/site.git log --oneline -- /www/site/index.php
取出版本 a1b2c3d 里的 index.php
git --git-dir=/opt/repo/site.git show a1b2c3d:index.php > /www/site/index.php</code></pre>
<h2>钩子里还要做什么</h2>
<p>上面的钩子只做了「检出 + 权限 + 快照」。实际生产中,根据站点类型还应该加上这些动作:</p>
<pre><code># 1. PHP 站点:重置 OPcache(否则改了代码还是跑旧的)
if command -v php >/dev/null 2>&1; then
php -r 'function_exists("opcache_reset") &amp;&amp; opcache_reset();' 2&gt;/dev/null || truefi
更稳的做法是通过一个受保护的 HTTP 端点触发:
curl -s -H "X-Deploy-Token: $TOKEN" https://example.com/_deploy/opcache-reset
2. 容器化部署:重建并重启
docker compose -f /opt/app/docker-compose.yml up -d --build
3. 静态站点:清理 Nginx 缓存
find /var/cache/nginx -type f -delete && systemctl reload nginx
4. 依赖安装:只有 composer.lock / package.json 变了才执行
if git --git-dir="$GIT_DIR_REAL" diff --name-only "$oldrev" "$newrev" | grep -q '^composer.lock$'; then
cd "$WORK_TREE" &amp;&amp; composer install --no-dev --optimize-autoloader --no-interactionfi
5. 数据库迁移:只在需要时执行,且要先备份
if git --git-dir="$GIT_DIR_REAL" diff --name-only "$oldrev" "$newrev" | grep -q '^migrations/'; then
mysqldump -uroot -p"$DBPASS" mydb &gt; "$BACKUP_DIR/db-$(date +%s).sql"
cd "$WORK_TREE" &amp;&amp; php migrate.phpfi</code></pre>
<p>第 4、5 条用到了 <code>git diff --name-only $oldrev $newrev</code>——这是在钩子里判断「这次推送改了哪些文件」的标准手法,比每次都全量执行依赖安装高效得多,也避免每次上线都跑一遍本来不需要的迁移。</p>
<h2>踩坑清单</h2>
<ul>
<li><strong>钩子没执行权限。</strong> 最常见的问题,<code>chmod +x</code> 别忘。而且 <code>post-receive</code> 脚本本身如果是从 Windows 传过来的,可能带 CRLF 换行,会报 <code>bad interpreter: No such file or directory</code>。用 <code>dos2unix</code> 或者 <code>sed -i 's/r$//' post-receive</code> 处理一下。</li>
<li><strong>推送成功但站点没变化。</strong> 先看 <code>/var/log/deploy.log</code> 有没有记录。没记录说明钩子没跑(权限问题);有记录但内容没变,检查 <code>core.worktree</code> 是否指向了正确的目录。</li>
<li><strong>文件权限错乱导致 403。</strong> 钩子以 deploy 用户运行,检出的文件属主是 deploy。Nginx 通常以 www-data 运行,必须 chown/chmod。如果用了 <code>umask</code>,可以在钩子开头加 <code>umask 022</code>。</li>
<li><strong><code>receive.denyCurrentBranch</code> 报错。</strong> 如果你没设 <code>core.bare false</code>,直接建了普通仓库当远程,push 时会被拒绝。按本文第一步配置即可。</li>
<li><strong>裸仓库目录别放在 web 根目录下。</strong> 有人把 <code>site.git</code> 放在 <code>/www</code> 里面,结果 <code>https://example.com/site.git/config</code> 能被公网访问,泄露仓库配置。仓库一定要放在 web 根之外(本文用的是 <code>/opt/repo</code>),或者在 Nginx 里显式 deny 掉 <code>.git</code>。</li>
<li><strong>大文件别进 Git。</strong> 上传目录、日志、缓存、node_modules 全部写进 <code>.gitignore</code>。Git 仓库一旦被塞进几百兆的图片,后续所有操作都会变慢,而且这个体积永远留在历史里删不掉。</li>
</ul>
<pre><code># 站点根目录里的 .gitignore 示例
/uploads/
/cache/
/logs/
node_modules/
vendor/
*.log
.env
wp-config.php</code></pre>
<p>特别注意 <code>.env</code> 和 <code>wp-config.php</code> 这类含数据库密码的文件。<strong>它们绝对不该进 Git</strong>。正确做法是把这些文件留在服务器上、不纳入版本控制;或者提交一个 <code>.env.example</code> 模板,实际配置在服务器上手工维护。</p>
<h2>让部署更安全:Nginx 层面兜底</h2>
<p>即使你再小心,也建议在 Nginx 里加一条兜底规则,禁止访问所有点开头的隐藏目录:</p>
<pre><code>location ~ /.(?!well-known) {
deny all;
access_log off;
log_not_found off;
return 404;}</code></pre>
<p><code>(?!well-known)</code> 是负向先行断言,作用是放行 <code>/.well-known/</code>(Let's Encrypt 验证、ACME 挑战需要用到),其余所有 <code>/.git</code>、<code>/.env</code>、<code>/.htaccess</code> 一律 404。这条规则成本极低,但能挡掉一整类因为误放文件而导致的源码泄露事故。</p>
<h2>小结</h2>
<p>整套方案的核心价值不在于「自动化」本身,而在于<strong>可回滚</strong>。上线不再是不可逆的操作,而是有 Git 记录、有物理快照、有单文件恢复三条路可选的过程。有了这一层保障,你会发现自己改代码的胆子大了很多——敢重构、敢试新配置,因为最坏情况也只是敲一条命令回到十分钟前。</p>
<p>实施顺序建议:先搭好裸仓库和最简单的 post-receive(只做 checkout + 权限),跑通一次完整的 push 上线;确认没问题之后再逐步加入快照、OPcache 重置、依赖安装这些增强项。<strong>一次只加一个变量</strong>,出问题时才知道是谁引起的。</p>
<p>还有一个容易被忽略的收益:当部署流程被写成脚本、放进 Git 之后,它本身就是一份可执行的文档。半年后你换了服务器、重装系统,只需要照着这几步重新执行一遍,就能把整条流水线复刻出来,而不用去回忆当初到底在面板上点过哪些按钮、改过哪些配置项。这也正是「基础设施即代码」这个说法对个人站长同样适用的原因——规模不重要,确定性才重要。</p>
<p>最后提醒一句:任何自动化部署在正式使用之前,都值得在一台测试机上完整演练一次,包括「故意推一个坏版本再回滚」这个动作。只有真正演练过回滚,你才能在事故发生的那几分钟里不慌。备份策略的价值从来不是体现在顺利的时候,而是体现在最糟糕的那一天。</p>
<div style="margin:20px 0;padding:10px 0;border-top:1px solid #eee;border-bottom:1px solid #eee">
<ins class="adsbygoogle" style="display:block;text-align:center" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-1561091167355374" data-ad-slot="8963568440"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>