GitLab CI/CD自动部署到服务器的实战方案
发布日期: 2026/07/28 阅读总量: 1

一、真实场景:凌晨三点,我还在手动上传代码

线上一个电商活动页出了bug,修复后需要立即上线。我登录服务器,用git pull拉取最新代码,结果发现服务器上的PHP 8.3扩展没装,Composer依赖冲突,折腾了20分钟。更糟的是,第二天发现另一个系统也改了一行,我没同步,线上出问题了。

这不是一个人的问题。团队5个开发,各自用FTP、rsync、甚至scp直接覆盖,服务器上版本混乱,发布全靠口述。我们需要一个自动化的部署流水线:GitLab提交代码后,自动测试、构建、推送并重启服务,所有人只操作Git,剩下的交给流水线。

下面我用两个真实方案对比,选出一个适合中小团队的低成本、高可靠性的自动部署方案,并附上6个我踩过的坑,帮你省下至少半天排查时间。

二、问题:GitLab CI/CD自动部署到底选哪种方式?

目标很简单:
1. 开发推送代码到GitLab仓库的main分支。
2. 自动触发CI/CD流水线:代码检查、测试(PHPUnit)、构建产物。
3. 自动将构建产物部署到生产服务器(Ubuntu 22.04),重启Web服务(Nginx + PHP-FPM)。
4. 整个过程需要可回滚,部署失败不影响线上。

常见的两种做法:

  • 方案A:SSH + rsync(直连服务器推送) — 简单、原始,依赖服务器端的Git和rsync。
  • 方案B:Docker镜像推送 + 服务器拉取 — 容器化,依赖GitLab Container Registry和Docker Compose。

我们分别测试了5次反复部署(每个方案部署一个带有10个PHP文件的简单Laravel项目,含Composer安装),结果如下:

指标方案A(SSH+rsync)方案B(Docker)
平均部署耗时(从代码推送到服务就绪)45秒12秒
需额外工具/服务服务器安装Git, rsync, PHP, Composer服务器安装Docker, Docker Compose
回滚复杂度需手动恢复旧代码或git revert直接运行旧版本镜像
依赖一致性问题服务器环境不同可能出问题容器封装完全一致
失败率(5次部署)2次因网络波动或SSH超时失败0次失败

结论:方案B(Docker镜像)更快、更可靠、环境一致性好。虽然前期多一个Docker化步骤,但长期维护收益大。 下面我们完全按方案B来实现。

三、完整实现:基于Docker镜像的GitLab CI/CD自动部署

环境版本:

  • GitLab Community Edition 16.3.1(内建GitLab CI/CD + Container Registry)
  • GitLab Runner 16.3.0(使用docker执行器)
  • Docker Engine 24.0.7(服务器端)
  • Docker Compose v2.24.1(服务器端)
  • 目标服务器:Ubuntu 22.04, 4核8G
  • 应用:PHP 8.3 + Nginx 1.24(容器内)

第一步:在GitLab项目内创建Dockerfile

将你的应用容器化。以Laravel项目为例:

# 基于官方PHP 8.3-fpm镜像
FROM php:8.3-fpm-alpine AS build

# 安装系统依赖
RUN apk add --no-cache git curl unzip libpng-dev libjpeg-turbo-dev freetype-dev \
    && docker-php-ext-install pdo_mysql bcmath gd

# 复制Composer安装器
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

COPY . /app
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache

# 生成最终生产镜像
FROM php:8.3-fpm-alpine
COPY --from=build /app /app
EXPOSE 9000
CMD ["php-fpm"]

注意:这里用了多阶段构建,最终镜像只包含运行文件,体积从1.2GB降到180MB。

第二步:编写docker-compose.yml(用于服务器启动服务)

version: '3.8'
services:
  app:
    image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
    restart: always
    ports:
      - "9000:9000"
    volumes:
      - app_data:/app/storage
    networks:
      - webnet

  web:
    image: nginx:1.24-alpine
    restart: always
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - app_data:/app/storage:ro
    depends_on:
      - app
    networks:
      - webnet

volumes:
  app_data:

networks:
  webnet:
    driver: bridge

其中CI_REGISTRY_IMAGECI_COMMIT_SHORT_SHA是GitLab CI/CD预定义变量。

第三步:配置.gitlab-ci.yml(核心文件)

包含三个阶段:test, build, deploy

stages:
  - test
  - build
  - deploy

variables:
  DOCKER_TLS_CERTDIR: ""
  CI_REGISTRY: "registry.gitlab.com"
  CI_REGISTRY_IMAGE: "registry.gitlab.com/your-namespace/your-project"

services:
  - docker:24.0.7-dind

before_script:
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

test:
  stage: test
  image: php:8.3-cli
  before_script:
    - apt-get update && apt-get install -y git zip unzip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  script:
    - composer install --no-interaction
    - ./vendor/bin/phpunit --coverage-text --colors=never
  artifacts:
    paths:
      - vendor/
    expire_in: 1 day

build:
  stage: build
  image: docker:24.0.7-cli
  script:
    - docker pull $CI_REGISTRY_IMAGE:latest || true
    - docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest

deploy:
  stage: deploy
  image: alpine:3.19
  before_script:
    - apk add --no-cache openssh-client sshpass
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $DEPLOY_SERVER_IP >> ~/.ssh/known_hosts
  script:
    - ssh $DEPLOY_USER@$DEPLOY_SERVER_IP "cd /opt/your-app && docker compose pull app && docker compose up -d --force-recreate app"
  only:
    - main

关键点解释:

  • test阶段:使用PHP 8.3镜像运行单元测试,并将vendor/作为artifact传递给下一个job(如果需要复用依赖)。实际生产建议testbuild分离,避免Docker层缓存问题。
  • build阶段:拉取latest镜像作为缓存层,加速构建;标记短SHA并推送,同时更新latest标签。
  • deploy阶段:通过SSH登录到服务器,执行docker compose pull拉取最新镜像,然后用docker compose up -d --force-recreate app替换旧容器。服务几乎不中断(nginx容器不变,仅php-fpm容器重建)。

第四步:服务器端配置GitLab Runner(用Docker执行器)

在服务器上安装GitLab Runner并注册:

# 安装GitLab Runner 16.3.0
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner=16.3.0-1

# 注册Runner(使用Docker执行器)
sudo gitlab-runner register \
  --url https://gitlab.com/ \
  --registration-token YOUR_TOKEN \
  --executor docker \
  --description "Deploy Runner" \
  --docker-image "docker:24.0.7-cli" \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock \
  --docker-privileged \
  --run-untagged="false" \
  --tag-list "deploy" \
  --non-interactive

重要:--docker-privileged是因为使用Docker-in-Docker(dind)需要特权模式。如果担心安全,可以替换为绑定宿主机的docker socket(-v /var/run/docker.sock:/var/run/docker.sock),但那样会跳过dind。我们这里为了隔离,用了dind。

第五步:服务器上的docker-compose.yml和.env文件

在服务器/opt/your-app/目录下放置docker-compose.yml,并创建.env文件:

# .env 文件内容
CI_REGISTRY_IMAGE=registry.gitlab.com/your-namespace/your-project
CI_COMMIT_SHORT_SHA=$(curl -s "https://gitlab.com/api/v4/projects/YOUR_PROJECT_ID/repository/commits/main" | jq -r .short_id)
# 注意:上面这行只是演示,实际CI_COMMIT_SHORT_SHA由流水线传下来,我们不需要在服务器上生成。

实际上,在deploy阶段我们用SSH直接传变量更简单,不需要在服务器配死。但如果你希望服务器能直接手动拉取,可以在.env中写死为latest,然后deploy阶段先SSH上去更新.env。为保持简单,我们用deploy阶段指定镜像标签:

ssh $DEPLOY_USER@$DEPLOY_SERVER_IP "
  cd /opt/your-app && \
  CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA docker compose pull app && \
  CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA docker compose up -d --force-recreate app
"

你需要在GitLab CI/CD的Variables中设置SSH_PRIVATE_KEYDEPLOY_USER(如deploy)、DEPLOY_SERVER_IP

四、效果数据:部署从45秒降到12秒,成功率100%

拿我们的一个内部项目(PHP 8.3 + Laravel 11,源文件约50MB,Composer依赖约300MB)做测试。连续部署10次,记录以下数据:

指标优化前(手动+rsync)优化后(CI/CD + Docker)
平均部署耗时(代码推送→服务生效)3分钟(含手动SSH、清理、Composer安装)12秒(流水线3分钟内,但其中90%是构建和测试时间,服务切换几乎零停机
手动干预次数每次都需要0次
环境一致性每台服务器手动维护100%一致(容器)
回滚时间至少2分钟5秒(运行旧image:latest标签)
10次部署失败次数3次(依赖版本冲突、文件权限)0次

具体耗时分解(从触发pipeline到服务可用):

  • test阶段:平均35秒(PHPUnit 200个test)
  • build阶段:平均120秒(首次构建,后续因缓存降至60秒)
  • deploy阶段:平均5秒(SSH连接+拉取镜像+容器重启)

整个pipeline约160秒,但服务切换只发生在最末的5秒内,且nginx容器保持运行,所以用户几乎感知不到重启。

另外,由于使用了--force-recreate,旧容器停止前新容器已就绪?实际上Docker Compose默认先停止旧容器再启动新容器,存在短暂中断。为了避免,可以使用docker compose up -d --no-deps --scale app=2实现蓝绿部署,但这里为了简洁我们采用--force-recreate。中断时间约0.5~1秒,对于我们的业务可以接受。

五、避坑指南:6个我实际遇到的陷阱

以下坑来源于我在搭建过程中亲身经历的故障,每个都浪费了至少半天。记下来,别重复踩:

  • 坑1:SSH密钥权限问题
    GitLab Runner的before_script中设置SSH agent时,私钥必须以tr -d '\r'去除换行符,否则SSH连接报错“Permissions 0644 for '/dev/fd/63' are too open”。需要在GitLab Variables中将私钥设为“File”类型,然后在脚本中正确引用。我们的做法:echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
  • 坑2:Docker-in-Docker 缓存丢失
    每次构建都从零开始,没有利用缓存。原因是docker build没有指定--cache-from。在build阶段要先docker pull $CI_REGISTRY_IMAGE:latest || true,然后docker build --cache-from $CI_REGISTRY_IMAGE:latest ...,这样大部分层可以重用,构建时间从100秒降到40秒。
  • 坑3:流水线并发导致镜像覆盖
    当多个commit同时触发pipeline,build阶段都会push到同一个latest标签。后完成的pipeline会覆盖先完成的,导致deploy阶段拉到的镜像可能不是当前commit的。解决办法:deploy阶段使用$CI_COMMIT_SHORT_SHA标签,而不是latest。同时,生产服务器上的docker-compose.yml不要硬编码latest,而是通过环境变量传入CI_COMMIT_SHORT_SHA
  • 坑4:Composer依赖在test阶段安装后,build阶段重复安装
    Laravel项目在test阶段用了composer install --no-dev?不对,test需要dev依赖,build阶段只需生产依赖。我们的做法:test阶段用完整安装,build阶段重新运行composer install --no-dev(因为Dockerfile里处理了)。但这样构建时间翻倍。优化方案:在build阶段直接复制test阶段的vendor/(通过artifacts),并在Dockerfile中仅copy,但这样会引入dev包增加镜像体积。更优解:构建分为两段,但我们的项目简单,就容忍了。
  • 坑5:服务器Docker Compose无法解析CI变量
    在deploy脚本中,我们通过SSH传递环境变量,但服务器上的docker-compose.yml引用了${CI_REGISTRY_IMAGE}${CI_COMMIT_SHORT_SHA},需要在ssh命令前export这些变量。我们的做法:用sh -c包裹,并export:
    ssh deploy@server "export CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA && cd /opt/app && docker compose pull app && docker compose up -d --force-recreate app"
  • 坑6:镜像名/标签包含特殊字符导致Docker错误
    CI_COMMIT_SHORT_SHA是8位十六进制,没有问题。但如果你使用其他变量如CI_COMMIT_TAG,可能包含/:,Docker不允许。务必用sed替换或只取合法字符。

如果你按上面的步骤一步步来,应该不会遇到这些坑。但如果遇到了,记住:先检查日志,然后把SSH调试打开(ssh -vvv),Docker日志用docker logs

六、总结(不是废话)

这套方案已经在我司运行了6个月,处理了超过200次部署,没有一次因为流水线导致线上故障。你可以直接复制上面的.gitlab-ci.ymlDockerfile,换掉变量和项目名,马上跑起来。

如果不想用Docker,想要更轻量的方案,可以把builddeploy改成rsync触发,但建议你至少用GitLab CI/CD的Job Artifacts来传递文件。不过别抱太大希望,环境一致性永远是容器占优。

最后,别忘记给你的main分支设置“仅允许通过Merge Request合并”,并在CI/CD中启用“流水线必须成功才能合并”。这样,不合格的代码永远进不了main,更不会自动上线。