提交 → 构建 → 推送 → 部署 → 回滚,一条链路讲完
不管用哪个平台,一条容器化流水线的骨架都一样:
同一个镜像 digest 永远对应同一份内容。不像「往服务器上传一堆文件」,传一半失败就处于未知状态。
测试环境验过的那个镜像,就是上生产的那一个。测试通过但线上行为不同——这类问题从根上被消掉。
旧镜像还在仓库里,回滚 = 让线上重新拉旧标签。不需要「反向改代码再发一次」。
镜像是有明确标识的制品,因此可以签名验真、可以在上线前扫漏洞。交付物有了身份,安全策略才有落点。
latest 是反模式。它不指向任何确定的东西——今天推的和昨天推的代表两套代码,回滚时你不知道该回到哪,出问题也无法复现。latest 可以用,但只能当「最近一次构建」的别名,绝不能作为「当前跑的是哪个版本」的判断依据。真正标识一个版本的,是 git commit sha。Dockerfile 层面的技巧(层顺序、多阶段、镜像瘦身)在 Dockerfile 最佳实践 里已经讲过,这里只补 CI 特有的三个问题。
你在自己机器上享受的层缓存,在 CI 里不存在——每个 job 都是崭新的一次。所以必须把缓存存到外面:
| 缓存后端 | 写法 | 适合 |
|---|---|---|
| GitHub Actions 缓存 | cache-from: type=gha / cache-to: type=gha,mode=max | GitHub Actions,零额外配置 |
| 镜像仓库缓存 | cache-from: type=registry,ref=仓库:buildcache | GitLab CI、自建 runner、跨平台通用 |
| 本地目录缓存 | cache-from: type=local,src=... | 自建 runner 且磁盘持久(配 actions/cache 使用) |
cache-to ... ,mode=max 会把所有中间层都缓存下来(不只是最终镜像的层),多阶段构建能明显受益——代价是缓存体积更大、上传更慢。构建慢的大项目值得,小项目不值。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 .
ARG 或 ENV 传进构建。ARG 会出现在镜像历史里,ENV 会留在最终镜像中,两者都会泄露。要么用 --secret,要么用 --mount=type=ssh。要同时产出 linux/amd64 和 linux/arm64,最常见的是用 QEMU 模拟另一种架构。它能用,但对编译型语言可能是数倍的时间开销——因为编译器本身也在被模拟执行。所以:
| 方式 | 做法 | 取舍 |
|---|---|---|
| QEMU 模拟 | docker/setup-qemu-action + --platform linux/amd64,linux/arm64 | 零成本、一条命令出双架构;构建时间显著变长,解释型语言尚可,编译型语言慎用 |
| 原生 runner | 在 arm64 机器上分别构建、再合并 manifest | 快得多,但需要维护两套 runner;CI 平台提供 arm64 runner 时优先选它 |
| 不做多架构 | 只发布 amd64 | 如果你的部署环境全是 x86,就别做——多架构是成本,不是勋章 |
下面是一个完整可用的工作流:多架构构建、推送到 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 |
act_runner),YAML 基本照搬;阿里云效流水线是可视化编排(也支持 flow.yml),把「构建 → 推送 → 部署」三个步骤拖出来即可,宿主换成云效的构建集群。阶段划分不变,变的只是配置文件格式。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
上面的 docker:dind 是最省事的写法,但它要求 runner 开启 privileged——等于把宿主机的 Docker 交给了流水线,任何能改 CI 配置的人都能借此拿到宿主机权限。自建 runner 上尤其要谨慎。
| 方案 | 需要特权 | 状态与取舍 |
|---|---|---|
docker:dind | 需要 | 官方文档默认方案,配置最简单;但特权容器的安全面最大,自建 runner 慎用 |
| Buildah | 不需要 | Red Hat 出品,2025 年 1 月进入 CNCF Sandbox,活跃维护;一次运行、无需守护进程,天然适配「一个 job 构建一次」 |
| BuildKit(rootless) | 不需要 | Docker 官方构建后端,缓存能力最好、原生支持多架构;以 rootless 方式跑在流水线里 |
Kaniko | 不需要 | 已归档(2025-06),存量的还能跑,不建议新项目采用 |
镜像一旦推到仓库,它就成了你的正式制品。这里有三件事必须定规矩:怎么打标签、怎么清理、怎么验真。
| 标签形式 | 例子 | 用途 |
|---|---|---|
| git commit sha | web:sha-a1b2c3d | 唯一可信的身份标识。回滚就回到这个标签,「当前跑的是哪次提交」永远查得到 |
| 语义化版本 | web:1.7.3 | 正式发布版本,给人看、给用户看,配合 release 流程 |
| 日期 | web:2026-09-30 | 辅助标签,方便按时间批量清理 |
| 分支名 | web:main | 开发 / 预览环境。不要用于生产,它随时会被覆盖 |
latest | web:latest | 只当别名,方便「拉最新的」。 |
每次提交都推一个镜像,一年下来仓库能吃掉几百 GB。三层清理各管一段:
GitLab 有「容器仓库清理策略」可按时间/数量定期删 tag;Harbor 有 tag 保留规则;GHCR 支持包版本保留设置。先做这一层,收益最大。
分支构建可以不推、或只做短期标签。真正需要长期保留的只有:已发布的版本 tag、以及生产正在用的 sha。
服务器上跑 docker image prune -a 前先看清楚——这条命令会删掉所有没在运行的镜像,回滚能力当场归零。要清就按时间过滤。
| 能力 | 工具 | 解决什么 |
|---|---|---|
| 签名与验签 | cosign(Sigstore) | 证明镜像确实出自你的流水线、且没被替换;部署前可加一道 cosign verify 闸门 |
| SBOM 与漏洞扫描 | syft + grype、trivy、docker scout | 产出一份「镜像里到底装了什么」的清单,并按 CVE 数据库比对已知漏洞 |
| 构建来源证明 | BuildKit 的 --provenance=true、GitHub 的 artifact attestation | 记录「谁、用什么源、在哪次构建里产出」——供应链攻击的第一道追溯依据 |
镜像推上去只是做了一半。真正让流水线见效的是最后这一步——怎么让线上换到新镜像,以及怎么退回来。
| 路径 | 怎么工作 | 适合 | 注意 |
|---|---|---|---|
SSH + compose pull | CI 通过 SSH 登录服务器,执行 docker compose pull && up -d | 单机 / 少量机器,最直观可控 | CI 要持有部署私钥——只放 CI secrets,绝不进仓库 |
| Watchtower | 服务器上跑一个常驻容器,定期检查仓库,发现新镜像自动拉取重建 | 单机、不想配部署环节 | 被动更新:你推什么它就上什么,标签策略必须严格;它还需要挂载 docker.sock(等价于宿主机 root) |
kubectl set image | CI 更新 Deployment 的镜像字段,由 K8s 滚动更新 | 已有集群 | 回滚是原生的——见 Kubernetes 入门 |
# 接在第 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 | 不好回滚——它只会往新版本走。要回滚得手工干预并暂停它的检查 | — |
| K8s | kubectl rollout undo deployment/web | 一次滚动的时间 |
echo 出来);② 部署后必须有健康检查——没有验证的部署等于把「发布成功」交给了运气。curl --retry 打一次 /healthz,失败就让流水线红掉。tools/deploy_site.py 走的是先备份 → md5 校验 → 再覆盖 → 冒烟验证。形式不同,原则是一样的:部署动作必须可回滚、必须能验证。而镜像化的服务(如本站的评论/统计 API)就是标准的「拉取新版本 + 重启 + 查健康」。如果第 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 → 再改个标签回滚一次。把这五步手工走通,再让流水线去自动化它——否则出了问题,你连「哪一步错了」都不知道。