GitLab CI/CD自动部署:rsync+tar发布方案
发布日期: 2026/08/01 阅读总量: 0

从一次痛苦的发布说起

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 installnpm 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全量覆盖的部署方式,建议花一天时间迁移到这套方案。踩过的坑我都写在上面了,照着做就能顺利跑起来。记住一条原则:部署脚本越简单越可靠,减少网络依赖、减少文件操作步骤、保留完整回滚路径。