写在前面 在内网、私有云、离线交付场景中,容器镜像通常存放在内部 Registry 里。很多人以为「内网就是安全的」,但镜像本身其实是明文存储 的——只要有人能 pull 到镜像,就能把里面的代码、配置文件、二进制全部扒出来。
OCI 镜像加密 就是解决这个问题的:让镜像 layer 在 Registry 中以密文形式存储,只有在被授权、配置了私钥的节点上才能解密运行。
今天这篇文章,我把生产环境落地 OCI 镜像加密的完整链路整理出来,涵盖架构原理、密钥管理、工具链编译、节点配置、K8s 部署、端到端验证、密钥轮换以及常见踩坑排查,全流程命令均可直接复用 。
一、整体架构 先把整体链路理清楚,再逐段拆解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ┌──────────────────────────────────────────────────────────────┐ │ 构建节点 │ │ stacker/buildah build → skopeo encrypt → push to zot │ │ (用 public .pem 加密 layer) │ └──────────────────┬───────────────────────────────────────────┘ │ encrypted OCI image ▼ ┌──────────────────────────────────────────────────────────────┐ │ zot registry(透明存储加密 blob,无需感知加密) │ └──────────────────┬───────────────────────────────────────────┘ │ pull encrypted image ▼ ┌──────────────────────────────────────────────────────────────┐ │ k0s worker node │ │ containerd → ocicrypt keyprovider → private.pem 解密 layer │ │ ↓ │ │ Pod 正常运行 │ └──────────────────────────────────────────────────────────────┘
核心原理
加密使用 JWE(JSON Web Encryption)+ RSA-OAEP ,由 skopeo 在 push 前对每个 image layer blob 加密
zot 作为标准 OCI registry,透明存储 加密 blob,无需任何特殊配置
containerd 通过 stream_processors 在拉取 layer 时调用 ctd-decoder 实时解密
没有配置私钥的节点,layer 解密失败,容器无法启动
💡 关键点 :加密发生在「构建侧 push 前」,解密发生在「运行侧 pull 时」,Registry 全程只搬运密文,这是这套方案能在存量 zot registry 上无感接入的根本原因。
状态
mediaType
明文 layer
application/vnd.oci.image.layer.v1.tar+gzip
加密 layer
application/vnd.oci.image.layer.v1.tar+gzip+encrypted
注意这个 +encrypted 后缀——它就是 containerd 识别「这是个加密 layer、需要走解密 stream processor」的信号。
二、密钥准备 在密钥管理节点 执行(私钥严格管控,不进代码仓库):
1 2 3 4 5 6 7 8 9 10 11 mkdir -p /etc/ocicrypt/keyscd /etc/ocicrypt/keys openssl genrsa -out private.pem 4096 openssl rsa -in private.pem -pubout -out public.pemchmod 600 private.pemchmod 644 public.pem
密钥分发原则
文件
分发到
用途
public.pem
构建节点
skopeo 加密 layer
private.pem
仅授权 worker 节点
containerd 运行时解密
⚠️ 安全红线 :私钥一旦泄露,加密防护失效。生产环境强烈建议将私钥存入 Vault ,通过 keyprovider gRPC 模式动态获取,而非落盘。
三、安装 imgcrypt 工具链 3.1 编译安装(适配 ARM64 Kylin) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 wget https://go.dev/dl/go1.22.0.linux-arm64.tar.gz tar -C /usr/local -xzf go1.22.0.linux-arm64.tar.gzexport PATH=$PATH :/usr/local/go/bin go version git clone https://github.com/containerd/imgcrypt.gitcd imgcrypt make make installls -la /usr/local/bin/ctd-decoderls -la /usr/local/bin/ctr-enc
编译完成后的产物校验:
3.2 确认 skopeo 支持 ocicrypt 1 2 3 4 5 skopeo --version skopeo copy --help | grep -E "encryption|decryption"
⚠️ 如果 Kylin 源的 skopeo 版本过旧(< 1.6),需从源码编译或使用 nerdctl 替代加密步骤。这是个高频踩坑点。
3.3 离线环境处理 离线内网环境通常无法直接联网拉依赖,需要提前在联网机器打包:
1 2 3 4 5 6 7 8 cd imgcrypt go mod download tar -czf imgcrypt-vendor.tar.gz vendor/ tar -xzf imgcrypt-vendor.tar.gz make GOFLAGS="-mod=vendor"
四、加密镜像并推送到 zot 4.1 将明文镜像导出到本地 OCI layout 1 2 3 4 5 6 7 8 9 10 skopeo copy \ --src-creds user:password \ docker://registry.internal/myapp:plain \ oci:/tmp/myapp-plain:latest skopeo copy \ docker-daemon:myapp:latest \ oci:/tmp/myapp-plain:latest
4.2 加密并一步推送到 zot 1 2 3 4 5 skopeo copy \ --encryption-key jwe:/etc/ocicrypt/keys/public.pem \ --dest-creds deploy:<password> \ oci:/tmp/myapp-plain:latest \ docker://zot.internal:5000/myapp:encrypted
4.3 验证加密结果 1 2 3 4 5 6 skopeo inspect \ --raw \ --creds deploy:<password> \ docker://zot.internal:5000/myapp:encrypted \ | python3 -m json.tool | grep -A2 mediaType
预期输出 :
1 "mediaType" : "application/vnd.oci.image.layer.v1.tar+gzip+encrypted" ,
4.4 固定镜像 Digest 1 2 3 4 5 skopeo inspect \ --creds deploy:<password> \ docker://zot.internal:5000/myapp:encrypted \ | python3 -m json.tool | grep Digest
💡 最佳实践 :生产环境用 digest(@sha256:...)而非 tag 引用镜像,防止 tag 漂移或被恶意替换。
五、配置 k0s worker 节点解密环境 以下操作在每个授权运行加密镜像的 worker 节点 上执行。
5.1 分发私钥 1 2 3 4 5 6 7 8 9 10 mkdir -p /etc/ocicrypt/keyschmod 700 /etc/ocicrypt/keys scp /etc/ocicrypt/keys/private.pem worker-node:/etc/ocicrypt/keys/chmod 600 /etc/ocicrypt/keys/private.pemls -la /etc/ocicrypt/keys/
5.2 创建 ocicrypt keyprovider 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 mkdir -p /etc/ocicryptcat > /etc/ocicrypt/ocicrypt_keyprovider.conf << 'EOF' { "key-providers" : { "provider-1" : { "cmd" : { "path" : "/usr/local/bin/ctd-decoder" , "args" : [] } } } } EOF
5.3 配置 k0s containerd stream_processors k0s 通过 drop-in 文件扩展 containerd 配置,无需修改主配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 mkdir -p /etc/k0s/containerd.dcat > /etc/k0s/containerd.d/ocicrypt.toml << 'EOF' [stream_processors] [stream_processors."io.containerd.ocicrypt.decoder.v1.tar.gzip" ] accepts = ["application/vnd.oci.image.layer.v1.tar+gzip+encrypted" ] returns = "application/vnd.oci.image.layer.v1.tar+gzip" path = "/usr/local/bin/ctd-decoder" args = ["-decryption-key-files" , "/etc/ocicrypt/keys/private.pem" ] [stream_processors."io.containerd.ocicrypt.decoder.v1.tar" ] accepts = ["application/vnd.oci.image.layer.v1.tar+encrypted" ] returns = "application/vnd.oci.image.layer.v1.tar" path = "/usr/local/bin/ctd-decoder" args = ["-decryption-key-files" , "/etc/ocicrypt/keys/private.pem" ] EOF
5.4 注入 keyprovider 环境变量到 k0s 服务 1 2 3 4 5 6 7 8 9 10 11 12 13 mkdir -p /etc/systemd/system/k0sworker.service.dcat > /etc/systemd/system/k0sworker.service.d/ocicrypt.conf << 'EOF' [Service] Environment="OCICRYPT_KEYPROVIDER_CONFIG=/etc/ocicrypt/ocicrypt_keyprovider.conf" EOF systemctl daemon-reload systemctl restart k0sworker systemctl show k0sworker | grep OCICRYPT
⚠️ 高频踩坑 :很多人配了 drop-in toml,但忘记注入 OCICRYPT_KEYPROVIDER_CONFIG 环境变量,导致 pull 成功但 run 失败。这一步一定不要漏。
5.5 验证 containerd 加载了 stream_processors 1 2 3 4 5 6 journalctl -u k0sworker --no-pager | grep -i "stream_processor\|ocicrypt\|decoder" k0s config --default | grep -A5 stream_processors 2>/dev/null || \ cat /run/k0s/containerd.toml 2>/dev/null | grep -A5 stream_processor
六、Kubernetes 部署加密镜像 6.1 创建 zot 拉取凭据 Secret 1 2 3 4 5 kubectl create secret docker-registry zot-creds \ --docker-server=zot.internal:5000 \ --docker-username=deploy \ --docker-password=<password> \ -n production
6.2 给授权 worker 节点打标签 1 2 3 4 5 kubectl label node worker-node-1 ocicrypt.enabled=true kubectl label node worker-node-2 ocicrypt.enabled=true
💡 这一步是「网络层防护」之外的调度层防护 :哪怕私钥没泄露,加密 Pod 也根本不会落到非授权节点上。
6.3 Pod 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 apiVersion: v1 kind: Pod metadata: name: myapp-encrypted namespace: production spec: nodeSelector: ocicrypt.enabled: "true" imagePullSecrets: - name: zot-creds containers: - name: myapp image: zot.internal:5000/myapp@sha256:<digest> imagePullPolicy: Always resources: limits: memory: "512Mi" cpu: "500m"
6.4 Deployment 配置示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 apiVersion: apps/v1 kind: Deployment metadata: name: myapp namespace: production spec: replicas: 2 selector: matchLabels: app: myapp template: metadata: labels: app: myapp spec: nodeSelector: ocicrypt.enabled: "true" imagePullSecrets: - name: zot-creds containers: - name: myapp image: zot.internal:5000/myapp@sha256:<digest> imagePullPolicy: Always
七、端到端验证 7.1 在授权节点验证解密运行 1 2 3 4 5 6 7 8 9 10 11 12 13 export OCICRYPT_KEYPROVIDER_CONFIG=/etc/ocicrypt/ocicrypt_keyprovider.conf ctr image pull \ --user deploy:<password> \ zot.internal:5000/myapp:encrypted ctr run --rm \ zot.internal:5000/myapp:encrypted \ test-decrypt \ echo "✅ 解密成功,容器正常运行"
7.2 在未授权节点验证拦截效果 1 2 3 4 5 6 7 8 9 10 ctr run --rm \ zot.internal:5000/myapp:encrypted \ test-fail \ echo "should not run"
✅ 如果在未授权节点上看到 no suitable private key found for decryption,恭喜——这正是加密生效的证明:镜像即使被拉下来也跑不起来 。
7.3 验证 Kubernetes Pod 调度行为 1 2 3 4 5 6 7 8 9 kubectl get pod myapp-encrypted -n production kubectl patch pod myapp-encrypted -n production \ -p '{"spec":{"nodeSelector":{"kubernetes.io/hostname":"unauthorized-worker"}}}' kubectl describe pod myapp-encrypted -n production | tail -20
八、密钥轮换流程 密钥不会一劳永逸,定期轮换是安全合规的硬性要求。好消息是 skopeo 原生支持「解密旧 key + 加密新 key」一步完成。
8.1 生成新密钥对 1 2 3 4 cd /etc/ocicrypt/keys openssl genrsa -out private-v2.pem 4096 openssl rsa -in private-v2.pem -pubout -out public-v2.pemchmod 600 private-v2.pem
8.2 用新公钥重新加密镜像 1 2 3 4 5 6 7 8 skopeo copy \ --decryption-key /etc/ocicrypt/keys/private.pem \ --encryption-key jwe:public-v2.pem \ --src-creds deploy:<password> \ --dest-creds deploy:<password> \ docker://zot.internal:5000/myapp:encrypted \ docker://zot.internal:5000/myapp:encrypted-v2
8.3 滚动更新
💡 过渡期建议 :在 ctd-decoder 的 args 中同时指定新旧两个私钥 ,待所有 Pod 切换完成后再移除旧私钥。这样能保证滚动更新期间新旧版本 Pod 都能正常运行,避免中断。
九、常见问题排查
现象
可能原因
排查命令
no suitable private key found
私钥路径错误或未配置
ls -la /etc/ocicrypt/keys/
stream processor not found
drop-in toml 未被 containerd 加载
journalctl -u k0sworker | grep stream
pull 成功但 run 失败
OCICRYPT_KEYPROVIDER_CONFIG 未传给 containerd 进程
systemctl show k0sworker | grep OCICRYPT
unknown flag --encryption-key
skopeo 版本 < 1.6
skopeo --version
zot 返回 400/500
zot 版本过旧,不支持 +encrypted mediaType
升级 zot ≥ 1.4(OCI 1.1)
Pod 一直 Pending
nodeSelector 未匹配到有私钥的节点
kubectl describe pod | grep -A5 Events
解密慢 / 镜像拉取超时
RSA 4096 解密 CPU 消耗较高
可改用 EC P-384 密钥(更快)
快速诊断脚本 把下面这段存成脚本,遇到问题先跑一遍,80% 的问题都能定位:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/bin/bash echo "=== 检查私钥 ===" ls -la /etc/ocicrypt/keys/private.pemecho "=== 检查 keyprovider 配置 ===" cat /etc/ocicrypt/ocicrypt_keyprovider.confecho "=== 检查 containerd drop-in ===" cat /etc/k0s/containerd.d/ocicrypt.tomlecho "=== 检查 k0sworker 环境变量 ===" systemctl show k0sworker | grep OCICRYPTecho "=== 检查 ctd-decoder 可执行 ===" ls -la /usr/local/bin/ctd-decoder /usr/local/bin/ctd-decoder --version 2>/dev/null || echo "无 --version 参数属正常" echo "=== 检查 containerd 日志(最近 50 行)===" journalctl -u k0sworker -n 50 --no-pager | grep -iE "ocicrypt|stream|decoder|decrypt"
附:加密 + 签名,构建双重防护 很多人会把「加密」和「签名」搞混,其实两者职责完全不同:
维度
加密(ocicrypt)
签名(cosign)
解决的问题
保密 :防止镜像内容被窃取
防篡改 :防止镜像被替换/植入后门
失效后果
layer 明文泄露
恶意镜像被当成可信镜像运行
这两者不互斥,强烈推荐同时启用 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 skopeo copy \ --encryption-key jwe:public.pem \ oci:/tmp/myapp-plain:latest \ docker://zot.internal:5000/myapp:encrypted cosign sign \ --key cosign.key \ zot.internal:5000/myapp:encrypted
💡 三层防护全景 :调度层(nodeSelector)+ 保密层(ocicrypt 加密)+ 完整性层(cosign 签名 + 准入校验),共同构成内网环境下的镜像安全闭环。
写在最后 OCI 镜像加密不是一项「锦上添花」的功能。只要你的镜像里有不想被随意拷贝、反编译、外泄的内容——核心代码、商业算法、私有配置、许可证逻辑——它就是刚需 。这套方案的精髓在于:
Registry 无感 :zot 不需要任何改造,透明存储密文
运行时解密 :containerd stream processor 机制让加密对上层透明
调度层兜底 :nodeSelector 确保密文镜像不会被调度到非授权节点
可轮换、可叠加 :密钥能轮换,还能和 cosign 签名叠加形成纵深防御
落地过程中最容易踩的坑集中在三处:skopeo 版本过低 、环境变量未注入 、密钥分发不安全 。把这几条盯住,整体方案就能稳定跑起来。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。
你在生产环境是用 ocicrypt、cosign,还是别的方案做镜像安全防护?落地过程中遇到过哪些坑?欢迎在评论区留言交流 ,我会逐一回复 👇