54.2 镜像与仓库
一句话
镜像仓库不是网盘,它是制品的分发与信任边界:决定谁能推、谁在拉、拉到的到底是哪份字节;tag 是给人看的别名,digest 才是给机器用的身份。
先破除四个误区
| 误区 | 现实 |
|---|---|
“用 latest 最省事” | latest 永远指向“最后一次推送”。事故时你想回到上一版,它已经指向了新版本——没有旧版本可回 |
| “私有仓库就等于安全” | 内网 Harbor 照样可能开匿名拉取、用弱口令、发永不过期的 CI token。安全来自鉴权 + 签名 + 扫描,不来自“它在内网” |
| “删了 tag 就释放空间” | tag 只是指针,blob 仍被 manifest 引用。不跑 GC 空间不回收;GC 跑错会把正在运行的镜像删掉 |
| “镜像越小越好” | 小是手段,不是目标。层复用率、拉取并发、可调试性(有没有 shell、有没有排查工具)同样是成本 |
一个镜像的四个身份
| 对象 | 是什么 | 什么时候变 |
|---|---|---|
| tag | 人类别名,指向一个 manifest(或 index) | 任何人 push 同名 tag |
| manifest | 单平台清单:config + 一组 layer | 任何一层或 config 变 |
| index(manifest list) | 多架构入口,指向多个 manifest | 增加/删除一个架构 |
| digest | sha256(manifest 字节) | 只有内容变才变 |
tag: 1.2.0 ──▶ index(sha256:d9e8…)
├──▶ manifest amd64(sha256:c64c…)
├──▶ manifest arm64(sha256:45e0…) ──┬─ config(sha256:ab3f…, 628 B)
│ └─ layer (sha256:3f26…, 4,092,319 B)
└──▶ manifest s390x(sha256:856b…)
镜像里没有内核,只有 rootfs 和依赖(原因见 6.1 容器与虚拟机的本质差异)。所以同一个基础层可以被多个镜像共享——层复用是容器分发成本低的主要原因。
关键点:digest 不是“镜像的某个属性”,它就是 manifest 的字节哈希。所以“拉到的内容”和“声明的身份”可以逐字节对照——这是容器生态里少有的、能自证的完整性,也是 54.1 不可变制品 成立的前提。
本机实测:digest 就是 manifest 的 sha256
下面这个脚本拉取 library/alpine:3.20 的 index,再按架构取子 manifest,分别比对“响应头里的 digest”和“本地算出的 sha256”:
#!/usr/bin/env bash
# 校验「tag → manifest → digest」这条链:拉到的字节,是否等于响应头声明的 digest。
# 用法:./check-digest.sh library/alpine 3.20 arm64
set -euo pipefail
REPO=${1:?repo}; TAG=${2:?tag}; ARCH=${3:-arm64}
REG=https://registry-1.docker.io/v2/${REPO}
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
TOKEN=$(curl -sS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${REPO}:pull" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
fetch() { # $1=reference $2=accept $3=前缀
curl -sS -D "$TMP/$3.hdr" -o "$TMP/$3.json" \
-H "Authorization: Bearer $TOKEN" -H "Accept: $2" "${REG}/manifests/$1"
grep -i '^docker-content-digest:' "$TMP/$3.hdr" | tr -d '\r' | awk '{print $2}'
}
sha_of() { shasum -a 256 "$1" | awk '{print $1}'; }
echo "repo=${REPO} tag=${TAG} arch=${ARCH}"
IDX=$(fetch "$TAG" application/vnd.oci.image.index.v1+json index)
IDX_CALC="sha256:$(sha_of "$TMP/index.json")"
printf "index header=%s computed=%s %s\n" "$IDX" "$IDX_CALC" \
"$([ "$IDX" = "$IDX_CALC" ] && echo OK || echo MISMATCH)"
CHILD=$(python3 - "$ARCH" "$TMP/index.json" <<'PY'
import json,sys
want,path=sys.argv[1],sys.argv[2]
for m in json.load(open(path))['manifests']:
if m['platform'].get('architecture')==want:
print(m['digest']); break
else:
sys.exit(f'no manifest for {want}')
PY
)
CH=$(fetch "$CHILD" application/vnd.oci.image.manifest.v1+json child)
CH_CALC="sha256:$(sha_of "$TMP/child.json")"
printf "child header=%s computed=%s %s\n" "$CH" "$CH_CALC" \
"$([ "$CH" = "$CH_CALC" ] && echo OK || echo MISMATCH)"
python3 - "$TMP/child.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
print(f"layers={len(d['layers'])} total={sum(l['size'] for l in d['layers'])} bytes")
PY
[ "$IDX" = "$IDX_CALC" ] && [ "$CH" = "$CH_CALC" ] || exit 1
本机运行结果(2026-10-29,退出码 0):
repo=library/alpine tag=3.20 arch=arm64
index header=sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc computed=sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc OK
child header=sha256:45e09956dc667c5eff3583c9d94830261fb1ca0be10a0a7db36266edf5de9e1d computed=sha256:45e09956dc667c5eff3583c9d94830261fb1ca0be10a0a7db36266edf5de9e1d OK
layers=1 total=4092319 bytes
再做一个反向验证:给子 manifest 末尾加一个换行,digest 立刻变。
$ printf '\n' >> child.json && shasum -a 256 child.json
c300553735b2fb29897cb2972edc1915c3564a26c4c308e8b5162f612e188437 child.json
$ grep -i docker-content-digest headers.txt # 仓库声明的是
docker-content-digest: sha256:45e09956dc667c5eff3583c9d94830261fb1ca0be10a0a7db36266edf5de9e1d
🔑 一个换行就换了身份。内容寻址没有“差不多”,只有等与不等。
仓库的七项能力
| 能力 | 没有会怎样 | 关键配置 |
|---|---|---|
| 鉴权与 RBAC | 谁都能推,供应链投毒;谁都能拉,私有代码外泄 | 项目级 robot 账号;CI 只给 push,运行集群只给 pull |
| 复制 / 同步 | 跨 region 拉取慢,出网费高 | 主从复制;就近拉取;复制失败告警 |
| 保留策略 + GC | 磁盘涨爆;或误删在跑镜像 | 保留“被部署过的 digest”+ 最近 30 天被 pull 的 |
| 漏洞扫描 | 不知道上线了什么 CVE | 推送时扫描;按严重级别阻断;结果进 SBOM |
| 签名与来源 | 无法证明镜像是 CI 构建的 | cosign keyless + 构建来源证明 |
| 代理缓存(pull-through) | 每次拉公网,慢且贵 | 缓存上游基础镜像,定期刷新 |
| 配额与限流 | 单项目打满存储;被拉爆带宽 | 项目配额 + 告警;按仓库限流 |
镜像分层与写时复制的机制在 6.2 镜像与分层;扫描器的选型与阈值在 6.6 镜像安全与扫描;供应链攻击面在 38.10 供应链安全。
产品怎么选
| 产品 | 形态 | 强项 | 代价 |
|---|---|---|---|
| Harbor | 自建 | 复制、扫描、签名、配额、RBAC 齐全,可控 | 要人运维;存储与 HA 自付 |
| AWS ECR | 托管 | 与 IAM / EKS / ECS 集成,跨区复制 | 绑定 AWS;注意 API 限流 |
| GCP Artifact Registry | 托管 | 多格式(镜像/包/chart),与 GKE 集成 | 绑定 GCP |
| Azure ACR | 托管 | AKS 集成,geo-replication | 绑定 Azure |
| Docker Hub | SaaS | 公共镜像生态最全 | 限流;私有仓库按量收费 |
| Nexus / Artifactory | 自建通用 | 镜像、Maven、npm 统一入口 | 重、贵、运维成本高 |
| 你的情况 | 选 |
|---|---|
| 已有云、团队 < 20 人 | 云托管仓库(ECR/ACR/Artifact Registry),别自建 |
| 多云 / 混合云 / 合规要求数据不出境 | Harbor 自建 + 跨站点复制 |
| 只拉公共基础镜像 | 配 pull-through cache,别让每个节点直连公网 |
| 既要镜像又要 Maven/npm | Artifactory/Nexus 统一,或分开但统一鉴权 |
拉取成本:层复用比“镜像变小”更值钱
5 个服务,每个镜像 1.2 GB,共享 0.8 GB 基础层,各自独有 0.4 GB(示例):
| 场景 | 单节点拉取量 | 500 节点全量发布 | 出网费(示例 $0.09/GB) |
|---|---|---|---|
| 无层共享(5 个独立 1.2 GB) | 6.0 GB | 3,000 GB | ≈ $270 |
| 共享基础层 | 0.8 + 5 × 0.4 = 2.8 GB | 1,400 GB | ≈ $126 |
| 只拉变化层(增量发布) | 约 0.4 GB | 200 GB | ≈ $18 |
三条结论:
- 层复用率由基础镜像决定,不是由“把镜像压到最小”决定。统一基础镜像(同 OS、同运行时版本)比逐个瘦身收益大。
- 增量发布只拉变化层,所以“把配置打进镜像”会让每一层都失效——配置必须外置(见 54.5 配置注入)。
- 跨 region 未复制时,每层还要多付 30–100 ms 的 RTT(量级),大镜像的首拉会明显变慢。
保留策略与 GC
| 规则 | 参数示例 | 为什么 |
|---|---|---|
| 保留被部署过的 digest | 覆盖回滚窗口(如 30 天,由部署清单反查) | 回滚目标不能消失 |
| 保留最近被 pull 的 | 30 天 | 覆盖最大回滚窗口 |
| 每仓库保留最近 tag | 50 个 | 开发便利,不是回滚依据 |
| 删除顺序 | 删 tag → 宽限 7 天 → GC blob | 删 tag 不减 blob |
| GC 期间 | 只读或停写 | 避免与 push 竞争,删掉正在写的层 |
⚠️ 最常见的翻车:保留策略写“最近 7 天”,而真正决定回滚可能在两周后——那时旧 digest 已经没了。保留窗口必须 ≥ 最大回滚窗口,包括节假日和 hotfix 分支。
生产落地:指名 → 可抄 → 可验 → 可退
指名:自建选 Harbor(内置 Trivy 扫描、复制、配额、robot 账号);托管选云仓库。签名用 cosign,漏洞与 SBOM 见 52.3 依赖与漏洞扫描。替代品:Nexus/Artifactory 适合“镜像 + 包”统一入口。
可抄(本机 Docker daemon 不可用,以下命令未验证):
export REG=harbor.example.com
docker login "$REG" -u 'robot$ci' -p "$HARBOR_TOKEN"
docker tag order:1.2.0 "$REG/prod/order:1.2.0"
docker push "$REG/prod/order:1.2.0"
# 取 digest:之后所有部署都只认它
docker inspect --format '{{index .RepoDigests 0}}' "$REG/prod/order:1.2.0"
# 预期:harbor.example.com/prod/order@sha256:...
# 用 Harbor API 核对“这个 digest 在不在、有没有被签”
curl -s -u "robot\$ci:$HARBOR_TOKEN" \
"https://$REG/api/v2.0/projects/prod/repositories/order/artifacts?with_tag=true&page_size=5" \
| python3 -c 'import sys,json;[print(a["digest"],[t["name"] for t in a.get("tags") or []]) for a in json.load(sys.stdin)]'
可验:本节 check-digest.sh 真跑过,两条 digest 都是 OK;把 manifest 改一个字节后 digest 立即不匹配。Harbor API 的预期输出是每个 artifact 一行“digest + tag 列表”,digest 不存在时返回空数组。
可退:
- 部署只引用
@sha256:,回滚 = 用旧 digest 重新 apply(见 54.3 部署编排)。 - 仓库故障:节点保留本地镜像,配 pull-through cache;新节点扩容失败要好于“拉到半张镜像跑起来”。
- 扫描/签名不通过:先在 CI 里
warn,稳定后enforce阻断推送;误杀时用项目白名单放行,不要关掉整条规则。
反模式
| 反模式 | 后果 |
|---|---|
部署 latest / 覆盖业务 tag | 无法回答“现在跑的是哪份字节”,回滚无目标 |
| 多架构镜像只锁子 manifest digest | 换架构的节点拉不到,或部署清单与节点架构耦合 |
| 删 tag 当作删镜像 | 空间不释放;真 GC 时又误删在跑镜像 |
| 所有服务共用一个 robot 账号、永久 token | 泄露后全仓库可推,供应链投毒 |
| 扫描只在本地跑,仓库不扫 | 推上去的镜像没人知道 CVE,SBOM 与制品脱节 |
| 没有 pull-through cache,节点直连公网 | 拉取慢、出网贵、上游限流直接拖垮扩容 |
| 保留策略只看时间,不看“被部署” | 回滚窗口被悄悄缩短 |
自检
Q1 为什么 latest 是最不适合回滚的 tag?
▸ 答案:因为它没有“历史”。latest 的定义就是“最后一次 push 的那个”,每次发布都会覆盖;事故时它已经指向新版本,旧版本只能靠 digest 或别的 tag 找。tag 是可变指针,回滚需要不可变身份——这就是为什么部署清单要写 digest。
Q2 删掉 tag 之后磁盘没降,为什么?
▸ 答案:tag 只是指针,manifest 和 layer(blob)仍被引用。只有跑 GC(Harbor 的 garbage collection、registry 的 garbage-collect)才会真正删除无引用的 blob。并且 GC 与 push 并发会出问题,通常要停写或只读;跑之前要确认“被部署过的 digest”被保留策略保护,否则会把在跑的镜像删掉。
Q3 部署时锁 index 的 digest 和锁单个平台 manifest 的 digest,有什么区别?
▸ 答案:锁 index digest 时,节点会按自己的架构从 index 里选子 manifest,同一份部署清单可以同时跑 amd64 和 arm64;锁子 manifest digest 则把架构也固定了,换架构的节点会拉不到。多数场景锁 index 更合适;只有当“必须确保所有节点跑同一架构”时才锁子 manifest。
Q4 私有仓库为什么仍可能被投毒?
▸ 答案:内网不等于可信。常见路径:匿名拉取/弱口令、CI token 权限过宽且长期有效、任意有 push 权限的人能覆盖 tag、部署时不验签(拉到什么跑什么)、基础镜像来源不明。防护是组合:最小权限 robot 账号 + 短 token + 部署只认 digest + 签名验签 + 推送时扫描。
Q5 保留策略应该怎么定,才能既不爆盘又能回滚?
▸ 答案:两条线同时满足:① 时间线——保留最近 30 天被 pull 的(≥ 最大回滚窗口);② 引用线——所有“被部署清单引用过的 digest”至少保留到回滚窗口结束(如 30 天),由部署系统反查。再叠加每仓库最近 N 个 tag 供开发使用。删除顺序是删 tag → 宽限期 → GC,且 GC 期间停写。判据不是“磁盘用了多少”,而是“最后一次可能需要回滚是什么时候”。