就绪端点失败时 up 仍可为一,直到独立的指标端点也失败。

Docker 健康状态与 Prometheus up 回答不同问题

Docker 健康状态与 Prometheus 的 up 序列来自不同检查。Compose 健康检查在应用容器内运行命令;Prometheus 则独立抓取配置的指标端点,并记录抓取是否成功。两项观察可以合理地不一致,因为应用可能已经无法处理有用工作,却仍能提供指标,也可能业务正常而指标端点损坏。理解区别,才能避免用一个绿灯替代全部服务状态。 坏掉的样子 把 up=1 解释成应用已经就绪,可能掩盖故障。它说明目标指标抓取成功,不说明登录、数据库写入或其他业务操作成功。对于这些问题,需要合适的行为指标或外部探测。自定义就绪 gauge 可以有用,但具体含义必须由应用契约定义,不能仅凭名字叫 ready,就认为它覆盖了所有必需依赖。 反向误解是认为 Docker 健康状态会自动导出到 Prometheus。Compose 中定义 healthcheck,不会自动创建 Prometheus 指标,也不会配置抓取目标。需要 exporter 或应用埋点明确暴露所需观察。即使完成导出,采集路径和新鲜度也与引擎健康状态不同,需要知道更新延迟和失败方式,才能解释不一致。 两种观察都不会自动定义重启或路由动作。Docker 的 restart: always 处理容器终止;容器仍在运行时,不会仅因 unhealthy 就重启。监控可以根据证据告警,明确配置的控制器可以采取动作,但这些连接不会因为两个工具恰好位于同一个 Compose 项目里就凭空出现。观察、告警和修复需要分别验证。 快速验证 使用宿主 Python、Docker Compose 和两个固定镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Python 3.11.16,以及 Linux arm64 上的 Prometheus 3.5.1。它构建临时 Prometheus 配置镜像,创建唯一项目,只把 Prometheus API 发布到宿主回环地址,结束时清理自身项目、数据卷和标签: ...

二月 21, 2028 · 2 分钟 · XB Field Notes
复制输入决定后续 RUN 的结果能否复用。

Docker 构建缓存复用已记录的工作,也可能复用过期结果

Docker 构建缓存保存构建操作的结果,让后续构建在相关输入仍匹配时复用已有工作。缓存命中不表示命令重新运行并生成相同输出,而是构建器接受了一份先前结果。这个区别直接影响软件包安装、生成时间戳、远程下载,以及输出可能变化却没有相应声明输入变化的操作。理解缓存时,首先要区分“再次执行”和“取出已经执行过的结果”。 坏掉的样子 没有变化的 RUN apt-get update 可以直接复用昨天的结果,完全不访问软件仓库。构建器通常不会为了判断这条 RUN 是否仍然新鲜,先检查远端仓库有没有更新。因此,缓存构建成功不能独立证明软件包已经收到最新安全修复。刷新策略必须明确,并与项目要求的可复现性一致,不能依赖某次构建恰好缓存失效。 COPY 的输入不同于普通命令字符串。文件内容和相关元数据参与缓存计算,但源文件修改时间本身不参与。部署脚本如果只是 touch 文件,依赖它的 RUN 仍可能继续命中缓存。反过来,即使文本完全相同,改变可执行权限也可能使 COPY 失效,从而重新执行后续工作。排查时需要比较实际输入,不能只看文件大小和肉眼可见的文字。 基础镜像、步骤实际使用的构建参数和前置依赖也会影响结果。缓存失效沿依赖传播,不能把复杂多阶段构建一律解释成从某行开始全部重做的直线。下面使用简单的单阶段链:复制文件发生变化,就使其后的时间戳 RUN 重新执行,而固定基础镜像的文件系统层仍可继续复用。这种受控结构让实验结论容易对应到真实原因。 快速验证 使用宿主 Python 运行脚本,并连接一个可用于实验的 Docker 构建器。如果本地没有指定摘要,先拉取它。实测环境是 Docker 29.3.1、Buildx 0.32.1,以及 Linux arm64 的 Python 3.11.16 基础镜像。构建和容器均关闭网络。脚本删除自己的临时标签,不清理其他镜像,也不对全局构建缓存执行 prune: from pathlib import Path from tempfile import TemporaryDirectory import json,os,shlex,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-cache-'+uuid.uuid4().hex[:10];tag=name+':probe' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);app=root/'app.txt';app.write_text('initial '+name);app.chmod(0o644) build_code="from pathlib import Path;import time;Path('/built-at').write_text(str(time.time_ns()))" (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY app.txt /app.txt\nRUN python -c {shlex.quote(build_code)}\n') def build(*options):return docker('build','--network','none','--progress','plain',*options,'-t',tag,str(root)) def stamp():return docker('run','--rm','--network','none',tag,'python','-c',"from pathlib import Path;print(Path('/built-at').read_text())").stdout.strip() build();initial=stamp();base_layers=inspect(BASE)['RootFS']['Layers'] result=build();assert stamp()==initial and 'CACHED' in result.stderr timestamp=app.stat().st_mtime_ns+10_000_000_000;os.utime(app,ns=(timestamp,timestamp)) build();assert stamp()==initial print('unchanged inputs reuse the RUN result; changing only source mtime also leaves that result cached') app.chmod(0o755);build();mode_changed=stamp();assert mode_changed!=initial app.write_text('changed '+name);build();content_changed=stamp();assert content_changed!=mode_changed print('changing copied-file permissions or content invalidates COPY and reruns the dependent RUN') build('--no-cache');assert stamp()!=content_changed assert inspect(tag)['RootFS']['Layers'][:len(base_layers)]==base_layers print('no-cache reruns the instruction while this pinned base retains the same filesystem layer prefix') finally:docker('image','rm',tag,check=False) 首次构建把纳秒时间戳写进 /built-at。第二次用相同输入构建,日志出现缓存操作,镜像中的时间戳也完全相同。这比比较构建耗时更有说服力:命令可能执行得很快,但保留下来的同一个时间戳表明,这里复用了之前记录的结果。测试同时看日志和目标文件,避免把其他步骤命中缓存误认为目标 RUN 命中。 ...

二月 20, 2028 · 1 分钟 · XB Field Notes
默认参数传给程序,包装脚本的进程结构决定谁收到停止信号。

ENTRYPOINT 和 CMD 定义可被运行者覆盖的启动默认值

镜像的 ENTRYPOINT 和 CMD 共同参与容器创建时的启动命令。使用 exec 形式 ENTRYPOINT 时,CMD 通常提供追加给入口的默认参数;没有入口时,CMD 可以提供可执行程序及其参数。这些是启动默认值,不是强制执行边界。有权限创建容器的运行者,可以替换入口或参数,因此不能把 ENTRYPOINT 描述成无论如何都不可改变的程序锁。 坏掉的样子 在 docker run image ... 中写到镜像名之后的参数,会替换 CMD,不是自动加到原 CMD 默认值之后。exec 形式入口仍然保留,除非显式覆盖。用户以为自己只是追加一个选项,却可能丢掉全部默认参数。因此,命令行工具需要说明哪些值可省略、哪些必须重新提供,以及自身参数解析器如何处理覆盖。 exec 形式使用 JSON 数组,不会隐式运行 shell。$DEMO 这样的字符串会原样传给程序,而不是自动展开环境变量。管道、重定向和通配符也需要真正的解释器,才会获得相应含义。shell 形式会改变参数和进程语义,所以两种形式之间的切换并不只是语法美化,不能只看命令表面似乎相同。 启动包装脚本还可能让服务藏在不转发停止信号的 shell 后面。Docker 通常向容器主进程发送停止信号,等待配置的超时,必要时强制终止。即使服务自身正确安装了 SIGTERM 处理函数,如果信号只到达另一个不会转发的进程,这个处理函数也无法发挥作用。排查优雅退出失败时,应先确认信号实际发给了谁。 快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 构建器运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。实验创建独立临时容器并关闭网络,等待每个服务打印就绪信息后测试停止,结束时删除自己的容器和镜像标签,不依赖启动后盲目等待固定时长来猜测服务已经准备好: ...

十月 3, 2027 · 2 分钟 · XB Field Notes
发布标签移到新产物,既有容器与另一个标签仍引用旧产物。

镜像标签可以移动,既有容器仍保留原先选择的镜像

镜像标签是与镜像引用关联的可读名字。如果仓库策略没有禁止重新指派,同一个标签之后可以标识另一个产物。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,而这两个引用最初都选择第一份镜像。这里直接验证默认标签规则,不靠名字含义猜测。 ...

八月 24, 2027 · 1 分钟 · XB Field Notes
项目重建网络,同时保留命名数据卷和稳定服务名。

Compose 项目把资源声明与外部输入组合起来

Docker Compose 把服务声明转化成以项目管理的容器、网络和存储连接。文件描述有用关系:服务运行哪个镜像,配置如何传入,以及使用哪些存储或网络。复现整套环境仍依赖镜像可用性、变量值、宿主能力和既有数据等外部输入。文件进入版本控制,并不自动固定这些输入;只有把它们识别清楚,声明才真正可审阅、可解释。 坏掉的样子 调用 shell 提供不同变量时,相同 Compose 文本可能解析成不同结果。环境文件参与插值,但不等于其中所有值都会自动注入每个容器。服务级 environment 声明和 env_file 设置有各自作用。应同时检查渲染配置和实际运行服务,不能因为某个文件里写了一个值,就认定它必然是最终生效值。 网络地址也是常见陷阱。Compose 网络中的服务可以通过服务名解析彼此,并使用容器端口;发布到宿主的端口属于另一条访问路径。如果客户端容器请求 localhost,通常请求会回到客户端自身,而不是兄弟 API。硬编码容器 IP 也会让替换更脆弱,稳定服务名更适合表达本来就属于服务关系的目标。 项目生命周期不意味着所有资源具有相同寿命。普通 down 删除项目容器和网络,命名卷则通常保留,除非明确要求删除卷。重建网络会改变其身份,也可能改变分配地址。数据仍在,不代表旧网络仍在;新容器已经创建,也不代表连接的数据卷为空。排查重建行为时,需要分别观察这些资源。 快速验证 使用宿主 Python、Docker Compose 和固定 Python 镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Linux arm64。生成的 Compose 文档使用 YAML 接受的 JSON 语法,不需要额外 Python 解析器,并采用唯一项目名。API 只发布到宿主回环地址,由引擎自动分配可用端口: ...

八月 18, 2027 · 2 分钟 · XB Field Notes
把频繁编辑的应用代码放到安装之后,稳定依赖层就能继续复用。

先复制依赖输入,再复制应用代码

Dockerfile 顺序决定昂贵构建操作依赖哪些先前输入。如果应用源码在依赖安装之前被复制,即使依赖声明未变,修改源码也可能让安装重跑。先复制依赖输入、安装依赖,再复制应用代码,可以建立更小的依赖边界,让它与安装器真正需要的工作匹配。这里优化的是依赖关系,而不只是把几行命令挪到看起来更整齐的位置。 坏掉的样子 在软件包安装前使用宽泛的 COPY . .,会让文档、生成输出或无关源码编辑,都可能成为重做昂贵工作的原因。问题不在于 COPY 天生很慢,而在于后续安装依赖了一份包含无关变化的文件系统状态。这些变化并不是选择和安装依赖所必需,却通过前置步骤扩大了失效范围。 如果安装还读取锁文件、本地包源码、补丁、工作区元数据或包管理器配置,仅复制一个清单也不够。遗漏真实输入,可能保留已经不符合构建意图的缓存结果。正确边界必须包含安装器实际使用的全部输入,而不是只选择常见 Dockerfile 示例里出现的某个文件名。否则,表面命中率提高,实际正确性却下降。 缓存顺序也不能修复可变依赖。相同安装命令可能一直保留旧结果,而干净构建却从远端解析到不同内容。固定版本和校验哈希解决产物选择问题,控制 COPY 顺序解决复用问题。两者都需要处理,构建更快并不能证明软件包图可复现,也不能证明依赖已经更新到本次发布要求的版本。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。它先通过 HTTPS 下载两个公开的 idna wheel,并在构建前验证固定 SHA-256。容器构建关闭网络,pip 从本地 wheel 目录安装,并强制要求哈希。实测环境为 Docker 29.3.1,Linux arm64 基础镜像内 Python 为 3.11.16: from pathlib import Path from tempfile import TemporaryDirectory from urllib.request import urlopen import hashlib,json,subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' WHEELS={ '3.10':('https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl','946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3'), '3.11':('https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl','771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea')} 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 name='xbcatm-order-'+uuid.uuid4().hex[:10];tags=[name+':early',name+':late'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'wheels').mkdir() for version,(url,digest) in WHEELS.items(): with urlopen(url,timeout=40) as response:data=response.read() assert hashlib.sha256(data).hexdigest()==digest (root/'wheels'/url.rsplit('/',1)[1]).write_bytes(data) install='RUN pip install --no-index --no-deps --require-hashes --find-links=/wheels -r /app/requirements.txt && python -c "import time;open(\'/installed\',\'w\').write(str(time.time_ns()))"\n' common=f'FROM {BASE}\nLABEL example.run="{name}"\nCOPY wheels /wheels\n' (root/'early.Dockerfile').write_text(common+'COPY requirements.txt app.py /app/\n'+install+'CMD ["python", "/app/app.py"]\n') (root/'late.Dockerfile').write_text(common+'COPY requirements.txt /app/\n'+install+'COPY app.py /app/\nCMD ["python", "/app/app.py"]\n') def requirement(version): (root/'requirements.txt').write_text(f'idna=={version} --hash=sha256:{WHEELS[version][1]}\n') def application(message): (root/'app.py').write_text(f'import idna\nprint({message!r},idna.__version__,idna.encode("bücher.example").decode())\n') def build(): for tag,kind in zip(tags,['early','late']):docker('build','--network','none','-f',str(root/(kind+'.Dockerfile')),'-t',tag,str(root)) def marks(): return [docker('run','--rm','--network','none',tag,'cat','/installed').stdout for tag in tags] requirement('3.10');application('first');build();first=marks() application('second');build();second=marks() assert first[0]!=second[0] and first[1]==second[1] for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.10 xn--bcher-kva.example' print('editing only application code reruns the early-copy dependency install but reuses the late-copy install') requirement('3.11');build();third=marks() assert all(a!=b for a,b in zip(second,third)) for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.11 xn--bcher-kva.example' print('changing the hashed requirement reruns both installs and actually changes idna from 3.10 to 3.11') finally:docker('image','rm',*tags,check=False) 提前复制版本的 Dockerfile 在 pip 之前一起复制 requirements.txt 与 app.py。延后复制版本先复制依赖声明,执行相同安装命令,再复制应用。两者都提前加入经过验证的 wheel 目录,因为这些文件确实是安装输入。pip 之后立即写入标记,用来观察安装所在操作是否真的执行,而不是凭耗时猜测。 ...

八月 17, 2027 · 2 分钟 · XB Field Notes
whiteout 从合并视图隐藏文件,原层仍然保留其字节。

后续镜像层删除文件,并不会擦除先前层的原始字节

容器镜像层描述按顺序应用的文件系统变化。一层可以新增或替换文件,也可以记录某个先前路径应从合并文件系统中消失。普通容器进程看到的是最终合并视图,但这个视图不等于随镜像分发的全部字节历史。因此,仅检查运行中的容器,可能遗漏早期层仍然保留的数据,尤其容易误判删除操作的实际效果。 坏掉的样子 把凭据复制进镜像,再通过后续 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。这验证最终运行视图,却不能证明镜像归档里也没有对应字节,所以实验接下来单独检查保存后的表示。把两个观察拆开,是避免将“看不见”误判为“没有保存”的关键。 ...

八月 16, 2027 · 2 分钟 · XB Field Notes
探测可以改变健康状态,却不会自动重启应用进程。

Docker 健康检查报告状态,恢复动作需要明确的消费者

Docker HEALTHCHECK 在容器内运行配置的命令,并把结果记录为健康状态。它提供了主进程是否运行之外的另一项观察,但含义取决于命令检查什么:进程存在、HTTP 响应,还是应用特定的就绪条件。Docker 不会因为出现 HEALTHCHECK 指令,就自动推导出一份通用业务就绪契约,检查者必须明确这个信号能证明哪些行为。 坏掉的样子 总是返回零的命令,即使应用无法提供有用工作,也会产生看起来正常的健康报告。shell 命令序列还可能因为最后一条命令成功,而掩盖前面的失败。反过来,如果探测依赖一个非必需外部服务,就可能把仍可工作的应用标成不健康,并促使消费该状态的系统执行不必要恢复。探测含义必须符合实际消费者需求。 独立 Docker 的重启策略响应容器终止,不是只要健康状态变成 unhealthy 就触发。加入 restart=always,不会让 Docker 因探测失败而重启仍然存活的应用。同样,健康检查也不会自动从所有代理的路由表中移除容器。必须有控制器或代理明确读取该状态,并实现预期动作,不能把观察配置当成恢复配置。 探测时序也容易被误解。重试阈值统计连续失败,启动宽限则改变早期哪些失败会被计数。启动期的一次成功可以结束初始宽限行为。检测耗时取决于命令持续时间、间隔、超时和配置,不能简单承诺一次请求失败就立刻停止流量。解释延迟时,应沿实际探测时间线检查,而不是只看一个 retries 数字。 快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。它只创建一个临时镜像和容器,关闭容器外部网络,使用自身回环 HTTP 服务。被改变的状态仅是实验内部一个无害就绪文件,结束后清理自己的容器和标签: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def inspect(container):return json.loads(docker('inspect',container).stdout)[0] def wait_for(container,wanted): deadline=time.monotonic()+25 while time.monotonic()<deadline: state=inspect(container) if state['State']['Health']['Status']==wanted:return state time.sleep(.1) raise AssertionError('health did not reach '+wanted) name='xbcatm-health-'+uuid.uuid4().hex[:10];tag=name+':probe' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'server.py').write_text('from http.server import BaseHTTPRequestHandler,HTTPServer\nfrom pathlib import Path\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n code=200 if Path("/ready").exists() else 503\n self.send_response(code);self.end_headers();self.wfile.write(str(code).encode())\n def log_message(self,*args):pass\nHTTPServer(("127.0.0.1",8080),Handler).serve_forever()\n') probe='import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080/health",timeout=.5).read()' (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY server.py /server.py\nHEALTHCHECK --interval=1s --timeout=1s --retries=2 --start-period=5s CMD ["python", "-c", {json.dumps(probe)}]\nCMD ["python", "/server.py"]\n') docker('build','--network','none','-t',tag,str(root)) began=time.monotonic() docker('run','-d','--name',name,'--network','none','--restart','always',tag) initial=inspect(name);assert initial['State']['Health']['Status']=='starting' first=wait_for(name,'unhealthy');assert first['State']['Running'] and first['RestartCount']==0 assert first['State']['StartedAt']==initial['State']['StartedAt'] assert first['State']['Health']['FailingStreak']>=2 assert any(log['ExitCode']!=0 and '503' in log['Output'] for log in first['State']['Health']['Log']) time.sleep(max(0,12-(time.monotonic()-began))) stable=inspect(name);assert stable['State']['Running'] and stable['RestartCount']==0 assert stable['State']['StartedAt']==initial['State']['StartedAt'] and stable['State']['Health']['Status']=='unhealthy' docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").touch()') healthy=wait_for(name,'healthy');assert healthy['RestartCount']==0 and healthy['State']['Health']['FailingStreak']==0 docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").unlink()') unhealthy=wait_for(name,'unhealthy');assert unhealthy['RestartCount']==0 and unhealthy['State']['Running'] assert unhealthy['State']['StartedAt']==initial['State']['StartedAt'] print('starting -> unhealthy -> healthy -> unhealthy follows the real HTTP probe; failure logs contain HTTP 503') print('restart=always does not restart a still-running process solely because its health is unhealthy') finally: docker('rm','-f',name,check=False);docker('image','rm',tag,check=False) /ready 不存在时,服务返回 HTTP 503。Dockerfile 健康检查使用 Python HTTP 客户端,遇到该状态会抛出错误并以失败状态结束。配置为一秒间隔、一秒超时、两次重试、五秒启动期。容器采用 restart=always 启动,初始检查记录健康状态为 starting,随后观察实际探测结果。 ...

七月 5, 2027 · 2 分钟 · XB Field Notes