Docker上でのGitHub Actions self-hosted runnerのデプロイ

はじめまして、BBSakura Networks株式会社でアルバイトとしてお世話になっておりましたnyahahanohaと申します。 高専の卒業と新しい会社への入社によりアルバイトを退職することとなりました。 最後に業務の一部を技術ブログとして紹介する機会を頂いたため、執筆しております。

はじめに

GitHub Actionsを利用してCD(Continuous Delivery)を構築する際、セキュリティの観点から外部にポートを公開したくないためにself-hosted runnerを利用する方や企業は多いのではないでしょうか。

この場合、self-hosted runnerをVM内に直接デプロイしてしまうと、その環境に対して手作業による操作が増えやすくなります。 また、手作業で入れたツールや依存関係が累積し、別環境への移行、障害時の復旧、同一構成の横展開の際に再現性の低さが問題になります。

そこで本記事では、私たちが取ったアプローチとしてDocker上にself-hosted runnerを構築する方法を紹介します。 これにより可搬性と再現性を高められます。

準備

この方法でself-hosted runnerを利用するにはGitHub Appが必要です。 下記の権限をつけたClientIDと秘密鍵を取得してください。

  • "Administration" repository permissions (write)
  • "Self-hosted runners" organization permissions (write)

これはコンテナ内で自動でself-hosted runnerの追加、削除を行うために必要です。

構成

Dockerコンテナは下記の構成で構築します。

/ dir
|- Dockerfile
|- docker-compose.yaml
|- entry-point.sh
|- private-key.pem

Dockerfile

Dockerfileそのものはとても単純です。 aptで必要なツール群のインストール、runnerユーザの作成、self-hosted runnerのインストールという三つの処理で構成されています。

FROM ubuntu:24.04

# ツール群のインストール
RUN apt update && \
    apt install -y --no-install-recommends \
    software-properties-common \
        ...
    rm -rf /var/lib/apt/lists/*

# ユーザの作成
RUN useradd -m -u 1001 -s /bin/bash runner \
 && mkdir -p /actions-runner \
 && chown -R runner:runner /actions-runner
USER runner

# self-hosted runnerのインストール
WORKDIR /actions-runner
RUN curl -o actions-runner-linux-x64-2.329.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.329.0/actions-runner-linux-x64-2.329.0.tar.gz
RUN echo "194f1e1e4bd02f80b7e9633fc546084d8d4e19f3928a324d512ea53430102e1d  actions-runner-linux-x64-2.329.0.tar.gz" | shasum -a 256 -c
RUN tar xzf ./actions-runner-linux-x64-2.329.0.tar.gz

ENTRYPOINT ["/entry-point.sh"]

ユーザ名は必ずrunnerとして作成してください。 そうでないとself-hosted runnerが動きません。

entry-point.sh

entry-pointは少し複雑です。 registration-tokenの取得、self-hosted runnerの設定、起動、停止を行います。

環境変数取得

処理の前に必要な変数を読み込みます。 今回はコンテナなので環境変数を用いて変数を読み込みました。

#!/bin/bash

set -euo pipefail

REQUIRED_VARS=("GITHUB_CLIENT_ID" "GITHUB_ORG" "GITHUB_REPO" "RUNNER_LABEL" "RUNNER_NAME")
for var in "${REQUIRED_VARS[@]}"; do
  if [ -z "${!var:-}" ]; then
    echo "Error: Environment value $var is not set." >&2
    exit 1
  fi
done

if [[ ! -f /private-key.pem ]]; then
  echo "Error: /private-key.pem does not exist." >&2
  exit 1
fi
if [[ ! -r /private-key.pem ]]; then
  echo "Error: /private-key.pem is not readable." >&2
  exit 1
fi

環境変数としては下記の5つを定義しています。

  • GITHUB_CLIENT_ID: GitHub AppsのクライアントID
  • GITHUB_ORG: GitHubの組織名
  • GITHUB_REPO: GitHubのレポジトリ名
  • RUNNER_LABEL: self-hosted runnerに付与されるラベル
  • RUNNER_NAME: self-hosted runnerの名前

また、秘密鍵が必要なので存在と読み取り権限を確認しています。

registration-tokenの取得

registration-tokenとはself-hosted runnerの起動や停止に使われるtokenのことです。

# 起動時
./config.sh --unattended --token $REGISTRATION_TOKEN

# 停止時
./config.sh remove --token $REGISTRATION_TOKEN

このトークンは自動で取得しない場合はGitHubのrepositoryページから取得することができます。

自動で取得する場合は、下記の流れで取得する必要があります。

1. GitHubAppからJWTセッションを取得

docs.github.com

JWTセッションの取得はドキュメントの通りなので割愛します。 取得したjwtは変数として置いておきます。

2. JWTセッションからAccess Token URL Listを取得します。

docs.github.com

JWTからAccess Token URL Listを取得するのは下記のコマンドです。

  access_token_url=$(curl -sL \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/app/installations | \
    jq -r ".[0].access_tokens_url" )

ドキュメントに示されているコマンドに-H "Authorization: Bearer ${jwt}"を追加するとjwtを利用して取得することが可能です。 ここではjqでaccess_tokens_urlを取得し、access_token_urlという変数に置いています。

3. Access Token URL ListからAccess Tokenを取得

次にAccess Token URLからAccess Tokenを取得するのは下記のコマンドです。

  access_token=$(curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    "$access_token_url" | \
    jq -r ".token")

これについてはドキュメントを見つけることができませんでしたが、先ほど取得したurlからtokenを得ることが可能でした。

4. Access TokenからRegistration Tokenを取得

docs.github.com

最後にAccess TokenからRegistration Tokenを取得するのは下記のコマンドです。

  curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${access_token}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/actions/runners/registration-token | \
    jq -r ".token"

ここではjwtを使わずにaccess_tokenを使います。 urlには各自のorganizationとrepoを含める必要があります。 jqでtokenを取り出すことでregistration_tokenを取得できます。

これでself-hosted runnerの起動、停止に必要なregistration-tokenの取得が可能になりました。 registration-tokenは1時間で期限切れとなります。 そのため、これらのコマンド群は起動、停止するたびに実行し、registration-tokenを取得することが重要です。 一度取得したものを使いまわすことは推奨されません。ご注意ください。

これらを関数として置くと下記のようになります。

pem=$(cat /private-key.pem)

b64enc() { openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'; }

get_registration_token() {
  # Get JWT Token (https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#example-using-bash-to-generate-a-jwt)
  now=$(date +%s)
  iat=$((${now} - 60)) # Issues 60 seconds in the past
  exp=$((${now} + 600)) # Expires 10 minutes in the future

  header_json='{
      "typ":"JWT",
      "alg":"RS256"
  }'
  # Header encode
  header=$( echo -n "${header_json}" | b64enc )

  payload_json="{
      \"iat\":${iat},
      \"exp\":${exp},
      \"iss\":\"${GITHUB_CLIENT_ID}\"
  }"
  # Payload encode
  payload=$( echo -n "${payload_json}" | b64enc )

  # Signature
  header_payload="${header}"."${payload}"
  signature=$(
      openssl dgst -sha256 -sign <(echo -n "${pem}") \
      <(echo -n "${header_payload}") | b64enc
  )
  # Create JWT
  jwt="${header_payload}"."${signature}"

  # Get the access token url (https://docs.github.com/ja/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token)
  access_token_url=$(curl -sL \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/app/installations | \
    jq -r ".[0].access_tokens_url" )

  # Get Access token
  access_token=$(curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    "$access_token_url" | \
    jq -r ".token")

  # Get Registration token (https://docs.github.com/ja/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-a-repository)
  curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${access_token}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/actions/runners/registration-token | \
    jq -r ".token"
}

self-hosted runnerの設定、起動、停止処理

先に環境変数で、GITHUB_CLIENT_ID、GITHUB_ORG、GITHUB_REPO、RUNNER_LABEL、RUNNER_NAME辺りは指定しておきましょう。

設定するのは下記のコマンドです。

./config.sh --unattended \
  --token $(get_registration_token) \
  --url https://github.com/${GITHUB_ORG}/${GITHUB_REPO} \
  --labels ${RUNNER_LABEL} \
  --name ${RUNNER_NAME}

get_registration_tokenは変数ではなく、先ほど実装したregistration-tokenを取得する関数です。 これを--token flagに指定することでregistration-tokenが利用できます。 --unattended flagをつけることで通常のインタラクティブモードを無効化することが可能です。 --url flagには各自のrepoを、--labels flagにはself-hosted runnerに追加するlabelを、--name flagにはself-hosted runnerの名前を指定してください。 これを実行すると./.runner dirが生成されます。 このdirが存在するかどうかで、登録済みかどうかを判別できます。

起動するのは下記のコマンドです。

while true; do
  ./run.sh &
  RUNNER_PID=$!
  wait $RUNNER_PID || true
  RUNNER_PID=""
  sleep 2
done

runnerが意図せず停止してしまった際に対応するため、waitコマンドでプロセスの停止を検知したら再起動するようにしています。

停止するのは下記のコマンドです。

cleanup() {
  if [[ -f ./.runner ]]; then
    echo "Runner cleanup: removing from GitHub..."
    ./config.sh remove --token $(get_registration_token)
  else
    echo "Runner was not registered, skipping cleanup."
  fi
}

stop_runner() {
  echo "Caught signal, stopping runner..."
  if [ -n "$RUNNER_PID" ]; then
    kill -TERM "$RUNNER_PID" 2>/dev/null
    wait "$RUNNER_PID" || true
  fi
  cleanup
  exit 0
}

trap stop_runner SIGINT SIGTERM

stop_runner関数ではrunnerの停止を、cleanup関数ではself-hosted runnerが登録されている場合にrunnerの削除処理を行います。 tokenはget_registration_tokenを利用して再度取得するのが重要です。 trapはrunnerの起動前に実行してください。

これらをまとめると下記のような実装になります。

cd /actions-runner

cleanup() {
  if [[ -f ./.runner ]]; then
    echo "Runner cleanup: removing from GitHub..."
    ./config.sh remove --token $(get_registration_token)
  else
    echo "Runner was not registered, skipping cleanup."
  fi
}

RUNNER_PID=""

stop_runner() {
  echo "Caught signal, stopping runner..."
  if [ -n "$RUNNER_PID" ]; then
    kill -TERM "$RUNNER_PID" 2>/dev/null
    wait "$RUNNER_PID" || true
  fi
  cleanup
  exit 0
}

echo "Runner configuration setup"
./config.sh --unattended \
  --token $(get_registration_token) \
  --url https://github.com/${GITHUB_ORG}/${GITHUB_REPO} \
  --labels ${RUNNER_LABEL} \
  --name ${RUNNER_NAME}

trap stop_runner SIGINT SIGTERM

echo "Starting runner..."
while true; do
  ./run.sh &
  RUNNER_PID=$!
  wait $RUNNER_PID || true
  RUNNER_PID=""
  sleep 2
done

全体

コード全体を置いておきます。

entry-point.sh全体

#!/bin/bash

set -euo pipefail

REQUIRED_VARS=("GITHUB_CLIENT_ID" "GITHUB_ORG" "GITHUB_REPO" "RUNNER_LABEL" "RUNNER_NAME")
for var in "${REQUIRED_VARS[@]}"; do
  if [ -z "${!var:-}" ]; then
    echo "Error: Environment value $var is not set." >&2
    exit 1
  fi
done

if [[ ! -f /private-key.pem ]]; then
  echo "Error: /private-key.pem does not exist." >&2
  exit 1
fi
if [[ ! -r /private-key.pem ]]; then
  echo "Error: /private-key.pem is not readable." >&2
  exit 1
fi

pem=$(cat /private-key.pem)

b64enc() { openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'; }

get_registration_token() {
  # Get JWT Token (https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#example-using-bash-to-generate-a-jwt)
  now=$(date +%s)
  iat=$((${now} - 60)) # Issues 60 seconds in the past
  exp=$((${now} + 600)) # Expires 10 minutes in the future

  header_json='{
      "typ":"JWT",
      "alg":"RS256"
  }'
  # Header encode
  header=$( echo -n "${header_json}" | b64enc )

  payload_json="{
      \"iat\":${iat},
      \"exp\":${exp},
      \"iss\":\"${GITHUB_CLIENT_ID}\"
  }"
  # Payload encode
  payload=$( echo -n "${payload_json}" | b64enc )

  # Signature
  header_payload="${header}"."${payload}"
  signature=$(
      openssl dgst -sha256 -sign <(echo -n "${pem}") \
      <(echo -n "${header_payload}") | b64enc
  )
  # Create JWT
  jwt="${header_payload}"."${signature}"

  # Get the access token url (https://docs.github.com/ja/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token)
  access_token_url=$(curl -sL \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/app/installations | \
    jq -r ".[0].access_tokens_url" )

  # Get Access token
  access_token=$(curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${jwt}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    "$access_token_url" | \
    jq -r ".token")

  # Get Registration token (https://docs.github.com/ja/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-a-repository)
  curl -sL \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${access_token}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/actions/runners/registration-token | \
    jq -r ".token"
}

cd /actions-runner

cleanup() {
  if [[ -f ./.runner ]]; then
    echo "Runner cleanup: removing from GitHub..."
    ./config.sh remove --token $(get_registration_token)
  else
    echo "Runner was not registered, skipping cleanup."
  fi
}

RUNNER_PID=""

stop_runner() {
  echo "Caught signal, stopping runner..."
  if [ -n "$RUNNER_PID" ]; then
    kill -TERM "$RUNNER_PID" 2>/dev/null
    wait "$RUNNER_PID" || true
  fi
  cleanup
  exit 0
}

echo "Runner configuration setup"
./config.sh --unattended \
  --token $(get_registration_token) \
  --url https://github.com/${GITHUB_ORG}/${GITHUB_REPO} \
  --labels ${RUNNER_LABEL} \
  --name ${RUNNER_NAME}

trap stop_runner SIGINT SIGTERM

echo "Starting runner..."
while true; do
  ./run.sh &
  RUNNER_PID=$!
  wait $RUNNER_PID || true
  RUNNER_PID=""
  sleep 2
done

docker-compose.yaml

docker-compose.yamlについて特筆するところはないですが、本構成では下記のような設定になりました。

---
# 起動の前にこのdocker-compose.yamlと同様のdirにprivate-key.pemを配置する
services:
  actions:
    init: true
    build: .
    container_name: actions
    restart: unless-stopped
    environment:
      - GITHUB_ORG=bbsakura
      - GITHUB_REPO=XXXXX
      - GITHUB_CLIENT_ID=XXXXX
      - RUNNER_LABEL=XXXXX_development
      - RUNNER_NAME=XXXXX_runner
    volumes:
      - ./private-key.pem:/private-key.pem:ro
      - ./entry-point.sh:/entry-point.sh:ro

実行

dir内でdocker compose up -dを実行すればself-hosted runnerが起動します。 self-hosted runnerが起動すればGitHub上からRunnerの存在が確認できます。 設定したlabelを利用し、runnerを選択してActionsを実行してください。

まとめ

GitHub Actionsのためのself-hosted runnerをコンテナ上に構築する方法を紹介しました。 registration-tokenの取得方法などはいくつかのドキュメントをまたがって読む必要があるため、本記事によって調査量を削減できたら幸いです。 また、仮想基盤チームのリーダーである早坂さんをはじめ、金井さん、酒井さん、チームメンバーの皆さんには大変お世話になりました。 本記事の実装についても、いくつか早坂さんによって直して頂いた部分があります。 アルバイト期間の1年間本当にありがとうございました。

参考文献

moba1.hatenablog.com