容器镜像里躺着数据库密码、API 密钥、内部证书,这种事在真实项目里太常见了。早期大家习惯把这些东西塞进环境变量或者配置文件,后来发现镜像一旦被推到公共仓库、或者被有权限的人随手docker save出来,里面的东西基本等于裸奔。于是"镜像加密"这个需求就冒出来了——不是加密某个文件,而是让整个镜像层在落盘和传输时都是密文,只有拿到密钥的节点才能解密运行。containerd 作为现在主流的容器运行时底座,从 1.5 版本前后就开始把镜像加解密做成了一套可插拔的机制,但官方文档写得比较散,很多人第一次接触时会被untrusted、keyprovider、image_filter这些概念绕晕。这篇就把 containerd 里镜像加解密的来龙去脉拆开讲清楚:它到底解决什么问题、底层靠什么实现、怎么配、怎么用、踩坑点在哪。不管你是刚接手容器平台运维的新手,还是想给内部镜像仓库加一层保护的开发者,都能从里面找到能直接抄的配置和能避开的雷。
1. 镜像加密到底在防谁:先把威胁模型想明白
在动手配任何东西之前,我习惯先把"这东西防的是谁"想清楚,否则很容易配了一堆参数却防错了对象。containerd 的镜像加密机制,本质上解决的是静态数据保护问题——镜像以密文形式存储在镜像仓库(registry)里,也以密文形式落在本地 content store 中,只有在容器真正启动、需要挂载 rootfs 的那一刻才解密。这个定位决定了它的能力边界。
1.1 它防得住的和防不住的
先说防得住的。镜像推到 registry 之后,如果仓库被拖库、或者传输链路被截获,攻击者拿到的是加密后的 layer blob,没有对应密钥解不开。本地磁盘上的 content store 同理,即使有人物理接触到磁盘或者拿到了宿主机上某个低权限账号,读到的也是密文。这对多租户环境、边缘节点部署、以及镜像需要经过第三方 CDN 分发的场景特别有价值。
再说防不住的,这点必须诚实。镜像加密不防运行时攻击——容器跑起来之后,rootfs 已经解密挂载在宿主机上了,能进到宿主机的人照样能看到明文。它也不防"有密钥的人作恶",密钥管理本身是另一套体系的事。还有一个常见误解:有人以为加密能防止镜像被未授权拉取,其实拉取权限是 registry 的 auth 机制管的,加密管的是"拉下来也看不懂"。这两件事要分开做。
提示:如果你的诉求是"控制谁能拉镜像",那该做的是 registry 的鉴权 + 命名空间隔离,别指望加密来解决。加密解决的是"数据在静止和传输状态下的机密性"。
1.2 和镜像签名不是一回事
很多人把加密和签名混为一谈。签名(比如 cosign、Notation 那套)解决的是完整性和来源可信——证明这个镜像确实是你构建的、没被篡改。加密解决的是机密性——别人拿到了也看不懂。两者是正交的,可以同时用:先签名保证来源,再加密保证内容。containerd 的加密机制里其实也带了一点完整性校验的意味(解密失败就拒绝启动),但它不替代签名体系。我在实际项目里一般是两个都上,签名走 CI 流水线,加密走构建末端的加密工具。
理解了这个威胁模型,后面看配置项就不会觉得是在背参数了——每个参数背后都对应着"在哪一层做保护"的取舍。
2. containerd 加解密机制的骨架:ocicrypt 与 keyprovider
containerd 自己并没有从零实现一套加密算法,它把这块能力委托给了ocicrypt这个库,再通过keyprovider机制把"密钥从哪来"这件事解耦出去。理解这个分层,是搞懂整套机制的关键。
2.1 ocicrypt 扮演的角色
ocicrypt 是 OCI 社区推动的一个镜像加密规范实现,它定义了加密后的 layer 在 OCI manifest 里长什么样、加密元数据(encryption annotations)怎么存。简单说,它规定了"密文镜像的格式标准"。containerd 集成 ocicrypt 之后,在拉取镜像时会识别 manifest 里的加密标注,如果发现是加密镜像,就调用配置好的 keyprovider 去要密钥,解密 layer 后再写进 content store 的解密视图。
这里有个容易忽略的细节:containerd 并不是把解密后的 layer 覆盖回原来的 content store,而是维护了一套解密后的快照视图。原始密文 blob 还在,解密结果单独管理。这样做的好处是密文始终可追溯,坏处是磁盘占用会翻倍——加密镜像多的情况下要留意存储容量。
2.2 keyprovider 的两种工作模式
keyprovider 是"密钥来源"的抽象。containerd 支持配置多个 keyprovider,按顺序尝试。常见的有两类:
| 类型 | 说明 | 适用场景 |
|---|---|---|
nodekeyprovider | 从节点本地文件系统读取密钥 | 单机、边缘节点、密钥已预置的场景 |
command | 调用外部命令动态获取密钥 | 对接 KMS、密钥服务、需要动态拉取的场景 |
nodekeyprovider配置简单,指定一个目录,containerd 去里面找匹配的密钥文件。command模式更灵活,每次需要密钥时执行一个外部程序,程序从任意后端(比如内部密钥管理服务)取回密钥并输出到标准输出。生产环境里我更倾向command模式,因为密钥可以集中管理、支持轮换,不用把密钥文件散落在每个节点上。
配置片段大概长这样(/etc/containerd/config.toml):
[plugins."io.containerd.grpc.v1.cri".image_decryption] key_providers = ["nodekeyprovider", "command"]注意key_providers是个数组,按顺序尝试。如果第一个 provider 找不到密钥,会继续问第二个。这个顺序设计有讲究:把本地快速路径放前面,把远程慢速路径放后面,能减少不必要的远程调用。
2.3 解密发生在哪个环节
这是很多人搞不清的点。解密不是发生在ctr images pull的那一刻就全部完成,而是惰性解密——拉取时只拉密文 blob 并校验,真正解密发生在容器创建、需要挂载 rootfs 时。这意味着:
- 拉取阶段不需要密钥,镜像可以先拉下来存着;
- 启动阶段才需要密钥,密钥服务不可用会导致容器起不来;
- 同一个加密镜像被多个容器使用时,解密结果可以复用(快照机制)。
这个设计对运维的影响是:密钥服务的可用性直接关系到容器启动成功率,必须做高可用。我见过有团队把密钥服务做成单点,结果密钥服务一挂,整个集群新容器全部起不来,这个教训值得记。
3. 从构建到运行:一条完整的加密镜像链路
光讲机制太虚,走一遍完整链路才踏实。下面这条链路是我在实际项目里验证过的:构建镜像 → 加密 → 推送 → 拉取 → 运行。每一步都有坑,逐个说。
3.1 构建阶段:用 ocicrypt-ctl 做加密
containerd 本身不提供加密工具,加密动作通常在构建末端用ocicrypt-ctl完成。它的工作方式是:读取一个未加密的 OCI 镜像布局(或者直接从 registry 拉),对每个 layer 用对称密钥加密(默认 AES-256-GCM),再用接收方的公钥把对称密钥封装起来,最后输出一个加密后的 OCI 布局。
命令大致是这样:
ocicrypt-ctl encrypt \ --input ./oci-layout-dir \ --output ./encrypted-layout \ --recipient-key ./pubkey.pem这里用的是信封加密(envelope encryption):真正加密 layer 的是随机生成的对称密钥(DEK),公钥只用来加密这个 DEK。为什么这么设计?因为非对称加密大文件太慢,对称加密快但密钥分发难,信封加密把两者优点结合起来——大文件走对称,密钥分发走非对称。理解这一点,后面密钥轮换的逻辑就顺了:轮换的是接收方密钥对,DEK 每次加密都是新的。
加密完成后,manifest 里会多出加密相关的 annotation,layer 的 mediaType 也会变成加密类型。用ctr或者skopeo查看 manifest 时能看到这些标注,这是判断一个镜像是否加密的直接依据。
3.2 推送与拉取:密文全程不落地明文
加密后的 OCI 布局推送到 registry,推上去的就是密文。这里有个实操细节:registry 不需要任何特殊配置,它只是存 blob,不关心内容是否加密。这也是这套机制优雅的地方——加密对 registry 完全透明,不需要改造仓库。
拉取时,containerd 按正常流程拉 manifest 和 blob,识别到加密标注后记录下来。此时 content store 里存的是密文。前面说过,解密是惰性的,所以ctr images pull能成功不代表密钥没问题,真正的考验在ctr run的时候。
3.3 运行阶段:密钥到位才能起容器
容器创建时,containerd 调用配置的 keyprovider 拿密钥,解密 layer,生成解密后的快照,然后挂载给容器。这一步如果密钥缺失或者解密失败,容器会卡在CreateContainer阶段,报错信息通常包含failed to decrypt或者no key provider之类的字样。
排查这类问题我一般按这个顺序走:
- 确认镜像确实是加密的(看 manifest annotation);
- 确认 keyprovider 配置生效(
containerd config dump看实际加载的配置); - 确认密钥文件/密钥服务能返回正确的密钥(手动跑一遍 command provider 的命令);
- 确认密钥 ID 匹配(加密时用的 recipient 和运行时找的 key 要对得上)。
第 4 点是最容易翻车的。ocicrypt 用密钥的指纹或者标识来匹配,如果加密时用的公钥和运行时配置的私钥不是一对,或者标识对不上,就会报"找不到密钥"。我建议在加密脚本里把 recipient 的标识打印出来存档,出问题时能快速比对。
4. 密钥管理:整套机制里最容易被低估的部分
加密算法本身是成熟的,真正难的是密钥怎么管。我见过太多项目把加密配通了就以为完事,结果密钥散落一地,最后安全性还不如不加密。这一节专门聊密钥管理的实操。
4.1 密钥放哪:三种常见方案对比
| 方案 | 优点 | 缺点 | 适用规模 |
|---|---|---|---|
| 节点本地文件 | 简单、无外部依赖、延迟低 | 密钥分散、轮换困难、节点失守即失守 | 小规模、边缘 |
| 内部密钥服务 | 集中管理、支持轮换、可审计 | 需要高可用、增加启动依赖 | 中大规模 |
| 云厂商 KMS | 托管、合规性好 | 有网络依赖、成本、跨云麻烦 | 云上环境 |
小规模或者边缘场景,本地文件够用,但一定要配合文件权限(0600)和磁盘加密。中大规模强烈建议上密钥服务,把commandprovider 指向一个从密钥服务取密钥的小程序。云上环境直接用托管 KMS 最省心,但要注意跨云迁移时的锁定问题。
4.2 密钥轮换怎么做才不翻车
轮换是密钥管理里最容易出事的一环。核心原则:新旧密钥要能共存一段时间。具体做法是,keyprovider 配置里同时保留新旧两套密钥来源,新镜像用新密钥加密,老镜像继续用老密钥解密,等所有老镜像都重新加密推送、所有老容器都重建之后,再下线旧密钥。
这个"共存窗口"的长度取决于你的镜像更新频率和容器生命周期。我一般留两周到一个月,期间监控还有没有容器在用旧密钥解密(可以在 keyprovider 的 command 程序里打日志)。确认没有旧密钥调用之后,再清理。
注意:千万别在轮换时直接删旧密钥。一旦有漏网的老镜像需要重启,删了旧密钥就意味着那个容器永远起不来,只能重新构建推送,非常被动。
4.3 密钥泄露了怎么办
假设最坏情况:某个 recipient 私钥泄露了。处置流程应该是:立即生成新密钥对 → 用新公钥重新加密所有相关镜像 → 推送新版本 → 逐步替换运行中的容器 → 吊销旧密钥。注意"重新加密"不是简单换个密钥,因为旧密文用旧密钥仍然能解,所以必须重新走一遍加密流程生成新 blob。这也是为什么加密要和 CI 流水线绑定——出问题时能快速批量重刷。
5. 实测中绕不开的几个坑
配置能跑通只是开始,生产环境里还有一堆细节。这一节把我踩过的和见别人踩过的坑集中列一下,都是能省下几小时排查时间的经验。
5.1 解密后磁盘占用翻倍
前面提过,containerd 保留密文 blob 的同时维护解密快照,磁盘占用会明显上升。一个 1GB 的加密镜像,实际可能占 2GB 左右(密文 + 解密快照,快照还有自己的开销)。节点磁盘规划时要把这个系数算进去。如果磁盘紧张,可以配置定期清理不再使用的解密快照,但要小心别把正在运行的容器依赖的快照清掉。
5.2 多架构镜像的加密处理
多架构镜像(manifest list)加密时,需要对每个架构的子 manifest 分别处理。有些加密工具对 manifest list 支持不完善,加密后架构信息丢失,导致拉取时选错架构或者直接失败。实操建议:加密后一定要用ctr images pull --platform指定架构验证一遍,确认每个架构都能正常拉取和解密。如果工具不支持,就退化成单架构分别加密、分别打 tag。
5.3 和镜像加速/缓存的兼容性
如果链路中间有镜像缓存或者加速层,要确认它们对加密 blob 是透传的。大部分缓存只按 digest 存 blob,不解析内容,所以没问题。但有些"智能"缓存会尝试解析 manifest 做优化,遇到加密标注可能出错。上线前在预发环境把完整链路跑一遍,别等生产才发现缓存层把加密标注弄丢了。
5.4 报错信息不友好导致的误判
containerd 解密失败的报错有时候很含糊,比如只报failed to mount,实际根因是密钥问题。排查时建议把 containerd 日志级别临时调到 debug,能看到更详细的 keyprovider 调用记录。另外ctr命令加--debug也能输出更多信息。我一般会在测试环境专门造一个"密钥缺失"的场景,把报错信息记下来,生产出问题时对照着看,能快很多。
6. 什么场景值得上,什么场景别硬上
最后聊聊选型判断。镜像加密不是银弹,用错了地方纯属给自己找麻烦。
值得上的场景:镜像要经过不可控的传输链路(比如跨公网、经过第三方 CDN);多租户集群里不同租户的镜像需要隔离;边缘节点物理安全不可控;合规要求静态数据必须加密。这些场景下加密带来的收益是实打实的。
不建议硬上的场景:单机开发环境、内部完全可信的集群、镜像更新极其频繁且密钥管理跟不上的团队。加密会增加构建复杂度、启动延迟、运维负担,如果威胁模型里根本没有"镜像被未授权读取"这一项,上了就是纯成本。我见过有团队为了"显得安全"硬上加密,结果密钥管理一团糟,反而制造了新的故障点。
真要上,建议从小范围开始:先挑一两个敏感镜像试点,把构建、加密、密钥管理、轮换的流程跑顺,再逐步推广。密钥管理方案一定要在试点阶段就定下来,别等铺开了再改,那时候迁移成本会高得让人想放弃。
我个人在实际操作中的体会是,镜像加密这套东西,配通只占两成工作量,剩下八成都在密钥管理和流程建设上。把密钥服务的高可用、轮换流程、泄露应急预案这三件事想清楚,比研究加密算法参数重要得多。另外,加密和签名一起上、registry 鉴权单独做,这三层各管各的,别指望一个机制解决所有安全问题——分层防御才是容器安全的正道。