镜像标签是与镜像引用关联的可读名字。如果仓库策略没有禁止重新指派,同一个标签之后可以标识另一个产物。v1.4 和 latest 的区别首先是命名惯例,而不是前者天然不可变。要复现部署,需要记录实际选择的产物,以及影响行为的平台和运行配置。只保存一个看起来像版本号的名字,并不足以证明当时运行的是哪些内容。
坏掉的样子
latest 不会要求仓库寻找最新上传镜像或最高语义版本,它是在引用省略标签时采用的惯例默认值。发布另一个版本,不会自动移动 latest;必须有人或自动化更新这个具体引用。如果把每次 push 都解释成自动更新 latest,就会错误理解发布流程,甚至误判为什么一个节点仍然取得旧版本。
标签的本地解析结果,也可能不同于仓库当前结果。拉取策略、本地镜像是否存在,以及编排工具的行为,共同决定何时查询仓库。因此,两台机器运行相同可读标签,仍可能使用不同产物。仅记录命令文本,会留下实际取回什么、是否保留缓存引用等不确定性;排查时应读取所选产物身份。
移动标签不会改写已经创建的容器所选择的镜像。重启该容器,会保留原配置的镜像和可写状态,不会按照标签的新目标重新创建实例。如果更新流程只是重新打标签,然后 restart 旧容器,命令可能成功,应用却仍运行旧内容。标签更新、容器替换和容器重启需要分别判断,不能混成同一个动作。
快速验证
先取得固定基础镜像,再用 Python 和 Docker 构建器运行脚本。实测环境是 Docker 29.3.1、Linux arm64。所有标签使用独特的本地名字,不向仓库推送任何内容;容器关闭网络,清理只删除实验自己的容器与标签。因此可以实际验证移动行为,又不改变已有发布引用:
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(name,kind='image'):return json.loads(docker(kind,'inspect',name).stdout)[0]
name='xbcatm-tags-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two',name+':release',name+':latest'];container=name+'-old'
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory)
(root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY version.txt /version.txt\nCMD ["cat", "/version.txt"]\n')
for tag,version in zip(tags,['one','two']):
(root/'version.txt').write_text(version+'\n');docker('build','--network','none','-t',tag,str(root))
one=inspect(tags[0])['Id'];two=inspect(tags[1])['Id'];assert one!=two
docker('image','tag',tags[0],tags[2]);docker('image','tag',tags[0],name)
assert inspect(tags[2])['Id']==inspect(tags[3])['Id']==one
docker('create','--name',container,'--network','none',tags[2])
docker('image','tag',tags[1],tags[2])
assert inspect(tags[2])['Id']==two and inspect(tags[3])['Id']==one
assert inspect(container,'container')['Image']==one
assert docker('start','--attach',container).stdout.strip()=='one'
assert docker('run','--rm','--network','none',tags[2]).stdout.strip()=='two'
assert docker('run','--rm','--network','none',tags[3]).stdout.strip()=='one'
print('moving release changes newly created containers; the existing container still runs its original image')
print('omitting a tag selected latest, but updating release did not update latest')
docker('image','rm',tags[2]);assert inspect(tags[1])['Id']==two
print('removing one tag leaves another reference to the same image intact; all operations stayed local')
finally:
docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False)
脚本构建两个镜像,/version.txt 分别包含 one 与 two,镜像身份不同。它先让 release 指向第一份镜像,再用没有显式标签的仓库名标记同一镜像。检查确认,省略标签会得到 latest,而这两个引用最初都选择第一份镜像。这里直接验证默认标签规则,不靠名字含义猜测。
移动 release 之前,脚本从该引用创建一个容器,但暂不启动。随后把 release 移到第二份镜像。此时 release 选择新产物,latest 仍选择第一份。实验把标签重新指派与默认标签惯例分开,说明相关名字不会自动一起移动;如果生产流程会同时更新多个标签,那是流程自身明确执行的行为。
启动先前创建的容器,输出 one;从 release 新建容器,输出 two;从 latest 新建容器,则输出 one。容器检查也保留最初所选的镜像身份。这些断言对照实际运行结果,因此不只是根据标签表的显示推理。尤其是“创建后尚未运行”的旧容器,仍然不会因标签移动而更换镜像。
删除 release 标签后,第二份镜像仍通过 two 标签可用。标签是引用,删除一个引用,不一定删除共享内容或所有其他名字。实际回收取决于剩余引用和引擎存储规则;本实验不会清理构建缓存,也不测量物理空间回收。不能把取消一个标签直接当作磁盘上的全部层已经消失。
实验比较当前引擎返回的身份,但不假定 inspect 的 Id 在所有版本和存储方式下都等于 OCI 配置摘要。现代镜像存储可能暴露不同对象层次。仓库索引、平台清单、镜像配置和层数据,各自都有摘要,所以说明身份时应保留对象类型与引用上下文。相同哈希格式不能代替对象含义。
提升经过测试的同一产物
通过仓库部署时,从发布流程记录带仓库名的摘要引用。摘要可能标识包含多个平台清单的镜像索引,因此仍要选择预期平台。保留对应产物及其必需数据,才能让后续回滚真正拉取成功;单独保存哈希无法恢复已经不可取得的内容。产物身份确定与产物持续可用,是不同的要求。
标签可以继续用于人类查找。如果发布流程承诺版本标签永不移动,就应配置并验证仓库的不可变策略,而不是看到版本形状就认定承诺成立。固定摘要也不会证明作者是谁或软件安全,来源证明与行为验证分别回答这些问题。内容寻址解决的是选择哪份对象,不会替代整个发布审查。
部署后,把运行工作负载选择的产物及其预期行为,与发布记录逐一对照。标签已更新、拉取成功和容器正在运行,是三个独立观察。只有把它们连接到同一份通过验证的产物,才能确认本次发布真正生效,而不是仅仅完成了若干看起来相关的命令。
延伸阅读
来源
- Docker image tag,本地标签创建与默认 latest 标签,已核验 2026-09-12。
- Docker image pull,按标签或摘要拉取仓库产物,已核验 2026-09-12。
- OCI image index 1.1.1,索引引用不同平台镜像清单的结构,已核验 2026-09-12。
- OCI image manifest 1.1.1,清单对配置与层对象的引用,已核验 2026-09-12。