按摘要固定容器基础镜像,可以选择确定的镜像对象,避免依赖会移动的标签。这消除了一类意外构建变化,但不会让所有派生镜像或运行实例都完全相同。应用源码、构建参数、下载依赖、目标平台、运行配置和持久数据,仍可能改变构建结果及其行为。因此,基础镜像不可变是一个明确的输入约束,不是完整发布已经可复现的证明。
坏掉的样子
两个 Dockerfile 可以使用相同 FROM 摘要,却生成不同应用。即使 Dockerfile 一样,构建参数变化或未固定的下载取得新内容,也可能改变输出。所以,仅声明基础镜像不可变,无法证明完整构建是确定性的。决定这个性质的是全部输入,以及施加在输入上的转换过程;遗漏任何真正影响结果的部分,都会留下解释缺口。
完全相同的应用镜像,在不同环境变量下运行,也可能选择不同端点、功能行为或存储。可写绑定挂载还能替换镜像内已经测试过的文件。把配置留在镜像外通常有价值,但只要配置参与定义发布行为,它仍然需要版本管理和审阅。外置不等于无须控制,更不意味着配置变化不会影响同一镜像的实际表现。
普通容器还拥有可写文件系统层。紧急编辑可以改变一个实例,而镜像和其他实例保持原样。之后从未修改的镜像替换实例,编辑就会消失。因此,不可变部署实践需要把产物替换与受控运行状态结合起来;只在 FROM 后写摘要,无法自动强制团队遵守这种操作方式,也无法阻止容器内手工修补。
快速验证
使用宿主 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-pinning-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two'];container=name+'-running'
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory)
(root/'Dockerfile').write_text(f'FROM {BASE}\nARG MESSAGE\nRUN printf "%s" "$MESSAGE" > /built-message\nENV MODE=default\nCOPY app.py /app.py\nCMD ["python", "/app.py"]\n')
(root/'app.py').write_text('from pathlib import Path\nimport os\nprint(Path("/built-message").read_text(),os.environ["MODE"])\n')
layers=inspect(BASE)['RootFS']['Layers']
for tag,message in zip(tags,['one','two']):
docker('build','--network','none','--build-arg','MESSAGE='+message,'-t',tag,str(root))
assert inspect(tag)['RootFS']['Layers'][:len(layers)]==layers
assert inspect(tags[0])['Id']!=inspect(tags[1])['Id']
assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default'
assert docker('run','--rm','--network','none',tags[1]).stdout.strip()=='two default'
assert docker('run','--rm','--network','none','-e','MODE=changed',tags[0]).stdout.strip()=='one changed'
print('both builds use the same pinned base layers; different build arguments produce different artifacts')
print('the identical application image behaves differently when runtime configuration changes')
docker('run','-d','--name',container,'--network','none',tags[0],'python','-c','import time;time.sleep(60)')
docker('exec',container,'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")')
assert docker('exec',container,'python','/app.py').stdout.strip()=='edited default'
assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default'
failed=docker('run','--rm','--network','none','--read-only',tags[0],'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")',check=False)
assert failed.returncode!=0 and 'Read-only file system' in failed.stderr
print('a writable container can drift while its image stays unchanged; a read-only root rejects that particular write')
finally:
docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False)
两次构建使用相同基础引用和应用源码,选择的构建差异只有 MESSAGE;Dockerfile 把它写入 /built-message。第一份镜像输出 one default,第二份输出 two default。完整镜像身份不同,但检查确认它们有序的基础文件系统层前缀完全一致。这里同时对照了公共输入和最终行为,避免把基础相同误当成全部相同。
这组结果证明的范围很明确:基础没有变化,但另一项显式输入改变了派生产物。它没有试图证明所有输入固定时,多次构建必然逐字节相同。要验证后者,还需要控制时间戳、依赖获取、工具链行为,以及构建器生成的元数据。不能把一次相同基础层检查扩展成未经执行的完整可复现构建结论。
接着,第一份应用镜像覆盖 MODE 为 changed,没有重新构建,却输出 one changed。这把产物身份与发布配置分开。仓库摘要可以标识应用字节,但不能单独告诉事故调查者,当时用了哪些环境值、凭据、挂载配置或外部服务。对于影响行为的输入,发布记录必须补上相应身份和管理位置。
随后,脚本从第一份镜像启动一个长时间运行容器,并修改其中的消息文件。该容器内应用输出 edited default,从未改变的镜像新建容器则仍输出 one default。修补只存在于被编辑实例的可写状态,不会自动进入替换实例。这个差异解释了为什么只修线上容器、没有生成新产物,会让相同故障在重建后再次出现。
最后,启用只读根文件系统,尝试相同编辑,会得到只读文件系统错误。这证明该镜像路径受到一个实际有效的写入限制,而不是证明所有运行状态都已冻结。显式可写挂载、外部数据库、环境设置和宿主内核,仍是独立的执行环境组成部分,需要按各自契约控制,不能从一次写入失败推断全部不可变。
记录发布,再替换对应实例
发布记录应连接经过测试的应用摘要、目标平台、配置修订和数据兼容要求。OCI 索引可以包含多个平台的清单,因此固定索引后,运行时仍会选择平台镜像。架构影响复现或诊断时,应记录实际选择,而不只记录一个跨平台索引摘要。这样,同一发布名字下的不同执行环境才有可追踪的解释。
需要的变更应进入新产物,经验证后通过部署机制替换实例。持久数据使用具有独立生命周期和恢复计划的存储,不要把容器可写层当成唯一持久副本,也不要在宣称属于同一固定发布的实例背后,悄悄修改共享配置。应用产物、数据和配置分别受控,才能让替换行为真正可预测。
固定摘要还意味着新的基础修复不会自动到来。需要持续跟踪更新、审阅新摘要、重建依赖应用,并在提升前测试。保留已知输入很有价值,但永远保留过期输入,不等于维护安全发布。固定与更新不是互相排斥,而是要求每次更新成为明确、可审阅的变更。
调查漂移时,分别比较产物、配置、挂载和运行时修改,因为它们有不同的负责人和证据来源。用可审阅发布替代随手修改实例,可以让差异变得可解释,同时承认相同镜像字节并不意味着外部状态相同,也不保证在所有负载下表现完全一样。这是控制边界,而不是夸大不可变镜像的能力。
延伸阅读
来源
- Docker build best practices,基础镜像摘要固定与主动更新流程,已核验 2026-09-12。
- Docker container run,运行环境覆盖与可写或只读文件系统,已核验 2026-09-12。
- The Twelve-Factor App: build, release, run,区分构建产物、发布配置与执行,已核验 2026-09-12。
- OCI image index 1.1.1,镜像索引中的平台选择,已核验 2026-09-12。