たゆたふ。

定まる所なく揺れ動き、いろいろやってみたメモ。など

ECS で Github Actions Self-hosted runner を動かす

前回のエントリで Github Actions の Self-hosted runner を試したことを書いた。

hero.hatenablog.jp

で、もともと何がやりたかったかというと、Github workflows の実行環境ではビルドできないほど大きな次の Docker イメージのビルド。イメージのサイズは約 10 GB にもなる。

qiita.com

毎回 EC2 インスタンスを起動しその中で Self-hosted runner を起動するのも面倒なので ECS で環境構築した。
それについて記す。

実現したいこと

もともとは Github workflow で Docker イメージをビルドし、それを GHCR1 に Push していた。
これまでなんとかビルドできていたが、イメージが大きくなりすぎて難しくなってきた。そもそもの欲張ったイメージの仕様上、小さくするのも難しい。
さらに Apple Silicon の MBP に乗り換えたので Arm 向けのイメージもビルドしたくなり、マルチアーキテクチャのビルドを追加したら遂にビルドできなくなってしまった2

それでビルド環境の増強でなんとかしたい。というのが要件だ。

整理すると次を実現したい。成果物である Docker イメージはこれまで同様 GHCR に Push したい。

  1. 大きな Docker イメージをビルドしたい
  2. x86_64 と arm64 の両方のアーキテクチャ向けにビルドしたい
  3. できるだけビルド時間は短くしたい
  4. 手間を少なく運用したい
  5. コストをできる限り減らしたい

MUST 要件は 1、2 のみ。 残りはより良いソリューションとするための検討課題。

Self-hosted runner を採用したこと

「大きな Docker イメージをビルドしたい」ので Github がホストするランナー3よりパワフルな環境を用意する必要がある。

当初は Self-hosted runner ではなく AWS Codebuild で実現しようかとも考えた。慣れているし。
しかし検討する中で、Self-hosted runner の方が「手間を少なく運用したい」を満たしやすいと考えた。

ビルドがいつも想定内の実行時間でうまく動けばいいのだろうけど、時間がかかったりエラーが発生するケースがあるかもしれない。 そうすると進捗やログの確認がしやすいほうがよい。CodeBuild だと、AWS Console を開いて見ることになる4けど、Self-hosted runner なら Github だけで完結する。

ということで Self-hosted runner を使うことに決めた。
また通常運用では「管理を Github 内で完結させるという」のも今後の検討の方針とした。

実行環境は EC2 ノードの ECS にした

さて、Self-hosted runner を使うとするとそれを動かす環境はどうしよう。
オンプレでリソースを用意なんてできないのでクラウドサービスを使う。
各種サービスの無料枠では Github の提供しているもの以上の環境を得られそうにないので、そこの課金はやむなしと考える。が、「コストをできる限り減らしたい」。
とすると利用する時間だけ起動し終わったら速やかに止めるようにしたい5

結論としては以下のように考えて AWS ECS を EC2 ノードで使うこと選択した。

シンプルには前回のエントリでやったように EC2 インスタンス上で実行すればいい。いちいちインスタンスの中に入って操作するのは面倒なら専用の AMI を作ればインスタンス起動時に Github runner を自動起動するものが作れるだろう。
しかし一方で、今後 Github runner もアップデートされていくだろう。それを考えると AMI よりも Docker イメージのメンテナンスの方がローカルマシン上でも動作確認できるし「手間を少なく運用したい」をより満たすだろう。 ということで、コンテナで運用することにした。

AWS でコンテナを動かす環境はいくつかあるが、 EKS クラスターを作るのは要件に対して大げさすぎし、クラスターが存在するだけで課金されるので「コストをできる限り減らしたい」的に NG。 App Runner も Web システムを動かすことに特化されすぎているし、 Lambda は最大 15 分でタイムアウトしてしまう。実行したい処理はおそらくその制限内では収まらない。
ということで ECS を使うことにした。

ECS を使うなら Fargate の方がクラスターノードのリソース管理が不要でいいかと思った。 が、Fargate の良くないところは CPU やメモリの数量は選べるが、その性能は選択できないところ。 実際に Fargate で提供される CPU と Github actions の実行環境のそれを比べてみると同等かやや劣っていた。 Github よりパワフルな環境を手に入れるための Self-hosted runner なのでこれでは本末転倒。

ということでインスタンスタイプで性能の良いものを選択できる EC2 を利用することとした。 実際、構築してみるとタスクを起動するとキャパシティプロバイダとオートスケーリンググループにより自動的にインスタンスクラスターに追加できる。そしてタスクを削除すると追加されたインスタンスが停止される(タスクがなくなれば、インスタンスも 0 台になる運用も可能)。直接的にインスタンスの運用はしなくていいので手間的には Fargate と変わりなかった。

ECS で Docker イメージをビルドするには

Github からは公式のイメージは公開されていないので Github runner のイメージは自分で作成した。

Docker in Docker でイメージをビルドするにはコンテナを特権モードで動かす必要がある6。タスク定義で privileged: true を設定し、Docker をインストールしたイメージを作れば良い。
ECS コンテナ内で Docker を使うにはもう1つ方法がある。ホストマシン側の Docker エンジンにコンテナからアクセスする方法があり今回はそれを利用した。コンテナ側に Docker エンジンインストールするとイメージが大きくなりそうだし、特権モードも必要ないし。

やり方は次の通り。

Docker イメージに次を含める。

  1. Docker エンジンは不要だけどコマンドは必要なので docker-ce-cli はインストールする
  2. GID=994docker という名前のユーザグループを作る
  3. docker を実行するユーザを作る。その際、ユーザグループ docker に入れる

Dockerfile の抜粋で示すと次の通り。

ARG USERNAME=user
ARG GROUPNAME=user
ARG UID=1000
ARG GID=1000
ARG DOCKER_GROUPNAME=docker
ARG DOCKER_GID=994

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
            docker-ce-cli

## Add user and workdir
RUN groupadd -g ${GID} ${GROUPNAME} && \
    groupadd -g ${DOCKER_GID} ${DOCKER_GROUPNAME} && \
    useradd -m -s /bin/bash -u ${UID} -g ${GID} -G ${DOCKER_GID} ${USERNAME}
USER ${USERNAME}

docker ユーザグループの GID が決め打ちすぎるのが気になるが、とりあえずココが原因でエラーになったりしてないので良しとする。

そして、タスク定義のコンテナ定義でホストの /var/run/docker.sock を同じパスでマウントする7

これで ECS のコンテナ内でホスト側(つまり、コンテナ自身の起動に利用されている)Docker エンジンを使える。
あとは、actions/runner: The Runner for GitHub Actions から runner を取ってきてインストールすれば Docker ビルドできる Self-hosted runner イメージの出来上がり。

Dockerfile 全体はこちら => Dockerfile

ちなみにこのイメージも x86_64 と Arm64 で使いたいのでマルチアーキテクチャでビルドしている。
ビルドステップが少ないのでこちらは Github hosted な runner で十分ビルドできる。

作ったもの

作ったものの全体は次の様の通り。

ECS cluster for Github self-hosted runners

これを次のように使っている。

  1. 予め起動用の Lambda 関数で Github runner の ECS タスクを起動する
  2. ビルド対象のリポジトリで workflow を Push などのトリガーで実行する
  3. workflow の Job は x86/arm64 それぞれのインスタンス上の Github runner タスクが並行して Docker イメージをビルドし GHCR に Push する
  4. 後続の Job でマルチプラットフォームとしてマニフェストを作成し、GHCR に Push する
  5. 終わったら停止用 Lambda 関数を使って Github runner の ECS タスクを止める

前述したが ECR クラスターのノードとなる EC2 インスタンスの起動/停止は直接操作していない。Capacity Provider と Auto Scaling Group がよしなに用意してくれる。

これらを CDK で構築した。そのリポジトリは次の通り。

github.com

これで要件の「大きな Docker イメージをビルドしたい」と「x86_64 と arm64 の両方のアーキテクチャ向けにビルドしたい」を実現した。Buildx を使ったマルチプラットフォームビルドだといずれかのイメージがエミュレーション環境でビルドされるので時間がかかるのだが、それぞれネイティブな環境を用意したのでエミュレーションオーバーヘッドによる遅さはなくなった。1時間以上かかっていたビルドが約 30 分に短縮できた。これには C6i/g.large というパワフルなインスタンスを使ったおかげもあるが、スポットインスタンスを使うことで、「できるだけビルド時間は短くしたい」を満たしながら「コストをできる限り減らしたい」もできていると思う。 はじめはタスクの起動/停止を簡便化する Lambda を用意していなかったのだが「手間を少なく運用したい」ので追加した。

Self-hosted runner とそれを使った workflow の動き

前回のエントリ(Github Actions の Self-hosted Runner をやってみた)では単に Self-hosted runner を起動してハロワ程度を試しただけだったが、実際に使うものを構築しながらもう少し詳しく調べてみた。

Self-hosted runner のマシンは外部にポートをさらさなくて良い

ドキュメントにも書かれているのだけど、runner は HTTPS long poll により Job の情報を受け取り常に runner 側から Github に通信する。runner にインターネット側から接続できるようにする必要はない。
実際、インターネットとの間に NAT がある家庭用ネットワークの中でも動く。

とすると AWS でも外部からの切断を遮断された Private Subnet で運用できると思う。
しかし、コストを減らすために Public Subnet で運用している。

Private subnet に置いても wrokflow の中で各種ライブラリパッケージや Docker イメージ等をダウンロードすると思うので runner からはインターネット側に通信できる必要がある。
そのためには NAT Gateway が必要になるが NAT Gateway は存在するだけで料金がかかるし、それ経由の通信が割高だからだ。
まあ、Public IP が付くとはいえ、外部からの接続は制限しているし、起動しているのは Job 実行の間だけだし良いだろう。

workflow の実行時に Self-hosted runner は起動していなくても良い

workflow から Self-hosted runner を使う Job を実行する前に runner を起動しておいたほうがスムーズではあるば、予め起動しておくことは MUST ではない。

workflow の Job は一旦キューイングされる。なので、キューイング時点で runner が起動していなくても runner が起動した時点でキューから Job を取得し処理される。なおドキュメント によると最大 24 時間キューに留めることができる。

今は手動で Self-hosted runner のタスクを上げ下げしているが、運用の仕方がパターン化してくれば自動化してもいいかもしれないと思っている。runner を起動する Job を追加して ephemeral (Job を処理したら自ら終了する)モードの runner の起動すれば Job 終了とともに runner も終了するので起動/停止が自動化できそう。

自動アップデート機能搭載

Github runner は自動アップデート機能を備えている。
以前はうまく起動していた runner の ECS タスクが起動直後に落ちるようになったので調べると起動時にアップデートがあれば自動的にダウンロードして、再起動していた。
再起動のためにプロセスが終了するのでその時点でコンテナも終了し、タスクが落ちていたというわけだ。

それ自体は便利な機能なのだけど、コンテナでの運用を考えるとそのためにコンテナが落ちるのは困る。 自動アップデートを無効化するには runner を設定する際に --disableupdate オプションを付ければよい。

まとめ

Self-hosted runner を利用してビルドできるようになった。
それだけでなくビルド時間も1時間以上 → 約 30 分と短縮できた。

管理も Github 側で Job の進行状況も確認できるし、ログも参照できる。
運用の手間も最小にできたと思うし、自動化の目処もある。
安いリージョン(us-east-1)での運用とスポットインスタンスの利用でコストも抑えられたと思う。

参考


  1. GitHub Container Registry

  2. エラーが発生したりはないが、ビルド時間が Github action の実行時間制限を超えてしまった。時間がかかるのは Arm64 向けのビルドが QEMU のエミュ環境になるからだと思われる。

  3. Supported runners and hardware resources

  4. AWS Console はログインが維持される時間が長くないのでしょっちゅうログイン操作を求められている。なので進捗やログ確認目的で開くのはめんどくさい。

  5. この点においてはリソース確保と開放を自動でやってくれる CodeBuild は便利だと思う。

  6. Fargate はそもそも特権モードでコンテナ起動できないのも採用できない理由だった。しかしkaniko という OSS を使うと特権モードでなくてもイメージをビルドできるらしい(Amazon ECS on AWS Fargate を利用したコンテナイメージのビルド | Amazon Web Services ブログ)。kaniko でイメージをビルドする Github action もあるようなのでそのうち試してみたい。

  7. https://github.com/HeRoMo/ecs-github-actions-runner/blob/f980828be1b49e5590f174471e20b9f105c83027/lib/constructs/EcsGithubActionsRunner.ts#L148-L155