タグはそのままなのに、本番では別のイメージが動いていた
目標
一度作ったアーティファクトを作り直さずに環境の間で移す作業を、手で行います。ダイジェストで指定してプロモーションし、タグが動いたことを捕まえる検査スクリプトと、記録にないものを止めるゲートを作り、ロールバックを古いダイジェストの再プロモーションとして実行し、どこに何があるのかを1つのファイルに残します。
なぜ重要なのか
環境ごとに再ビルドすると、環境ごとに違うものが出てきます。ベースイメージのタグが動き、依存関係のパッチが上がり、ビルドマシンのツールのバージョンが違います。そのため、開発環境で通ったテストは、本番環境のアーティファクトについては何も語ってくれません。テストしたものとデプロイしたものが別物だからです。一度作ってそれを移すだけにすれば、この変数は消えますが、その原則は、何を移すのかを指す名前から漏れ出します。タグは人が付けたラベルなので、いつでも別のものを指すようにできてしまい、その瞬間、昨日検証したものと今日デプロイされるものが分かれます。ダイジェストは内容から計算されたアドレスなので、動きません。そのため、現場のルールは1つにまとまります。タグは読むために、ダイジェストは指定するために。ロールバックも同じルールの上に立っています。古いダイジェストがレジストリに残ってさえいれば、ロールバックは再ビルドではなく、古いアドレスをもう一度指す作業になり、戻したものが以前と同じものであることを、バイトで証明できます。
ステップ
/root/promote/appに練習用のgitリポジトリを作ってください。ファイルmain.py、orders.conf、README.mdを置き、コミットを2つ以上積んだあと、最後のコミットに注釈なしのタグv1.2.0を付けます。続いて/root/promote/release-name.sh <저장소경로>(プレースホルダーはリポジトリのパスです)を作ってください。リポジトリからリリース名を1行で取り出して標準出力に出すスクリプトです。規則は<가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리>(プレースホルダーは、最も近いタグ、そのタグ以降のコミット数、コミットの7桁です)で、コミットされていない変更があれば、末尾に-dirtyを付けます。タグが1つもないリポジトリなら、タグの位置にv0.0.0を、コミット数の位置に全コミット数を書きます。gitリポジトリではないパスを受け取ったら、標準出力には何も出さず、0以外のコードで終了します。実行権限を付け、/root/promote/appに対して実行した結果を/root/promote/release.txtに1行で保存してください。- 環境ごとのレジストリを、OCIイメージレイアウトのディレクトリで擬似的に再現します。
/root/promote/regの下にdev・staging・prodを置きます。ビルドマシンが今回のリリースのアーティファクトとして出力したものが/opt/images/busybox_1.36.tar(oci-archive)だとします。これを開発環境のレイアウト/root/promote/reg/devに、リリース名をタグとして付けて1回だけ入れてください(リリース名は/root/promote/release.txtの値です)。続いて/root/promote/resolve.sh <레이아웃> <태그>(プレースホルダーはレイアウトとタグです)を作ってください。そのタグが今指しているダイジェストをsha256:<64자리 16진수>(プレースホルダーは16進数64桁です)の1行で出力し、0で終了します。そのようなタグがなければ、標準出力には何も出さず3で、レイアウトではないパスを受け取ったら2で終了します。実行権限を付けたあと、そのダイジェストを/root/promote/dev-digest.txtに1行で保存してください。最後に/root/promote/releases.tsvを作り、1行目に<다이제스트><탭><판이름><탭><커밋40자리>(プレースホルダーは、ダイジェスト、リリース名、コミットの40桁で、間はタブです)を書いてください。コミットは、そのリリースを作ったコミットの完全なハッシュです。 - 誰かが同じリリース名のタグで別のアーティファクトを開発環境に押し込んだ状況を作ります。
/opt/images/nginx_1.27-alpine.tarを/root/promote/reg/devの同じタグ(リリース名)でコピーしてください。続いて/root/promote/tag-moved.txtに、ちょうど3行を書いてください。1行目のbefore <다이제스트>(プレースホルダーはダイジェストです)は押し込む前にそのタグが指していた値、2行目のafter <다이제스트>は今指している値、3行目のorphan yesまたはorphan noは、古いダイジェストのエントリがindex.jsonにまだ残っているかです(ラベルを失っても、エントリとブロブが残っていればyes)。値は暗記して書かず、その場で確認して書いてください。 /root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(プレースホルダーは、コピー元のレイアウト、コピー先のレイアウト、ダイジェスト、コピー先のタグです)を作ってください。コピー元からそのダイジェストを探し、コピー先のレイアウトにそのタグで上げます。作り直しません。移したあとのコピー先のマニフェストのバイト列が、コピー元と1バイトでも違ってはいけません。コピー元にそのダイジェストのエントリやブロブがなければ、1行目にNOTFOUND <다이제스트>を出して3で、成功したらPROMOTED <다이제스트>を出して0で、それ以外の誤り(コピー元がレイアウトではない、ダイジェストの形式ではない、コピーに失敗した)にはERRORで始まる行を出して2で終了します。コピー先のレイアウトがまだなければ作る必要があり、ラベルがない(タグを失った)ダイジェストも移せなければなりません。実行権限を付けたあと、/root/promote/dev-digest.txtのダイジェストを/root/promote/reg/devから/root/promote/reg/stagingへ、リリース名のタグを付けて移し、移したあとにステージングから読み直したダイジェストを/root/promote/staging-digest.txtに1行で保存してください。開発環境に書き留めた値と同じでなければなりません。/root/promote/tag-drift.sh <레이아웃> <태그> <기대다이제스트>(プレースホルダーは、レイアウト、タグ、期待するダイジェストです)を作ってください。そのタグが今指している値を、期待値と照合します。同じならOK <다이제스트>を出して0で、違えばMOVED <기대> <실제>(プレースホルダーは期待値と実際の値です)を出して3で、そのようなタグがそもそもなければMISSING <태그>を出して4で、レイアウトではないパスならERRORで始まる行を出して2で終了します。実行権限を付けたあと、/root/promote/dev-digest.txtを期待値として開発環境に対して実行した結果を/root/promote/drift-dev.txtに、ステージングに対して実行した結果を/root/promote/drift-staging.txtに保存してください。開発環境はMOVED、ステージングはOKが出る必要があります。/root/promote/approved.txtを作り、ステージングで確認を終えたダイジェスト(開発環境に書き留めた、その値)を1行で書いてください。#で始まるコメント行と空行はあってもかまいません。続いて/root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(プレースホルダーは、承認リスト、コピー元のレイアウト、コピー先のレイアウト、ダイジェスト、コピー先のタグです)を作ってください。承認リストにそのダイジェストが1行全体として書かれているときだけプロモーションし、なければREFUSED <다이제스트>を出して5で終了します。このとき、コピー先のレイアウトは1バイトも変わってはいけません。プロモーションは前のステップのpromote.shに渡し、その出力と終了コードをそのまま伝えます(PROMOTED0・NOTFOUND3・ERROR2)。承認リストのファイルがなければ、ERRORで始まる行を出して2で終了します。実行権限を付けたあと、承認されたダイジェストを/root/promote/reg/stagingから/root/promote/reg/prodへ、タグcurrentを付けてプロモーションしてください。最後に、開発環境のタグが今指している(承認されていない)ダイジェストで同じプロモーションを試み、その出力を/root/promote/gate-refused.txtに保存してください。1行目はREFUSEDで始まっている必要があります。- 新しいバージョンをもう1つ出して、元に戻してみます。
/root/promote/appにコミットをもう1つ積み、タグv1.3.0を付けたあと、新しいリリース名をrelease-name.shで取り出してください。/opt/images/alpine_3.20.tarをそのバージョンのアーティファクトとみなし、/root/promote/reg/devに新しいリリース名のタグで入れ、/root/promote/releases.tsvに2行目を同じ形式で追記し、/root/promote/approved.txtにもそのダイジェストを1行追記してください。続いて、ゲートを通して新しいバージョンを/root/promote/reg/devから/root/promote/reg/prodのタグcurrentへプロモーションし、すぐ続けて旧バージョンのダイジェストを同じタグで再プロモーションして元に戻してください。最後に/root/promote/rollback.txtに3行を書いてください。before <되돌리기 직전 current 가 가리키던 다이제스트>(プレースホルダーは、元に戻す直前にcurrentが指していたダイジェストです)、after <되돌린 뒤의 다이제스트>(プレースホルダーは、元に戻したあとのダイジェストです)、そしてidentical yesまたはidentical no(戻したマニフェストのバイト列が、開発環境にある旧バージョンのバイト列と同じかどうかをcmpで確認した結果)です。 /root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일>(プレースホルダーは、レジストリのルート、リリース表、出力ファイルです)を作ってください。レジストリのルートの下にある各ディレクトリを環境1つとみなし(その中にindex.jsonがあるものだけ)、ラベルが付いたエントリだけを集めて、タブで区切った表を出力ファイルに書きます。1行目はヘッダー行env<탭>tag<탭>digest<탭>release<탭>commit(プレースホルダーはタブです)で、続く行は<환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋>(プレースホルダーは、環境、タグ、ダイジェスト、リリース名、コミットで、間はタブです)です。リリース名とコミットはリリース表からダイジェストで探し、なければ2つの列の両方に-を書きます。本文の行はLC_ALL=C sortで、環境・タグ・ダイジェストの順に並べ替えます。標準出力にはLEDGER <본문 줄 수>(プレースホルダーは本文の行数です)を1行出力して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を置き、コミットを2つ以上積んだあと、最後のコミットに注釈なしのタグv1.2.0を付けます。続いて/root/promote/release-name.sh <저장소경로>(プレースホルダーはリポジトリのパスです)を作ってください。リポジトリからリリース名を1行で取り出して標準出力に出すスクリプトです。規則は<가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리>(プレースホルダーは、最も近いタグ、そのタグ以降のコミット数、コミットの7桁です)で、コミットされていない変更があれば、末尾に-dirtyを付けます。タグが1つもないリポジトリなら、タグの位置にv0.0.0を、コミット数の位置に全コミット数を書きます。gitリポジトリではないパスを受け取ったら、標準出力には何も出さず、0以外のコードで終了します。実行権限を付け、/root/promote/appに対して実行した結果を/root/promote/release.txtに1行で保存してください。
git describeに--tags --long --abbrev=7を付けると、タグがない場合を除いて、規則どおりの文字列が出ます。--dirtyも一緒に確認します。タグがないとdescribe自体が失敗するので、そのときに使う分岐を別に用意する必要があります。set -eを有効にすると、その失敗でスクリプトが先に終了します。コミット数はgit rev-list --count、短いハッシュはgit rev-parse --short=7です。
アーティファクトを1回だけ作り、そのダイジェストを書き留めた
環境ごとのレジストリを、OCIイメージレイアウトのディレクトリで擬似的に再現します。/root/promote/regの下にdev・staging・prodを置きます。ビルドマシンが今回のリリースのアーティファクトとして出力したものが/opt/images/busybox_1.36.tar(oci-archive)だとします。これを開発環境のレイアウト/root/promote/reg/devに、リリース名をタグとして付けて1回だけ入れてください(リリース名は/root/promote/release.txtの値です)。続いて/root/promote/resolve.sh <레이아웃> <태그>(プレースホルダーはレイアウトとタグです)を作ってください。そのタグが今指しているダイジェストをsha256:<64자리 16진수>(プレースホルダーは16進数64桁です)の1行で出力し、0で終了します。そのようなタグがなければ、標準出力には何も出さず3で、レイアウトではないパスを受け取ったら2で終了します。実行権限を付けたあと、そのダイジェストを/root/promote/dev-digest.txtに1行で保存してください。最後に/root/promote/releases.tsvを作り、1行目に<다이제스트><탭><판이름><탭><커밋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に、ちょうど3行を書いてください。1行目のbefore <다이제스트>(プレースホルダーはダイジェストです)は押し込む前にそのタグが指していた値、2行目のafter <다이제스트>は今指している値、3行目のorphan yesまたはorphan noは、古いダイジェストのエントリがindex.jsonにまだ残っているかです(ラベルを失っても、エントリとブロブが残っていればyes)。値は暗記して書かず、その場で確認して書いてください。
jq . reg/dev/index.jsonを、押し込む前と後に1回ずつ見てください。タグはエントリに付いたラベルにすぎず、実体はblobs/sha256/<다이제스트>(プレースホルダーはダイジェストです)だということが、ここでわかります。古いエントリがどう変わったのか、annotationsを目で見比べてください。beforeの値は、前のステップですでにファイルに書き留めてあります。
タグではなくダイジェストを指定して、ステージングへ移した
/root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(プレースホルダーは、コピー元のレイアウト、コピー先のレイアウト、ダイジェスト、コピー先のタグです)を作ってください。コピー元からそのダイジェストを探し、コピー先のレイアウトにそのタグで上げます。作り直しません。移したあとのコピー先のマニフェストのバイト列が、コピー元と1バイトでも違ってはいけません。コピー元にそのダイジェストのエントリやブロブがなければ、1行目にNOTFOUND <다이제스트>を出して3で、成功したらPROMOTED <다이제스트>を出して0で、それ以外の誤り(コピー元がレイアウトではない、ダイジェストの形式ではない、コピーに失敗した)にはERRORで始まる行を出して2で終了します。コピー先のレイアウトがまだなければ作る必要があり、ラベルがない(タグを失った)ダイジェストも移せなければなりません。実行権限を付けたあと、/root/promote/dev-digest.txtのダイジェストを/root/promote/reg/devから/root/promote/reg/stagingへ、リリース名のタグを付けて移し、移したあとにステージングから読み直したダイジェストを/root/promote/staging-digest.txtに1行で保存してください。開発環境に書き留めた値と同じでなければなりません。
実測: このskopeo(1.13.3)のoci:トランスポートは、ダイジェストでの参照を受け付けません。oci:<디렉터리>@sha256:...もoci:<디렉터리>:sha256:...も拒否されます(プレースホルダーはディレクトリです)。ところが、レイアウトのindex.jsonは単なるディスクリプターの一覧で、ディスクリプター1つにラベルを付ける作業はjqでできます。そのダイジェスト1つだけを入れた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に、ステージングに対して実行した結果を/root/promote/drift-staging.txtに保存してください。開発環境はMOVED、ステージングはOKが出る必要があります。
前のステップで作ったresolve.shを再利用してください。同じことを2回実装すると、2か所がずれます。スクリプトが自分の隣のスクリプトを呼ぶには、$(dirname "$0")を使います。タグがない場合と、タグが別の値を指している場合は、原因も対応も異なるので、終了コードを混ぜないでください。採点は、自分で作ったレイアウトで3通りの結末をすべて試します。
記録にないダイジェストが、本番環境の手前で止まった
/root/promote/approved.txtを作り、ステージングで確認を終えたダイジェスト(開発環境に書き留めた、その値)を1行で書いてください。#で始まるコメント行と空行はあってもかまいません。続いて/root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그>(プレースホルダーは、承認リスト、コピー元のレイアウト、コピー先のレイアウト、ダイジェスト、コピー先のタグです)を作ってください。承認リストにそのダイジェストが1行全体として書かれているときだけプロモーションし、なければREFUSED <다이제스트>を出して5で終了します。このとき、コピー先のレイアウトは1バイトも変わってはいけません。プロモーションは前のステップのpromote.shに渡し、その出力と終了コードをそのまま伝えます(PROMOTED 0・NOTFOUND 3・ERROR 2)。承認リストのファイルがなければ、ERRORで始まる行を出して2で終了します。実行権限を付けたあと、承認されたダイジェストを/root/promote/reg/stagingから/root/promote/reg/prodへ、タグcurrentを付けてプロモーションしてください。最後に、開発環境のタグが今指している(承認されていない)ダイジェストで同じプロモーションを試み、その出力を/root/promote/gate-refused.txtに保存してください。1行目はREFUSEDで始まっている必要があります。
grepでリストを探すときは、-x(行全体で一致)と-F(正規表現として読まない)を一緒に付けてください。どちらか1つでも欠けると、コメントに書かれたダイジェストや、より長い文字列の一部が承認として読まれます。採点は、その2つをそのまま試します。拒否するときは、コピーをそもそも開始してはいけません。終了コードをそのまま伝えるには、呼び出したスクリプトの$?を受け取っておき、最後にexitしてください。
元に戻す作業は、再ビルドではなく、古いダイジェストを再プロモーションすることだった
新しいバージョンをもう1つ出して、元に戻してみます。/root/promote/appにコミットをもう1つ積み、タグv1.3.0を付けたあと、新しいリリース名をrelease-name.shで取り出してください。/opt/images/alpine_3.20.tarをそのバージョンのアーティファクトとみなし、/root/promote/reg/devに新しいリリース名のタグで入れ、/root/promote/releases.tsvに2行目を同じ形式で追記し、/root/promote/approved.txtにもそのダイジェストを1行追記してください。続いて、ゲートを通して新しいバージョンを/root/promote/reg/devから/root/promote/reg/prodのタグcurrentへプロモーションし、すぐ続けて旧バージョンのダイジェストを同じタグで再プロモーションして元に戻してください。最後に/root/promote/rollback.txtに3行を書いてください。before <되돌리기 직전 current 가 가리키던 다이제스트>(プレースホルダーは、元に戻す直前にcurrentが指していたダイジェストです)、after <되돌린 뒤의 다이제스트>(プレースホルダーは、元に戻したあとのダイジェストです)、そしてidentical yesまたはidentical no(戻したマニフェストのバイト列が、開発環境にある旧バージョンのバイト列と同じかどうかをcmpで確認した結果)です。
元に戻すのに新しい仕組みは必要ありません。前のステップのゲートを、古いダイジェストでもう一度呼ぶだけです。これが成り立つには、古いブロブを削除していないことと、その値がreleases.tsvに書かれていることが必要です。cmpで比べる2つのファイルは、各レイアウトのblobs/sha256/<다이제스트의 16진수 부분>(プレースホルダーはダイジェストの16進数の部分です)です。cmpは違うと0以外の値を返すので、set -eを有効にしているとそこで止まります。
どこに何が動いているのかを、1つのファイルで答えられるようにした
/root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일>(プレースホルダーは、レジストリのルート、リリース表、出力ファイルです)を作ってください。レジストリのルートの下にある各ディレクトリを環境1つとみなし(その中にindex.jsonがあるものだけ)、ラベルが付いたエントリだけを集めて、タブで区切った表を出力ファイルに書きます。1行目はヘッダー行env<탭>tag<탭>digest<탭>release<탭>commit(プレースホルダーはタブです)で、続く行は<환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋>(プレースホルダーは、環境、タグ、ダイジェスト、リリース名、コミットで、間はタブです)です。リリース名とコミットはリリース表からダイジェストで探し、なければ2つの列の両方に-を書きます。本文の行はLC_ALL=C sortで、環境・タグ・ダイジェストの順に並べ替えます。標準出力にはLEDGER <본문 줄 수>(プレースホルダーは本文の行数です)を1行出力して0で終了します。ルートがないか、リリース表がなければ、ERRORで始まる行を出して2で終了します。出力ファイルの親ディレクトリがなければ作ります。実行権限を付けたあと、/root/promote/regと/root/promote/releases.tsvで実行して、/root/promote/ledger.tsvを残してください。
環境の一覧をスクリプトに書き込まないでください。ルートを走査すれば、環境が増えてもそのまま動きます。jqの@tsvがタブでつないでくれます。ラベルのないエントリは、annotationsがそもそもないか、そのキーがありません。2つの表をダイジェストで結ぶには、awk -F'\t'にファイルを2つ渡し、NR == FNRで最初のファイルを記憶する方法が短く済みます。並べ替えは、ヘッダーを付ける前に行わないと、ヘッダーが途中に挟まってしまいます。