GitLab CI/CD 端到端流水线

注:本节代码需手动删除注释后使用!!!!!

一、端到端 CI/CD 流水线

本章目标:编写一条完整的流水线,实现闭环:代码提交 → 自动测试 → 自动构建镜像 → 推送阿里云ACR → ECS自动部署 + 手动回滚
我们将分阶段逐个编码实现 Job,最后合并为一份完整流水线配置文件。

整条流水线长什么样?

流水线划分为 5 个串行阶段,前序 Stage 失败则后续 Stage 终止执行。

stages:
  - test           # 单元测试,代码校验
  - build_image    # Docker构建镜像,导出tar镜像包
  - push_image     # 加载tar镜像,推送至阿里云ACR私有仓库
  - deploy         # SSH远程ECS,拉取镜像、启停容器、健康校验
  - rollback       # 手动触发,回滚至上一个可用版本
流程解释
  1. test:执行pytest单元测试,测试异常直接终止整条流水线,避免无效打包部署。

  2. build_image:测试通过后构建 Docker 镜像,使用流水线 ID 作为唯一不可变镜像标签,镜像导出为image.tar,通过 GitLab 制品向下游传递。

  3. push_image:接收上游 tar 镜像文件,本地加载镜像,补全仓库路径标签,推送阿里云容器镜像仓库。

  4. deploy:SSH 免密登录 ECS 服务器,拉取新镜像、销毁旧容器、启动新版本容器、应用健康检查;部署成功落地版本号到服务器本地文件。

  5. rollback:人工手动触发 Job,读取服务器留存历史版本标签,拉取旧镜像完成容器回滚。


1.1 阶段一:test —— 让流水线先帮你检查代码

目标:代码git push后自动执行单元测试,测试失败流水线直接终止,阻断后续打包部署。
设计原则:测试放在流水线首阶段,提前拦截故障代码,节约构建、服务器资源,是 CI 流水线通用规范。

1.1.1 项目目录结构
my-flask-app/
├── app.py                # Flask 应用(带健康检查接口)
├── requirements.txt      # Python 依赖
├── tests/
│   └── test_app.py       # 单元测试
├── Dockerfile            # 镜像构建文件
└── .gitlab-ci.yml        # CI/CD 流水线配置
1.1.2 前置文件

为了跑通我们编写的流水线,你先需要编写以下几个文件(放在 Git 仓库根目录):

  1. app.py —— 一个极简的 Flask 应用
from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello from CI/CD!"

@app.route('/health')
def health():
    # 健康检查接口,返回 200 表示服务正常
    return jsonify({"status": "ok"}), 200

if __name__ == '__main__':
    # 监听所有网卡,端口由环境变量 PORT 指定(默认 5000)
    port = int(os.environ.get('PORT', 5000))
    app.run(host='0.0.0.0', port=port)
  1. requirements.txt —— 依赖清单
Flask==2.3.3
pytest==7.4.0
1.1.3 最小测试用例
def test_hello():
    assert "hello" in "hello world"
1.1.4 Test 阶段基础 Job 配置
  1. 我们需要一个名为run_tests的 job用来执行测试任务,归属于 test 阶段
  2. 该测试依赖于 Python镜像(python:3.9-slim)
  3. 执行 test_app.py 脚本进行测试
stages:
  - test

run_tests:
  stage: test
  image: python:3.9-slim
  before_script:
    - pip install pytest  #python:3.9-slim不带pytest,要手动安装
  script:
    - pytest tests/   #执行tests目录下所有测试用例

配置逐行说明

配置项说明
stage: test绑定至 test 阶段,同阶段 Job 并行执行,阶段串行
image: python:3.9-slimGitLab Runner 基于该 Python 镜像创建运行容器
before_script脚本前置执行命令,用于安装项目依赖
script核心执行逻辑,pytest 用例失败会返回非 0 退出码,流水线标记失败
1.1.5 集成测试报告(Artifacts 制品留存)

光执行测试还不够,我们需要知道测试结果,因此需要输出可视化报告并生成制品留存:安装pytest-html生成可视化测试报告,通过artifacts保存报告,流水线结束可在 GitLab 页面下载查看:

run_tests:
  stage: test
  image: python:3.9-slim
  before_script:
    - pip install pytest pytest-html
  script:
    - pytest tests/ --html=report.html
  artifacts:
    paths:
      - report.html
    when: always       # 无论流水线成功/失败都保留报告
    expire_in: 3 days  # 制品自动过期清理时间
1.1.6 阶段自测方案
  1. 修改用例:assert 1 == 2,提交代码,观察流水线失败变红

  2. 恢复正确用例,重新提交,流水线执行成功变绿


1.2 阶段二:build_image —— 构建镜像并保存为 tar 文件

目标:测试通过后构建 Docker 镜像,导出 tar 镜像包,通过 GitLab 制品传递给下一阶段 Push Job。
为什么导出 tar?
GitLab 每个 Job 运行在独立临时容器,Job 执行完毕容器销毁,本地构建的 Docker 镜像随之丢失;通过docker save打包成文件,借助artifacts跨 Job 传输镜像。

1.2.1 项目 Dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
1.2.2 Build 镜像 Job 配置
stages:
  - test
  - build_image

docker_build:
  stage: build_image
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  variables:
    DOCKER_HOST: tcp://docker:2375  #Docker引擎路径
    DOCKER_TLS_CERTDIR: ""   #关闭TLS安全验证,防止连接失败
  script:
    - docker build -t myapp:$CI_PIPELINE_ID .
    - docker save myapp:$CI_PIPELINE_ID -o image.tar
  artifacts:
    paths:
      - image.tar
    expire_in: 1 day

配置说明

  1. services: docker:xxx-dind:Docker-in-Docker,容器内部启动 Docker 守护进程,实现容器内构建镜像;

  2. DOCKER_HOST:指定 Docker 客户端连接内部 DinD 服务地址;

  3. $CI_PIPELINE_ID:GitLab 内置环境变量,每条流水线唯一 ID,用作不可变镜像标签;

  4. docker save:镜像导出为本地image.tar,制品配置使下游 Job 自动拉取该文件。

1.2.3 自测方式

临时注释test阶段配置,仅保留 build_image,提交代码;在流水线 Job 详情页下载image.tar,验证文件正常生成。


1.3 阶段三:push_image —— 把镜像推送到阿里云 ACR

目标:读取上游image.tar,本地加载镜像、重命名仓库标签、推送至阿里云私有镜像仓库 ACR。

1.3.1 前置准备
  1. 阿里云 ACR 信息示例

    • 仓库域名:registry.cn-hangzhou.aliyuncs.com

    • 命名空间:devops-demo

    • 镜像仓库名:my-flask-app

  2. GitLab 项目→设置→CI/CD→变量,添加环境密钥变量(项目密文,不写入配置文件)

    变量名内容
    ALIYUN_USERACR 登录用户名
    ALIYUN_PASSWORDACR 仓库登录密码
1.3.2 Push Job 配置
stages:
  - test
  - build_image
  - push_image

docker_push:
  stage: push_image
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  before_script:
  # 先登录仓库
    - docker login -u "$ALIYUN_USER" -p "$ALIYUN_PASSWORD" registry.cn-hangzhou.aliyuncs.com
  script:
    # 加载上游制品镜像包
    - docker load -i image.tar
    # 重打阿里云全路径标签
    - docker tag myapp:$CI_PIPELINE_ID registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:ci-$CI_PIPELINE_ID
    # 推送远程ACR仓库
    - docker push registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:ci-$CI_PIPELINE_ID
1.3.3 功能验证

流水线执行成功后,登录阿里云 ACR 控制台,对应仓库下查看新增镜像,标签格式:ci-流水线ID


1.4 阶段四:deploy —— 自动部署到云服务器

Docker镜像已经推送至我们的仓库中,接下去我们要用流水线将其从仓库中拿出来,部署到服务器上。

目标:SSH 远程连接阿里云 ECS,拉取新镜像、关停旧容器、启动新版本、应用健康检测、落地版本号用于后续回滚。

1.4.1 部署前置配置
  1. ECS 安全组放行:22 端口 (SSH) + 应用端口 5000

  2. ECS 服务器预装 Docker 环境;

  3. GitLab CI 新增服务器连接变量:

    变量名说明
    ECS_HOSTECS 公网 IP 地址
    ECS_USER服务器登录用户名(如 root)
    ECS_PASSWORD服务器登录密码
1.4.2 最简部署 Job
stages:
  - test
  - build_image
  - push_image
  - deploy

deploy_to_ecs:
  stage: deploy
  image: alpine:latest
  before_script:
  	# 安装 SSH 远程连接工具,让 CI 能登录你的服务器执行部署命令
    - apk add --no-cache openssh-client sshpass
  script:
  # 自动登录服务器并执行部署
  - sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
      docker login -u $ALIYUN_USER -p $ALIYUN_PASSWORD registry.cn-hangzhou.aliyuncs.com &&
      docker pull registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:ci-$CI_PIPELINE_ID &&
      docker stop myapp || true &&
      docker rm myapp || true &&
      docker run -d --name myapp -p 5000:5000 --restart=unless-stopped registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:ci-$CI_PIPELINE_ID
    "

|| true:容器不存在时 stop/rm 命令不抛出异常,避免部署中断;--restart=unless-stopped:服务器意外重启自动拉起容器。

1.4.3 新增应用健康检查(规避部署成功但服务异常)

docker run后追加健康探测脚本,连续 5 次探测应用/health接口,失败则部署 Job 报错:

sleep 3
for i in 1 2 3 4 5; do
  if curl -s http://localhost:5000/health > /dev/null; then
    echo "Health check OK"
    exit 0
  fi
  sleep 2
done
echo "Health check FAILED"
exit 1
1.4.4 落地当前版本号(回滚依赖)

部署成功后,在 ECS 服务器/opt/myapp/version.current写入当前镜像标签,作为回滚目标版本:

echo "ci-$CI_PIPELINE_ID" > /opt/myapp/version.current

该脚本放入after_script,部署成功后自动执行。

script:
    # 命令同上
after_script:
    - sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
      mkdir -p /opt/myapp &&
      echo 'ci-$CI_PIPELINE_ID' > /opt/myapp/version.current
      "
1.4.5 部署验证
  1. 首次部署完成:浏览器访问http://ECS公网IP:5000可正常访问服务;

  2. 修改项目代码重新提交,流水线自动触发全流程,服务自动更新。


1.5 阶段五:rollback —— 手动触发回滚

目标:新版本故障时,人工点击 GitLab 流水线按钮,一键回滚至上一个稳定版本。

  • when: manual:Job 不会随流水线自动运行,需在 GitLab 页面手动点击启动。
stages:
  - test
  - build_image
  - push_image
  - deploy
  - rollback

rollback:
  stage: rollback
  image: alpine:latest
  when: manual
  before_script:
    - apk add --no-cache openssh-client sshpass
  script:
    - |
      OLD_TAG=$(sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "cat /opt/myapp/version.current")
      if [ -z "$OLD_TAG" ]; then
        echo "No previous version found"
        exit 1
      fi
      sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
        docker pull registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:$OLD_TAG &&
        (docker stop myapp || true) &&
        (docker rm myapp || true) &&
        docker run -d --name myapp -p 5000:5000 registry.cn-hangzhou.aliyuncs.com/devops-demo/my-flask-app:$OLD_TAG
      "
回滚实操验证
  1. 部署 V1 版本→修改代码部署 V2 版本;

  2. 进入流水线页面,手动执行 rollback Job;

  3. 访问服务,确认应用退回 V1 版本。


1.6 组合起来:完整的 .gitlab-ci.yml

使用全局变量统一管理仓库地址、命名空间,修改配置只需变更顶部变量,可直接复制使用,按需替换自定义参数。

stages:
  - test
  - build_image
  - push_image
  - deploy
  - rollback

# 全局自定义变量,按需修改
variables:
  REGISTRY: "registry.cn-hangzhou.aliyuncs.com"
  NAMESPACE: "你的命名空间"
  IMAGE_NAME: "你的仓库名"
  IMAGE_TAG: "ci-$CI_PIPELINE_ID"
  IMAGE_FULL: "$REGISTRY/$NAMESPACE/$IMAGE_NAME:$IMAGE_TAG"

# pip依赖缓存,加速测试安装依赖
cache:
  key:
    files:
      - requirements.txt
  paths:
    - .cache/pip

# ========== 1.自动化测试阶段 ==========
run_tests:
  stage: test
  image: python:3.9-slim
  before_script:
    - pip install --cache-dir .cache/pip pytest
  script:
    - pytest tests/

# ========== 2.镜像构建&导出tar ==========
docker_build:
  stage: build_image
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  script:
    - docker build -t $IMAGE_NAME:$CI_PIPELINE_ID .
    - docker save $IMAGE_NAME:$CI_PIPELINE_ID -o image.tar
  artifacts:
    paths:
      - image.tar
    expire_in: 1 day

# ========== 3.推送镜像至阿里云ACR ==========
docker_push:
  stage: push_image
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker login -u "$ALIYUN_USER" -p "$ALIYUN_PASSWORD" $REGISTRY
  script:
    - docker load -i image.tar
    - docker tag $IMAGE_NAME:$CI_PIPELINE_ID $IMAGE_FULL
    - docker push $IMAGE_FULL

# ========== 4.ECS自动化部署+健康检查 ==========
deploy_to_ecs:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client sshpass
  script:
    - sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
      set -e;
      docker login -u $ALIYUN_USER -p $ALIYUN_PASSWORD $REGISTRY &&
      docker pull $IMAGE_FULL &&
      (docker stop myapp || true) &&
      (docker rm myapp || true) &&
      docker run -d --name myapp --restart=unless-stopped -p 5000:5000 $IMAGE_FULL &&
      sleep 3 &&
      for i in 1 2 3 4 5; do
        if curl -s http://localhost:5000/health > /dev/null; then
          echo 'Health check passed';
          exit 0;
        fi;
        sleep 2;
      done;
      echo 'Health check failed' && exit 1;
      "
  # 部署成功后写入当前版本号
  after_script:
    - sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
      mkdir -p /opt/myapp && echo $IMAGE_TAG > /opt/myapp/version.current
      "
  environment:
    name: production
    url: http://$ECS_HOST:5000
  only:
    - main # 仅main分支触发自动部署

# ========== 5.手动回滚Job ==========
rollback:
  stage: rollback
  image: alpine:latest
  when: manual
  before_script:
    - apk add --no-cache openssh-client sshpass
  script:
    - |
      OLD_TAG=$(sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "cat /opt/myapp/version.current")
      if [ -z "$OLD_TAG" ]; then
        echo "No previous version found"
        exit 1
      fi
      OLD_IMAGE_FULL="$REGISTRY/$NAMESPACE/$IMAGE_NAME:$OLD_TAG"
      sshpass -p "$ECS_PASSWORD" ssh -o StrictHostKeyChecking=no $ECS_USER@$ECS_HOST "
        docker login -u $ALIYUN_USER -p $ALIYUN_PASSWORD $REGISTRY &&
        docker pull $OLD_IMAGE_FULL &&
        (docker stop myapp || true) &&
        (docker rm myapp || true) &&
        docker run -d --name myapp --restart=unless-stopped -p 5000:5000 $OLD_IMAGE_FULL
      "
  environment:
    name: production
    action: rollback

补充拓展优化建议

  1. 安全优化:摒弃sshpass明文密码登录,改用 SSH 密钥免密登录 ECS,私钥存入 GitLab CI 变量;

  2. 版本优化:使用 SemVer 语义化标签替代流水线 ID;

  3. 部署优化:生产环境替换原生 docker run 为 Docker Compose/K8s 部署;

  4. 通知优化:配置流水线钉钉 / 企业微信告警,部署成功 / 失败推送消息。

更多推荐