latest タグのための job 順序づけ
こんにちは。天久保 Advent Calendar 2022 の 23 日目の記事です。
GitHub Actions でコンテナイメージをビルドし、レジストリに push することがあるでしょう。そして、イメージを push する際に、最新のイメージによって常に更新されているタグを使っていることがあると思います。latest
タグがお馴染みですね。他にも、ブランチ名でタグをつけている場合が該当します。そのようなタグのつけかたをしている場合に、十分な配慮をしないと「最新のイメージによってタグが更新されている」が成り立たなくなる場合があります。
ここからは main
ブランチへの push で latest
タグが更新される workflow を例として考えていきます。build と push でジョブが分かれている想定です。build ジョブのことを指して build
, push ジョブのことを指して push
と単に表記します。
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps: ...
push:
runs-on: ubuntu-latest
needs: [build]
steps:
- ...
- run: docker push ...
まず、想定される動作を追ってみます。main
ブランチに連続した 2 回の push があったとき、それぞれ build
が走り、次に push
が走ります。これが下の図のような時系列で起こる場合、2 つ目の push がまさしく latest
としてタグ付けされることになります。latest
になる push
の実行を青色で示します。
一方で、ビルドにかかる時間は常に一定ではありません。そのため、1 つ目の build
で長い時間がかかってしまうと、下図のように push
の順序が逆転してしまう可能性があります。
これは古いイメージが latest
として誤ってタグ付けされてしまっており、望んだ状態ではありません。
プロダクションで latest
タグに依存していることは多くないとは思いますが、そうはいっても latest
とタグをつける以上こういった状態になることを想定して開発するのには一定の認知上の負荷があります。さらに、OSS では latest
タグのイメージがユーザーがアプリケーションを試すうえでもっとも利用しやすいものとなり、それが最新のものと一致していないことは誤解を招きかねません。
このように push 順序を逆転させるほどビルド時間が長くなるようなことは現実的に起こり得ないように思えるかもしれません。しかし、例えばビルド時間が短くてジョブがキューに入っている時間に影響されやすいプロジェクトや、単にビルド時間に大きく影響するコミットなどによって、意外とこの問題が起こりうるシチュエーションは存在します。
何より、latest
タグが最新でない可能性を暗黙の了解としてプロジェクト内で共有するのは難しいです。多くの場合でこの問題を放置しても大きな問題は起きないでしょうが、一方で一般的に latest
タグが最新であるという暗黙の了解があります。そのためそうなっていない状態を放っておくには気持ちが悪いような微妙さがあり、サクッと解決できるならしてしまいたいです。
ここまで、GitHub Actions において依存関係を持つ job の順序がイベントの順序と異なってしまう問題について説明しました。この記事では、この問題を解決するためのいくつかの方法を考えます。
concurrency
🉑 Workflow の もっとも簡単な解決策として、workflow レベルで concurrency
キーを指定する方法があります。グループ名を指定し、同一グループの workflow が同時に起動することを制限します。
この制限により、結果として build
と push
の順序がイベントの順序に従うようになります。下図に実行の例を示します。
一行の追加で済むため非常に楽ですが、build
を並列に行えなくなる欠点があります。main
に頻繁に変更が入るプロジェクトではこれは無視できない影響があるでしょう。
concurrency
❌ Job の 次に思いつくのは job レベルで concurrency
キーを使用する方法です。workflow レベルのものと同様に、同一グループの job が同時に起動することを制限します。
しかし、これは job が同時に起動するのを制限するだけで、その順序については何も言っていません。そして、job 起動のタイミングがビルドにかかる時間よって変化する以上、問題の解決にはなりません。例えば、下のような実行があり得ます。
💪 GitHub API で頑張る
1 つ前の workflow run の push
が終わるまで push
の実行を待つことで、build
の並列性を犠牲にすることなくこの問題は解決できそうです。下図のような実行になります。
1 つ前の workflow run の状態を取得する方法としてまず思いつくのが GitHub API を利用した方法です。各 workflow run にはその workflow 内で 1 ずつ増える run number という自然数が割り当てられています。
すなわち、「1 つ前の workflow run」は run number が 1 だけ小さい workflow run として検出できます。actions/github-script
での実装例を示します。
const listRuns = github.paginate.iterator(
github.rest.actions.listWorkflowRuns,
{
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: context.workflow,
event: "push",
},
);
let previousRun = null;
for await (const { data: runs } of listRuns) {
previousRun = runs.find((run) => run.run_number === context.runNumber - 1);
if (previousRun) {
break;
}
}
if (!previousRun) {
throw Error(
`could not find previous run (run_number: ${context.runNumber - 1})`,
);
}
core.info(`previous run: ${previousRun.id}`);
previousRun
として 1 つ前の workflow run が検出できました。previousRun
の push
の status が completed
になるまで待機します。
async function checkJobStatus() {
const listJobs = github.paginate.iterator(
github.rest.actions.listJobsForWorkflowRun,
{
owner: context.repo.owner,
repo: context.repo.repo,
run_id: previousRun.id,
},
);
let targetJob = null;
for await (const { data: jobs } of listJobs) {
targetJob = jobs.find((job) => job.name === "push");
if (targetJob) {
break;
}
}
core.info(`target job: ${targetJob?.id} (${targetJob?.status})`);
return targetJob?.status;
}
while ((await checkJobStatus()) !== "completed") {
await new Promise((resolve, _reject) => setTimeout(resolve, 1000));
}
この処理を push
の実行前に行うことで、1 つ前の push
が終わるまで push
が実行されないようにできます。結果、確実に push
がイベントの順序で実行されることを保証できました。
スクリプトが長いので、実際には action に切り出しておく[1]と良いでしょう。下に composite action としての実装例を示します:
また、何か問題があった際に永久に待ち続けてしまうので、timeout-minutes
で適切なタイムアウトを設定するべきです。
🦾 外部に状態を持たせて頑張る
Run number が 1 だけ小さい workflow run の push
が終わっているか判定する方法として、push
が終わったときにフラグを立ててそれを後続のジョブで待つこともできそうです。フラグの置き場として、ここでは S3 を利用します[2]。次のようにして前回のジョブが終わるのを待機します:
# BUCKET=...
KEY=$GITHUB_REPOSITORY/$GITHUB_WORKFLOW/$GITHUB_JOB/$(( GITHUB_RUN_NUMBER - 1))
until aws s3api head-object --bucket "$BUCKET" --key "$KEY"; do
sleep 1
done
そして、push
が終了する際にオブジェクトを作成します。インラインで書く場合は if: always()
、action に切り出す場合は post job cleanup として実装することになります:
# BUCKET=...
KEY=$GITHUB_REPOSITORY/$GITHUB_WORKFLOW/$GITHUB_JOB/$GITHUB_RUN_NUMBER
aws s3api put-object --bucket "$BUCKET" --key "$KEY"
このようにして、push
がイベントの順序で実行されることを保証できました。この方法はコード量こそ少ないですが外部サービスに依存して状態を保持する必要があり、OSS においては採用しにくい側面があります。さらに、初回の実行においてはオブジェクトが存在しないので別の手段で作っておく必要がある点にも注意が必要です。
😁 Run number でイメージにタグをつける
ジョブの終了状態をイメージのタグに埋め込むのはどうでしょうか。すなわち、次のようにして前回のジョブが終わるのを待機します:
until docker manifest inspect $IMAGE_REPOSITORY:run-$(( GITHUB_RUN_NUMBER - 1 )) > /dev/null; do
echo "$IMAGE_REPOSITORY:run-$(( GITHUB_RUN_NUMBER - 1 )) not found" 2>&1
sleep 1
done
そして run-$GITHUB_RUN_NUMBER
タグを(latest
などに加えて)イメージにつけて push します。
この方法は、元々の workflow に数行追記するだけで機能するため魅力的です。一方で、push
を前回の成功の後に実行するようにしているので、一度 push
が失敗してしまうとその後 push
が一切実行できなくなってしまう点に問題があります。if: failure()
なステップで何かしらダミーのイメージを push するなどの対応は考えられますが、意味のないイメージが push されるのは直感的にわかりにくく、理想的であるとは言えません。
まとめ
latest
タグがついたイメージの push をイベントの順序に合わせる方法について考察しました。
手軽さ | 正確さ | |
---|---|---|
Workflow の concurrency | o | △ |
GitHub API で頑張る | △ | o |
外部に状態を持たせて頑張る | △ | o |
Run number でイメージにタグをつける | o | △ |
プロジェクトの開発速度や開発環境など、それぞれの要件に合わせて最適な選択肢を検討する一助になれば幸いです。
Discussion