コンテナ化したSelf-hosted Runnerからコンテナを起動する

# コンテナ化したSelf-hosted Runnerからコンテナを起動する

## やりたいこと
- 統合テストのために GitHub Actions でエフェメラルな DB (できればコンテナ)を構築したい
- Self-hosted Runner は諸般の事情によりコンテナで動いている
  - コンテナからコンテナを起動しなければいけない！


## 環境
- Self-hosted Runner (Ubuntu 22.04 コンテナ)

## アプローチ
コンテナからコンテナを起動する場合、大きく次の2つのアプローチがあります。
- Docker in Docker (dind)
- Docker outside of Docker (dood)

詳しくは次の記事をどうぞ。

https://qiita.com/t_katsumura/items/d5be6afadd6ec6545a9d

### Docker in Docker
起動元のコンテナに別の Docker デーモンを構築し、分離されたコンテナ環境を作る方法です。
完全に Self-hosted Runner 内でコンテナが分離されるのが魅力的ですが、以下の点を考慮して今回は採用を見送りました。

- 起動元コンテナを特権コンテナ、rootユーザとして起動する必要がある
  - `/sbin/init` を PID 1 で起動して systemd で Docker デーモンを起動する方法がよく取られているようです
    - `sbin/init` は root 起動する必要がありその配下のアプリケーションプロセスをうまいこと(`CMD`とか`ENTRYPOINT`などで)一般ユーザで起動する方法がよくわからなく、諦めました

以下の記事には `--privileged` コンテナとして起動しないことが可能と言及されていますが、

https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/
> Update (July 2020) : when I wrote this blog post in 2015, the only way to run Docker-in-Docker was to use the -privileged flag in Docker. Today, the landscape is very different. Container security and sandboxing advanced very significantly, with e.g. rootless containers and tools like sysbox. The latter lets you run Docker-in-Docker without the -privileged flag,

docker公式ドキュメントでも[rootlessコンテナに対しても`--privileged`オプションを付与している](https://docs.docker.com/engine/security/rootless/#rootless-docker-in-docker)通り、執筆時時点では難しそうでした。

### Docker outside of Docker
ホスト VM の Docker デーモンを共有して、「兄弟」のコンテナを操作する方法です。
環境が完全には分離されない点、ホスト VM の Docker デーモンに対する完全なコントロールをゲストコンテナに渡すことになる点が問題になる場合がありますが、特権が不要でコンテナ内は一般ユーザでもプロセスを実行できます。
今回は構築がdindよりもかなり簡単だったのでとりあえずこちらで試してみることにしました。

実際に採用する場合はセキュリティリスクを十分考慮してください。

## ランナーのコンテナを構築
DooD を実現するためのランナーコンテナへの細工は非常にシンプルで、以下のことを行うのみです。
- docker CLI をインストール (docker engine は不要です)
<!--- docker グループに実行ユーザを追加 (一般ユーザで実行する場合) -->

なお、doodに関係しない部分は省略します。

### Docker CLI をインストール
公式の手順にある通り apt リポジトリを追加してインストールするだけです（`docker-ce-cli`のみでOKです）。

```Dockerfile
RUN apt-get update &&\
    apt-get install -y ca-certificates curl &&\
    install -m 0755 -d /etc/apt/keyrings &&\
    curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc &&\
    chmod a+r /etc/apt/keyrings/docker.asc &&\
    echo \
    "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
    $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
    tee /etc/apt/sources.list.d/docker.list > /dev/null &&\
    apt-get update &&\
    apt-get install docker-ce-cli
```

https://docs.docker.com/engine/install/debian/#install-using-the-repository

<!--
### docker グループに実行ユーザを追加
公式の手順とほぼ同じです。

```Dockerfile
RUN groupadd docker &&\
    usermod -aG docker username
```
- `$USER`が展開されなかったので実際のユーザ名に置き換えました

https://docs.docker.com/engine/install/linux-postinstall/

-->
## ランナーコンテナを起動
ランナーコンテナをビルドしたら、以下の点に気をつけて起動します。
- `docker.sock` をマウントする
- ホストの docker グループへコンテナユーザを追加する

```bash
docker run -d --rm --name <container_name>\
    -v /var/run/docker.sock:/var/run/docker.sock \
    --group-add $(awk -F: '$1 == "docker" {print $3}' /etc/group)
    <image_name>
```

`--group-add` でホストの docker グループへユーザを参加させる部分は次の記事を参考にしました。

https://qiita.com/hiroyuki_onodera/items/c6c6e6163d61ddf4dd5c

このあとは `configure.sh` と `run.sh` を実行してリポジトリへの接続設定を行いますが、省略します。

（この時点でコンテナ内から`docker`コマンドが利用可能であることを確かめ、ホストVM上のコンテナが見えることを確認しておきます）

## サービスコンテナとランナーコンテナの接続
ここまででランナーから `docker` コマンドを利用できるようになりましたが、これだけでは本来やりたかったランナーから DB コンテナへの接続ができません。

これは、 DB コンテナのポートを公開してホストネットワークに晒したとしても、ランナーの`localhost`とホストの`localhost`は異なるため、ランナーからホストの`localhost`へ接続することができないからです。

これを解決するために、ランナーのコンテナをDBコンテナが所属する docker network に所属させることにしました。

まずは DB コンテナを構築します。
コンテナのライフサイクルを自動で管理してくれる GitHub Actions の機能であるサービスコンテナを利用します。

```yaml
jobs:
  test:
    runs-on: [self-hosted, linux, x64]

    services:
      postgres:
        image: postgres:16-bookworm
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      # (略)
```

https://docs.github.com/ja/actions/using-containerized-services/creating-postgresql-service-containers

サービスコンテナの挙動を調べてみると、最初に `github_network_<ID>` という名前の docker network を作成したのちにその docker network 上でコンテナを起動しているようでした。
このネットワーク名は `jobs.container.network` コンテキストから取得可能だったため、これを利用します。

```yaml
    steps:
      - name: Connect self-hosted runner to docker network
        run: |
          docker network connect ${{ job.container.network }} $(uname -n)
        if: ${{ runner.environment == 'self-hosted' }}

      # (略)

      - name: Detach self-hosted runner from docker network
        run: |
          docker network disconnect ${{ job.container.network }} $(uname -n)
        if: ${{ always() }}
```
- `$(uname -n)` でホストネームを取得しています
  - ホストネームがコンテナIDと同一になることを仮定しています
- 最後にランナーコンテナを docker network から切断します
  - サービスコンテナの破棄時に docker network の削除が自動で行われますが、1つでもアタッチされているコンテナが残っていると削除に失敗し、ホストの docker デーモンに不要な docker network が溜まっていくので、削除前にデタッチしておきます
  - `if: ${{ always() }}` で前のステップが失敗した場合も必ず実行されるようにします

ネットワークからの切断部分はもう少しエレガントに書きたいところですが、まだ [Composite Actions では Post step を定義できない](https://github.com/actions/runner/issues/1478)ようなので妥協しました。

## まとめ
- コンテナ化した self-hosted runner からテスト用のコンテナをDooDで起動した
- self-hosted runner から別のコンテナへ接続するために docker network を制御する step を追加した
- VM 上や GitHub-managed なランナーでのコンテナ起動とほぼ変わらない使用感を実現できた
  - テストが終わったら綺麗さっぱり消えてくれるので便利！

## おまけ
Actions Runner Controller (ARC) を使えばもっと上手く解決できるのかもしれない

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