一句话总结
在云原生场景里,流水线的核心产物就是容器镜像;
这一篇讲怎么把镜像构建得又小又快又可追溯--多阶段构建瘦身,BuildKit/Kaniko 提速,制品仓库存放,Tag 策略保证"部署的就是测试过的那个".
前置回顾
第 01 篇提到云原生的制品就是容器镜像,并立了一条铁律:一次构建,到处部署.第 02 篇我们在流水线里留了一个 build Job.本篇就是把那个 Job 填满:它到底该怎么构建镜像,构建出的镜像放哪,怎么命名才能让"一次构建到处部署"真正成立.
先看一个"能跑但很糟"的 Dockerfile
很多人第一次写后端镜像是这样的(以 Go 为例):
|
它能跑,但有个致命问题:最终镜像里塞进了整个 Go 编译工具链--编译器,源码,依赖缓存,全打进去了.可运行时根本不需要这些,你要的只是那个编译好的 server 二进制.结果就是一个 ~800MB 的镜像,真正有用的可能就 15MB.
镜像大有什么坏处?拉取慢(每次部署都要传),攻击面大(多一个工具就多一处漏洞),存储贵.这就引出第一个核心技术.
多阶段构建:把"构建"和"运行"分开
多阶段构建(Multi-stage Build)的思路:用一个"重"镜像负责编译,再把编译产物只拷贝 到一个"轻"镜像里运行,编译环境直接丢弃.
|
关键就是 COPY --from=builder:最终镜像基于轻量的 alpine,只装了一个二进制文件.结果从 ~800MB 降到 ~15MB.
多阶段构建像搬家:打包阶段你需要一屋子的工具(剪刀,胶带,纸箱);但搬到新家(运行环境),你只带打包好的箱子,那些工具留在旧居丢掉.镜像里不该有"搬家工具".
运行镜像越小越安全
更进一步,可以用 distroless 或 scratch(空镜像)作为运行阶段基础--里面连 shell 都没有.没有 shell,没有包管理器,攻击者即使进入容器也"无从下手".这是第 04 篇"减少攻击面"思路在镜像层面的体现.
理解分层与构建缓存:为什么调整顺序能提速
Docker 镜像是分层(Layer)的:Dockerfile 里每条指令生成一层,层层叠加.构建时,Docker 会缓存每一层;只要某条指令和它之前的内容没变,就直接复用缓存层,不重新执行.
关键规则:一旦某一层变了,它后面的所有层缓存全部失效.这直接决定了指令顺序的优劣.看上面 Dockerfile 里这个刻意的安排:
|
为什么不直接 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:
|
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 下来.
|
| 仓库 | 定位 |
|---|---|
| Docker Hub | 最大的公共仓库,适合开源/公开镜像 |
| GHCR(GitHub Container Registry) | 和 GitHub 仓库无缝集成,私有项目首选 |
| 云厂商(ECR / GCR / ACR) | 和对应云的 K8s,权限体系打通 |
| Harbor | 可私有部署,带漏洞扫描,签名,权限,镜像复制,企业内网常用 |
仓库不只是"存储",好的制品仓库还承担漏洞扫描,镜像签名,访问控制 等职责--这些正是第 04 篇的主题,届时会看到 Harbor 如何在仓库层面充当一道安全门.
Tag 策略:让"一次构建,到处部署"真正成立
这是本篇最重要,也最容易被忽视的一节.镜像通过 名称:tag 来标识,比如 myapp:v1.2.0.怎么打 tag,直接决定了你的交付可不可信.
为什么不能只用 latest
|
latest 是一个会被覆盖的,漂移的 标签.用它部署,等于直接违反第 01 篇的"一次构建,到处部署"--你根本无法保证生产跑的就是测试验证过的那个制品.
生产环境严禁用 latest 部署
用 latest 部署会导致:无法回滚(不知道上一个 latest 是什么),无法复现问题(不知道线上跑的是哪次构建),环境间不一致.这是生产事故的经典源头.
用不可变 tag:把镜像和提交绑定
正确做法是给每次构建打一个**唯一且不可变(Immutable)**的 tag,最常用的就是 Git commit SHA:
|
这样一来,镜像和源码提交一一对应,可追溯:线上跑的是 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 填完整了,它综合了本篇所有要点:
|
到这里,流水线已经能稳定产出小巧,可追溯,缓存加速的镜像并推到仓库.但还差最后一道关:这个镜像质量够格,安全可信 吗?这正是第 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 保证可追溯,版本号方便沟通.
动手练习
- 对比镜像体积:给一个项目写一个单阶段 Dockerfile,
docker build后用docker images看体积;再改成多阶段,对比前后大小. - 验证缓存失效:故意把
COPY . .放到RUN 下载依赖之前,改一行代码重新构建,观察依赖层缓存是否失效,构建变慢;再调回正确顺序对比. - 推送到 GHCR:把镜像推送到 GHCR,分别用
:latest和:<commit-sha>各打一个 tag,在仓库页面观察两者的区别. - 验证不可变性:再推一次(改点内容),确认
latest被覆盖,而旧的 SHA tag 依然存在且内容不变--亲手验证"不可变"的含义.