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 模板供其他项目复用,而不是每个项目复制一份流水线文件。






