GitHub Actions self-hosted runners のオートスケーリング構成の紹介(クラウドサービス開発を支える CI の裏側)

はじめに

こんにちは、クラウド&ネットワークサービス部で SDPF のベアメタルサーバー・ハイパーバイザーの開発をしている山中です。

先日 NTT Engineers' Festa という技術イベントが開催され、多くのエンジニアで賑わいました。 NTT Engineers' Festa は NTT グループのエンジニアが技術交流するイベントであり、ハンズオンやディスカッション、登壇発表など様々なセッションが数日に渡って行われます。

私もこのイベントに参加し、自分のチームで行っている GitHub Actions の self-hosted runners を活用した Continuous Integration(以下、CI)事例について発表をしました。 概要としては、オンプレミスの VMware vSphere(以下、vSphere)環境上で自作の Ruby アプリケーションと Docker によって self-hosted runners のオートスケーリングを行い、数十以上のリポジトリを対象とした月に数万分ほど時間がかかる大規模な CI を実行しているという話になります。

この記事では、発表内容をブログ向けにアレンジし、当日話せなかった内容も加えてご紹介したいと思います。 発表で用いたスライドはこちらになります。

GitHub Actions について

GitHub Actions は GitHub が提供する自動化プラットフォームです。 プルリクエストやイベントに対する様々なイベントをトリガーとして、事前に定義した workflow を GitHub 上から実行できます。 workflow は GitHub 上で構成管理されるため、GitHub から提供される様々なエコシステムと自然に連携でき、効率的に開発を行える点が非常に大きな魅力となっています。

self-hosted runners について

self-hosted runners は workflow 内の処理(job)を自分が用意した環境で実行できる仕組みです。 通常よく使われる GitHub-hosted runners とは異なり、runner が動作するマシンやコンテナなどの環境を自分で用意する手間はありますが、その手間の代わりに runner の利用時間に伴う従量課金がありません

自分で環境を用意するということは、用途に応じたハードウェア構成・指定したパッケージ等が入った環境を使って job を実行できるということであり、 自分が用意した環境上に OSS の actions/runner を起動して GitHub に登録するだけで準備は整うため、 細かな要件が求められる CI でも利用可能です。

なぜ self-hosted runners のオートスケーリングが必要なのか?

無料利用可能で便利な self-hosted runners ですが、実際のサービス開発における CI で使用するにあたって、解決しておきたい大きな課題が2つ存在します。

1つ目の課題は、job の実行数が self-hosted runners の登録数に縛られるという点です。 1つの self-hosted runners が同じ時間に処理できる job は1つであるため、 例えば複数人が同時に workflow を実行し、10個の job が GitHub 側の queue に積まれた場合、self-hosted runners を1つしか登録していなければ、10個のジョブはシーケンシャルにしか実行できません。

もう1つの課題は、self-hosted runners の動作する環境は複数の job 間で使い回されてしまうという点です。 self-hosted runners は基本的には一度登録したら登録されっぱなしのため、ある job の実行により self-hosted runners の動作する環境が汚されてしまった場合(ファイルを配置したりシステムの設定を変更するなど)、その self-hosted runners を利用して次に実行される job は汚れた環境で実行されることになってしまいます。 workflow の設計を工夫すればこの問題を回避できますが、CI の結果が不安定(flaky)になる潜在的な要因となってしまうため好ましくありません。

これらの課題を解決するのが self-hosted runners のオートスケーリング です。 job を実行するごとに self-hosted runners が起動するマシンやコンテナを必要な分だけ動的に用意することで、job の同時実行数の制限や環境が使い回されることによる問題を解消できます。

self-hosted runners のオートスケーリング構成例

私のチームで開発している SDPF ベアメタルサーバー1 / SDPF ハイパーバイザー2 の CI における、self-hosted runners のオートスケーリング構成についてご紹介します。

全体構成

実際に運用している構成は以下の図になります。

私たちのチームは物理サーバーを提供するサービスを開発するチームでもあり、開発で使用する検証環境も物理サーバーを使って構築しています。 検証環境内の物理サーバーには VMware ESXi をインストールしており、VMware vCenter を使って vSphere 環境を構築しています。 この vSphere 環境上に本番環境を模した検証環境を複数作成しており、検証環境内の VM を使って各種テストを実行しています。

この vSphere 環境上で GitHub Actions と連携して CI の中心的な役割を担うのが、自作の runner controller と Docker が動作する VM です。 この VM は GitHub からの webhook と連携し、以下のように動作します。

  1. workflow を実行し job が GitHub Actions の queue に積まれる。
  2. job が queue に積まれた旨の webhook が runner controller へ送信される。
  3. runner controller は webhook の内容に応じて runner が埋め込まれたコンテナを起動する。
  4. コンテナ起動とともにコンテナ内で runner が起動し、webhook の送信元のリポジトリに runner が登録される。
  5. 登録された runner を job が確保する。
  6. 確保された runner が動作する環境上で job の処理が実行される。
  7. job の終了後、GitHub から runner が自動で登録解除され、コンテナも自動で削除される。

job を複数実行すると 1台の CI 用 VM 上で runner が起動したコンテナが複数デプロイされることになりますが、 実際に負荷がかかるテスト処理などは検証環境内の別の VM で実行しているため、このようなシンプルな構成でも安定した動作しています。 runner のアプリケーションもコンテナイメージに封入しているため、複数コンテナを起動してもディスク容量は大きく消費しないようにもなっています。

コンテナの動作が軽量なこともあり、job が queue へ積まれてから10秒ほどで runner が GitHub へ登録されるようになっています。 コンテナを使用することで「job の実行環境の分離性」も簡単に手に入れることもできるため、self-hosted runners のオートスケーリングとコンテナはとても相性が良いと言えます。

もともと私たちのチームでは、GitHub Actions を導入する前は Jenkins を使って CI を回しており、Jenkins を使っていた頃からなるべくデグレしないようにと考えた結果、このような構成となりました。

self-hosted runners のオートスケーリングを行うための OSS も公式で紹介されていますが、 既に存在するオンプレミス資産の活用やチームメンバーのスキルセットなどを鑑みた結果、OSS には頼らず自分たちで仕組みを作ったという経緯になります。

runner controller

runner controller は Ruby の Sinatra(Web アプリケーションフレームワーク)を用いて書いた100行にも満たない小さなアプリケーションです。

runner controller のソースコード(一部抜粋)

class RunnerController < Sinatra::Base
  RUNNER_LABEL = 'custom-runner'

  post '/' do
    data = JSON.parse(request.body.read)

    # in-progress や completed の場合は何もしない
    halt if data.dig('workflow_job', 'status') != 'queued'

    job_id = data.dig('workflow_job', 'id')
    run_id = data.dig('workflow_job', 'run_id')
    repo   = data.dig('repository',   'name')
    labels = data.dig('workflow_job', 'labels')

    logger.info("job of #{repo} is queued. job_id: #{job_id}, run_id: #{run_id}")

    # runs-on に所定のラベル名が指定されていない場合は何もしない
    unless labels.include?(RUNNER_LABEL)
      logger.warn("skipped because labels do not have #{RUNNER_LABEL}. job_id: #{job_id}, labels: #{labels}")
      halt
    end

    # コンテナ起動
    command = [
      'sudo docker run',
      '--detach',
      '--volume ~/.ssh:/home/user/.ssh',
      "--env GITHUB_USERNAME=#{ENV.fetch('GITHUB_USERNAME')}",
      "--env GITHUB_TOKEN=#{ENV.fetch('GITHUB_TOKEN')}",
      "--env REPOSITORY=#{repo}",
      "--env RUNNER_LABEL=#{RUNNER_LABEL}",
      '--rm',
      'action-runner:latest'
    ].join(' ')

    stdout, stderr, status = Open3.capture3(command)
    raise stderr unless status.success?

    container_id = stdout.chop
    logger.info("container #{container_id} for #{repo} runs. job_id: #{job_id}, run_id: #{run_id}")
  end
end

runner controller は、受け取った webhook の内容を読み取り「workflow_job の status が queued」かつ「workflow_job の labels に指定した値が含まれている」場合のみコンテナを起動します。 コンテナの起動方法には以下のようないくつかの工夫を行っています。

  • ホスト側の ~/.ssh ディレクトリをマウントすることで、コンテナイメージに鍵や接続設定を埋め込まずにコンテナ内から検証環境内の他の VM への ssh でのコマンド実行や Ansible 適用を可能としている。
  • self-hosted runners の起動・登録時に必要な情報をコントローラから渡すことで、イメージの再利用性を高めたり機密情報を持たせないようにしている。
    • self-hosted runners の登録に必要なクレデンシャル、登録先のリポジトリ、登録時の割り当てるラベルなどを渡している。
  • --rm オプションを付けることで self-hosted runners の処理終了時にコンテナも自動で削除されるようにしている。

runner controller に GitHub から webhook を送信するためには、GitHub 側で webhook の登録もしなければなりません。 私たちのチームでは扱っているリポジトリが数十以上あり、全てのリポジトリに手動で webhook を登録するのは大変なため、 以下のように gh コマンドを使ってターミナルから簡単に webhook を設定できるようにしていたりもします。

gh api /repos/<owner>/<repo>/hooks --input - << EOL
{
  "name": "web",
  "active": true,
  "events": ["workflow_job"],
  "config": {
    "url": "<runner_controller_endpoint>",
    "content_type": "json",
    "insecure_ssl": "0",
    "secret": "<webhook_secret>"
  }
}
EOL

この仕組みでは runner はリポジトリごとに登録していますが、リポジトリごとではなく organization にも登録 できます。 organization の admin 権限を持っている場合は、organization に runner を登録して各リポジトリの workflow から使用させたほうが runner の管理の複雑度は減ります。

self-hosted runners コンテナイメージ

Dockerfile は以下になります。

Dockerfile(一部抜粋)

# syntax = docker/dockerfile:1.3-labs
FROM ubuntu:18.04

# job の実行時に必要になる ruby や Ansible などインストール
...

# 作業用の一般ユーザを作成
ARG USER=user
RUN <<EOL
  useradd -m ${USER} --group sudo
  echo "${USER} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
EOL
USER ${USER}

# docker build 時に指定したバージョンの action-runner を配置
ARG RUNNER_VERSION=dummy
RUN <<EOL
  mkdir ~/action-runner && cd ~/action-runner
  wget https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
  tar xzf actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
  rm actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
  sudo ./bin/installdependencies.sh
EOL

# self-hosted runners の起動・登録スクリプトを配置、実行権限を付与
COPY ./wake_runner.sh /home/${USER}/wake_runner.sh
RUN <<EOL
  sudo chown ${USER}:${USER} ~/wake_runner.sh
  sudo chmod 755 ~/wake_runner.sh
EOL

ENTRYPOINT ~/wake_runner.sh

自分で Dockerfile を書くことの一番のメリットは、Ruby や Ansible といった自分たちの CI で必要になるソフトウェアを予めインストールしておくことができるという点です。

GitHub-hosted runners 上で動かすことを想定した job では、ruby/setup-ruby の action を呼び出し job の中で Ruby を使用可能とするのが定番ですが、この場合 Ruby を使いたい job 全てに ruby/setup-ruby の action 呼び出しを定義しなければいけません。

一方、自前で用意した環境に予め Ruby がインストールされている状態にしておけば、ruby/setup-ruby の action をわざわざ呼び出さずとも workflow 内のどこでもいつでも Ruby が使用可能となるわけです。 このメリットはかなり大きく、冗長な記述を減らすことができますし、workflow の記述を本質的に行いたい処理(実際にテストを流す部分など)に注力できるようにもなります。

イメージの build 時には、封入する runner のバージョンを指定できるようにしています。 以下のコマンドを workflow のスケジュール実行機能 を使って定期実行することで、GitHub-hosted runners と同様に、新しいバージョンの runner がリリースされると自動的にそのバージョンの runner を使って CI を回すことができるようにも作り込んでいます。

latest_runner_version=$(
  git ls-remote --tags https://github.com/actions/runner.git |
  jq -rR 'capture("/v(?<version>.+)$") | .version' |
  tail -1
)

docker buildx build -f Dockerfile \
                    -t action-runner:latest \
                    -t action-runner:${latest_runner_version} \
                    --build-arg RUNNER_VERSION=${latest_runner_version} \
                    ./context

ENTORYPOINT で指定しているスクリプトは以下です。

#!/bin/bash -ex

cd ~/action-runner

REGISTRATION_TOKEN=$(curl -X POST -u ${GITHUB_USERNAME}:${GITHUB_TOKEN} -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/<owner>/${REPOSITORY}/actions/runners/registration-token | jq -r .token)

./config.sh --url https://github.com/<owner>/${REPOSITORY} --token ${REGISTRATION_TOKEN} --unattended --disableupdate --ephemeral --labels ${RUNNER_LABEL}
./run.sh

デフォルトの挙動では、runner は job の実行前に自身を最新バージョンへ自動でアップデートするようになっていますが、 私たちの仕組みでは runner のアップデートは先ほど説明した定期実行 workflow によるイメージの作り直しによって実現しているため --disableupdate オプションを指定して自動アップデートを抑制しています。

この仕組みにおいて重要な役割を担っているのが --ephemeral オプションです。 このオプションを指定して self-hosted runners を起動することで、job を1つ実行した runner は job の終了後に自動で GitHub から登録解除され、プロセスも終了するようになります。 これにより複数の job で1つの self-hosted runners が使い回されなくなり、コンテナ起動時に指定した --rm オプションと組み合わせて、runner の終了時にコンテナの削除まで行うことで、不要になったファイルやプロセスが何も残らないようにしています。

導入してみた感想

Jenkins の運用・追加開発が辛い状況を改善したいということで導入した GitHub Actions ですが、狙い通り開発の効率が格段に向上しました。 workflow ファイルも書きやすく、GitHub とも密結合しているため CI の開発のやりやすさが格段に向上しました。 こういった使いやすい自動化ツールを導入することで、様々な日々の作業を自動化しようというモチベーションが湧きやすくなると感じました。

self-hosted runners のオートスケーリングも非常に便利であり、並列数や課金のことを一切気にせず大量の job を実行できるという体験は非常に気持ち良いです。 これにより開発自体もスケールしやすくなっていると感じています。 と言いつつも、最初はオートスケーリングなしで始めるのも全然ありです。 前回実行した job のゴミを削除する処理を workflow の最初に仕込んだり workflow の書き方を工夫することで、job 間で self-hosted runners が使い回されることの影響は抑えることができます。 私たちも最初は1リポジトリに1 self-hosted runners を専属で起動させっぱなしの状態から始め、ある程度 CI がこなれてきてからさらなる改善としてオートスケーリングに取り組みました。

GitHub Actions 自体の機能の進化も早く、今回紹介した self-hosted runners のオートスケーリングのための機能のほとんどは、2021年リリースとなっています。 ロードマップ を眺めると将来的に実装される予定の機能も分かるため、今後どういった機能が実装されるかもある程度見越して CI を作り込むのが吉だと思います。 メジャーな機能は GitHub の Changelog をウォッチしておくと素早くキャッチアップでき、マイナーですが便利な機能の追加が runner のリリースノート からたまに見つかることもあるため、こちらも時々チェックするのがオススメです。

さいごに

いかがでしたでしょうか?

GitHub Actions は CI ツールの中では後発ですが、ユーザー数や活用事例もどんどん増加し、既に確固たる地位を築いているように思われます。 みなさんも GitHub Actions の self-hosted runners をオートスケーリングさせて快適な CI ライフをぜひ送ってみてください。

次回の記事では、この仕組み上で動かしている workflow の例についてご紹介したいと思います。 様々な機能を盛り込みつつ workflow を作り込んでいるため、実際に開発をするにあたっての参考例として、みなさんのお役に立てるのではないかと考えています。


最後になりますが、SDPF クラウドは国内最大級のクラウドサービスです。 開発メンバーは、数千台以上の物理サーバーの操作の自動化をはじめとした、技術的難易度の高い課題に取り組みつつ、日々より良いサービスにしようと邁進しております。 今回紹介した CI 環境の設計・構築など、大規模サービスだからこそのやりがいのある課題もたくさん転がっています。

もし私たちのチームに興味を持たれた方は こちら からの応募をお願いいたします。


  1. 専有型の物理サーバーをオンデマンドに利用可能とするサービス。 https://sdpf.ntt.com/services/baremetal-server/

  2. ベアメタルサーバー上に vSphere ESXi や Hyper-V など代表的なハイパーバイザーを予めセットアップした状態で利用可能とするサービス。https://sdpf.ntt.com/services/vsphere/ https://sdpf.ntt.com/services/hyper-v/

© NTT Communications Corporation All Rights Reserved.