第 20 章 容器镜像与 Registry:你 kubectl apply 之前的那层

墨:老哥,前面 19 章我们几乎把 K8s 内部拆穿了,但有一个东西一直当黑盒——镜像。你说 image: nginx:1.25,它从哪来、怎么到节点、怎么保证”在我机器上能跑”的咒语被破?这章补这个黑盒。先问:你说镜像”不可变”,那 nginx:latest 和 nginx:1.25 哪个真不可变?


20.1 镜像是”分层文件系统”,不是”一个文件”

墨:你 docker build 打出的镜像,本质是个啥? 是一堆**只读层(layer)**的叠加,最上面是容器的可写层(第 10.1)。每一层是个文件系统的”差量(diff)”。

比如你的 Dockerfile:

FROM golang:1.22          # 层1:基础镜像全部文件
WORKDIR /app
COPY go.mod go.sum ./     # 层2:加俩文件
COPY . .                  # 层3:加源码
RUN go build -o app ./    # 层4:编译出二进制
CMD ["./app"]             # 层5:启动命令

每层只记录”相对上一层变了什么”。最终镜像 = 层1 ∪ 层2 ∪ 层3 ∪ 层4 ∪ 层5 的文件视图。

为什么分层是神设计? 因为层可以被缓存和共享:

  • 你改了源码(层3)重新 build,层1/2(go.mod 没变)直接从缓存复用,不用重拉基础镜像、不用重 COPY go.mod;
  • 两台机器都跑 nginx:1.25,基础层在两边只存一份(节点上 layer 去重);
  • 镜像传网络只传”缺的层”,不是整个镜像——你改了一行代码,push/pull 只传那一层的差量。

墨:这像不像你熟悉的什么? 像 Git 的对象存储(每个 commit 是 tree 的差量)、像第 5 章 etcd 的 MVCC(保留历史版本)、像第 11 章的分片(大对象拆小片传输)。“把大东西拆成可复用/可差量传输的小片”是计算机通用的省空间省带宽套路。 镜像分层是它在”软件分发”上的落地。

20.1.1 为什么这破除了”在我机器上能跑”

经典甩锅:“在我机器上能跑啊!” 根源是环境不一致——你机器有这个库、生产没有;你 Go 1.22 编译、生产用 Go 1.21 跑。容器镜像把”应用 + 它依赖的一切(基础系统、库、配置)“烤进一组层,到处跑都是同一组层 → 环境被冻结在镜像里 → “能跑”可复现。这是容器革命的核心价值,比”轻量”更根本。


20.2 镜像怎么到节点:Registry 与拉取

墨:节点 kubelet 要跑 Pod,镜像从哪拉? 从 Registry(镜像仓库):Docker Hub、Harbor、云厂商的容器 registry(ECR/GCR/ACR)、或私服。流程:

  1. Pod spec 写 image: registry.example.com/myapp:v1.2;
  2. kubelet(经 CRI 运行时)发现节点没有这镜像层 → 向 Registry **拉取(pull)**缺失层;
  3. 层到本地,运行时组装成可写容器的根文件系统;
  4. 启动容器。

墨:如果镜像很大(几 GB),Pod 启动会不会很慢? 会,而且这是”启动延迟”的常见元凶(第 16.10 排障里”镜像拉取慢”)。所以镜像越小启动越快。这引出下一节:怎么让镜像小。


20.3 让镜像小:多阶段构建 + 精简基础

墨:你刚那个 Dockerfile(FROM golang 编译)有个大坑,看出来没? 最终镜像里带着整个 Go 工具链和源码(几 GB),但运行时只需要那个编译好的 app 二进制(几 MB)。这是新手最常见的镜像膨胀。

多阶段构建(multi-stage build) 解决:

FROM golang:1.22 AS build      # 阶段1:编译
WORKDIR /app
COPY . .
RUN go build -o app ./

FROM gcr.io/distroless/base    # 阶段2:极简运行镜像(只有 libc + 证书)
COPY --from=build /app/app /app
CMD ["/app"]

最终镜像只含阶段2(distroless 基础 + 你的二进制),没有 Go 工具链、没有源码、没有包管理器。体积从几 GB 降到几 MB。

墨:为什么用 distroless / alpine 而不是 Ubuntu? 因为运行镜像里你不需要 shell、不需要 apt、不需要一堆系统工具——它们只是攻击面(CVE 更多)和体积。distroless 是”只够跑你的二进制”的最小系统。更小的镜像 = 更快拉取 + 更少漏洞 + 更小爆炸半径。这也是安全(缩小攻击面)和性能(快拉取)的双赢。

演化看点: 早期大家 FROM ubuntu 一把梭,镜像动辄 1GB+。社区演化出 alpine(~5MB 基础)、distroless(Google,极致精简)、chainguard(更安全的 distroless 变体)。趋势:运行镜像从”通用 OS”走向”只为应用定制的最小骨架”。又见”通用→专用、大→小”的演化(和 etcd v2→v3、Dockershim 移除同源的”减负”取向)。


20.4 镜像缓存与”镜像pull 策略”

墨:节点上已经有 myapp:v1.2 了,Pod 重建时会重新拉吗? 看 imagePullPolicy:

  • IfNotPresent(默认,且镜像带 tag 时):本地有就不拉;
  • Always(默认,且镜像用 :latest 或 digest 时):每次都拉;
  • Never:只用本地,不拉(离线/测试)。

⚠️ 坑: 用 :latest 标签 → 默认 Always 拉取 → 每次 Pod 起都去 Registry 拉一次,慢且 Registry 压力大;更糟的是 :latest 没有固定指向(今天 latest 是 v1.2,明天是 v1.3),你不知道节点跑的是哪个——破坏”不可变”(第 12.3 提过)。生产铁律:用固定版本号或 digest,别用 :latest。

20.4.1 节点级镜像缓存与预热

大集群里”新节点起来、要拉几百个镜像”会很慢。优化:

  • 镜像预热(pre-pull):节点加入集群时 DaemonSet 预先拉常用镜像;
  • 节点池常驻:同类负载放固定节点池,镜像已在本地;
  • Registry 就近/CDN:Registry 多副本或走 CDN,减少跨区拉取延迟;
  • kubelet 镜像 GC:节点磁盘满时 kubelet 删不用的镜像(第 15.7 磁盘压力驱逐相关)。

20.5 镜像签名与供应链安全(接第 13.7)

墨:你 image: myapp:v1.2 从 Registry 拉下来,你怎么确定它没被篡改、真是你团队打的? 你不知道——除非签名。

演化(Sigstore/cosign): 用 cosign 给镜像打数字签名(基于密钥/密钥less 的 OIDC 流程)。签名存到 Registry 关联的”签名存储”。然后**准入 webhook(如 Connaisseur、Kyverno、或 Sigstore 的 policy controller)**在 Pod 创建时校验:“这镜像有合法签名吗?” 没有就拒绝(第 13.7、14.4)。

为什么这是”供应链安全”的关键? 因为攻击不一定打你的集群,可能打构建/分发链:攻击者篡改基础镜像(如某热门开源镜像被投毒)、或在 Registry 里替换你的镜像。签名 + 准入校验把”镜像来源可信”在进入集群的门口卡死。这是零信任(zero trust)在”软件供应链”上的应用——不信任任何未签名的镜像,即使它来自你的 Registry。

演化看点: 这能力和 K8s 核心无关,K8s 只提供”准入 webhook”这个钩子(第 4.5),具体签名/校验由 Sigstore 生态做。又是”核心给钩子、安全能力由生态补”的模式(和第 4 章 webhook、第 12 章外置密钥、第 9 章 CNI 同构)。K8s 的设计哲学再次立功:它不用自己造安全,它把”卡点”开放给你。


20.6 镜像的”不可变”到底指什么(回答开头问题)

回到开头:nginx:latest vs nginx:1.25,谁不可变?

  • nginx:1.25 这个 tag 指向的镜像内容是不可变的(一旦 push,层就定死,除非你强制覆盖 tag——Registry 通常允许覆盖 tag,所以 tag 本身不是铁律);
  • nginx:latest 是”浮动 tag”:维护者可以随时把 latest 指向新镜像,今天 latest=v1.25 明天=v1.26。所以 :latest 不保证”你跑的就是昨天那个”;
  • 最不可变的是 digest:nginx@sha256:abc... 锁定具体镜像内容(digest 是层内容的哈希),任何改动 digest 都变。所以严格不可变用 digest。

墨:那生产该用 tag 还是 digest? 折中:CI 里用 digest 锁定(构建时解析出 digest,确保精确),部署清单里用固定版本 tag(人类可读、易管理),或把 digest 写进清单(最稳但可读性差)。关键是别用 :latest——它既不人类可读也不精确,两头不靠。


20.7 一个容易被忽略的点:镜像里的 Secret

墨:有人把数据库密码 ENV DB_PASS=xxx 烤进 Dockerfile 的 ENV,行不行? 大忌。ENV 会烧进镜像层,任何人拿到镜像 docker inspect 或 history 都能看到密码。而且改密码要重新 build(第 12.1 配置写死的同源问题)。

正确:密码走 Secret(第 12 章) 注入运行时(env 或 volume),绝不进镜像。镜像只含”代码+依赖”,不含”机密”。这也是为何 ConfigMap/Secret 要外挂——它们本质是把”会变/会密”的东西从”不可变的镜像”里剥出来(第 12.1 的边界)。


20.8 本章演化线小结

  • 镜像是分层只读文件系统,层可缓存/共享/差量传输(通用”拆小片”套路);
  • 多阶段构建 + distroless/alpine 让运行镜像从”通用 OS”→“最小骨架”,快拉取+小攻击面;
  • :latest 浮动不精确,生产用固定 tag 或 digest;节点镜像缓存/预热加速启动;
  • 供应链安全:cosign 签名 + 准入 webhook 校验,零信任进集群门口;
  • 镜像不可变指内容定死,digest 最精确;Secret 绝不进镜像(外挂);
  • 签名能力靠 K8s webhook 钩子 + Sigstore 生态,核心不造安全。

20.9 本章思考题

🔧 思考题 1

你的 Dockerfile 是 FROM ubuntu + apt install golang + 编译 + 跑。镜像 1.2GB。请改写为多阶段 + 精简基础,说明每层为什么能去掉,并估计最终体积量级与启动加速逻辑。

【参考答案】 改写:FROM golang:1.22 AS build 编译出二进制;FROM gcr.io/distroless/base 只 COPY 二进制。去掉的层:(1) ubuntu 基础系统(~80MB,运行不需要 shell/apt)→ 用 distroless(~20MB 仅 libc+证书);(2) golang 工具链(~300MB+,只编译时需要)→ 放 build 阶段,运行阶段不带;(3) 源码(运行不需要)→ 只 COPY 编译产物。最终镜像 ≈ 二进制(几 MB) + distroless(~20MB) = 约 20-30MB,从 1.2GB 降到 ~25MB(缩小 ~50 倍)。启动加速:节点 pull 只需拉 25MB 而非 1.2GB,网络传输 ~50 倍快;且镜像层少、解层快;磁盘占用小,节点能存更多镜像、GC 少触发。这题练你把”编译时依赖”和”运行时依赖”分离——这是镜像优化的第一性原理,也是第 10.1 “配置/代码分离”思想在镜像层的体现。

🔧 思考题 2

你说 :latest 会被准入/webhook 怎么处理?假设你用 Kyverno 策略”禁止 :latest 镜像”。一个 Pod 用了 myapp:latest 会被怎样?如果攻击者把恶意镜像也打成 myapp:latest 推到 Registry 并覆盖原 tag,签名校验能拦吗?分”有签名校验”和”无签名校验”两种答。

【参考答案】 (1) Kyverno 策略”禁止 :latest”是 validating webhook:Pod 创建请求带 image: myapp:latest → Kyverno 校验失败 → 拒绝创建(返回 403 类错误,describe 看 Events 有 Kyverno 拒绝原因)。这是”防误用 :latest”的姿势。(2) 攻击者覆盖 tag 推恶意镜像:若无签名校验,Registry 接受覆盖、kubelet 拉到恶意层、Pod 跑恶意代码——:latest 浮动 + 无签名 = 完全暴露。(3) 若有 cosign 签名 + 准入校验:攻击者推的恶意 myapp:latest 没有合法签名(他没你的私钥)→ 准入 webhook 校验失败 → 拒绝创建。即使他覆盖了 tag,也进不了集群。结论::latest 的问题(浮动、不精确)靠”禁止 :latest 策略”防误用;但”篡改/投毒”靠签名校验防恶意——两者互补:前者管规范、后者管安全。只禁 :latest 不签名,挡得住手滑挡不住攻击;只签名不禁 :latest,攻击者可推带签名的恶意 :latest(若他有密钥则另说)。所以生产要”固定 tag/digest + 签名校验”双保险。这题把第 12/13/20.5 串成镜像安全闭环。

🔧 思考题 3

节点磁盘满触发 kubelet 镜像 GC(删不用的镜像)。如果 GC 删了个”当前没跑但马上要调度过来”的镜像,会发生什么?怎么避免节点频繁”拉了删、删了拉”的抖动?

【参考答案】 现象:节点磁盘压力(第 15.7)触发 kubelet 按 LRU 删”最久未用”镜像。若删了某镜像、紧接着调度器把用该镜像的 Pod 调来 → kubelet 重新 pull(慢、且磁盘压力可能还在)→ 拉完跑、过会儿又被 GC 删 → 抖动。避免:(1) 合理设镜像 GC 阈值:kubelet 的 imageGCHighThreshold/LowThreshold(默认 85%/80%)给足空间,别太激进;(2) 节点池亲和:同类负载(都用同镜像)放固定节点池,镜像常驻不被 GC(第 20.4.1);(3) 镜像预热 DaemonSet:节点加常驻常用镜像,GC 一般不删”正在被 Pod 用的”(kubelet 只删没被引用的);(4) 磁盘扩容/独立盘:镜像盘和容器根盘分离,避免互相挤;(5) 降节点密度:单节点别塞太多异构镜像的 Pod。根因:镜像 GC 和调度缺协同——kubelet 不知道”即将调度来”的需求。折中是”留余量 + 预热 + 池亲和”三招。这题考你理解”节点资源(磁盘)和调度(镜像需求)的隐性耦合”,和第 6/15 章的资源协调一脉相承。

🔧 思考题 4(进阶)

OCI 镜像规范现在不止放”容器文件系统”,还能放”镜像签名、SBOM、甚至 WASM 模块”。从”镜像作为通用制品分发格式”的角度,评 OCI Artifact 的演化意义,并对比”K8s 把所有东西都变对象”和”OCI 把所有东西都变 artifact”的相似哲学。

【参考答案】 OCI 原本只定义”容器镜像格式”,演化出 OCI Artifact(任意制品:helm chart、SBOM、签名、WASM、甚至非容器文件)都可存 Registry、用同套分发。意义:(1) 统一分发通道:不用为 chart/SBOM/签名各建一套存储分发,复用 Registry(已全球部署、有缓存/CDN/鉴权);(2) 供应链可追溯:SBOM(软件物料清单)+ 签名都挂镜像,审计/合规可查;(3) WASM 等新型负载:WASM 模块也能当 OCI artifact 分发,容器不是唯一运行时。对比 K8s:K8s 把”一切集群状态”建模成”对象”(Pod/CRD/…),统一存 etcd、统一 watch、统一 RBAC;OCI 把”一切软件制品”建模成”artifact”,统一存 Registry、统一 pull、统一签名。两者哲学同构:用”一个统一的元模型 + 一套统一机制”替代”每类东西各一套系统”。K8s 的元模型是”声明式对象+控制循环”,OCI 的元模型是”内容寻址 artifact+分层分发”。你做平台时也该想:我的领域能不能抽象成”一种统一实体 + 一套机制”?能,就赢了(扩展性与生态都受益)。这是全书”统一抽象”哲学(18.2/14.7)在软件分发领域的回响。


20.10 小结

  • 镜像=分层只读文件系统,层可缓存/共享/差量传输(通用”拆小片”套路);
  • 多阶段构建 + distroless/alpine 让运行镜像最小化,快拉取 + 小攻击面;
  • :latest 浮动不精确,生产用固定 tag/digest,禁 :latest;节点缓存/预热加速;
  • 供应链安全:cosign 签名 + 准入 webhook 校验,进集群门口零信任;
  • 镜像内容不可变,digest 最精确;Secret 绝不进镜像;
  • OCI Artifact 把”一切制品”统一分发,与 K8s”一切变对象”哲学同构。

下一章讲服务网格 Istio——它和 K8s 的 Service/NetworkPolicy 是什么关系?是替代还是互补?为什么它用 sidecar(第 8.8)?这章把”K8s 原生流量治理”和”网格增强”的边界划清。