阶段一 · CI 流水线与制品

镜像构建与制品管理

一句话总结

在云原生场景里,流水线的核心产物就是容器镜像;
这一篇讲怎么把镜像构建得又小又快又可追溯--多阶段构建瘦身,BuildKit/Kaniko 提速,制品仓库存放,Tag 策略保证"部署的就是测试过的那个".

前置回顾

第 01 篇提到云原生的制品就是容器镜像,并立了一条铁律:一次构建,到处部署.第 02 篇我们在流水线里留了一个 build Job.本篇就是把那个 Job 填满:它到底该怎么构建镜像,构建出的镜像放哪,怎么命名才能让"一次构建到处部署"真正成立.

先看一个"能跑但很糟"的 Dockerfile

很多人第一次写后端镜像是这样的(以 Go 为例):

FROM golang:1.22          # 完整的 Go 工具链镜像,约 800MB
WORKDIR /app
COPY . .
RUN go build -o server . # 编译
CMD ["./server"]

它能跑,但有个致命问题:最终镜像里塞进了整个 Go 编译工具链--编译器,源码,依赖缓存,全打进去了.可运行时根本不需要这些,你要的只是那个编译好的 server 二进制.结果就是一个 ~800MB 的镜像,真正有用的可能就 15MB.

镜像大有什么坏处?拉取慢(每次部署都要传),攻击面大(多一个工具就多一处漏洞),存储贵.这就引出第一个核心技术.

多阶段构建:把"构建"和"运行"分开

多阶段构建(Multi-stage Build)的思路:用一个"重"镜像负责编译,再把编译产物只拷贝 到一个"轻"镜像里运行,编译环境直接丢弃.

# ───── 阶段 1:构建(用完即弃)─────
FROM golang:1.22 AS builder # 给这个阶段起名 builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download # 先单独下依赖(配合缓存,见下文)
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# ───── 阶段 2:运行(最终镜像)─────
FROM alpine:3.20 # 极简基础镜像,约 8MB
WORKDIR /app
COPY --from=builder /app/server . # ← 只从 builder 阶段拷贝那个二进制
CMD ["./server"]

关键就是 COPY --from=builder:最终镜像基于轻量的 alpine,只装了一个二进制文件.结果从 ~800MB 降到 ~15MB.

多阶段构建像搬家:打包阶段你需要一屋子的工具(剪刀,胶带,纸箱);但搬到新家(运行环境),你只带打包好的箱子,那些工具留在旧居丢掉.镜像里不该有"搬家工具".

运行镜像越小越安全

更进一步,可以用 distrolessscratch(空镜像)作为运行阶段基础--里面连 shell 都没有.没有 shell,没有包管理器,攻击者即使进入容器也"无从下手".这是第 04 篇"减少攻击面"思路在镜像层面的体现.

理解分层与构建缓存:为什么调整顺序能提速

Docker 镜像是分层(Layer)的:Dockerfile 里每条指令生成一层,层层叠加.构建时,Docker 会缓存每一层;只要某条指令和它之前的内容没变,就直接复用缓存层,不重新执行.

关键规则:一旦某一层变了,它后面的所有层缓存全部失效.这直接决定了指令顺序的优劣.看上面 Dockerfile 里这个刻意的安排:

COPY go.mod go.sum ./    # 1. 先只拷贝依赖清单
RUN go mod download # 2. 下载依赖 -- 只要清单没变,这层就一直命中缓存
COPY . . # 3. 再拷贝全部源码
RUN go build -o server . # 4. 编译

为什么不直接 COPY . . 再 download?因为源码几乎每次提交都在变,而依赖清单很少变.把"易变的源码拷贝"放在"下载依赖"之后,就能让耗时的依赖下载层长期命中缓存--你改一行业务代码,只有 3、4 重跑,1、2 秒过.

这是初学者最常踩的提速坑

"把不常变的放前面,常变的放后面"是写 Dockerfile 的黄金法则.如果你把 COPY . . 写在最前面,那么每次改任何一行代码都会让后面所有层(包括漫长的依赖下载)缓存失效,每次构建都慢得像第一次.这与第 02 篇 GitHub Actions 的依赖缓存是同一个道理:缓存的命中取决于"输入变没变".

在流水线里构建:BuildKit 与 Kaniko

本地 docker build 你熟.但在 CI 流水线(尤其跑在 K8s 上的 Runner)里构建镜像,会遇到新问题,催生了两种工具.

BuildKit:更快的现代构建引擎

BuildKit 是 Docker 官方的新一代构建引擎(现在 docker build 默认就用它).相比老引擎,它的优势:

  • 并行构建:多阶段之间没有依赖的阶段可以并行跑,而非死板地从上到下
  • 更强的缓存:支持把缓存导出到镜像仓库,让不同 Runner 之间共享构建缓存(回应第 02 篇"每个 Job 是全新机器"的痛点)
  • 缓存挂载:RUN --mount=type=cache 可以缓存依赖目录,跨构建复用

在 GitHub Actions 里,用官方的 docker/build-push-action 就自动启用了 BuildKit:

- uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/myapp:${{ github.sha }}
cache-from: type=gha # 从 GitHub Actions 缓存读构建缓存
cache-to: type=gha,mode=max # 把构建缓存写回去,下次复用

Kaniko:在容器里安全地构建容器

有一个经典难题:CI 的 Runner 本身常常是一个跑在 Kubernetes 里的容器,现在要在这个容器里 docker build 出另一个镜像.传统 docker build 需要访问宿主机的 Docker 守护进程(Docker daemon),这要求容器拥有近乎宿主机 root 的特权(挂载 docker.sock 或开特权模式)--这是个巨大的安全隐患:一旦该容器被攻破,等于攻破了整个节点.

Kaniko(Google 出品)解决这个问题:它不需要 Docker daemon,不需要特权,在一个普通容器内就能解析 Dockerfile,逐层构建并推送到仓库.

怎么选?

简单记:
GitHub 托管 Runner / 有 daemon 的环境 → 用 BuildKit(build-push-action),最快最省心.
构建跑在 Kubernetes 集群里,不想给特权 → 用 Kaniko,以安全换取一点速度.
核心矛盾是:"在容器里造容器"既要能力又不能给过大权限.

制品仓库:镜像造好了放哪

镜像构建出来,需要一个集中的地方存放和分发,这就是镜像仓库(Image Registry),也叫制品仓库.流水线把镜像 push 上去,部署时集群再 pull 下来.

CI 流水线 ──push──▶  [ 镜像仓库 ]  ◀──pull──  Kubernetes 集群
Harbor / GHCR /
Docker Hub / ECR ...
仓库 定位
Docker Hub 最大的公共仓库,适合开源/公开镜像
GHCR(GitHub Container Registry) 和 GitHub 仓库无缝集成,私有项目首选
云厂商(ECR / GCR / ACR) 和对应云的 K8s,权限体系打通
Harbor 可私有部署,带漏洞扫描,签名,权限,镜像复制,企业内网常用

仓库不只是"存储",好的制品仓库还承担漏洞扫描,镜像签名,访问控制 等职责--这些正是第 04 篇的主题,届时会看到 Harbor 如何在仓库层面充当一道安全门.

Tag 策略:让"一次构建,到处部署"真正成立

这是本篇最重要,也最容易被忽视的一节.镜像通过 名称:tag 来标识,比如 myapp:v1.2.0.怎么打 tag,直接决定了你的交付可不可信.

为什么不能只用 latest

周一:构建并推送 myapp:latest         (内容是 commit A)
周三:又构建推送 myapp:latest (内容变成 commit C,latest 被覆盖!)

部署时写 image: myapp:latest:
- "latest" 到底指向哪个版本?取决于你哪天拉的 -- 不确定!
- 测试环境周一拉的 latest(A),生产周三拉的 latest(C)→ 跑的根本不是同一个东西

latest 是一个会被覆盖的,漂移的 标签.用它部署,等于直接违反第 01 篇的"一次构建,到处部署"--你根本无法保证生产跑的就是测试验证过的那个制品.

生产环境严禁用 latest 部署

latest 部署会导致:无法回滚(不知道上一个 latest 是什么),无法复现问题(不知道线上跑的是哪次构建),环境间不一致.这是生产事故的经典源头.

用不可变 tag:把镜像和提交绑定

正确做法是给每次构建打一个**唯一且不可变(Immutable)**的 tag,最常用的就是 Git commit SHA:

# 第 02 篇见过的写法:用本次提交的 SHA 当 tag
tags: registry.example.com/myapp:${{ github.sha }}
# → myapp:a1b2c3d... 每次提交对应唯一镜像,永不覆盖

这样一来,镜像和源码提交一一对应,可追溯:线上跑的是 myapp:a1b2c3d,你立刻就能 git checkout a1b2c3d 看到精确的源码;回滚就是把部署改回上一个 SHA.常见的组合 tag 策略:

Tag 可变? 用途
a1b2c3d(commit SHA) 不可变 部署用这个,精确可追溯
v1.2.0(语义化版本) 不可变 正式发版,给人看的版本号
latest / main 可变 仅供"随手拉最新"的便利,不用于生产部署

latest 像门牌号"老王家"--今天住老王,明天可能搬来老李,指代不稳定.commit SHA 像身份证号--全局唯一,终身绑定一个人,你说哪个号,就精确指向那一个,永不混淆.

把第 02,03 篇连起来:完整的构建 Job

现在可以把第 02 篇那个空的 build Job 填完整了,它综合了本篇所有要点:

build:
needs: test # 02 篇:测试通过才构建(快速失败)
runs-on: ubuntu-latest
permissions:
id-token: write # 02 篇:用 OIDC,免长期密钥
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v6
with:
push: true
# 03 篇:用不可变的 commit SHA 当 tag
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha # 03 篇:复用 BuildKit 构建缓存
cache-to: type=gha,mode=max
# Dockerfile 内部用多阶段构建瘦身(03 篇)

到这里,流水线已经能稳定产出小巧,可追溯,缓存加速的镜像并推到仓库.但还差最后一道关:这个镜像质量够格,安全可信 吗?这正是第 04 篇要装上的门禁.

快速回顾

  • 多阶段构建:构建阶段编译,运行阶段只拷贝产物,镜像从几百 MB 降到几十 MB,更小更安全
  • 分层缓存:某层变则其后所有层缓存失效;把"不常变的依赖下载"放在"常变的源码拷贝"之前以长期命中缓存
  • BuildKit(有 daemon,求快)与 Kaniko(容器内,无特权,求安全)是两种流水线构建方案
  • 制品仓库(GHCR / Harbor 等)集中存放分发镜像,好的仓库还兼具扫描,签名,权限
  • Tag 策略是关键:生产严禁用会漂移的 latest;用 commit SHA 等不可变 tag,让镜像与提交一一对应,落实"一次构建,到处部署"

多阶段构建里,前面的 builder 阶段会被打进最终镜像吗?

不会.最终镜像只包含最后一个 FROM 阶段 的内容,加上你用 COPY --from 显式拷贝进来的文件.builder 阶段只在构建时存在,构建完就丢弃,不占最终镜像一丁点体积.

既然 commit SHA 这么好,那语义化版本号 v1.2.0 还有什么用?

两者面向不同对象.SHA 是给机器和精确追溯用的(部署,回滚,定位),但人记不住也读不懂 a1b2c3d 代表什么.v1.2.0 是给 用的--表达"这是第几个正式版本,兼容性如何".实践中常给同一个镜像同时打两种 tag:SHA 保证可追溯,版本号方便沟通.

动手练习

  1. 对比镜像体积:给一个项目写一个单阶段 Dockerfile,docker build 后用 docker images 看体积;再改成多阶段,对比前后大小.
  2. 验证缓存失效:故意把 COPY . . 放到 RUN 下载依赖 之前,改一行代码重新构建,观察依赖层缓存是否失效,构建变慢;再调回正确顺序对比.
  3. 推送到 GHCR:把镜像推送到 GHCR,分别用 :latest:<commit-sha> 各打一个 tag,在仓库页面观察两者的区别.
  4. 验证不可变性:再推一次(改点内容),确认 latest 被覆盖,而旧的 SHA tag 依然存在且内容不变--亲手验证"不可变"的含义.