镜像用有序的不可变层变更集描述文件系统,并带有默认命令等配置。相关镜像可以引用相同基础层,同时加入不同应用文件。容器运行时,再在镜像之上拥有自己的可写层。区分镜像、共享文件层和容器可写状态,才能解释为什么镜像复用能够节省工作,却不会让某个容器里的日常编辑自动出现在所有邻居中。

坏掉的样子

直接把几个相关镜像显示的大小相加,可能高估它们合计占用的本地存储,因为共享内容可能被重复计数。传输大小、解包后的文件大小,以及快照存储实际分配的空间,也不是同一个指标。共享层确实支持复用,但不能在没有测量具体存储实现和统计口径时,声称节省了一个精确的磁盘容量。

另一个常见错误,是把容器里的修改视为镜像修改。在一个普通容器中安装软件包或修改文件,改变的是该容器的可写文件系统状态。从原镜像新建容器,不会自动继承这次修复。于是,手工修好运行中的服务后再替换实例,原故障可能重新出现,因为用于替换的产物从未包含那次修复。

共享层也不是通用的应用兼容性边界。两个应用即使基础层相同,仍可能使用不同配置、挂载数据、架构,或者在后续层加入不同库版本。应比较有序文件系统标识,以及被验证的实际行为。仅仅看到可读标签相同,无法确定运行的是哪些字节,也无法解释运行设置是否一致。

快速验证

先取得固定摘要的 Python 基础镜像,再使用 Python 和 Docker 构建器运行脚本。实测环境为 Docker 29.3.1、Linux arm64,镜像内 Python 为 3.11.16。实验创建两个相关镜像和两个临时容器,不使用容器网络,结束时只删除自身命名的容器和标签,不触碰其他实验或服务:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,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-layers-'+uuid.uuid4().hex[:10];tags=[name+':left',name+':right'];containers=[name+'-one',name+'-two']
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'shared.py').write_text('value=10\n# '+name+'\n')
        (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY shared.py /app/shared.py\nCOPY main.py /app/main.py\nCMD ["python", "/app/main.py"]\n')
        for tag,increment in zip(tags,[1,2]):
            (root/'main.py').write_text(f'from shared import value\nprint(value+{increment})\n')
            docker('build','--network','none','-t',tag,str(root))
        left=inspect(tags[0])['RootFS']['Layers'];right=inspect(tags[1])['RootFS']['Layers']
        assert left[:-1]==right[:-1] and left[-1]!=right[-1]
        assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='11'
        assert docker('run','--rm','--network','none',tags[1]).stdout.strip()=='12'
        print('two images share',len(left)-1,'filesystem layer identities and differ in the final application layer')
        for container in containers:
            docker('run','-d','--name',container,'--network','none',tags[0],'python','-c','import time;time.sleep(60)')
        docker('exec',containers[0],'python','-c',"from pathlib import Path;Path('/app/shared.py').write_text('value=100\\n')")
        assert docker('exec',containers[0],'python','/app/main.py').stdout.strip()=='101'
        assert docker('exec',containers[1],'python','/app/main.py').stdout.strip()=='11'
        assert '/app/shared.py' in docker('diff',containers[0]).stdout
        assert inspect(tags[0])['RootFS']['Layers']==left
        assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='11'
        print('a write in one container changes its writable layer while the sibling container and original image retain the old code')
finally:
    docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False)

两次构建复制相同的 shared.py,其中定义数值十。最后的 main.py 不同:一个加一,另一个加二,所以程序分别输出 11 与 12。镜像检查显示,在本次基础镜像和构建结构下,前五个文件系统层标识相同,最后的应用层标识不同。这同时提供文件身份和程序结果两类证据。

五层是本次观察,不是所有 Python 镜像必须遵守的数量。真正有意义的断言,是比较最后一次 COPY 之前的全部层,并确认最后一层不同。如果发布者改变基础结构,新摘要可能具有不同层数,但共享原则仍然成立。不要把某次 inspect 输出里的计数硬编码成对整个镜像生态的结论。

随后,从第一个镜像启动两个长时间运行的容器。脚本只把第一个容器中的 /app/shared.py 改为定义一百。执行其应用得到 101,执行兄弟容器的应用仍得到 11;docker diff 同时报告被编辑容器中的路径变化。实验因此能够区分共享只读内容与某个运行实例独有的修改。

原镜像的层标识保持不变,再从该镜像创建新容器,输出仍然是 11,证明运行时编辑没有写回镜像。这里使用普通镜像路径,没有共享卷或宿主绑定挂载。如果两个容器连接同一个可写挂载,修改可见性就由该共享存储决定,不能直接套用本实验的隔离结果,需要单独检查挂载关系。

检查到的 RootFS.Layers 列表通过 DiffID 标识未压缩的文件系统变更集。不要随意把这些值替换成仓库清单摘要、压缩层数据摘要或镜像配置摘要。它们都是内容标识,但各自对应不同的序列化对象,在存储、获取和产物选择中的用途不同。看见相同的 sha256 前缀格式,不意味着它们表达相同层次的身份。

构建并替换预期产物

相关应用确实能够共享依赖时,让共同依赖输入保持稳定,并把应用特有代码放在后面复制。这样有利于缓存和内容复用,但不需要为了增加相同哈希数量,强迫无关应用使用人为拼凑的公共依赖集合。合理边界应来自软件实际需求,否则镜像看似共享更多,升级和漏洞修复反而被不必要地绑在一起。

必要的运行时修复应转化成可审阅的镜像变更,并测试替换容器。持久应用数据通过明确的存储契约保存,不要依赖容器可写层在替换后仍然存在。另一方面,也不能因为删除容器,就认定独立管理的卷数据已经删除。镜像产物、实例寿命和持久数据,需要分别记录和管理。

做容量核算时,使用适合构建器和镜像存储的工具测量共享与独占空间;做发布追踪时,记录产物引用和运行配置;排查故障时,同时检查镜像内容与容器差异。这些问题相互关联,但单个镜像大小列或者一个标签不能同时回答全部问题。把每个观察对应到它真正能证明的范围,才能避免错误的修复和容量判断。

延伸阅读

来源