🐋

コンテナのセルフホストランナーの中でコンテナを使えるようにするrunner-container-hooks

2023/06/02に公開

以前にセルフホストランナーの知られざる機能であるジョブの前後に任意のスクリプトを実行できるhookを紹介しました。
https://zenn.dev/dena/articles/20220808_github_actions_hooks
今回はセルフホストランナーの知られざる機能の紹介第二弾としてactions/runner-container-hooksを紹介します。

runner-container-hooksは2023年現在では比較的新しい機能で、自分もいつ頃に知ったのかは覚えていないのですが、actions/runnerのリポジトリには2022年の4-5月頃に追加されていたようです。実装のpull-reqから少し遅れて5月には設計ドキュメントと言えるADRのpull-reqが出されています。
https://github.com/actions/runner/pull/1853
https://github.com/actions/runner/pull/1891

このADRを見たところ自分がセルフホストランナーを運用する上で今まではどうしても不可能であったコンテナの中で起動したセルフホストランナーの中でコンテナ型のactionなどが実行できないという制約を突破できることが確認できたので紹介したいと思います。

ちなみに現在ではGitHubのドキュメントにもひっそりとこれに関するページが存在していたりするので全くの非公開機能というわけではないです。自分も今まで気がついていなかったのですが、今回ブログを書くにあたって一応調べてみたところ初めて発見しました。一体いつの間に追加されていたんだ・・・。
https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs

コンテナで動かすセルフホストランナーではコンテナが使えない問題

runner-container-hooksについて紹介する前に、そもそもなぜコンテナで動かすセルフホストランナーではコンテナが使えないのか、それ以前になぜコンテナでセルフホストランナーを動かしたいのかを説明します。

セルフホストランナーをコンテナで動かすメリット

大規模な組織でセルフホストランナーを運用しようと考えた場合、素朴にマシンを調達してセルフホストランナーを建てるだけでは以下の問題があります。

  1. キュー待ちの対策としてオートスケーリングの導入
  2. ビルドに必要なツールをあらかじめマシンにインストールするなど環境の統一
  3. 前に実行されたジョブのディレクトリや認証情報が見えてしまう

1はAWSやGoogle Cloudを活用し、VM(EC2, GCE)もしくはコンテナ(k8s, ECSなど)でセルフホストランナーを動かすことでオートスケールが容易になるためこれで解決します。オートスケールのために必然的にVMではマシンイメージ、コンテナではDockerfileを用意することになるので2の環境統一の問題も解決されます。3の問題はGitHubが用意している通常のランナーと同様に、1つのジョブが完了した後にVMやコンテナを破棄する仕組みを構築することで解決が可能です。

これらの問題について詳しくはCI/CD Test Night #6の自分の発表で解説したので興味がある方はぜひご覧ください。
https://www.docswell.com/s/Kesin11/K98GJJ-cicd_testnight_6_github_actions#p10

VMかコンテナのどちらでも要件を満たすことは可能で、実際に既に大規模なセルフホストランナーを実現している各社のテックブログやOSSを見るとどちらの方法でも可能であることがわかります。

個人的にはマシンイメージをpackerなどで作成するよりもDockerfileの方が手軽であることや、オートスケール自体をk8sやECSといったコンテナオーケストレーターに任せやすいという理由からコンテナでセルフホストランナーを動かす方が有望だと考えています。

コンテナの中でコンテナ(docker)を動かす方法

一方でセルフホストランナーをコンテナで動かす場合に問題となるのがジョブの中でコンテナを動かす方法(だいたいdocker)です。VMであればdockerのサービスをバックグラウンドで起動するだけなのですが、コンテナの中でdockerを使えるようにするには工夫が必要で有名な方法は以下の2つです。

  • DooD(Docker outside of Docker)
  • DinD(Docker in Docker)

2つの方式の違い自体についてはdind(docker-in-docker)とdood(docker-outside-of-docker)でコンテナを料理するの記事の図を見ていただくのが分かりやすいのでぜひ見てほしいです。これをセルフホストランナーの事情に当てはめると実は複数のジョブ間で環境を隔離できるかの問題に関係してきます。

DooDの図
DinDの図

https://www.docswell.com/s/Kesin11/K98GJJ-cicd_testnight_6_github_actions#p36 のスライド36, 37より転載

コンテナの中でdockerを動かす方法として広く知られているのはdocker.sockをマウントするDooD方式でしょう。しかし、DooDでは1台のホストマシンに複数のセルフホストランナー用のコンテナを立ち上げた場合に別のジョブでビルドしたイメージを他のジョブから見ることが容易であるため、せっかくジョブごとにコンテナを分けて隔離した意味がなくなってしまいます。そのためセルフホストランナーというユースケースにおいてはDinDの方が適していると考えています。

実際にDinDでセルフホストランナーを提供している例として、クックパッドのs4ichiさんが紹介されているECSのsidecarにrootless dockerを立ち上げてランナーのコンテナから接続している構成があります。
https://techlife.cookpad.com/entry/2022/11/07/124025

自分でもこの記事を参考にして、ECSの代わりにdocker composeで同様の構成を再現してみました。
https://zenn.dev/link/comments/256cccb322672f

ちなみにDinDを可能にするための要件として、dockerを立ち上げるためのコンテナは privileged オプションを付けて起動する必要があります。これはrootless dockerであっても同様です。従って、FargateやGKE Autopilotのようなコンテナを動かすホストマシン自体がマネージドなサービスでは基本的に使えないということになります[1]

ジョブの中でdockerコマンドを使う

セルフホストランナーを動かしているコンテナにdockerをインストール(クライアント)し、DinDもしくはDooDで動かしているdocker(サーバー)に接続できてさえいればジョブの中でdockerコマンドを使うこと自体は可能です。

ただし、 docker run -v $PWD:/path/to/work/dir のようにbind mountを使う場合ジョブを正しく動かすためには注意が必要です。ランナーのコンテナ内でdocker(サーバー)も動かして同一コンテナ内で完結させる構成ならば問題にならないはずですが、前述の例のようにsidecarとして立ち上げた別のコンテナのdockerに接続させている場合はランナーとdockerのコンテナの間で少なくとも実行中のジョブのディレクトリが共有されている必要があります。

s4ichiさんの記事では dind-rootless を sidecar として起動する ECS Task の図中の Volume to share files がおそらくこのための共有ディレクトリであり、自分のdocker composeの例では volumes に以下を追加しているのがこれに相当します。

volumes:
  - type: volume
    source: workspace
    target: /_work

自分のdocker composeでは /_work にマウントしていますが、これは自分が使用しているセルフホストランナー用のイメージであるmyoung34/docker-github-actions-runnerがここを使用しているためです。独自のDockerfileでランナー用のコンテナを用意している場合はそのコンテナ内のディレクトリ構成に合わせてください[2]

ジョブの中でGitHub Actionsのコンテナの機能を使うことはできない

やっと本題に入ることができました。ここでのGitHub Actionsのコンテナ機能とは具体的には以下の3つのことです。

これらの機能はGitHubが用意しているランナー、あるいはLinuxのVM上のセルフホストランナーであれば問題なく動きます。一方でコンテナ上のセルフホストランナーで実行しようとした場合、docker自体が利用可能であったとしても以下のエラーが発生してしまいます。

Error: Container feature is not supported when runner is already running inside container.

このエラーはdocker側の問題ではなく、セルフホストランナーのプロセス内でthrowされたエラーです。このエラー文をactions/runnerのリポジトリで検索してみるとすぐに発見できました。

https://github.com/actions/runner/blob/22d1938ac420a4cb9e3255e47a91c2e43c38db29/src/Runner.Worker/ContainerOperationProvider.cs#L530-L534

どうやらcgroupsの情報を見てランナー自体が既にコンテナ内で動作しているかどうかを判定しているようです。セルフホストランナーのプロセスの中でチェックされてしまっているので外部から何かをいじったところでこのチェックを回避はできないため、dockerが使えたとしてもコンテナの機能を使うことはできませんでした。そう、今までは。

Runner Container Hooksの登場

Runner Container Hooksの使い方

Runner Container Hooksを使うとこのコンテナ内のランナーではコンテナの機能が使えないという問題を回避することが可能になります。

必要なのはactions/runner-container-hooksからリリースされている一部のjsのファイルと、そのパスを指定するための ACTIONS_RUNNER_CONTAINER_HOOKS 環境変数だけです。自身でランナーのコンテナ用のDockerfileを用意している場合は、例えばこのような数行を追加するだけです。(実際に試すことができるフルのサンプルコードはこちらになります)

ARG RUNNER_CONTAINER_HOOKS_VERSION=0.3.2
ARG RUNNER_DIR=/actions-runner
RUN cd "${RUNNER_DIR}" \
  && curl -fLo runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-docker-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \
  && unzip ./runner-container-hooks.zip -d ./runner-container-hooks-docker \
  && rm -f runner-container-hooks.zip
ENV ACTIONS_RUNNER_CONTAINER_HOOKS="${RUNNER_DIR}/runner-container-hooks-docker/index.js"

これを追加したDockerfileからビルドされたコンテナでランナーを立ち上げて以下のジョブを実行すると、今まで見られたエラーは現れなくなり、実行しようとしたコンテナの機能を実現するためのdockerのコマンドが実行されていることがログから確認できます。

# サービスコンテナとしてredisを立ち上げ、stepsのコマンドをubuntuのコンテナの中で実行するジョブ
run_container:
  runs-on: [self-hosted]
  services:
    redis:
      image: redis
  container:
    image: ubuntu
    options: --cpus 1
  steps:
    ...省略
Run '/actions-runner/runner-container-hooks-docker/index.js'
/usr/bin/docker ps --all --quiet --no-trunc --filter label=636f6e7461696e65722d3274756c4e33737a5869785a6b
/usr/bin/docker network prune --force --filter label=636f6e7461696e65722d3274756c4e33737a5869785a6b
/usr/bin/docker network create --label 636f6e7461696e65722d3274756c4e33737a5869785a6b github_network_bfd57881-ccc3-4c68-ae04-334ec3d015e0
a74c52fe535dba8d6efd186fa719b156d13a3c1f4ce6d1547e23f213968db8d5
/usr/bin/docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
dbf6a9befcde: Pulling fs layer
dbf6a9befcde: Verifying Checksum
dbf6a9befcde: Download complete
dbf6a9befcde: Pull complete
Digest: sha256:dfd64a3b4296d8c9b62aa3309984f8620b98d87e47492599ee20739e8eb54fbf
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest
/usr/bin/docker create --label=636f6e7461696e65722d3274756c4e33737a5869785a6b --network=github_network_bfd57881-ccc3-4c68-ae04-334ec3d015e0 --name d3258adceb6841638e076fe76c8de5aa__3c988f --cpus 1 -e HOME -v=/var/run/docker.sock:/var/run/docker.sock -v=/_work:/__w -v=/actions-runner/externals:/__e -v=/_work/_temp:/__w/_temp -v=/_work/_actions:/__w/_actions -v=/opt/hostedtoolcache:/__t -v=/_work/_temp/_github_home:/github/home -v=/_work/_temp/_github_workflow:/github/workflow --entrypoint tail ubuntu -f /dev/null
894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983
/usr/bin/docker start 894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983
894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983
/usr/bin/docker pull redis
Using default tag: latest
latest: Pulling from library/redis
f03b40093957: Pulling fs layer

... レイヤーのpullのログが大量に続くため一部省略

635073d8ccd5: Pull complete
Digest: sha256:f9724694a0b97288d2255ff2b69642dfba7f34c8e41aaf0a59d33d10d8a42687
Status: Downloaded newer image for redis:latest
docker.io/library/redis:latest
/usr/bin/docker create --label=636f6e7461696e65722d3274756c4e33737a5869785a6b --network=github_network_bfd57881-ccc3-4c68-ae04-334ec3d015e0 --name 76e4971c05e34d678df4b3d9201137b3__871095 --entrypoint tail redis -f /dev/null
f2effb53ea3f0730bdc83819521980ef7c195975ea82acf54090a81b9cde0ce6
/usr/bin/docker start f2effb53ea3f0730bdc83819521980ef7c195975ea82acf54090a81b9cde0ce6
f2effb53ea3f0730bdc83819521980ef7c195975ea82acf54090a81b9cde0ce6
/usr/bin/docker exec 894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983 sh -c [ $(cat /etc/*release* | grep -i -e '^ID=*alpine*' -c) != 0 ] || exit 1
/usr/bin/docker port 894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983
/usr/bin/docker port f2effb53ea3f0730bdc83819521980ef7c195975ea82acf54090a81b9cde0ce6
/usr/bin/docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" 894f8683553cebd360040f94d8334d7e5a3cea05da1366eccf175d846eb9f983
/usr/bin/docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" f2effb53ea3f0730bdc83819521980ef7c195975ea82acf54090a81b9cde0ce6
""
Healthcheck is not set for container ubuntu, considered as healthy
""
Healthcheck is not set for container redis, considered as healthy
All services are healthy

実際に意図していた通り、このジョブの場合はredisのコンテナがバックグラウンドで起動し、steps のコマンドはubuntuコンテナの中で実行されます🎉。

まとめ

今回はセルフホストランナーの知られざる機能の紹介第二弾としてrunner-container-hooksを紹介しました。また、前提知識としてコンテナでセルフホストランナーを動かしたい理由と、コンテナの中でdockerを動かす方法も解説しました。

runner-container-hooksをセットアップすること自体は非常に簡単でDockerfileに数行追加するだけですが、むしろ前提条件であるコンテナの中でdockerを使えるようなインフラ構成を実現することの方が難しいです。コンテナで動かすランナーを実現できたらついでにrunner-container-hooksも設定しておくとVM上で動かすセルフホストランナーと使い勝手の差はなくなるのでより便利になるはずです。

本題は以上です。このあとの話はさらにrunner-container-hooksの中身に興味がある人向けの蛇足になります。

蛇足:Runner Container Hooksの仕組みと中身

そもそも何を目指して作られた機能なのかはADRとGitHub Docsの2つのドキュメントに書いてあり、内容はだいたい同じっぽいですがGitHub Docsに置いてある方がより詳しく書かれてそうです。

https://github.com/actions/runner/blob/main/docs/adrs/1891-container-hooks.md
https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs

この仕組みで実現したいことは、GitHub Actionsのコンテナに関する機能を呼び出した際にそれを実行するツールとしてdocker以外も利用可能にすることのようです。ADRの冒頭には例としてPodmanやk8sが挙げられています。
docker以外のツールも利用可能にするため直接dockerのコマンドを組み立てるのではなくコンテナに関する機能を呼び出す方法を抽象化し、使うイメージのタグやマウントするボリュームの情報などをJSONで用意します。このJSONのインターフェースはTypeScriptで定義されています。
https://github.com/actions/runner-container-hooks/blob/main/packages/hooklib/src/interfaces.ts

そして、このJSONを受け取って実際に実行されるdockerなどコンテナを扱うツールのコマンドの組み立てと、その実行自体を任意のjsのスクリプトに移譲しています。先述のDockerfile例で紹介した ENV ACTIONS_RUNNER_CONTAINER_HOOKS="${RUNNER_DIR}/runner-container-hooks-docker/index.js" の実体は https://github.com/actions/runner-container-hooks/tree/main/packages/docker のTypeScript全体をindex.jsの1ファイルとしてビルド[3]したものです。あとはこの中のコードを見ていけば分かると思いますが、先ほどのJSONの中に書かれていた情報を使って必要なdockerのコマンドを組み立てています。

実のところこのdockerのhooksは参考実装程度のもので、本命はk8sのhooksの方なのでしょう。そちらのREADMEを見た雰囲気として、ジョブ内でのコンテナ関連の機能を実現するためにk8sの中でdockerを使うのではなく、代わりに一時的なpodを立ち上げてジョブのコンテナと接続することで実現しているようです。

READMEには今やGitHub公式に移管されたactions/actions-runner-controllerで使われているとも書かれています。自分はARCを利用したことがないので実際のところは分からないのですが、たしかにACTIONS_RUNNER_CONTAINER_HOOKSの環境変数で検索してみると使ってそうな気配を感じます

ということで、おそらくはk8sというかARCを想定して追加された機能なのだと思いますがdocker composeで建てるランナーのようにk8sを使わない構成であっても参考実装のおこぼれに預かりdockerのhooksで動いた、というのがこの記事の真相になります。

dockerやk8s以外のコンテナを扱うPodmanなんかも似たようなhooksのスクリプトを用意できればおそらく動くのではないかと思います。もしrunner-container-hooksに元々用意されているhooksをそのまま使えない事情がある場合は自分でhooksスクリプトを書いて動かしたり、pull-requestを送ってみてもいいかもしれませんね。

脚注
  1. 詳しくはそれぞれのプラットフォームのドキュメントを参照してほしいですが、特権を与えるということはセキュリティを弱めることになるためマネージドなサービスでは一般的に許可されていないでしょう。 ↩︎

  2. 自分のdocker composeの例では他に /actions-runner, /opt/hostedtoolcache もマウントしていますがこれもmyoung34/docker-github-actions-runnerに特化した設定です。 ↩︎

  3. npm run buildではtscでts -> jsにトランスパイルし、nccで依存モジュールも含めて1ファイルにまとめています。 ↩︎

Discussion