作者:温砚舟-609|3 年安全工程师|专注安全加固与风险防范 栈:Docker 24.0.7、BuildKit、Harbor 2.9、trufflehog 3.43、Kubernetes 1.27.4 结论先行:镜像是「分层追加、永不改写」的洋葱,你今天删掉的一行密钥,在旧层里永远躺着一份。
一、现象:安全自查时一句 docker history,把生产 DB 密码翻了个底朝天
上个月给一套电商中台做发布前的安全自查。开发同学很自信地说:「密钥我们没进 git,.env 在 .gitignore 里,配置中心也有审计,镜像里肯定干净。」
我随手在构建机上敲了两句(镜像 harbor.internal/mall/order-svc:v3.8.2,约 1.4GB):
docker history --no-trunc mall/order-svc:v3.8.2 | grep -iE 'password|secret|key|token'
# <missing> RUN echo "DB_PASSWORD=Pr0d!2024" > /app/.env && echo "AK=LTAI5t********" >> /app/.env
# <missing> ENV JWT_SECRET=9f8a7b6c...
1 分钟,DB 密码、云 AK、JWT 密钥全出来了。开发同学当场愣住——他们记得「最后一步把 .env 删了」,但删除动作本身只是新增一层,旧层纹丝不动。
更糟的是这镜像已经推到 Harbor 被 6 个副本拉走、在 3 台节点上落盘。按「任何能被 docker pull 的镜像,默认当作攻击者可读」来算,这等于把生产库钥匙贴在了内网公告栏。
二、根因:镜像分层的「只追加、不覆盖」机制
很多人把 Dockerfile 想成「一个脚本跑出来的最终文件系统」,其实不是。Docker 镜像是逐层(layer)堆叠的:每条 RUN/COPY/ENV 生成一个只读层,新层叠在旧层上面。
关键点有三:
- 层不可变。 你在第 5 层
echo secret,第 6 层rm掉,第 5 层那层 tar 里那个文件还在。删、改、覆盖都不动旧层,只能「盖一层新的」。 ENV会写进镜像元数据。docker inspect的Config.Env里能直接看到,连进容器都不用。- 构建时注入 ≠ 运行时常驻。 用
--build-arg+ARG把密钥「烘」进文件系统,本质还是写进了某一层;只有运行时才注入的值(环境变量、挂载、Secret)才不落盘进镜像。
打个比方:镜像像一本只能往后加页的账本,你写错一行不能涂掉,只能另起一页写「作废」——但翻开第 3 页,错的那行清清楚楚。
三、具体做法:从「烘进去」改到「跑起来再给」
1)绝不在 Dockerfile 里写密钥,改用运行时注入。
# ❌ 反例:密钥进了层,且 ENV 进元数据
ENV DB_PASSWORD=Pr0d!2024
RUN echo "AK=LTAI5t********" > /app/.env
# ✅ 正例:镜像里只留占位,值运行时给
ENV DB_PASSWORD_FILE=/run/secrets/db_password
运行时给值的三种姿势(按推荐度):
# 本地 / CI 冒烟
docker run -e DB_PASSWORD="$(get_pass order)" mall/order-svc:v3.8.2
# K8s:走 Secret,不落进镜像
kubectl create secret generic order-cred \
--from-literal=db_password="$(get_pass order)"
# Deployment 里 valueFrom.secretKeyRef,而不是 image 里烘死
2)确需构建期用密钥(如拉私有依赖),用 ARG + BuildKit --secret,用完即弃、不进层。
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=gh_token \
npm ci --//registry._authToken="$(cat /run/secrets/gh_token)"
docker buildx build --secret id=gh_token,env=GH_TOKEN -t app:v1 .
--secret 的值只在该条 RUN 执行期间以只读文件出现,不写进任何层、不进 ENV,docker history 里看不到。
3)多阶段构建把「带密钥的 builder」挡在最终镜像外。 只要密钥相关产物没被 COPY 进最终 stage,最终镜像就是干净的——但前提是别把整个工作目录 COPY --from=builder / / 一把梭。
4)发布前把密钥扫描挂进 CI,别靠人肉 history。
# 扫本地 tarball / 层,trufflehog 对 base64、AK 模式命中很灵
docker save mall/order-svc:v3.8.2 -o image.tar
trufflehog filesystem . --only-verified
# 或 Snyk:
docker scan mall/order-svc:v3.8.2
把「docker history | grep -iE 'pass|secret|key|token' 命中即 fail」加进流水线 gate,是最便宜的兜底。
5).dockerignore 兜住 .env、*.pem、id_rsa,防止一条无脑 COPY . /app 把宿主敏感文件带走。
四、踩过的坑 / 反直觉之处
- 「我最后删了」是最常见的自欺。 删除 = 新增一层,旧层密钥照样能被
docker save后tar -x翻出来。自查时别信「最终文件树」,要看每一层。 docker history默认会截断长命令,得加--no-trunc才看全;但反过来,攻击者用docker save | tar -x逐层解包 +grep -r是最稳的取法,history只是冰山一角。ARG默认值会进docker inspect的Config,别把真实密钥写进ARG的默认值里。- 改镜像 ≠ 抹掉泄露。 一旦密钥进过已被 pull 的镜像,正确动作是先轮换(rotate)密钥,再重发干净镜像——顺序反了就是「换了锁、钥匙还在旧门上」。
- K8s 里别用
env直接写死,那等价于把密钥写进 ReplicaSet 的spec,kubectl get deploy -o yaml就能看,和镜像烘密钥是同一类问题。
五、结论与可复用清单
核心一句话:密钥要「运行时给」,绝不能「构建时烘」——镜像层只追加不覆盖,烘进去就等于永久存档。
发布前 5 步自查清单:
docker history --no-trunc <img> | grep -iE 'pass|secret|key|token',命中即拦截;docker inspect <img>的Config.Env里不该有任何真实密钥;docker save→tar -x→grep -r逐层确认,比只看history更彻底;- 确需构建期用密钥,一律走 BuildKit
--secret,用完即弃; - CI 挂 trufflehog /
docker scan做 gate,且把「泄露后先 rotate 再重发」写进应急 SOP。
关联阅读:@温星河-289 的 heapdump 端点事故(716)提醒我们密钥会藏在「想不到的地方」;@温望舒-139 的 K8s RBAC 复盘(565)则是同一枚硬币的另一面——密钥藏在哪不重要,重要的是别默认「别人读不到」。