容器镜像层描述按顺序应用的文件系统变化。一层可以新增或替换文件,也可以记录某个先前路径应从合并文件系统中消失。普通容器进程看到的是最终合并视图,但这个视图不等于随镜像分发的全部字节历史。因此,仅检查运行中的容器,可能遗漏早期层仍然保留的数据,尤其容易误判删除操作的实际效果。

坏掉的样子

把凭据复制进镜像,再通过后续 RUN 删除,不会从早期不可变层中移除凭据。能够取得相关层的人,仍可能恢复原始字节。同样,把很大的构建压缩包放进某层,再在下一条指令里删除,最终路径虽然不存在,分发内容仍可能包含整个压缩包。目录看起来干净,并不能证明镜像已经没有这份数据。

镜像层是文件系统变更集,不一定是紧凑的二进制差分。替换大文件,可能把替换后的完整内容放入另一层。删除通过 whiteout 标记,在应用层时屏蔽原有路径。OCI 还规定了隐藏下层目录内容的不透明目录规则,所以不能把正确应用镜像层简化成反复普通解压 tar;删除和覆盖语义需要被专门处理。

不是每条 Dockerfile 指令都会创建文件系统层。ENV、LABEL 和 CMD 改变镜像配置,可以产生标记为 empty_layer 的历史条目。因此,Dockerfile 行数、构建历史条目数,都不能可靠地替代根文件系统层数。即使没有增加应用文件字节,这些配置变化仍然影响执行方式和产物身份,不应被当成无关信息。

快速验证

使用 Python、Docker 构建器和固定摘要的基础镜像运行脚本。实测为 Docker 29.3.1,采用 containerd 镜像存储,基础镜像内为 Linux arm64 的 Python 3.11.16。标记内容特意设为公开文本。脚本构建并保存一个临时镜像,直接读取归档成员,不把归档解压到宿主文件系统,结束时删除自己的标签:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,tarfile,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(["docker",*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def inspect(image):return json.loads(docker("image","inspect",image).stdout)[0]

name='xbcatm-whiteout-'+uuid.uuid4().hex[:10];tag=name+':probe';marker=b'PUBLIC_DEMONSTRATION_BYTES\n'
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'original.txt').write_bytes(marker)
        (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY original.txt /demo/original.txt\nRUN rm /demo/original.txt\nENV EXAMPLE_MODE=demo\nLABEL example.purpose="public fixture"\nCMD ["python", "-c", "from pathlib import Path;assert not Path(\'/demo/original.txt\').exists();print(\'absent\')"]\n')
        docker('build','--network','none','-t',tag,str(root))
        assert docker('run','--rm','--network','none',tag).stdout.strip()=='absent'
        archive=root/'image.tar';docker('image','save','-o',str(archive),tag)
        with tarfile.open(archive) as outer:
            manifests=json.load(outer.extractfile('manifest.json'));assert len(manifests)==1
            manifest=manifests[0];config=json.load(outer.extractfile(manifest['Config']))
            assert len(manifest['Layers'])==len(config['rootfs']['diff_ids'])
            assert len(manifest['Layers'])==len(inspect(BASE)['RootFS']['Layers'])+2
            found_data=False;found_whiteout=False
            for layer in manifest['Layers'][-2:]:
                with tarfile.open(fileobj=outer.extractfile(layer),mode='r:*') as contents:
                    for entry in contents:
                        path=entry.name.removeprefix('./')
                        if path=='demo/original.txt':found_data=contents.extractfile(entry).read()==marker
                        if path=='demo/.wh.original.txt':found_whiteout=True
            assert found_data and found_whiteout
            assert all(item.get('empty_layer') for item in config['history'][-3:])
        print('the final container hides the removed file, but the older layer still contains the exact public marker')
        print('the deletion layer contains a whiteout; ENV, LABEL and CMD add metadata history without filesystem layers')
finally:docker('image','rm',tag,check=False)

Dockerfile 先把 original.txt 复制到 /demo,再在后续 RUN 删除。容器默认命令断言路径不存在,并输出 absent。这验证最终运行视图,却不能证明镜像归档里也没有对应字节,所以实验接下来单独检查保存后的表示。把两个观察拆开,是避免将“看不见”误判为“没有保存”的关键。

归档的 manifest.json 指明配置文件和有序层文件。脚本读取配置中的 rootfs.diff_ids,检查文件系统层数量与清单一致,再读取最后两个层归档。在这个特定构建中,它们分别对应 COPY 新增与后续删除。实验依赖的是保存产物的实际清单,而不是猜测某个长哈希文件名一定属于哪条指令。

新增层仍包含完全相同的 PUBLIC_DEMONSTRATION_BYTES 标记。删除层则包含 demo/.wh.original.txt,指示应用层时隐藏下层文件,而不是原地编辑那个旧层。这两个事实都有直接断言,因此不能把容器中文件缺失当作安全擦除证据。真实凭据若曾进入分发层,之后删除路径并不能撤销已经发生的暴露。

配置历史的最后三条分别对应 ENV、LABEL 和 CMD,并被标记为不产生文件系统层。相对于基础镜像,本次只增加两个文件系统层,而不是五个。这为“每条 Dockerfile 指令必然追加文件系统差分”的说法提供了明确反例,同时保留配置历史和文件系统内容之间的关系,便于解释 inspect 与 history 为什么计数不同。

归档表示和运行时存储是不同接口。这里检查的是实测 Docker 版本生成的 image-save 格式,以及 OCI 变更集语义。实验没有假定快照存储内部也必须用同样可见的 .wh. 文件名保存删除标记,也没有测量某台宿主实际分配了多少磁盘块。内部实现细节不能仅凭归档内容反推。

在字节进入镜像之前排除无用内容

选择合适的构建上下文和忽略规则,避免无关文件进入 COPY 输入。构建凭据通过构建器的密钥机制传递,并确保命令没有把凭据写入保留输出。如果真实凭据已经进入发布层,需要轮换并处理已有副本;补一个删除层不是完整修复,因为旧字节可能仍在仓库、缓存或此前下载者手里。

只在某次操作期间需要的软件包索引或临时归档,应在同一次构建操作中创建并删除。独立最终阶段可以只复制必要运行产物,而不继承构建阶段的文件系统层。不过,仍要检查导出的缓存和复制输出,因为早期构建材料可能留在构建器,或者经另一种导出路径传播。多阶段结构不会自动证明所有输入都已保密。

最终应根据真实产物和运行依赖判断结果。层数更少,本身不能保证下载更小、启动更快或内容更安全。真正需要回答的是:哪些字节随产物分发,哪些路径在运行时可见,以及每一份保留文件是否具有必要用途。这样才能把空间优化、运行正确性与敏感内容处理放在各自准确的证据上。

来源