GitLab CI 搭建完整的镜像流水线:构建、扫描、推送与部署衔接

很多团队的 GitLab CI 停留在「能跑通」阶段:流水线里只有一条 docker build,镜像打完就结束,没有测试门禁、没有漏洞扫描,部署还要人工去目标机器上 pull。本文把一条完整的镜像流水线拆开讲:Runner 怎么选型注册、阶段怎么划分、镜像构建用 docker 还是 kaniko、缓存和扫描门禁怎么加、不同分支和 Tag 怎么用 rules 分流,最后把部署阶段和 GitLab 的 environment 衔接起来。

适用环境:GitLab CE/EE 15 及以上版本,Runner 独立部署在一台 Linux 主机(或 Kubernetes 集群)上,镜像仓库使用 GitLab 自带的 Container Registry 或 Harbor 一类私有仓库,下文以 gitlab.example.com 占位。

前置条件

  • 一台已运行的 GitLab 实例,管理员已开启 Container Registry(或准备好私有仓库地址与账号)。
  • 一台 Linux 主机用作 Runner 执行机(示例 IP 192.0.2.10),已安装 Docker 或准备使用 kaniko 方案。
  • 一个示例项目,包含 Dockerfile,能在本地正常构建出镜像。
  • 准备好项目的 CI 配置文件编辑权限,以及在「设置 → CI/CD → Runner」里查看注册令牌的权限。

第一步:Runner 执行器选型与注册

执行器决定了作业的隔离方式。shell 执行器最简单但无隔离,作业之间互相污染;docker 执行器每个作业一个容器,干净且易于固定工具版本,是最常用的选择;kubernetes 执行器把作业调度成 Pod,适合 Runner 规模大、需要弹性的场景。本文用 docker 执行器,注册命令如下:

# 在 Runner 主机上安装 gitlab-runner 后执行
gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.example.com/" \
  --token "glrt-xxxxxxxxxxxx" \
  --executor "docker" \
  --docker-image "docker:24" \
  --description "docker-runner-01" \
  --tag-list "docker,linux"

注意新版 GitLab 推荐用身份验证令牌(glrt- 开头)在项目或群组的 Runner 页面创建 Runner 后再注册,而不是用全局注册令牌。构建镜像时作业容器需要调用宿主机 Docker,常见做法是给 Runner 配置特权模式或挂载 docker.sock,二者都有安全代价,后文 kaniko 方案正是为了绕开这一点。

第二步:划分流水线阶段

一条完整的镜像流水线建议分五个阶段:build(编译与单元测试)、test(代码检查与集成测试)、scan(镜像漏洞扫描,可与构建合并)、push(推送镜像到仓库)、deploy(部署衔接)。先声明阶段顺序,再逐个写作业:

stages:
  - build
  - test
  - scan
  - push
  - deploy

variables:
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  DOCKER_DRIVER: overlay2

第三步:镜像构建的两种方式

方式一是经典的 docker build:在作业里用 docker:dind 服务或挂载宿主机 sock,写法直观、构建速度快,但需要特权容器或等价权限,在多租户 Runner 上风险较高。方式二是 kaniko:Google 开源的镜像构建工具,在普通用户空间内按 Dockerfile 逐层构建并直接推送,无需 Docker 守护进程和特权模式,是 Kubernetes 执行器和受限环境下的默认选择。代价是构建速度通常略慢,且对个别 Dockerfile 指令(尤其是依赖守护进程行为的写法)兼容性要实测。

# 方式一:docker build(需要 dind 或 sock)
build-image-dind:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker build -t "$IMAGE_NAME:$CI_COMMIT_SHA" .
    - docker push "$IMAGE_NAME:$CI_COMMIT_SHA"

# 方式二:kaniko(无需特权)
build-image-kaniko:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:v1.23.2-debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/Dockerfile"
      --destination "$IMAGE_NAME:$CI_COMMIT_SHA" --destination "$IMAGE_NAME:latest"

第四步:缓存与制品

缓存(cache)加速依赖下载,制品(artifacts)在阶段之间传递构建产物,两者不能混用:cache 是尽力而为、可能被清理的,artifacts 是确定传递的。依赖缓存用分支或锁文件做 key,避免不同分支互相污染:

build-app:
  stage: build
  image: maven:3.9-eclipse-temurin-17
  cache:
    key:
      files:
        - pom.xml
    paths:
      - .m2/repository
  script:
    - mvn -Dmaven.repo.local=.m2/repository package -DskipTests
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 day

第五步:镜像漏洞扫描门禁

用 Trivy 在 scan 阶段扫描已推送(或本地构建)的镜像,把高危漏洞变成流水线失败条件。注意退出码的控制:只对 CRITICAL/HIGH 拦截,低危只报告,避免门禁过严导致团队绕过它。

scan-image:
  stage: scan
  image:
    name: aquasec/trivy:0.54.1
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL
      --username "$CI_REGISTRY_USER" --password "$CI_REGISTRY_PASSWORD"
      "$IMAGE_NAME:$CI_COMMIT_SHA"

第六步:用 rules 控制分支与 Tag 行为

老式的 only/except 已被 rules 取代。典型策略:功能分支只构建测试不推送;主分支推送并部署到测试环境;打 Tag 时推送正式版本并允许手动部署生产。rules 是从上到下匹配、命中即停,顺序写错是最常见的事故来源。

push-image:
  stage: push
  script:
    - docker tag "$IMAGE_NAME:$CI_COMMIT_SHA" "$IMAGE_NAME:$CI_COMMIT_REF_SLUG"
    - docker push "$IMAGE_NAME:$CI_COMMIT_REF_SLUG"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

deploy-prod:
  stage: deploy
  script:
    - echo "deploy $IMAGE_NAME:$CI_COMMIT_TAG to prod"
  environment:
    name: production
    url: https://app.example.com
  rules:
    - if: $CI_COMMIT_TAG
      when: manual

第七步:部署阶段与环境衔接

deploy 阶段本身通常只做一件事:调用目标环境的部署手段。可以是 SSH 到目标机执行 docker compose pull && up,可以是 kubectl set image,也可以只是触发下游流水线或运维平台的 Webhook。关键是把 environment 写对:GitLab 会据此记录每次部署的版本与环境状态,支持在界面上查看部署历史和一键回滚到上一个成功版本。部署凭据用 CI/CD 变量管理,生产环境变量设为 Protected 且 Masked,只允许受保护分支和 Tag 读取。

验证

提交一次功能分支推送:流水线应只跑 build/test/scan,不出现 push 与 deploy。再合并到主分支:出现 push,deploy 到测试环境自动执行。最后打一个 Tag(如 v1.0.0):deploy-prod 作业以手动待执行状态出现,点击后环境页面 production 显示该 Tag 版本。在仓库的 Container Registry 中确认对应 SHA 和 Tag 的镜像都已存在。

常见问题

Runner 一直 pending 接不到作业。先看 Runner 标签与作业 tags 是否匹配,再看 Runner 是否被限定了只能跑受保护分支。执行机上执行 gitlab-runner verify 和 gitlab-runner run 可看到连接报错,特权模式没开也会导致 dind 作业启动失败。

缓存配了但不生效。cache 默认在同一分支内共享,新分支第一次跑必然是冷的;分布式缓存还需要 Runner 配置 S3 兼容存储,本地缓存则要求作业落在同一台 Runner 上。另外把 key 设成固定字符串会让不同分支互相覆盖缓存,也是「命中了却拉到旧依赖」的常见原因。

流水线根本不触发。优先查 rules:最后一条兜底规则缺失时,不匹配任何规则的作业不会被创建,整条流水线可能直接不生成。另外 workflow:rules 控制的是整条流水线是否创建,和作业级 rules 混写时容易互相遮挡,先临时注释逐段排查。

扫描门禁把所有流水线都拦住。基础镜像里带的历史漏洞会长期存在,正确做法是给门禁设基线:只拦新增高危,或用 .trivyignore 记录已评估过的 CVE 并注明理由,而不是把 --exit-code 改成 0 整体关掉。

小结

一条可用的镜像流水线,骨架是 Runner 隔离方式和阶段划分,血肉是缓存、扫描门禁与 rules 分流,落点是 environment 记录的部署历史。建议先在示例项目上把五段跑通,再逐项加严扫描与权限,过程中沉淀成 Include 模板供其他项目复用,而不是每个项目复制一份流水线文件。

发表回复

后才能评论