はじめまして、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セッションを取得
JWTセッションの取得はドキュメントの通りなので割愛します。 取得したjwtは変数として置いておきます。
2. JWTセッションからAccess Token URL Listを取得します。
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を取得
最後に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年間本当にありがとうございました。