【GitHub Actions】self-hosted runner をローカル環境の docker compose で起動してみた

<div align="center">

![303da6e9-86d4-d0ba-6923-cd2199145f3a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/eef74ffc-0da5-297b-553e-0bc848354dd8.png)

</div>


GitHub Actions で独自のランナーをホストする事ができる `self-hosted runner` というソフトウェアがあります。  
この記事では、`self-hosted runner` をローカル環境（docker compose）に構築する方法を紹介します。  

# 前提

- `self-hosted runner` は docker compose で起動します
- GitHub Actions のコンテナ機能を使えるようにするため、~~公式の~~コミュニティ版の rootless dind イメージを利用します

```:実行環境
Windows 10
Docker Desktop 4.29.0
self-hosted runner v2.316.1
runner-container-hooks v0.6.0
```

# self-hosted runner とは
冒頭に記載した通り、独自のランナーを作成してそのプロセス上でGitHub Actionsのワークフローを実行するソフトウェアです。  
[GitHub ホステッド ランナー](https://docs.github.com/ja/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners)では提供されていない機能/OSを利用したい場合や、CI/CDの実行時間やコスト削減を目的として導入するケースが多いようです。

- https://github.com/actions/runner

以下、[公式ドキュメント](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners)より引用。

> Self-hosted runners offer more control of hardware, operating system, and software tools than GitHub-hosted runners provide. With self-hosted runners, you can create custom hardware configurations that meet your needs with processing power or memory to run larger jobs, install software available on your local network, and choose an operating system not offered by GitHub-hosted runners. Self-hosted runners can be physical, virtual, in a container, on-premises, or in a cloud.

# self-hosted runner をコンテナで起動するには
[Actions Runner Controller](https://github.com/actions/actions-runner-controller) という k8s オペレータを公式が提供しています。  
上記レポジトリを確認すると [dind 用のコンテナイメージ](https://github.com/actions-runner-controller/actions-runner-controller/pkgs/container/actions-runner-controller%2Factions-runner-dind-rootless)がありましたので、それをベースイメージとして利用する事にします。  
また、`self-hosted runner` 上でコンテナ機能を利用するためには [runner-container-hooks](https://github.com/actions/runner-container-hooks) を導入する必要があります。  
詳細は公式ドキュメントを参照してください。

- https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/customizing-the-containers-used-by-jobs

# 概要
ざっくり、以下の流れでCIを実行してみます。  
ビルドしたコンテナイメージは `self-hosted runner` と一緒に起動する[ローカルレジストリ](https://hub.docker.com/_/registry)に格納します。

1. GitHub に変更をPushする
1. `self-hosted runner` はロングポーリング（TCP443）でGitHubの変更を検知する
1. 変更を検知したら、GitHub Actions ワークフローの実行をトリガーする
1. UT, コンテナイメージのビルド/プッシュを実行する

<div align="center">

![63a54636-5f11-5899-8817-826b0983ab6b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/19b486a8-dc64-0d59-615d-9942b25840f9.png)

</div>

# 実装

ディレクトリ構成は以下となります。  
`sample-app` は Fast API と MySQL で動く適当なサンプルアプリです。

```sh:ディレクトリ構成
.
|-- compose.yaml
|-- .env
|-- .env.setup
|-- .github
|   `-- workflows
|       |-- _build.yaml
|       `-- publish-image.yaml
|-- sample-app
|   |-- app.py
|   |-- ...
|   |-- requirements.txt
|   |-- Dockerfile
|   `-- compose.yaml
|-- auth
|   `-- htpasswd
|-- runner
|   |-- Dockerfile
|   |-- cleanup.sh
|   |-- .env
|   `-- docker-daemon.json
`-- setup-runner
    |-- Dockerfile
    `-- set_registration_token.sh
```

## runner/Dockerfile
公式の dind イメージに幾つか変更を加えています。  

- `/etc/arc/hooks/job-completed.d` に Post 処理用スクリプトを追加
  - 詳細は[公式ドキュメント](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/running-scripts-before-or-after-a-job)を参照してください
- `/runnertmp/.env` に Env ファイルを追加
  - ここで定義した環境変数が `self-hosted runner` のプロセスに適用されます
- `docker-daemon.json` を追加
  - Dockerデーモンに insecure-registry 等を設定するため
  - rootlessモードなので、ユーザーのホームディレクトリ配下に設置する
- `runner-container-hooks` をインストール

```dockerfile:runner/Dockerfile
FROM ghcr.io/actions-runner-controller/actions-runner-controller/actions-runner-dind-rootless:v2.316.0-ubuntu-22.04

COPY cleanup.sh /etc/arc/hooks/job-completed.d/cleanup.sh
COPY --chown=runner .env /runnertmp/.env
COPY --chown=runner docker-daemon.json /home/runner/.config/docker/daemon.json

USER root
RUN mkdir -p -m 755 /var/lib/docker /runner \
  && chown -R runner:runner /runner /var/lib/docker \
  && chmod -R +x /etc/arc/hooks

USER runner

ENV ACTIONS_RUNNER_CONTAINER_HOOKS=${HOME}/runner-container-hooks-docker/index.js
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.6.0
RUN cd ${HOME} \
  && curl -fL -o 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 \
  && chown -R runner ./runner-container-hooks-docker

VOLUME [ "/runner", "/var/lib/docker" ]
```

## runner/.env

`self-hosted runner` のプロセスに適用する環境変数を定義します。  
[公式ドキュメント](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/using-a-proxy-server-with-self-hosted-runners?learn=hosting_your_own_runners&learnProduct=actions#using-a-env-file-to-set-the-proxy-configuration)によると、起動スクリプトと同じ階層に `.env` が配置されていると自動で読み込まれるようです。

```
TZ=Asia/Tokyo
```

## runner/cleanup.sh

ワークフロー実行後の Post 処理で実行されるスクリプトです。  
ログ出力とプライベートレジストリのログアウト処理を追加してみました。

```sh:runner/cleanup.sh
#!/bin/bash

set -Eeo pipefail
set -x

trap catch ERR

# exit 0を返せないと後続のstepに進む前にFailedになってしまうので必ずexit 0で終了させる
catch() {
  echo "Trap ERR! exit 0 for run job"
  exit 0
}

DATE=$(date "+%Y/%m/%d %H:%M:%S")
echo "${DATE} Job completed: ${GITHUB_REPOSITORY} ${GITHUB_JOB}"
echo "${DATE} WORKFLOW: ${GITHUB_WORKFLOW}"
echo "${DATE} RUNNER_NAME: ${RUNNER_NAME}"
echo "${DATE} RUN_ID": "${GITHUB_RUN_ID}"

docker logout private-registry.local:5000
```

## runner/docker-daemon.json

Docker デーモンに読み込ませるコンフィグファイルです。  
ローカル環境に起動したプライベートレジストリを `insecure-registries` に追加します。

- https://matsuand.github.io/docs.docker.jp.onthefly/registry/insecure/
- https://docs.docker.jp/config/daemon/daemon.html#configure-the-docker-daemon

```:runner/docker-daemon.json
{
  "insecure-registries" : [
    "private-registry.local:5000"
  ]
}
```

## setup-runner/Dockerfile

`self-hosted runner` をGitHubに登録するためには、アクセストークンが必要です。  
`self-hosted runner` を起動する前に、こちらのコンテナで [GitHub の Rest API](https://docs.github.com/ja/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-a-repository) からアクセストークンを取得しておきます。

```dockerfile:setup-runner/Dockerfile
FROM alpine
WORKDIR /work
RUN apk --no-cache add curl bash jq
COPY set_registration_token.sh .

CMD [ "sleep", "infinity" ]
```

## setup-runner/set_registration_token.sh

アクセストークンを取得して、`.env` に追記するためのスクリプトです。  
`self-hosted runner` を起動する前に、このスクリプトを実行します。

```sh:setup-runner/set_registration_token.sh
#!/bin/sh

response=$(curl -sSL \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_PAT" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/<Organization名>/<Repository名>/actions/runners/registration-token)

token=$(echo "$response" | jq -r '.token')

sed -i "s|RUNNER_TOKEN=.*|RUNNER_TOKEN=$token|" /mount/.env
```

## compose.yaml
docke compose で `self-hosted runner` を起動します。  
`setup-runner` コンテナが正常終了すると、`runner[1-2]` が起動されます。  
また、プライベートレジストリとUIも一緒にデプロイしてしまいます。  

```yaml:compose.yaml
x-templates: &runner
  build:
    context: ./runner
    dockerfile: Dockerfile
  privileged: true
  restart: always
  env_file:
    - .env
  depends_on:
    setup-runner:
      condition: service_completed_successfully
  deploy:
    resources:
      limits:
        memory: 2g
        cpus: '2'
  networks:
    - runner-net

services:
  runner1:
    <<: *runner
    container_name: self-hosted-runner-example-1
    hostname: self-hosted-runner-example-1
    environment:
      RUNNER_NAME: self-hosted-runner-example-1
    volumes:
      - type: volume
        source: runner-docker-volume1
        target: /home/runner/.local/share/docker

  runner2:
    <<: *runner
    container_name: self-hosted-runner-example-2
    hostname: self-hosted-runner-example-2
    environment:
      RUNNER_NAME: self-hosted-runner-example-2
    volumes:
      - type: volume
        source: runner-docker-volume2
        target: /home/runner/.local/share/docker

  setup-runner:
    build:
      context: setup-runner
      dockerfile: Dockerfile
    command: sh ./set_registration_token.sh
    env_file:
      - .env
      - .env.setup
    volumes:
      - type: bind
        source: ./
        target: /mount
    networks:
      - runner-net

  private-registry:
    image: registry
    container_name: private-registry.local
    hostname: private-registry.local
    ports:
      - 5000:5000
    environment:
      REGISTRY_HTTP_SECRET: "<適当な文字列>"
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm"
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_HTTP_ADDR: 0.0.0.0:5000
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
    volumes:
      - type: bind
        source: ./auth
        target: /auth
      - type: volume
        source: registry-volume
        target: /var/lib/registry
        volume:
          nocopy: true
    networks:
      - runner-net

  registry-ui:
    image: klausmeyer/docker-registry-browser:latest
    ports:
      - 8080:8080
    environment:
      DOCKER_REGISTRY_URL: "http://private-registry.local:5000"
      ENABLE_DELETE_IMAGES: "true"
      SECRET_KEY_BASE: "<適当な文字列>"
    depends_on:
      - private-registry
    networks:
      - runner-net

volumes:
  runner-docker-volume1:
  runner-docker-volume2:
  registry-volume:

networks:
  runner-net:
```

## .env

`runner[1-2]` コンテナに読み込ませる環境変数を `.env` 定義します。  
ドキュメントが見つけられなかったので、この辺りの設定は元イメージの起動スクリプトから確認しました。

- https://github.com/actions/actions-runner-controller/tree/master/runner

```:.env
GITHUB_URL=https://github.com/

# RUNNER_ORG=<Organization名>
# RUNNER_REPO=<Repository名>
RUNNER_REPO=<Organization名>/<Repository名>
RUNNER_EPHEMERAL=false
RUNNER_TOKEN=xxxxx
RUNNER_HOME=/runner
RUNNER_ASSETS_DIR=/runnertmp
RUNNER_GRACEFUL_STOP_TIMEOUT=15
STARTUP_DELAY_IN_SECONDS=5
WAIT_FOR_DOCKER_SECONDS=120
DOCKER_ENABLED=true

# LOG_DEBUG_DISABLED=false
# LOG_NOTICE_DISABLED=false
# LOG_WARNING_DISABLED=false
# LOG_ERROR_DISABLED=false
# LOG_SUCCESS_DISABLED=false
```

## .env.setup

`.env.setup` は `setup-runner` コンテナが Rest API を実行するためのアクセストークンを設定します。

```:.env.setup
GITHUB_PAT=ghp_xxx
```

今回のケースでは、アクセストークンに以下の権限が必要となります。

- administration:write

# GitHub Actions ワークフロー
実際に動かす GitHub Actions のワークフローを実装します。  
ワークフロー定義ファイルは `.github/workflows` 配下に置く必要があります。

## .github/workflows/_build.yaml

コンテナイメージをビルドして任意のレジストリにプッシュするワークフローです。

```yaml:.github/workflows/_build.yaml
name: Publish image to container Registry
on:
  workflow_call:
    inputs:
      REGISTORY_HOST:
        description: 'Container registry hostname'
        type: string
        required: true
      REGISTORY_USER:
        description: 'Container registry username'
        type: string
        required: true
      IMAGE_NAME:
        description: 'Container image name'
        type: string
        required: true
      IMAGE_TAG:
        description: 'Container tag name'
        type: string
        required: true
      BUILD_CONTEXT:
        description: 'Docker build context'
        type: string
        default: '.'
      DOCKERFILE:
        description: 'Dockerfile path'
        type: string
        default: './Dockerfile'
      CURRENT_DIR:
        description: 'Current directory path'
        type: string
        default: './'
    secrets:
      REGISTRY_PASSWD:
        description: 'Password for container registry login'
        required: true

env:
  REGISTORY_HOST: ${{ inputs.REGISTORY_HOST }}
  REGISTORY_USER: ${{ inputs.REGISTORY_USER }}
  IMAGE_NAME: ${{ inputs.IMAGE_NAME }}
  IMAGE_TAG: ${{ inputs.IMAGE_TAG }}
  BUILD_CONTEXT: ${{ inputs.BUILD_CONTEXT }}
  DOCKERFILE: ${{ inputs.DOCKERFILE }}
  CURRENT_DIR: ${{ inputs.CURRENT_DIR }}

jobs:
  publish:
    name: Publish container image
    runs-on: [self-hosted, linux, x64]
    permissions:
      contents: read
    steps:
      - name: Publish container image
        run: |
          cd $CURRENT_DIR
          echo ${{ secrets.REGISTRY_PASSWD }} | docker login $REGISTORY_HOST -u $REGISTORY_USER --password-stdin
          docker build -t "$REGISTORY_HOST/$IMAGE_NAME:$IMAGE_TAG" $BUILD_CONTEXT -f $DOCKERFILE
          docker push "$REGISTORY_HOST/$IMAGE_NAME:$IMAGE_TAG"
```

## .github/workflows/publish-image.yaml

所謂 CI を実行するためのワークフローです。  
main ブランチにプッシュするとこのワークフローがトリガーされます。  
Unit Test を実行するために `self-hosted runner` 上でアプリケーション用の `docker compose` を起動しています。  
※`self-hosted runner` を起動したままにしておくこと、job間でcloneしたレポジトリが共有できるようになっています

```yaml:
name: Publish image to Private Registry
on:
  push:
    branches:
      - main

jobs:
  setup:
    runs-on: [self-hosted, linux, x64]
    permissions:
      contents: read
    steps:
      - name: Check out source repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

  test:
    needs: setup
    runs-on: [self-hosted, linux, x64]
    permissions:
      contents: read
    steps:
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      - name: Install dependencies
        run: |
          python -m pip install -r sample-app/requirements.txt
          python -m pip install pytest flake8
      - name: Lint with flake8
        run: flake8
      - name: Start containers
        run: |
          cd sample-app
          docker compose up -d
      - name: Execute pytest
        run: pytest
        env:
          MYSQL_HOST: 127.0.0.1
      - name: Stop containers
        if: always()
        run: |
          cd sample-app
          docker compose down

  publish:
    name: Publish container image
    needs: test
    uses: ./.github/workflows/_build.yaml
    with:
      REGISTORY_HOST: private-registry.local:5000
      IMAGE_NAME: sample-app
      IMAGE_TAG: ${{ github.sha }}
      REGISTORY_USER: test-user
      CURRENT_DIR: ./sample-app
    secrets:
      REGISTRY_PASSWD: ${{ secrets.PRIVATE_REGISTRY_TOKEN }}
```


# 起動準備

プライベートレジストリのベーシック認証用に `htpasswd` を作成します。  

```
$ REG_USER=test-user
$ REG_PASSWORD=xxxxx
$ docker run -it --rm httpd htpasswd -nb -B ${REG_USER} ${REG_PASSWORD} > ./auth/htpasswd
```

ここで定義したパスワードは [GitHub Actions Secret](https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions) に登録しておきます。

# ワークフロー実行
ワークフローを実行する前に `self-hosted runner` を起動します。

```sh
$ docker compose up -d
...
[+] Running 6/6
 ✔ Network self-hosted-runner-dind-rootless_runner-net        Created                                                   0.0s 
 ✔ Container private-registry.local                           Started                                                   0.2s 
 ✔ Container self-hosted-runner-dind-rootless-setup-runner-1  Exited                                                    0.2s 
 ✔ Container self-hosted-runner-example-2                     Started                                                   0.1s 
 ✔ Container self-hosted-runner-example-1                     Started                                                   0.2s 
 ✔ Container self-hosted-runner-dind-rootless-registry-ui-1   Started                                                   0.1s 
```

`self-hosted-runner-example-[1-2]` の Status が「Idle」になっていればOKです。

<div align="center">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/6927c565-3b30-fa55-e7f9-16f9140d0ec0.png)

</div>

もし登録がうまくいかない場合は、コンテナの実行ログを確認してみましょう。  
最後にワークフロー実行します。

```sh
$ git commit --allow-empty -m "empty commit" && git push origin main
```

GitHub の Actions タブから、ワークフローの実行状況を確認できます。

<div align="center">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/9f1f36f8-0abb-e083-9801-59043c8ec193.png)

</div>

サイドメニューから任意のジョブ名を選択すると、GitHub ホステッドランナーと同様に実行ログが確認できました。

<div align="center">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/c6998064-035e-6d32-fccf-44ce8b2e9ea6.png)

</div>

最後に `runner/cleanup.sh` もちゃんと実行されていました。

<div align="center">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621075/3b734b7f-bd0d-284a-9ce0-50a8965ddc36.png)

</div>


# 補足

使い始める前に、まずは公式ドキュメントを読みましょう。  

- https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners

## コンテナイメージ

コンテナイメージは他にも幾つかありましたので、必要に応じて差し替えると良いでしょう。

- https://github.com/actions-runner-controller/actions-runner-controller/pkgs/container/actions-runner-controller%2Factions-runner
- https://github.com/actions-runner-controller/actions-runner-controller/pkgs/container/actions-runner-controller%2Factions-runner-dind
- https://github.com/actions-runner-controller/actions-runner-controller/pkgs/container/actions-runner-controller%2Factions-runner-dind-rootless

※ 2024/6/15 追記：
上記イメージはコミュニティ版（？）のようで、公式の提供するイメージは以下となります。  

- https://github.com/actions/runner/pkgs/container/actions-runner

## self-hosted runner の管理レベル

以下の管理レベル毎に `self-hosted runner` を登録することが可能です。

- repository
- organization
- enterprise

管理レベルによって GitHub Rest API のエンドポイントが若干異なりますので、必要に応じて変更する必要があります。

- https://docs.github.com/ja/rest/actions/self-hosted-runners?apiVersion=2022-11-28

また、`self-hosted runner` を誰でも登録できてしまうとセキュリティの観点でよろしくない（任意コードの実行 等々）ため、運用方法の検討が必要です。

- https://docs.github.com/ja/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization
- https://docs.github.com/ja/enterprise-cloud@latest/admin/policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise

## エフェメラル ランナー

一度ジョブを実行するとプロセス（コンテナ）が停止する**エフェメラル ランナー**として起動することができます。  
都度クリーンな環境でジョブを実行したい場合や自動スケーリングを作りこむ場合は、エフェメラル ランナーを選択しましょう。  
コンテナで起動する場合は `RUNNER_EPHEMERAL: true` で指定可能です。

- https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners#using-ephemeral-runners-for-autoscaling

## actions-runner-controller
公式の k8s オペレータがありますので、kubernetes環境で実行する場合はこちらの導入を検討しましょう。

- https://github.com/actions/actions-runner-controller
- https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners-with-actions-runner-controller/quickstart-for-actions-runner-controller

ちなみに `actions-runner-controller` の dind モード（のデフォルト設定）は、dind コンテナをサイドカー方式でデプロイして `docker.sock` を `self-hosted runner` コンテナに共有（マウント）しています。

## runner-container-hooks

コンテナ上で起動した `self-hosted runner` で、Actions のコンテナ機能が利用できるようになります。  
詳細は以下ドキュメントを参照してください。

- https://github.com/actions/runner-container-hooks/tree/main/docs/adrs
- https://zenn.dev/kesin11/articles/20230514_container_hooks

# まとめ

ソースコード管理システムに付随するCI/CDツールは、一定数の需要があると思います。  
また、`self-hosted runner` は実行環境を選ばないので、利用者側でのカスタマイズもしやすいと思いました。  
何かしらの理由でGitHub ホステッドランナーが使えない場合は、次の選択肢として `self-hosted runner` の導入を検討されてみてはいかがでしょうか。

# 参考リンク
- https://zenn.dev/kesin11/articles/20230514_container_hooks
- https://zenn.dev/dena/articles/20220808_github_actions_hooks
- https://zenn.dev/kesin11/scraps/6a5eed6e2d5cba