🔀

latest タグのための job 順序づけ

2022/12/25に公開

こんにちは。天久保 Advent Calendar 2022 の 23 日目の記事です。

https://adventar.org/calendars/8233

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 の実行を青色で示します。

normal

一方で、ビルドにかかる時間は常に一定ではありません。そのため、1 つ目の build で長い時間がかかってしまうと、下図のように push の順序が逆転してしまう可能性があります。

out-of-order

これは古いイメージが latest として誤ってタグ付けされてしまっており、望んだ状態ではありません。

プロダクションで latest タグに依存していることは多くないとは思いますが、そうはいっても latest とタグをつける以上こういった状態になることを想定して開発するのには一定の認知上の負荷があります。さらに、OSS では latest タグのイメージがユーザーがアプリケーションを試すうえでもっとも利用しやすいものとなり、それが最新のものと一致していないことは誤解を招きかねません。

このように push 順序を逆転させるほどビルド時間が長くなるようなことは現実的に起こり得ないように思えるかもしれません。しかし、例えばビルド時間が短くてジョブがキューに入っている時間に影響されやすいプロジェクトや、単にビルド時間に大きく影響するコミットなどによって、意外とこの問題が起こりうるシチュエーションは存在します。
何より、latest タグが最新でない可能性を暗黙の了解としてプロジェクト内で共有するのは難しいです。多くの場合でこの問題を放置しても大きな問題は起きないでしょうが、一方で一般的に latest タグが最新であるという暗黙の了解があります。そのためそうなっていない状態を放っておくには気持ちが悪いような微妙さがあり、サクッと解決できるならしてしまいたいです。

ここまで、GitHub Actions において依存関係を持つ job の順序がイベントの順序と異なってしまう問題について説明しました。この記事では、この問題を解決するためのいくつかの方法を考えます。

🉑 Workflow の concurrency

もっとも簡単な解決策として、workflow レベルで concurrency キーを指定する方法があります。グループ名を指定し、同一グループの workflow が同時に起動することを制限します。

https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#concurrency

この制限により、結果として buildpush の順序がイベントの順序に従うようになります。下図に実行の例を示します。

workflow-concurrency

一行の追加で済むため非常に楽ですが、build を並列に行えなくなる欠点があります。main に頻繁に変更が入るプロジェクトではこれは無視できない影響があるでしょう。

❌ Job の concurrency

次に思いつくのは job レベルで concurrency キーを使用する方法です。workflow レベルのものと同様に、同一グループの job が同時に起動することを制限します。

https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idconcurrency

しかし、これは job が同時に起動するのを制限するだけで、その順序については何も言っていません。そして、job 起動のタイミングがビルドにかかる時間よって変化する以上、問題の解決にはなりません。例えば、下のような実行があり得ます。

out-of-order

💪 GitHub API で頑張る

1 つ前の workflow run の push が終わるまで push の実行を待つことで、build の並列性を犠牲にすることなくこの問題は解決できそうです。下図のような実行になります。

in-order

1 つ前の workflow run の状態を取得する方法としてまず思いつくのが GitHub API を利用した方法です。各 workflow run にはその workflow 内で 1 ずつ増える run number という自然数が割り当てられています。

https://docs.github.com/actions/learn-github-actions/contexts#github-context

すなわち、「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 が検出できました。previousRunpush の 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 としての実装例を示します:

https://gist.github.com/coord-e/6e51adecef8ff627de78183e81225b4f

また、何か問題があった際に永久に待ち続けてしまうので、timeout-minutes で適切なタイムアウトを設定するべきです。

https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-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

プロジェクトの開発速度や開発環境など、それぞれの要件に合わせて最適な選択肢を検討する一助になれば幸いです。

脚注
  1. job 名を input にしつつ ↩︎

  2. S3 じゃなくてもなんでもいいと思います ↩︎

Discussion