你的容器镜像在「裸奔」吗?OCI 镜像加密实战

写在前面

在内网、私有云、离线交付场景中,容器镜像通常存放在内部 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 上无感接入的根本原因。

加密后 layer mediaType 变化

状态 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/keys
cd /etc/ocicrypt/keys

# 生成 RSA 4096 密钥对
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pem

# 严格限制权限
chmod 600 private.pem
chmod 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
# 确保 Go 环境(需要 1.21+)
wget https://go.dev/dl/go1.22.0.linux-arm64.tar.gz
tar -C /usr/local -xzf go1.22.0.linux-arm64.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version

# 编译 containerd/imgcrypt
git clone https://github.com/containerd/imgcrypt.git
cd imgcrypt
make
make install
# 产出二进制安装到 /usr/local/bin/

# 验证
ls -la /usr/local/bin/ctd-decoder
ls -la /usr/local/bin/ctr-enc

编译完成后的产物校验:

imgcrypt 编译安装产物

3.2 确认 skopeo 支持 ocicrypt

1
2
3
4
5
skopeo --version
# 需要 >= 1.6.0

# 确认支持加密参数
skopeo copy --help | grep -E "encryption|decryption"

⚠️ 如果 Kylin 源的 skopeo 版本过旧(< 1.6),需从源码编译或使用 nerdctl 替代加密步骤。这是个高频踩坑点。

3.3 离线环境处理

离线内网环境通常无法直接联网拉依赖,需要提前在联网机器打包:

1
2
3
4
5
6
7
8
# 打包 Go module 缓存
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
# 方式一:从现有 registry 拉取
skopeo copy \
--src-creds user:password \
docker://registry.internal/myapp:plain \
oci:/tmp/myapp-plain:latest

# 方式二:从本地 docker daemon 导出
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
# 查看 manifest,确认 layer mediaType 包含 +encrypted
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
# 获取加密后镜像的 digest,后续 Pod 配置使用 digest 引用
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/keys
chmod 700 /etc/ocicrypt/keys

# 安全传输私钥(生产环境建议 Ansible Vault 或 SCP + 跳板机)
scp /etc/ocicrypt/keys/private.pem worker-node:/etc/ocicrypt/keys/
chmod 600 /etc/ocicrypt/keys/private.pem

# 验证权限
ls -la /etc/ocicrypt/keys/
# -rw------- 1 root root ... private.pem

5.2 创建 ocicrypt keyprovider 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir -p /etc/ocicrypt

cat > /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.d

cat > /etc/k0s/containerd.d/ocicrypt.toml << 'EOF'
# OCI 镜像加密解密配置
# containerd/imgcrypt stream processor

[stream_processors]

# 处理 tar+gzip+encrypted 格式(最常用)
[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"]

# 处理 tar+encrypted 格式(非 gzip 压缩的 layer)
[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.d

cat > /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
# 检查 containerd 配置是否合并了 drop-in
journalctl -u k0sworker --no-pager | grep -i "stream_processor\|ocicrypt\|decoder"

# 或直接查看 k0s 生成的 containerd 完整配置
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 不会调度过去

💡 这一步是「网络层防护」之外的调度层防护:哪怕私钥没泄露,加密 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
# 使用 digest 固定镜像,防止 tag 漂移或替换
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
# 设置环境变量后用 ctr 直接测试
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
# 在没有私钥的节点上(不设置 keyprovider)
ctr run --rm \
zot.internal:5000/myapp:encrypted \
test-fail \
echo "should not run"

# 预期报错类似:
# ctr: failed to create shim task: OCI runtime create failed:
# failed to handle stream: failed to decrypt:
# no suitable private key found for decryption

✅ 如果在未授权节点上看到 no suitable private key found for decryption,恭喜——这正是加密生效的证明:镜像即使被拉下来也跑不起来

7.3 验证 Kubernetes Pod 调度行为

1
2
3
4
5
6
7
8
9
# 授权节点上的 Pod 应正常 Running
kubectl get pod myapp-encrypted -n production

# 强制调度到未授权节点(临时测试)
kubectl patch pod myapp-encrypted -n production \
-p '{"spec":{"nodeSelector":{"kubernetes.io/hostname":"unauthorized-worker"}}}'

# 预期 Pod 状态变为 ErrImagePull 或 CreateContainerError
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.pem
chmod 600 private-v2.pem

8.2 用新公钥重新加密镜像

1
2
3
4
5
6
7
8
# skopeo 支持同时指定解密旧 key 和加密新 key
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 滚动更新

1
2
3
4
5
6
# 1. 分发新私钥到所有授权 worker
# 2. 更新 containerd drop-in 配置,args 改为新私钥路径(或追加两个密钥)
# 3. 重启 k0sworker
# 4. 更新 Pod/Deployment 使用新 tag/digest
# 5. 确认新版本 Pod 全部 Running
# 6. 删除旧版本镜像和旧私钥

💡 过渡期建议:在 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.pem

echo "=== 检查 keyprovider 配置 ==="
cat /etc/ocicrypt/ocicrypt_keyprovider.conf

echo "=== 检查 containerd drop-in ==="
cat /etc/k0s/containerd.d/ocicrypt.toml

echo "=== 检查 k0sworker 环境变量 ==="
systemctl show k0sworker | grep OCICRYPT

echo "=== 检查 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
# 1. 先加密推送
skopeo copy \
--encryption-key jwe:public.pem \
oci:/tmp/myapp-plain:latest \
docker://zot.internal:5000/myapp:encrypted

# 2. 再对加密后的镜像签名
cosign sign \
--key cosign.key \
zot.internal:5000/myapp:encrypted

# 结果:
# - 没有私钥 → layer 无法解密(ocicrypt)
# - 签名不匹配 → admission webhook 拒绝调度(policy-controller)
# - 两层防护叠加

💡 三层防护全景:调度层(nodeSelector)+ 保密层(ocicrypt 加密)+ 完整性层(cosign 签名 + 准入校验),共同构成内网环境下的镜像安全闭环。


写在最后

OCI 镜像加密不是一项「锦上添花」的功能。只要你的镜像里有不想被随意拷贝、反编译、外泄的内容——核心代码、商业算法、私有配置、许可证逻辑——它就是刚需。这套方案的精髓在于:

  1. Registry 无感:zot 不需要任何改造,透明存储密文
  2. 运行时解密:containerd stream processor 机制让加密对上层透明
  3. 调度层兜底:nodeSelector 确保密文镜像不会被调度到非授权节点
  4. 可轮换、可叠加:密钥能轮换,还能和 cosign 签名叠加形成纵深防御

落地过程中最容易踩的坑集中在三处:skopeo 版本过低环境变量未注入密钥分发不安全。把这几条盯住,整体方案就能稳定跑起来。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。

你在生产环境是用 ocicrypt、cosign,还是别的方案做镜像安全防护?落地过程中遇到过哪些坑?欢迎在评论区留言交流,我会逐一回复 👇


你的容器镜像在「裸奔」吗?OCI 镜像加密实战
https://www.boer.xyz/posts/oci-image-encryption/
作者
boer
发布于
2026年7月2日
许可协议