💡 这一页讲什么:讲怎么用 Docker 做 CI/CD,不是讲 Jenkins / GitLab 的平台教程。主线只有一条——镜像怎么从代码变成线上跑着的东西,以及出问题怎么退回去。

流水线四阶段与镜像的角色 1/7

不管用哪个平台,一条容器化流水线的骨架都一样:

提交代码 → 构建镜像 → 推送仓库 → 部署上线 → (失败)回滚

为什么镜像是理想的交付物

不可变

同一个镜像 digest 永远对应同一份内容。不像「往服务器上传一堆文件」,传一半失败就处于未知状态。

一次构建,多处运行

测试环境验过的那个镜像,就是上生产的那一个。测试通过但线上行为不同——这类问题从根上被消掉。

回滚就是换标签

旧镜像还在仓库里,回滚 = 让线上重新拉旧标签。不需要「反向改代码再发一次」。

可签名、可扫描

镜像是有明确标识的制品,因此可以签名验真、可以在上线前扫漏洞。交付物有了身份,安全策略才有落点。

⚠️ 先立一条规矩:latest 是反模式。它不指向任何确定的东西——今天推的和昨天推的代表两套代码,回滚时你不知道该回到哪,出问题也无法复现。latest 可以用,但只能当「最近一次构建」的别名,绝不能作为「当前跑的是哪个版本」的判断依据。真正标识一个版本的,是 git commit sha。

构建阶段优化 2/7

Dockerfile 层面的技巧(层顺序、多阶段、镜像瘦身)在 Dockerfile 最佳实践 里已经讲过,这里只补 CI 特有的三个问题。

问题一:CI runner 是无状态的,本地缓存每次都丢

你在自己机器上享受的层缓存,在 CI 里不存在——每个 job 都是崭新的一次。所以必须把缓存存到外面:

缓存后端写法适合
GitHub Actions 缓存cache-from: type=gha / cache-to: type=gha,mode=maxGitHub Actions,零额外配置
镜像仓库缓存cache-from: type=registry,ref=仓库:buildcacheGitLab CI、自建 runner、跨平台通用
本地目录缓存cache-from: type=local,src=...自建 runner 且磁盘持久(配 actions/cache 使用)
💡 写 cache-to ... ,mode=max 会把所有中间层都缓存下来(不只是最终镜像的层),多阶段构建能明显受益——代价是缓存体积更大、上传更慢。构建慢的大项目值得,小项目不值。

问题二:BuildKit 已经是默认,但你得知道它带来了什么

Docker 23 之后 BuildKit 已是默认构建后端。它在 CI 里真正有用的三点:DAG 并行(互不依赖的多阶段会同时跑)、更好的缓存键(内容寻址,比旧的层哈希更准)、secret mount(构建时用密钥但不落进任何层):

# Dockerfile 里:构建时读密钥,但不会留在镜像里
RUN --mount=type=secret,id=npmrc,dst=/root/.npmrc \
    npm ci --production

# 构建时传入
docker buildx build --secret id=npmrc,src=.npmrc -t myapp .
⚠️ 这条正好接上第 5 节的规矩:私钥、token、私有仓库凭据一律不要用 ARG 或 ENV 传进构建。ARG 会出现在镜像历史里,ENV 会留在最终镜像中,两者都会泄露。要么用 --secret,要么用 --mount=type=ssh。

问题三:多架构镜像——QEMU 能跑通,但慢得你可能无法接受

要同时产出 linux/amd64 和 linux/arm64,最常见的是用 QEMU 模拟另一种架构。它能用,但对编译型语言可能是数倍的时间开销——因为编译器本身也在被模拟执行。所以:

方式做法取舍
QEMU 模拟docker/setup-qemu-action + --platform linux/amd64,linux/arm64零成本、一条命令出双架构;构建时间显著变长,解释型语言尚可,编译型语言慎用
原生 runner在 arm64 机器上分别构建、再合并 manifest快得多,但需要维护两套 runner;CI 平台提供 arm64 runner 时优先选它
不做多架构只发布 amd64如果你的部署环境全是 x86,就别做——多架构是成本,不是勋章
💡 先问一句「我真的需要 arm64 吗」。常见理由是「团队有人用 M 系列 Mac 本地跑」,但那完全可以在本地 build,不必占用 CI 时间给所有架构都出产物。

GitHub Actions 实战 3/7

下面是一个完整可用的工作流:多架构构建、推送到 GHCR、带构建缓存、按 git 信息自动打标签。

# .github/workflows/docker.yml
name: Build and push image

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # 推 GHCR 必须有这项
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=sha-,format=short

      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

几个容易踩的点

点说明
GITHUB_TOKEN 就够了推 GHCR 不需要自己建 PAT。但要显式声明 permissions: packages: write——默认权限是只读的,不写这一项会推失败
GHCR 镜像路径必须全小写github.repository 保留仓库名大小写,含大写字母时会推失败。需要先转小写再作为镜像名
PR 上只构建、不推送push: ${{ github.event_name != 'pull_request' }}。PR 来自 fork 时没有写权限,推送必然失败
第三方 Action 是供应链风险上面用的都是 Docker 官方与 GitHub 官方的 action。用第三方 action 时,至少钉到完整版本号(@v1.2.3),更严谨的钉到 commit sha
💡 换成别的平台,逻辑完全一样。Gitea Actions 直接兼容 GitHub Actions 的语法(runner 用 act_runner),YAML 基本照搬;阿里云效流水线是可视化编排(也支持 flow.yml),把「构建 → 推送 → 部署」三个步骤拖出来即可,宿主换成云效的构建集群。阶段划分不变,变的只是配置文件格式。

GitLab CI 实战 4/7

GitLab 的好处是它自带容器镜像仓库,且凭据变量($CI_REGISTRY_*)是自动注入的,不用手工配 secret。

# .gitlab-ci.yml
stages:
  - build

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE: $CI_REGISTRY_IMAGE

build-and-push:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
  script:
    - docker buildx create --use --driver docker-container
    - docker buildx build
      --platform linux/amd64,linux/arm64
      --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
      --cache-to   type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
      --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
      --tag $CI_REGISTRY_IMAGE:latest
      --push .
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

关键分歧:DinD 需要特权,那不用特权怎么办?

上面的 docker:dind 是最省事的写法,但它要求 runner 开启 privileged——等于把宿主机的 Docker 交给了流水线,任何能改 CI 配置的人都能借此拿到宿主机权限。自建 runner 上尤其要谨慎。

⚠️ 特别提醒:Kaniko 已经不再维护了。过去几年「无特权构建镜像」的标准答案是 Kaniko,但 Google 已于 2025 年 6 月归档该仓库(转为只读),不再有上游修复与安全补丁;GitLab 也已移除 Kaniko 相关文档,转而推荐 Buildah / Podman。存量流水线不会立刻坏掉,但新项目不要再默认选 Kaniko——这是 2026 年和几年前最大的信息差之一。
方案需要特权状态与取舍
docker:dind需要官方文档默认方案,配置最简单;但特权容器的安全面最大,自建 runner 慎用
Buildah不需要Red Hat 出品,2025 年 1 月进入 CNCF Sandbox,活跃维护;一次运行、无需守护进程,天然适配「一个 job 构建一次」
BuildKit(rootless)不需要Docker 官方构建后端,缓存能力最好、原生支持多架构;以 rootless 方式跑在流水线里
Kaniko不需要已归档(2025-06),存量的还能跑,不建议新项目采用
💡 怎么选:CI 平台提供受管的构建环境时(如 GitLab.com 共享 runner)用 DinD 最省心;一旦要自建 runner,优先 Buildah 或 BuildKit rootless——不值得为了少写几行配置,把宿主机 Docker 暴露给流水线。

制品与供应链 5/7

镜像一旦推到仓库,它就成了你的正式制品。这里有三件事必须定规矩:怎么打标签、怎么清理、怎么验真。

标签策略

标签形式例子用途
git commit shaweb:sha-a1b2c3d唯一可信的身份标识。回滚就回到这个标签,「当前跑的是哪次提交」永远查得到
语义化版本web:1.7.3正式发布版本,给人看、给用户看,配合 release 流程
日期web:2026-09-30辅助标签,方便按时间批量清理
分支名web:main开发 / 预览环境。不要用于生产,它随时会被覆盖
latestweb:latest只当别名,方便「拉最新的」。

清理:不清会出事

每次提交都推一个镜像,一年下来仓库能吃掉几百 GB。三层清理各管一段:

仓库侧:保留策略

GitLab 有「容器仓库清理策略」可按时间/数量定期删 tag;Harbor 有 tag 保留规则;GHCR 支持包版本保留设置。先做这一层,收益最大。

CI 侧:不要无脑全推

分支构建可以不推、或只做短期标签。真正需要长期保留的只有:已发布的版本 tag、以及生产正在用的 sha。

端侧:定期清本地

服务器上跑 docker image prune -a 前先看清楚——这条命令会删掉所有没在运行的镜像,回滚能力当场归零。要清就按时间过滤。

签名与 SBOM:让「这个镜像是我构建的」可验证

能力工具解决什么
签名与验签cosign(Sigstore)证明镜像确实出自你的流水线、且没被替换;部署前可加一道 cosign verify 闸门
SBOM 与漏洞扫描syft + grype、trivy、docker scout产出一份「镜像里到底装了什么」的清单,并按 CVE 数据库比对已知漏洞
构建来源证明BuildKit 的 --provenance=true、GitHub 的 artifact attestation记录「谁、用什么源、在哪次构建里产出」——供应链攻击的第一道追溯依据
💡 这些不必一次全上。优先顺序建议:先做漏洞扫描(能立刻发现基础镜像里的高危组件)→ 再钉基础镜像版本并定期更新 → 最后加签名验签。完整的供应链清单见 安全加固清单。

部署与回滚 6/7

镜像推上去只是做了一半。真正让流水线见效的是最后这一步——怎么让线上换到新镜像,以及怎么退回来。

三条落地路径

路径怎么工作适合注意
SSH + compose pullCI 通过 SSH 登录服务器,执行 docker compose pull && up -d单机 / 少量机器,最直观可控CI 要持有部署私钥——只放 CI secrets,绝不进仓库
Watchtower服务器上跑一个常驻容器,定期检查仓库,发现新镜像自动拉取重建单机、不想配部署环节被动更新:你推什么它就上什么,标签策略必须严格;它还需要挂载 docker.sock(等价于宿主机 root)
kubectl set imageCI 更新 Deployment 的镜像字段,由 K8s 滚动更新已有集群回滚是原生的——见 Kubernetes 入门

路径一:SSH 触发拉取(附完整片段)

  # 接在第 3 节的 build job 之后
  deploy:
    needs: [build]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: 准备 SSH
        run: |
          mkdir -p ~/.ssh
          printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" >> ~/.ssh/known_hosts

      - name: 拉取新镜像并重启
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" '
            cd /opt/myapp &&
            docker compose pull &&
            docker compose up -d &&
            docker compose ps
          '
💡 注意上面用的是 DEPLOY_HOST_KEY 这个 secret,而不是 ssh-keyscan。ssh-keyscan 虽然方便,但它等价于「信任第一次拿到的公钥」,中间人只要在那一次拦到就能长期冒充。把服务器主机公钥固定成一个 secret,是更严谨的做法。

回滚:比发布更该被验证的能力

路径回滚动作耗时
SSH + Compose把 compose 文件里的镜像标签改回上一个 sha,再 up -d(或直接 docker run 指定旧标签)取决于是拉旧镜像还是本地已有
Watchtower不好回滚——它只会往新版本走。要回滚得手工干预并暂停它的检查—
K8skubectl rollout undo deployment/web一次滚动的时间
⚠️ 两条铁律:① 凭据只放 CI secrets,不进仓库、不写进镜像、不留在日志里(注意别 echo 出来);② 部署后必须有健康检查——没有验证的部署等于把「发布成功」交给了运气。curl --retry 打一次 /healthz,失败就让流水线红掉。
💡 顺带说一句本站的做法。这个站的静态部分是「文件同步」而不是「镜像部署」——tools/deploy_site.py 走的是先备份 → md5 校验 → 再覆盖 → 冒烟验证。形式不同,原则是一样的:部署动作必须可回滚、必须能验证。而镜像化的服务(如本站的评论/统计 API)就是标准的「拉取新版本 + 重启 + 查健康」。

最小可用模板与演进清单 7/7

如果第 3 节那份完整配置看起来太重,这里是能跑的 20 行版本。先让它绿,再按需要加能力。

# .github/workflows/docker.yml —— 最小可用版
name: Build and push
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/OWNER/REPO:sha-${{ github.sha }}
⚠️ 这份最小版只写了一个标签:sha-<完整提交哈希>。这是刻意的——它保证「每个产物都可追溯到唯一一次提交」。等你想给人看版本号时,再加 §3 里的 metadata-action 补语义化标签。

按需往上加,不要一次全上

什么时候加加什么对应章节
构建开始变慢cache-from / cache-to 缓存后端第 2 节
团队有人用 Mac 且需要本地跑同一镜像多架构构建(优先原生 arm64 runner)第 2 节
需要给人看的版本号metadata-action 生成 semver 标签第 3 节
仓库体积开始不受控镜像保留策略 + 按 sha 清理第 5 节
有人问「线上跑的是哪个版本」严格标签策略 + 部署时记录 sha第 5 / 6 节
对安全有要求漏洞扫描 → 签名验签第 5 节
想让发布完全自动化部署 job + 健康检查 + 回滚演练第 6 节
💡 最后一条建议:先手动跑通一遍整条链路。本地 docker build → docker push → 到服务器 docker compose pull && up -d → 再改个标签回滚一次。把这五步手工走通,再让流水线去自动化它——否则出了问题,你连「哪一步错了」都不知道。

继续往下走