标签没有变,生产环境跑的却是另一个镜像
目标
亲手把只构建一次的产物在环境之间移动,而不重新构建。用摘要指定并晋级,建立能抓出标签移动的检查器和拦截没有记录的内容的门禁,把回退作为对旧摘要的重新晋级来执行,并用一个文件记录各处有什么。
为什么重要
每个环境都重新构建,每个环境得到的就会是不同的东西。基础镜像标签会移动,依赖的补丁会升高,构建机器的工具版本也不一样。所以在开发环境通过的测试,对生产环境的产物什么也说明不了——因为测试的东西和部署的东西不是同一个东西。只构建一次、只做移动,这个变量就会消失,但这条原则会从指向被移动对象的名字那里漏掉。标签是人贴上去的标牌,随时可以让它指向别的东西,那一刻昨天验证的和今天部署的就分道扬镳了。摘要是根据内容计算出的地址,不会被移动。所以现场的规则归结为一条——标签用来读,摘要用来指定。回退也建立在同一条规则上。只要旧摘要还留在注册表中,回退就不是重新构建,而是重新指向旧地址,并且可以用字节来证明回退得到的东西与从前是同一个东西。
步骤
- 在
/root/promote/app创建一个练习用的 git 仓库——放入文件main.py、orders.conf、README.md,堆叠两个以上提交,然后给最后一个提交打上无注释的标签v1.2.0。接着创建/root/promote/release-name.sh <저장소경로>(占位符为仓库路径)。这个脚本从仓库中提取一行发布名称并输出到标准输出。规则是<가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리>(占位符依次为最近的标签、该标签之后的提交数、7 位提交哈希),如果有未提交的改动,就在末尾加上-dirty。如果仓库里一个标签也没有,标签位置写v0.0.0,提交数位置写总提交数。如果收到的路径不是 git 仓库,标准输出中什么也不要输出,并以非 0 码退出。赋予执行权限,把在/root/promote/app上运行的结果保存为/root/promote/release.txt中的一行。 - 用 OCI 镜像布局目录来模拟各环境的注册表。在
/root/promote/reg下放置dev、staging、prod。假设构建机器为这次发布产出的产物是/opt/images/busybox_1.36.tar(oci-archive)。把它以发布名称作为标签,只放进一次开发环境布局/root/promote/reg/dev(发布名称是/root/promote/release.txt中的值)。然后创建/root/promote/resolve.sh <레이아웃> <태그>(占位符依次为布局与标签)——它把该标签当前指向的摘要以一行sha256:<64자리 16진수>(占位符为 64 位十六进制数)输出,并以 0 退出。如果没有这样的标签,标准输出中什么也不要输出,以 3 退出;如果收到的路径不是布局,以 2 退出。赋予执行权限之后,把该摘要保存为/root/promote/dev-digest.txt中的一行。最后创建/root/promote/releases.tsv,并在第一行写入<다이제스트><탭><판이름><탭><커밋40자리>(占位符依次为摘要、制表符、发布名称、制表符、40 位提交哈希)。提交是生成该发布的那个提交的完整哈希。 - 构造这样一种情形:有人用同样的发布名称标签,把另一个产物推进了开发环境。把
/opt/images/nginx_1.27-alpine.tar复制到/root/promote/reg/dev的同一个标签(发布名称)上。然后在/root/promote/tag-moved.txt中恰好写三行——第一行before <다이제스트>(占位符为摘要)是推进之前该标签所指向的值,第二行after <다이제스트>是现在所指向的值,第三行orphan yes或orphan no表示旧摘要的条目是否仍留在 index.json 中(即使失去了标签,只要条目和 blob 还在就是 yes)。值不要凭记忆填写,要当场确认后再写。 - 创建
/root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(占位符依次为源布局、目标布局、摘要、目标标签)。在源中找到该摘要,并以该标签上传到目标布局。不要重新构建——移动之后,目标的清单字节与源相比不能有一个字节的差异。如果源中没有该摘要的条目或 blob,就在第一行输出NOTFOUND <다이제스트>并以 3 退出;成功则输出PROMOTED <다이제스트>并以 0 退出;其他错误(源不是布局、不是摘要的形态、复制失败)则输出以ERROR开头的行并以 2 退出。目标布局还不存在时必须创建它,并且没有标签(丢失了标签)的摘要也必须能够移动。 赋予执行权限后,把/root/promote/dev-digest.txt中的摘要,以发布名称标签从/root/promote/reg/dev移到/root/promote/reg/staging,并把移动后在 staging 重新读取的摘要保存为/root/promote/staging-digest.txt中的一行。它必须与记录在开发环境中的值相同。 - 创建
/root/promote/tag-drift.sh <레이아웃> <태그> <기대다이제스트>(占位符依次为布局、标签、期望摘要)。把该标签当前指向的值与期望值对照。相同时输出OK <다이제스트>并以 0 退出;不同时输出MOVED <기대> <실제>(占位符依次为期望值与实际值)并以 3 退出;根本没有这个标签时输出MISSING <태그>并以 4 退出;路径不是布局时输出以ERROR开头的行并以 2 退出。赋予执行权限后,以/root/promote/dev-digest.txt作为期望值,把在开发环境上运行的结果保存为/root/promote/drift-dev.txt,在 staging 上运行的结果保存为/root/promote/drift-staging.txt。开发环境应得到MOVED,staging 应得到OK。 - 创建
/root/promote/approved.txt,并用一行写入在 staging 中确认完毕的摘要(即记录在开发环境中的那个值)。以#开头的注释行和空行可以有。接着创建/root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(占位符依次为批准列表、源布局、目标布局、摘要、目标标签)。只有当批准列表中整行写着该摘要时才晋级,没有的话输出REFUSED <다이제스트>并以 5 退出——这时目标布局不能有一个字节的变化。晋级交给前面步骤的 promote.sh 来做,并原样转发它的输出和退出码(PROMOTED0 ·NOTFOUND3 ·ERROR2)。批准列表文件不存在时,输出以ERROR开头的行并以 2 退出。赋予执行权限后,把已批准的摘要从/root/promote/reg/staging以标签current晋级到/root/promote/reg/prod。最后,用开发环境的标签当前指向的(未获批准的)摘要尝试同样的晋级,并把输出保存为/root/promote/gate-refused.txt。第一行必须以REFUSED开头。 - 再发布一个新版本并回退试试。在
/root/promote/app再堆叠一个提交并打上标签v1.3.0,然后用release-name.sh提取新的发布名称。把/opt/images/alpine_3.20.tar当作该版本的产物,以新的发布名称标签放进/root/promote/reg/dev,在/root/promote/releases.tsv中以相同格式追加第二行,并在/root/promote/approved.txt中也追加一行该摘要。然后通过门禁把新版本从/root/promote/reg/dev晋级到/root/promote/reg/prod的标签current,紧接着把旧版本的摘要以同一个标签重新晋级,完成回退。最后在/root/promote/rollback.txt中写三行——before <되돌리기 직전 current 가 가리키던 다이제스트>(占位符为回退之前 current 指向的摘要)、after <되돌린 뒤의 다이제스트>(占位符为回退之后的摘要),以及identical yes或identical no(用cmp确认回退后的清单字节与开发环境中旧版本的字节是否相同的结果)。 - 创建
/root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일>(占位符依次为注册表根目录、发布表、输出文件)。把注册表根目录下的每个目录视为一个环境(只取其中有index.json的),只收集带标签的条目,把以制表符分隔的表写入输出文件。第一行是表头env<탭>tag<탭>digest<탭>release<탭>commit(占位符为制表符),随后的行是<환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋>(占位符依次为环境、制表符、标签、制表符、摘要、制表符、发布名称、制表符、提交)。发布名称和提交根据摘要在发布表中查找,没有的话两栏都写-。正文行用LC_ALL=C sort按环境、标签、摘要的顺序排序。标准输出中输出一行LEDGER <본문 줄 수>(占位符为正文行数)并以 0 退出。根目录不存在或发布表不存在时,输出以ERROR开头的行并以 2 退出。输出文件的上级目录不存在时要创建。赋予执行权限后,用/root/promote/reg和/root/promote/releases.tsv运行,并留下/root/promote/ledger.tsv。
参考
- 在这个实验 Pod 中无法启动容器,也没有注册表服务器——seccomp 阻止创建新的用户命名空间。不要使用
podman run、podman build、buildah。取而代之的是,把 OCI 镜像布局目录(blobs/sha256/、index.json、oci-layout)当作各环境的注册表,并用 bash、git 2.43、jq 1.7、skopeo 1.13.3 来处理。 /opt/images/*.tar是 oci-archive:skopeo inspect --raw oci-archive:/opt/images/busybox_1.36.tar。清单原文的 sha256 就是该镜像的摘要。不同机器的架构分别是 arm64/amd64,所以不要凭记忆填写值,要当场计算。- 实测:这个 skopeo 的
oci:传输不接受摘要引用(oci:<디렉터리>@sha256:...和:sha256:...都会被拒绝)。布局的index.json只不过是一张标签地图,这个事实就是打开那个位置的钥匙。 - 常见错误:只用
grep -q搜索批准列表,结果让写在注释行里的摘要通过了。要同时加上-x和-F。 - 常见错误:把晋级实现成“用同一个标签再复制一次”。如果标签已经移动,那一刻被移过去的就是另一个东西。
- OCI Image Layout · OCI Descriptor · skopeo-copy(1) · git-describe(1) · Semantic Versioning · OCI Distribution Spec
发布名称开始由人来起之后,没有人知道它对应哪个提交
在 /root/promote/app 创建一个练习用的 git 仓库——放入文件 main.py、orders.conf、README.md,堆叠两个以上提交,然后给最后一个提交打上无注释的标签 v1.2.0。接着创建 /root/promote/release-name.sh <저장소경로>(占位符为仓库路径)。这个脚本从仓库中提取一行发布名称并输出到标准输出。规则是 <가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리>(占位符依次为最近的标签、该标签之后的提交数、7 位提交哈希),如果有未提交的改动,就在末尾加上 -dirty。如果仓库里一个标签也没有,标签位置写 v0.0.0,提交数位置写总提交数。如果收到的路径不是 git 仓库,标准输出中什么也不要输出,并以非 0 码退出。赋予执行权限,把在 /root/promote/app 上运行的结果保存为 /root/promote/release.txt 中的一行。
给 git describe 传入 --tags --long --abbrev=7,除了没有标签的情况之外,得到的字符串就和规则完全一致。也要看看 --dirty。没有标签时 describe 本身会失败,所以要为这种情况另设一个分支。加上 set -e 的话,脚本会先死在这次失败上。提交数用 git rev-list --count,短哈希用 git rev-parse --short=7。
产物只构建一次,并记下了它的摘要
用 OCI 镜像布局目录来模拟各环境的注册表。在 /root/promote/reg 下放置 dev、staging、prod。假设构建机器为这次发布产出的产物是 /opt/images/busybox_1.36.tar(oci-archive)。把它以发布名称作为标签,只放进一次开发环境布局 /root/promote/reg/dev(发布名称是 /root/promote/release.txt 中的值)。然后创建 /root/promote/resolve.sh <레이아웃> <태그>(占位符依次为布局与标签)——它把该标签当前指向的摘要以一行 sha256:<64자리 16진수>(占位符为 64 位十六进制数)输出,并以 0 退出。如果没有这样的标签,标准输出中什么也不要输出,以 3 退出;如果收到的路径不是布局,以 2 退出。赋予执行权限之后,把该摘要保存为 /root/promote/dev-digest.txt 中的一行。最后创建 /root/promote/releases.tsv,并在第一行写入 <다이제스트><탭><판이름><탭><커밋40자리>(占位符依次为摘要、制表符、发布名称、制表符、40 位提交哈希)。提交是生成该发布的那个提交的完整哈希。
skopeo copy --insecure-policy oci-archive:<파일> oci:<디렉터리>:<태그>(占位符依次为文件、目录、标签)会创建布局——上级目录必须事先存在。布局的 index.json 是清单描述符(descriptor)的列表,标签写在 annotations 的 org.opencontainers.image.ref.name 中。用 jq 取出该注解与标签相同的那个条目的 .digest 即可。要当心,值不存在时 jq 输出的 null 不要原样流出去。制表符用 printf '%s\t%s\t%s\n' 写入。
往同一个标签推进另一个镜像,结果只有标签被移走了
构造这样一种情形:有人用同样的发布名称标签,把另一个产物推进了开发环境。把 /opt/images/nginx_1.27-alpine.tar 复制到 /root/promote/reg/dev 的同一个标签(发布名称)上。然后在 /root/promote/tag-moved.txt 中恰好写三行——第一行 before <다이제스트>(占位符为摘要)是推进之前该标签所指向的值,第二行 after <다이제스트> 是现在所指向的值,第三行 orphan yes 或 orphan no 表示旧摘要的条目是否仍留在 index.json 中(即使失去了标签,只要条目和 blob 还在就是 yes)。值不要凭记忆填写,要当场确认后再写。
在推进前后各看一次 jq . reg/dev/index.json。从这里可以看出,标签只是贴在条目上的标牌,实体是 blobs/sha256/<다이제스트>(占位符为摘要)。请用眼睛对比旧条目的 annotations 发生了什么变化。before 的值,在前面的步骤里已经写进文件了。
不用标签,而是指定摘要移到了 staging
创建 /root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(占位符依次为源布局、目标布局、摘要、目标标签)。在源中找到该摘要,并以该标签上传到目标布局。不要重新构建——移动之后,目标的清单字节与源相比不能有一个字节的差异。如果源中没有该摘要的条目或 blob,就在第一行输出 NOTFOUND <다이제스트> 并以 3 退出;成功则输出 PROMOTED <다이제스트> 并以 0 退出;其他错误(源不是布局、不是摘要的形态、复制失败)则输出以 ERROR 开头的行并以 2 退出。目标布局还不存在时必须创建它,并且没有标签(丢失了标签)的摘要也必须能够移动。 赋予执行权限后,把 /root/promote/dev-digest.txt 中的摘要,以发布名称标签从 /root/promote/reg/dev 移到 /root/promote/reg/staging,并把移动后在 staging 重新读取的摘要保存为 /root/promote/staging-digest.txt 中的一行。它必须与记录在开发环境中的值相同。
实测:这个 skopeo(1.13.3)的 oci: 传输不接受摘要引用——oci:<디렉터리>@sha256:... 和 oci:<디렉터리>:sha256:...(占位符为目录)都会被拒绝。不过布局的 index.json 只是一个描述符列表,给某个描述符贴上标签这件事可以用 jq 来做。在临时目录里创建一个只含该摘要的 index.json,并让 blobs 指向源,skopeo 就会把这个临时布局当作源来读取。这个指向在临时目录内部解析,所以源路径必须是绝对路径。临时目录用 mktemp -d 和 trap ... EXIT 来清理。
装上检查器之后,开发环境的标签被移动这件事暴露了出来
创建 /root/promote/tag-drift.sh <레이아웃> <태그> <기대다이제스트>(占位符依次为布局、标签、期望摘要)。把该标签当前指向的值与期望值对照。相同时输出 OK <다이제스트> 并以 0 退出;不同时输出 MOVED <기대> <실제>(占位符依次为期望值与实际值)并以 3 退出;根本没有这个标签时输出 MISSING <태그> 并以 4 退出;路径不是布局时输出以 ERROR 开头的行并以 2 退出。赋予执行权限后,以 /root/promote/dev-digest.txt 作为期望值,把在开发环境上运行的结果保存为 /root/promote/drift-dev.txt,在 staging 上运行的结果保存为 /root/promote/drift-staging.txt。开发环境应得到 MOVED,staging 应得到 OK。
请重复使用前面步骤中创建的 resolve.sh——同一件事实现两遍,两处就会出现偏差。脚本要调用自己旁边的脚本,就用 $(dirname "$0")。没有标签的情形和标签指向了不同值的情形,原因和应对都不同,所以不要把退出码混在一起。评分器会用它自己创建的布局,把三种结果全部测试一遍。
没有记录的摘要,在生产环境的门口被拦住了
创建 /root/promote/approved.txt,并用一行写入在 staging 中确认完毕的摘要(即记录在开发环境中的那个值)。以 # 开头的注释行和空行可以有。接着创建 /root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(占位符依次为批准列表、源布局、目标布局、摘要、目标标签)。只有当批准列表中整行写着该摘要时才晋级,没有的话输出 REFUSED <다이제스트> 并以 5 退出——这时目标布局不能有一个字节的变化。晋级交给前面步骤的 promote.sh 来做,并原样转发它的输出和退出码(PROMOTED 0 · NOTFOUND 3 · ERROR 2)。批准列表文件不存在时,输出以 ERROR 开头的行并以 2 退出。赋予执行权限后,把已批准的摘要从 /root/promote/reg/staging 以标签 current 晋级到 /root/promote/reg/prod。最后,用开发环境的标签当前指向的(未获批准的)摘要尝试同样的晋级,并把输出保存为 /root/promote/gate-refused.txt。第一行必须以 REFUSED 开头。
用 grep 搜索列表时,请同时加上 -x(整行匹配)和 -F(不按正则解读)。缺少其中任何一个,写在注释里的摘要或更长字符串的一部分都会被当作批准——评分器会把这两种情形原样测试。拒绝时,必须根本不开始复制。要原样转发退出码,就接住被调用脚本的 $?,最后再 exit。
回退不是重新构建,而是重新晋级旧摘要
再发布一个新版本并回退试试。在 /root/promote/app 再堆叠一个提交并打上标签 v1.3.0,然后用 release-name.sh 提取新的发布名称。把 /opt/images/alpine_3.20.tar 当作该版本的产物,以新的发布名称标签放进 /root/promote/reg/dev,在 /root/promote/releases.tsv 中以相同格式追加第二行,并在 /root/promote/approved.txt 中也追加一行该摘要。然后通过门禁把新版本从 /root/promote/reg/dev 晋级到 /root/promote/reg/prod 的标签 current,紧接着把旧版本的摘要以同一个标签重新晋级,完成回退。最后在 /root/promote/rollback.txt 中写三行——before <되돌리기 직전 current 가 가리키던 다이제스트>(占位符为回退之前 current 指向的摘要)、after <되돌린 뒤의 다이제스트>(占位符为回退之后的摘要),以及 identical yes 或 identical no(用 cmp 确认回退后的清单字节与开发环境中旧版本的字节是否相同的结果)。
回退不需要任何新机制——把前面步骤的门禁用旧摘要再调用一次就是全部。要使这成立,必须没有删除旧 blob,而且那个值必须写在 releases.tsv 里。要用 cmp 比较的两个文件,是各布局的 blobs/sha256/<다이제스트의 16진수 부분>(占位符为摘要的十六进制部分)。cmp 在不同时返回非 0 值,所以如果加了 set -e,就会在那里停住。
让一个文件就能回答“什么在哪里运行”
创建 /root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일>(占位符依次为注册表根目录、发布表、输出文件)。把注册表根目录下的每个目录视为一个环境(只取其中有 index.json 的),只收集带标签的条目,把以制表符分隔的表写入输出文件。第一行是表头 env<탭>tag<탭>digest<탭>release<탭>commit(占位符为制表符),随后的行是 <환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋>(占位符依次为环境、制表符、标签、制表符、摘要、制表符、发布名称、制表符、提交)。发布名称和提交根据摘要在发布表中查找,没有的话两栏都写 -。正文行用 LC_ALL=C sort 按环境、标签、摘要的顺序排序。标准输出中输出一行 LEDGER <본문 줄 수>(占位符为正文行数)并以 0 退出。根目录不存在或发布表不存在时,输出以 ERROR 开头的行并以 2 退出。输出文件的上级目录不存在时要创建。赋予执行权限后,用 /root/promote/reg 和 /root/promote/releases.tsv 运行,并留下 /root/promote/ledger.tsv。
不要把环境列表写死在脚本里——遍历根目录,环境增加了也能照常工作。jq 的 @tsv 会用制表符把它们连起来。没有标签的条目,要么根本没有 annotations,要么没有那个键。用摘要把两张表连起来,最简短的办法是给 awk -F'\t' 传入两个文件,并用 NR == FNR 记住第一个文件。排序必须在加上表头之前进行,表头才不会被夹到中间。