🔧

新しいjust-in-time runnerでセルフホストランナーのオートスケールが劇的に楽になりそう

2023/06/09に公開

2023/06/03に突如としてGitHub Actionsのセルフホストランナー向けの新機能がアナウンスされました
https://github.blog/changelog/2023-06-02-github-actions-just-in-time-self-hosted-runners/

最初にこの記事とリンク先のドキュメントを見たときにはどういう意図でこのオプションが追加されたのか全く分からなかった[1]のですが、改めて考えてみたら実は相当便利、というかセルフホストランナーをオートスケールさせる場合に現状ネックとなっていることをかなり解消できる可能性を秘めているということに気がついたので色々調べてみました。

just-in-time(JIT) runnerとは

まず最初に今回追加されたjust-in-time runnerについて公式のアナウンスから辿れる情報をまとめてましょう。ちなみに、JITはコンパイラの文脈でよく使われる略称ですが今回の話はコンパイラによってランナーが速くなるとかそういう話ではないです。この文章中でもJITは何度も使いますが混同しないようにしてください。

https://github.blog/changelog/2023-06-02-github-actions-just-in-time-self-hosted-runners/
https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-just-in-time-runners
https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-configuration-for-a-just-in-time-runner-for-an-organization--code-samples

6/2のGitHubのブログを起点として辿れるドキュメントはこのあたりです。何が可能になったのかを簡単にまとめると、新しく追加されたREST APIによって1ジョブだけを処理できるセルフホストランナーを登録できるようになりました。ブログでの説明ではこの挙動によってランナーを登録するための強めの権限のトークンを外部に晒す必要がなくなることでセキュリティの向上が見込めると述べられていました。

実際にはセキュリティ面以外でも今まで不可能だったことが色々と可能になっているようで、このAPIで登録されたセルフホストランナーは1ジョブを処理すると自動的にGitHub上から登録が削除されます。今まではジョブ完了後にランナーの登録は残り続けていたので、削除するにはランナー削除のAPIを叩くかセルフホストランナーのマシンから登録削除用のコマンドを実行する必要がありました。さらにREST APIのドキュメントのリクエストを見るとランナーの名前やラベルなど、これも従来はセルフホストランナーのマシンから登録用のコマンド実行時に指定していたパラメータが含まれていることが分かります。

つまり、セルフホストランナーの登録や登録解除についてセルフホストランナーのマシン側から行っていた処理がAPIだけで可能になったということです。その利点についてドキュメント中では特に触れられていないのですが、今までセルフホストランナーのオートスケーリングを考えてきた人間としてはこれはかなり大きな変化であると感じました。

なお、今回の記事はセルフホストランナーをAWSやGCPといったクラウドを使って大規模にオートスケールさせるインフラを前提で書いていくため、そのようなインフラ構成でセルフホストランナーを運用するための基礎知識については触れません。そのあたりについては大規模なセルフホストランナー運用をテーマにした CI/CD Test Night #6 の発表資料が参考になると思います。

JIT runnerの使い方

ここまではドキュメントから分かることでしたが、ここからは実際に自分で実験してみた結果をまとめていきます。

従来のセルフホストランナーの起動方法

その前に、まずは比較のために従来のセルフホストランナーの登録方法を改めて紹介しましょう。1台のマシン上で常時動かすランナーではなく、VMやコンテナなどでオートスケールさせる前提のランナーではこのようなオプションを指定して起動させていました(Organization用のランナー)。必要なオプションは結構多いですね。

./config.sh \
  --url https://github.com/{ORG_NAME} \
  --token {TOKEN} \
  --unattended \
  --name runner1 \
  --runnergroup Default \
  --labels label1,label2 \
  --replace \
  --disableupdate \
  --ephemeral

./run.sh

JIT runnerの起動方法

最初にAPIでランナーを登録し、そのレスポンスに含まれる encoded_jit_config を使ってセルフホストランナーを立ち上げるという流れになります。POSTでJSON形式のリクエストを投げる必要があるため、今回は雑なワンライナーで gh api にリクエスト用のJSONを渡しています。

echo '{"name":"runner1","runner_group_id":1,"labels":["container"]}' | gh api -X POST orgs/{YOUR_ORG}/actions/runners/generate-jitconfig --input -
{
  "runner": {
    "id": 1412,
    "name": "runner1",
    "os": "unknown",
    "status": "offline",
    "busy": false,
    "labels": [
      {
        "id": 340,
        "name": "container",
        "type": "read-only"
      }
    ],
    "runner_group_id": 1
  },
  "encoded_jit_config": "{長いbase64エンコードされたテキスト}"

本来はレスポンスの encoded_jit_config にbase64された長いテキストが入っているのですが、今回は省略しています。このAPIを送った時点でGitHub上にセルフホストランナーがオフライン状態で登録されます。まずここが従来と大きく異なる点です。今まではセルフホストランナーのマシン上で config.sh を実行しないとランナーの登録もできませんでした。

次に encoded_jit_config の値をコピペなどで run.sh に渡して起動させます。

./run.sh --jitconfig "{長いbase64エンコードされたテキスト}"

√ Connected to GitHub

Current runner version: '2.304.0'
2023-06-07 05:56:30Z: Listening for Jobs

普通に起動したときのログと全く変わらないようです。この方法で立ち上げたランナーはジョブを完了するとGitHub上からも自動的にランナー登録が削除されるのを確認したので、たしかにドキュメント通りの挙動でした。ちなみにセルフホストランナーのプロセスを自分でctrl+cによって止めた場合はGitHub上にランナー登録は残り続けました。

actions/runnerの公式イメージのコンテナをJIT runnerとして起動する

自分はオートスケールさせるための基盤としてk8sやECSといったコンテナの利用を想定しているため、 actions/runner のイメージでも試してみました。このイメージのDockerfileにはCMDもENTRYPOINTのどちらも指定されていないため、自分でrun.sh を--entrypointに指定してやります。

docker run --rm --entrypoint=./run.sh ghcr.io/actions/actions-runner:2.304.0 --jitconfig "長いbase64エンコードされたテキストをコピペ"

これで問題なく起動できました。ちなみにこのイメージはデフォルトでランナーの詳細なログが出るようにされているため、普通のランナーに比べると大量のログが出ます。デバッグ中は便利かもしれませんが、本番運用などでは docker run -e ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT="” のようにログ出力用の環境変数を上書きして空にすることでログを抑えることができます。

encode_jit_config の中身

APIレスポンスに含まれる encode_jit_config をそのまま渡すだけでランナーを起動できたということは、この中身に何か秘密があるはずです。base64でエンコードされているだけのようなので逆にデコードしてみたところ、一応JSONにはなっていましたが2重にbase64エンコードされていたようです。

{
  ".runner": "base64エンコードされたテキスト1",
  ".credentials": "base64エンコードされたテキスト2",
  ".credentials_rsaparams": "base64エンコードされたテキスト3",
}

.credentials.credentials_rsaparams に関しては公開しても危険性が無いかどうかがちょっと分からなかったので一応秘密にしておき、.runnerだけさらにbase64デコードしてみました。気になる人は自分の encode_jit_config の中身を見てみてください。

{
  ".runner": {
    "AgentId": "1425",
    "AgentName": "runner1",
    "DisableUpdate": "True",
    "Ephemeral": "True",
    "PoolId": "1",
    "PoolName": null,
    "ServerUrl": "https://pipelines.actions.githubusercontent.com/WBM5AcdbdwqzoywxXc7q2rVnYQs9DjgqgFqfvWWLGGs0FuAigV/",
    "WorkFolder": "_work"
  },
  ".credentials": { 秘密にしておきます}
  ".credentials_rsaparams": { 秘密にしておきます }
}

.runner の中身を見てみるとAPIのリクエストに含めていたnameやらrunner_group_idに対応しているっぽいことが分かりますね。さらに言うと、この .runner, .credentials, .credentials_rsparams はそのキー名の時点で config.sh を実行したときに生成されているファイルと同じなのでおそらく中身も同じなのでしょう。つまり今まで config.sh を実行しないと生成されなかった設定ファイルがAPIを叩くだけで生成できるようになった、ということなのだと思います。

JIT runnerを使うと何が嬉しいのか

ここまでは実際に試してみた挙動の解説でしたが、ここからは分かった挙動をもとにした自分の考察です。

ラベルの指定が柔軟になり、Runner Groupが使いやすくなる

実はこの新しいAPIからランナーを登録すると、今までは絶対にデフォルトで付けられていたself-hosted, Linux(OSごとのラベル), X86(CPUアーキテクチャのラベル)の3つのラベルが付きません。実際にGitHub上のランナー一覧のページでAPIから登録したランナー確認してみたスクショです
実際に登録されたランナー

これの何が嬉しいかというのを説明するのはちょっと難しいのですが、例えば複数のスペックのセルフホストランナーを提供して、その使い分けのためにスペックを表すラベル(small, largeなど)を付けるという運用が今まででは少し難しかった問題を解決できます。例としてCybozuの@miyajanさんの資料を引用します(スライドP25)。

https://www.docswell.com/s/miyajan/ZW1XJX-large-scale-github-actions-self-hosted-runner-by-philips-terraform-module#p25

初期案の runs-on: [self-hosted, internal-network, large] のように、本来は機能(internal-network)とスペック(large)という直行する概念ごとにラベルを用意してその組み合わせでセルフホストランナーを呼び出せるようにしたいのです。しかし、今のGitHub ActionsのYAMLの仕様では runs-on のラベルは完全一致ではなくて部分一致でどれか1つでもマッチしていれば呼び出されてしまいます。つまり、仮にスペックのラベルがsmall, largeの2種類ある場合にスライドの例にあるように runs-on: [self-hosted, internal-network] とだけ指定するとsmallかlargeのどちらのランナーが呼び出されるかはランダムになってしまいます。従って、概念ごとにラベルを分ける運用は実質的に不可能であり internal-network-large のようにそれぞれの概念をミックスした1つのラベルで表現しないと運用者側が意図した使い方をユーザーに強制できないのです。ちなみにそのような工夫をしたとしても、ユーザーに runs-on: self-hosted という指定をされると無意味で全てのランナーの中からランダムで呼び出されます。仮にLinuxとmacOSのランナーが混ざっていてもお構いなしにランダムです[2]

miyajanさんのスライドでは触れられていませんでしたが現在ではRunner Groupという機能も追加されており、本来はラベルで表したかったいずれかの概念はRunner Groupでも表現可能です。例えば、internal-network というRunner Groupを作っておき、そこに登録したランナーをユーザーが呼び出したい場合はYAMLでこのように指定させることができます

runs-on:
  group: internal-network
  labels: large

group を指定しなかった場合は暗黙的に Default グループを指定したことになるため、ユーザーが明示的に group: internal-network を指定しない限りはこのランナーが呼び出されることはなくなるため、ラベルの方はスペックの指定だけに使うことが可能になります。従って internal-network-large という全部入りのラベルも作る必要はなくなり解決、見せかけて実は落とし穴があります。

落とし穴というのはYAMLでは従来のラベル指定のみの runs-on: [] 記法がまだ使えるということと、全てのセルフホストランナーには必ず self-hosted のラベルが付いていることです。Runner GroupをDefaultから独自のものに変更しようが、ユーザーが単に runs-on: self-hosted と指定してきた場合はやはりランダムに呼び出される対象として含まれてしまいます。ユーザー側のリテラシーの問題とも考えられるのですが、ちょっと間違ってるけど何となく動いてしまうルールというのは、明示的に細かく指定しないと正しく使えないルールと比較して徹底させることが難しいためセルフホストランナーの運用者側としては今まで頭の痛い問題でした。そう、今までは。

最初に説明したように、JIT runner用の新しいAPIからランナーを登録した場合は今まで厄介者だった self-hosted のラベルを付けないという選択が可能になったのです!これによって本当に必要なラベルだけを付与した上でRunner Groupも併用することで、本当に必要なジョブでしか使ってほしくないランナーがユーザー側の雑なラベル指定によって意図せずに使われてしまうという問題を解決できるはずです[3]

ジョブ完了後に自動的にGitHubからランナー登録が削除される

オートスケーリングさせるセルフホストランナーでは基本的にランナー名をランダムにしてEphemeralオプションで起動し、1ジョブを処理したらVMやコンテナ破棄します。このように実際のランナーは既に削除されていてもGitHub側のランナーの登録情報に関しては、セルフホストランナー側から config.sh remove を実行するかAPIからランナー登録を削除しないと使い終わったランナーがオフライン状態で一覧に残り続けてしまうという問題がありました[4]

これを解決するためにユーザー側で run.sh のラッパースクリプトを用意し、そちらでジョブの終了やctrl-cによる中断を検知して自動的に config.sh remove を実行するというアプローチも見られました(myoung34/docker-github-actions-runnerの例)。とはいえJIT runnerを使えば削除をGitHub側が行ってくれるようになったので、このようなロジックを各自が車輪の再発明をしなくて済むようになるのは嬉しいです。

config.shのオプションを駆使する必要がなくなり、設定の主体をwebhookを受けるバックエンド側に寄せられる

最初の方に紹介しましたが、config.sh で設定する際の各オプションは歴史的な経緯やセルフホストランナーのスコープ(Repository, Organization, Enterprise)によって排他的な項目もあったりとそこそこ複雑です。

このような処理の例としてmyoung34/docker-github-actions-runnerのentrypoint.shを見てほしいのですが、それほど難しい処理ではないものの様々なバリエーションのオプションに対応させるため少々面倒な感じとなっています。bashよりも表現力のある言語のスクリプトに置き換えて解決する方法もありますが、コンテナサイズをなるべく小さくするためになるべく無駄なものはインストールしたくないという事情もあります。

JIT runnerであればwebhookを受けるバックエンドから直接APIを叩いてランナーを登録するため、環境変数経由でオプションを受け渡して config.sh にセットするという手間自体が不要になります。APIのレスポンスに含まれる encode_jit_config だけは何らかの方法でセルフホストランナーを起動するVMやコンテナに渡す必要がありますが、今までランナー名やラベルなどの情報を渡すために複数の環境変数が必要だったことに比べればかなり簡略化できるはずです。

今後のセルフホストランナーをオートスケーリングさせるインフラ構成の展望

ここまでの内容でちょっとずつ触れてきましたが、JIT runnerの登場によって今まで各社やOSSがそれぞれ設計してきたオートスケーリングさせるためのアーキテクチャが一気に簡略化できそうです。特に個人的に大きな変化となりそうなのが、今までセルフホストランナー側に持たないといけなかった config.sh のオプションを組み立てるロジックがほぼ不要になることです。

今までは自分で実装するよりも昔からメンテされていて様々なオプションに対応しているmyoung34/docker-github-actions-runnerを使うほうが楽だと思っていたので、これをベースにした独自のDockerfileを用意して追加で必要なツールのインストールやラッパースクリプトの拡張をしていました。ただ最近では機能が増えた結果、entrypoint.shが複雑化してきたことや、誰かがよかれと思って追加した機能の影響を受けて自分の環境でうまく動かなくなってしまった経験もあることがちょっと悩みのタネでした[5]

JIT runnerであれば config.sh オプションのためのロジックはAPIを叩くバックエンド側に移動するため、セルフホストランナーを動かすコンテナ側ではそのようなロジックは不要でシンプルになります。そのためコンテナのイメージ自体はもう公式のactions/runnerのイメージをそのまま使うか、あるいはそのDockerfileをベースにして自分たちの用途で追加が必要なツールだけを apt install するDockerfileを用意するので十分になりそうです。

一方、オートスケールのためにGitHub Actionsのwebhookを受けるバックエンド側ではJIT runnerのAPIを叩く処理を追加する必要があります。といっても今までもwebhookの中身から必要なパラメータを環境変数などにセットしてVMやコンテナを起動する役目を担っていたはずなのでそこをちょっと変更するだけで済むと思います。おそらく今後はこういう流れになるでしょう。

  • ユーザーがジョブを実行するとwebhookが飛ぶ
  • バックエンドでwebhookを受ける
    • 中身を取り出してJIT runnerのAPIを叩く
    • レスポンスに含まれる encode_jit_config を取り出す
    • VMやコンテナを起動するリクエストに encode_jit_config を環境変数経由などで渡す
  • VMやコンテナは起動後に渡された encode_jit_config の中身を run.sh --jitconfig に渡してセルフホストランナーを起動する
  • ジョブが終わったらVMやコンテナを破棄する

流れ的には今までとそれほど大差は無いのですが、セルフホストランナー側の処理が薄くなったことやジョブ終了後にランナー登録を解除するなどの処理が不要になったことで全体のフローを今までより若干シンプルにできそうです。

おわりに

セルフホストランナーをオートスケールさせるためのアーキテクチャのうちwebhookを受けてランナーを立ち上げる方式はここ1年ぐらいでやり方がほぼ固まってきたかなと思っていたのですが、突然のJIT runnerの登場によってもう少し発展というか簡略化が見込めそうです。

さらに今回のJIT runnerと関係があるかはわかりませんが、今後はV2と称されるGitHubのサーバー側への機能追加を含めた大幅なアップデートも予定されてそうな雰囲気ですので、セルフホストランナー界隈は今後さらに発展していきそうです。

https://zenn.dev/korosuke613/scraps/703218980ddc5d

https://github.com/actions/actions-runner-controller/tree/master/docs/preview/gha-runner-scale-set-controller

脚注
  1. 自分の初見ツイート https://twitter.com/Kesin11/status/1665335604366962688 ↩︎

  2. そもそも runs-on のマッチングロジック自体が使いにくすぎると思うのですが、GitHub側も互換性の面から今さらもう変えられないのだろうな。。 ↩︎

  3. ちなみに今まででもDefault以外のRunner Groupに分けた時点でそのグループを利用できるリポジトリなどを限定してしまうことで意図せずに使われてしまうことを防ぐこも可能ではありました。ただこの方法は許可する対象をいちいち追加する必要などがあるのでその管理は面倒でしょう。 ↩︎

  4. 実は一定時間使われていないオフライン状態のランナーはGitHub側が自動で削除してくれるので今まででも永久に残り続けるわけではなかったです。 ↩︎

  5. ランダムなランナー名を生成するロジックが独自の乱数からhostnameに依存するように変更されたことでECSなどでうまく動かなくなってしまったのを報告したことがあります myoung34/docker-github-actions-runner#275 ↩︎

Discussion