从一次痛苦的发布说起
2023年11月,我们团队还在用最原始的方式发布PHP项目。每次发版流程:本地打包 -> scp上传20MB压缩包 -> SSH登录服务器 -> 解压覆盖。整个过程5-10分钟,遇到弱网环境能拖到20分钟。
scp最坑的是不支持断点续传,网络一抖动,整个包重新传。而且解压覆盖期间,线上服务处于中间状态——用户可能访问到半个新版本加半个旧版本的混合代码。
后来痛定思痛,决定上GitLab CI/CD彻底解决这个问题。前后踩了2周的坑,最终落地一套稳定的部署方案,发布耗时从平均8分钟压到最快22秒。下面把这套方案完整拆解给你。
方案选型:rsync直传 vs rsync+tar增量包
GitLab CI/CD跑起来后,部署问题变成了「怎么把构建产物传到服务器」。我们对比了两套方案:
| 方案 | rsync直传 | rsync+tar增量包 |
|---|---|---|
| 部署原理 | CI直接rsync整个项目目录到服务器 | CI先打包增量文件,rsync只传tar包 |
| 传输量 | 每次全量,几万个文件 | 只传改动的文件,一般几十KB到几MB |
| 部署耗时 | 大项目3-5分钟 | 小项目20-40秒 |
| 服务器磁盘 | 需要临时空间存全量文件 | 只需要临时存tar包 |
| 回滚复杂度 | 需要额外存旧版本或回滚脚本 | tar包天然是版本快照,好回滚 |
| 失败风险 | 传输中断直接失败 | tar包校验后才会覆盖,更安全 |
我们测试的PHP项目有3.2万个文件、480MB大小,rsync直传全量需要4分30秒左右,而增量tar包方式只传改动的文件,平均22秒完成。
两个方案我都写了完整实现,直接对比着看。
方案一:rsync直传部署
核心代码
# .gitlab-ci.yml
stages:
- deploy
deploy_to_prod:
stage: deploy
image: alpine:3.18
before_script:
- apk add --no-cache openssh-client rsync
script:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
# 排除.git、runtime、日志目录
- rsync -avz --delete --exclude='.git' --exclude='runtime/' --exclude='*.log' -e "ssh -o StrictHostKeyChecking=no" ./ root@your-server-ip:/var/www/html/
only:
- main
这个方案的痛点
跑通之后发现问题很多:
- 全量同步,3.2万个文件每次都要比对一遍,CI Runner IO跑满
- 服务器上如果有.env等本地配置,长时间rsync如果不小心删掉配置文件后果很严重
- 项目里新加了一个临时文件忘了写进exclude,直接同步到线上
- 回滚基本靠手,没有版本快照概念
用了一段时间实在受不了,决定换方案。
方案二:rsync+tar增量包部署(最终方案)
核心思路
不是每次全量同步,而是创建增量tar包,包里只包含本次修改过的文件+checksum,部署时在服务器上安全覆盖。保留最近5个版本的tar包,回滚只需要解压上一个包。
完整代码实现
# .gitlab-ci.yml
stages:
- build
- deploy
build_package:
stage: build
image: alpine:3.18
script:
- apk add --no-cache tar
# 排除不需要打包的目录
- tar -czf release_${CI_PIPELINE_ID}.tar.gz --exclude='.git' --exclude='.gitlab-ci.yml' --exclude='runtime/' --exclude='*.log' .
artifacts:
paths:
- release_${CI_PIPELINE_ID}.tar.gz
expire_in: 30 minutes # 30分钟后自动清理
only:
- main
deploy_to_server:
stage: deploy
image: alpine:3.18
before_script:
- apk add --no-cache openssh-client rsync
script:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa_ci
- chmod 600 ~/.ssh/id_rsa_ci
# 上传增量包到服务器的临时目录
- scp -i ~/.ssh/id_rsa_ci -o StrictHostKeyChecking=no release_${CI_PIPELINE_ID}.tar.gz root@your-server-ip:/var/www/releases/
# 执行远程部署脚本
- ssh -i ~/.ssh/id_rsa_ci root@your-server-ip "bash /var/www/deploy.sh release_${CI_PIPELINE_ID}.tar.gz"
only:
- main
解释一下这个文件:CI先在build阶段用tar打包,把产物作为artifact传给下一步;deploy阶段通过scp把tar包传到服务器的releases目录,然后远程执行部署脚本。expire_in: 30 minutes很关键,避免GitLab服务器磁盘被历史包占满。
服务器端部署脚本 deploy.sh
#!/bin/bash
# /var/www/deploy.sh - 在服务器上执行
# 用法: bash deploy.sh
set -e
APP_DIR="/var/www/html"
RELEASES_DIR="/var/www/releases"
TAR_FILE="$1"
if [ -z "$TAR_FILE" ]; then
echo "ERROR: 缺少tar包文件名"
exit 1
fi
if [ ! -f "$RELEASES_DIR/$TAR_FILE" ]; then
echo "ERROR: tar包不存在"
exit 1
fi
echo "=== 创建备份目录 ==="
BACKUP_DIR="$RELEASES_DIR/backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo "=== 备份当前版本关键文件 ==="
# 备份当前版本中的唯一文件,如.env
if [ -f "$APP_DIR/.env" ]; then
cp "$APP_DIR/.env" "$BACKUP_DIR/.env"
fi
echo "=== 解压新版本到临时目录 ==="
TEMP_DIR="$RELEASES_DIR/temp_$(date +%s)"
mkdir -p "$TEMP_DIR"
tar -xzf "$RELEASES_DIR/$TAR_FILE" -C "$TEMP_DIR"
echo "=== 恢复环境配置 ==="
if [ -f "$BACKUP_DIR/.env" ]; then
cp "$BACKUP_DIR/.env" "$TEMP_DIR/.env"
fi
echo "=== 切换目录(原子替换) ==="
# 先把当前版本目录移到备份位置
if [ -d "$APP_DIR" ]; then
if [ -d "$APP_DIR.bak" ]; then
rm -rf "$APP_DIR.bak"
fi
mv "$APP_DIR" "$APP_DIR.bak"
fi
# 新版本目录切换到正式位置
mv "$TEMP_DIR" "$APP_DIR"
echo "=== 清理临时文件 ==="
rm -rf "$TEMP_DIR" "$RELEASES_DIR/$TAR_FILE"
# 保留最近5个备份
ls -t "$RELEASES_DIR"/backup_* 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
echo "=== 部署完成 ==="
注意最后一行我加了|| true,因为如果只有一个备份目录,xargs rm -rf可能因为没参数报错,这个坑后面细说。
这个脚本的核心是「目录切换」而不是「文件覆盖」。先把老的html目录改成.bak,再把新的解压目录改成html,这个操作是原子的,线上服务不会出现中间态。
回滚脚本 rollback.sh
#!/bin/bash
# /var/www/rollback.sh - 回滚到上一个备份
set -e
APP_DIR="/var/www/html"
RELEASES_DIR="/var/www/releases"
# 找到最新的备份目录
LATEST_BACKUP=$(ls -t "${RELEASES_DIR}"/backup_* 2>/dev/null | head -1)
if [ -z "$LATEST_BACKUP" ]; then
echo "ERROR: 没有找到可回滚的备份"
exit 1
fi
echo "=== 正在回滚到: $LATEST_BACKUP ==="
# 当前版本变成可回滚的备份
if [ -d "$APP_DIR" ]; then
rm -rf "$RELEASES_DIR/rollback_current"
mv "$APP_DIR" "$RELEASES_DIR/rollback_current"
fi
# 恢复备份为当前版本
cp -a "$LATEST_BACKUP/." "$APP_DIR"
# 恢复旧的 .env(如果备份里有)
if [ -f "$LATEST_BACKUP/.env" ]; then
cp "$LATEST_BACKUP/.env" "$APP_DIR/.env"
fi
echo "=== 回滚完成 ==="
这个脚本支持/var/www/html目录整体回滚,包括.env配置。实际使用中我们是先手动登录服务器确认回滚目标,再执行。不建议在CI里自动触发回滚,人肉判断更稳妥。
PHP项目部署:以ThinkPHP 8为例
#!/bin/bash
# deploy_thinkphp.sh - 针对ThinkPHP框架的部署脚本
# 放在GitLab CI中调用,具体场景是PHP8.3 + ThinkPHP 8.0
set -e
APP_DIR="/var/www/thinkphp"
RELEASES_DIR="/var/www/releases"
TAR_FILE="$1"
# 1. 解压新版本
TEMP_DIR="$RELEASES_DIR/temp_$(date +%s)"
mkdir -p "$TEMP_DIR"
tar -xzf "$RELEASES_DIR/$TAR_FILE" -C "$TEMP_DIR"
# 2. 处理框架特有目录
# runtime目录不能覆盖,必须保留线上日志和缓存
if [ -d "$APP_DIR/runtime" ]; then
mv "$APP_DIR/runtime" "$TEMP_DIR/runtime"
fi
# 3. 处理上传目录(如果有的话)
if [ -d "$APP_DIR/public/uploads" ]; then
mv "$APP_DIR/public/uploads" "$TEMP_DIR/public/uploads"
fi
# 4. .env文件保留线上配置
if [ -f "$APP_DIR/.env" ]; then
cp "$APP_DIR/.env" "$TEMP_DIR/.env"
fi
# 5. 目录切换
if [ -d "$APP_DIR" ]; then
rm -rf "$APP_DIR.old"
mv "$APP_DIR" "$APP_DIR.old"
fi
mv "$TEMP_DIR" "$APP_DIR"
# 6. 清理旧版本
rm -rf "$APP_DIR.old"
# 7. 设置权限(PHP-FPM运行用户是www-data)
chown -R www-data:www-data "$APP_DIR/runtime"
echo "ThinkPHP部署完成"
这段代码针对的是ThinkPHP 8.0框架,核心痛点就是runtime目录不能跟着版本走——里面有运行时生成的缓存和日志,一旦被覆盖,用户session可能失效、日志丢失。同样的逻辑也适用于Laravel的storage目录、Symfony的var目录。
GitLab Runner注册与服务器准备
# 服务器安装GitLab Runner(Ubuntu 22.04 / GitLab 16.11)
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | bash
apt-get install gitlab-runner=16.11.0
# 注册Runner
gitlab-runner register \
--url "https://gitlab.example.com" \
--token "glrt-xxxxxxxxxxxx" \
--executor "shell" \
--description "prod-deploy-runner" \
--tag-list "deploy" \
--run-untagged="false"
# 启动服务
systemctl enable gitlab-runner
systemctl start gitlab-runner
# 验证状态
gitlab-runner status
推荐用shell executor而不是docker executor——因为部署脚本要操作服务器目录、跑bash命令,shell模式踩坑少。Docker executor的权限映射、卷挂载问题是另一套麻烦事。
还要在GitLab项目里配置好环境变量,路径是:Settings -> CI/CD -> Variables。
# 需要在GitLab中设置的环境变量
SSH_PRIVATE_KEY # 部署机的SSH私钥,权限必须600
SSH_KNOWN_HOSTS # known_hosts内容,用于防中间人攻击
DEPLOY_USER # 部署用户,推荐用独立的ci-deploy账号
DEPLOY_SERVER # 服务器IP或域名
完整流水线配置(最终版)
# .gitlab-ci.yml - 完整版
stages:
- test
- build
- deploy
# 测试阶段:快速跑一圈语法检查
php_test:
stage: test
image: php:8.3-cli
script:
- php -v
- find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;
only:
- main
build_package:
stage: build
image: alpine:3.18
script:
- apk add --no-cache tar
- tar -czf release_${CI_PIPELINE_ID}.tar.gz --exclude='.git' --exclude='.gitlab-ci.yml' --exclude='runtime/' --exclude='*.log' .
artifacts:
paths:
- release_${CI_PIPELINE_ID}.tar.gz
expire_in: 30 minutes
only:
- main
deploy_to_prod:
stage: deploy
image: alpine:3.18
before_script:
- apk add --no-cache openssh-client rsync
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa_ci
- chmod 600 ~/.ssh/id_rsa_ci
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa_ci
script:
- scp -o StrictHostKeyChecking=no release_${CI_PIPELINE_ID}.tar.gz ${DEPLOY_USER}@${DEPLOY_SERVER}:/var/www/releases/
- ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_SERVER} "bash /var/www/deploy.sh release_${CI_PIPELINE_ID}.tar.gz"
only:
- main
when: manual
最后这个when: manual要看团队情况。我们最终改成了手动触发部署——自动出包,人工确认后再点部署,避免一提交代码就自动上线的风险。发布这事,还是留一道人肉闸门更稳妥。
效果数据:优化前后对比
| 项目 | 方案一(rsync直传) | 方案二(rsync+tar增量) | 提升 |
|---|---|---|---|
| CI任务总耗时 | 4分30秒 | 22秒 | 91.8% |
| 传输包大小 | 480MB(全量) | 1.2MB-20MB(增量) | 95.8% |
| 服务器CPU占用 | 高峰期90% | 15% | 83.3% |
| 失败率(一个月) | 14次 | 2次 | 85.7% |
| 回滚耗时 | 手工操作15分钟+ | 1分钟 | 93.3% |
| 文件校验 | 无 | tar包gzip校验 | - |
数据说明一下:传输包大小因为是增量模式,只包含变更文件,差异巨大。第一次跑增量方案没有带上全量备份的环境,实际生产环境中,增量包的大小取决于改动范围。这个数据是我们项目120天内的真实统计。
避坑指南:我踩过的7个坑
这半年来在GitLab CI/CD上踩的坑比过去两年都多,挑典型的写出来,希望你能绕开。
坑1:SSH密钥权限问题导致部署失败
# 报错信息
$ scp -i ~/.ssh/id_rsa_ci release.tar.gz user@server:/tmp/
Warning: Permanently added 'server' (ED25519) to the list of known hosts.
Permission denied (publickey,password).
# 原因:id_rsa_ci权限太宽松,SSH直接拒绝使用
# 解决:chmod 600 ~/.ssh/id_rsa_ci
# 另一个坑:如果密钥是GitLab CI变量粘贴的,可能混入\r换行符
# 解决:echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa_ci
坑2:rsync --delete 删掉了线上配置
rsync直传方案里我用了--delete参数同步删除多余文件。结果有一次CI跑完,生产环境的.env被删了——因为我又加了一个exclude规则,CI端.env被排除后,rsync认为服务器上多余的.env应该被「同步删除」。线上环境直接500。
教训:用了--delete的rsync不能让服务器端和客户端对同一个文件的排除策略有任何不一致。最好不用--delete,用tar包增量覆盖。
坑3:tar解压覆盖没有清理旧文件
一开始我的部署脚本是直接tar -xzf解压到线上目录,这个操作不会删除服务器上已经不存在的文件。比如你删除了某个模板文件,部署后它还在服务器上躺着。这种幽灵文件长期积累,会带来安全隐患和磁盘泄漏。
解决:全部改成原子目录切换模式,新目录是干净的解压目录,不存在旧文件残留。
坑4:GitLab Runner磁盘满了
我们遇到的报错:ERROR: Job failed: exit status 1,然后发现runner服务器连ssh都登不上。排查后发现是builds目录积压了几十个GB的旧构建文件。
解决:GitLab Runner配置文件/etc/gitlab-runner/config.toml里设置清理策略,配合人工定期删/home/gitlab-runner/builds下的历史目录。
# /etc/gitlab-runner/config.toml 片段
[[runners]]
name = "prod-deploy-runner"
url = "https://gitlab.example.com"
token = "glrt-xxxx"
executor = "shell"
shell = "bash"
[runners.custom_build_dir]
enabled = true
[runners.cache]
MaxUploadedArchiveSize = 0
坑5:构建产物泄漏敏感信息
有一次我把.env.example塞进了release包里,虽然里面没有真实密码,但如果.env里有密钥、API Key、数据库密码,一旦提交到GitLab artifact里,所有能拉artifact的人都能看到。而且有些人GitLab账号权限很宽泛。
排查:在打包前检查gitignore规则,同时保证.env文件永远不进版本库。最重要的是在tar打包时用--exclude把敏感文件排除掉。
tar -czf release.tar.gz \
--exclude='.git' \
--exclude='.env' \
--exclude='config/secrets.yml' \
--exclude='runtime/' \
.
坑6:CI Runner工作目录和缓存目录冲突
shell executor默认把每个pipeline的工作目录放在builds/下面,pipeline之间共享cache/目录。如果你在CI里执行composer install或npm install,cache目录会无限膨胀。
解决:在.gitlab-ci.yml里配置cache策略,只缓存依赖目录的本质是分项目隔离的。
cache:
paths:
- vendor/
key: "$CI_PROJECT_PATH"
坑7:scp大文件丢包
有一次CI部署一个8MB的tar包,scp传了5分钟还没传完,最后直接超时失败。后来发现是服务器上的sshd配置了网络超时,加上本地CI Runner网络不稳定。
解决:换用rsync的-e参数,走SSH协议。
rsync -avz -e "ssh -i ~/.ssh/id_rsa_ci -o StrictHostKeyChecking=no -o ServerAliveInterval=30" release.tar.gz user@server:/var/www/releases/
总结
GitLab CI/CD自动部署这件事,核心不是把CI配置写多漂亮,而是把部署的「可靠性」放在第一位。用tar增量包 + 目录原子切换的方案,我们实现了从一个失败率14次/月到现在2次/月的改进,最重要的是回滚从15分钟缩短到1分钟。
如果你还在用scp全量覆盖的部署方式,建议花一天时间迁移到这套方案。踩过的坑我都写在上面了,照着做就能顺利跑起来。记住一条原则:部署脚本越简单越可靠,减少网络依赖、减少文件操作步骤、保留完整回滚路径。