Open13

コンテナで動かしたGithub Actionsセルフホストランナーでrootless dockerを利用する検証

Kesin11Kesin11

先日の自分の趣味全開で主催させてもらったCI/CD Test Night #6で高まったテンションが冷めやらぬうちにs4ichiさんの発表にあったrootless dockerを自分のところでも再現できるかを検証している

具体的にこのスライドのp25の構成のように、ランナーを動かすコンテナとは別にsidecardでrootelss dockerの公式イメージを動かして接続させるというもの。
https://www.docswell.com/s/s4ichi/5RXQLG-cookpad-self-hosted-runner-infra#p25

Kesin11Kesin11

s4ichiさんの構成はECS on EC2なので最終的には同じようにECS上で再現させたいが、まずは仕組みを理解するためにdocker composeによる再現を目指す。

さらにその前段として、セルフホストランナーは関係無しにコンテナの間同士でdocker(クライアント) -> rootless docker(サーバー)の接続をする方法を検証した。

結論、このようなcompose.ymlで可能であることが確認できた。

services:
  docker: # クライアントから接続する際にtcp://docker:2376にする必要があるので `docker` である必要がある
    image: docker:24.0-dind-rootless
    privileged: true
    ports:
      - "2376:2376" # デフォルトで2376ポートがlistenされている
    networks:
      - dind-rootless
    volumes:
    # dind-rootlessが作成してくれる証明書をクライアント側に渡すためにボリュームをマウントする
    # https://github.com/docker-library/docker/blob/679ff1a932a1bdf4341fb0d5cb06f088c77d44bf/24/dind/dockerd-entrypoint.sh#LL130C1-L130C1
      - type: volume
        source: rootless-certs
        target: /certs
  client:
    image: docker:24.0-dind-rootless
    # docker exec でコンテナ内に入って実験するのでentrypointは何でもよいがとりあえずsh
    tty: true
    entrypoint: sh
    environment:
      DOCKER_TLS_VERIFY: "1"
      DOCKER_CERT_PATH: /rootless-certs/client/ 
      DOCKER_HOST: tcp://docker:2376 # ホスト名がdockerでないと証明書の検証が通らない。例えばtcp://dind:2376にするとこのようなエラーになる
      # ERROR: error during connect: Get "https://dind:2376/v1.24/info": tls: failed to verify certificate: x509: certificate is valid for docker, f54ac70a5c66, localhost, not dind
    networks:
      - dind-rootless
    volumes:
      # 同じボリュームをマウントすることで証明書を持ち込む
      - type: volume
        source: rootless-certs
        target: /rootless-certs
    depends_on:
      - docker

networks:
  dind-rootless:
volumes:
  rootless-certs:

サーバーとクライアントのコンテナを起動させる

docker compose up

クライアントのコンテナに入ってdockerコマンドを実行できるか試す

$ docker exec -it client-1 sh

/ $ docker version
Client:
 Version:           24.0.2
 API version:       1.43
 Go version:        go1.20.4
 Git commit:        cb74dfc
 Built:             Thu May 25 21:50:49 2023
 OS/Arch:           linux/arm64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          24.0.2
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.4
  Git commit:       659604f
  Built:            Thu May 25 21:34:53 2023
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          v1.7.1
  GitCommit:        1677a17964311325ed1c31e2c0a3589ce6d5c30d
 runc:
  Version:          1.1.7
  GitCommit:        v1.1.7-0-g860f061
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
 rootlesskit:
  Version:          1.1.0
  ApiVersion:       1.1.1
  NetworkDriver:    vpnkit
  PortDriver:       builtin
  StateDir:         /tmp/rootlesskit1117617536
 vpnkit:
  Version:          7f0eff0dd99b576c5474de53b4454a157c642834

/ $ docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
Digest: sha256:dfd64a3b4296d8c9b62aa3309984f8620b98d87e47492599ee20739e8eb54fbf
Status: Image is up to date for ubuntu:latest
docker.io/library/ubuntu:latest
/ $ docker run ubuntu echo "hello dind"
hello dind

どちらも同じ docker:24.0-dind-rootless のイメージを使っているので、うっかりクライアント側でもdockerが動いてしまっていてそれを使っているだけなのでは?というわけではないことも確認しておく。
試しに DOCKER_HOST の環境変数を消してみるとちゃんとdockerに接続できない、というエラーがちゃんと出た。

/ $ unset DOCKER_HOST
/ $ docker version
Client:
 Version:           24.0.2
 API version:       1.43
 Go version:        go1.20.4
 Git commit:        cb74dfc
 Built:             Thu May 25 21:50:49 2023
 OS/Arch:           linux/arm64
 Context:           default
Cannot connect to the Docker daemon at tcp://localhost:2376. Is the docker daemon running?
Kesin11Kesin11

このcompose.ymlでなぜ動くのかを解説する。

まずサーバーとクライアントのコンテナ同士が通信できるように同じ dind-rootless ネットワークに入れておく。

サーバーへの接続をTCPで行う方法だが、rootless dockerのドキュメントを見るとそれが書いてあるが、正直全く役に立たなかった。
https://docs.docker.com/engine/security/rootless/#expose-docker-api-socket-through-tcp

docker:24.0-dind-rootless イメージを使う場合、何らかのentrypointが既に決まっているのでコンテナを起動すればrootless dockerが起動するのでどうやってオプションを渡せるのかが不明だし、自分でentrypointを書き換えようと思ってもそもそも dockerd-rootless.sh なるものがどこにあるのかが不明。

このイメージのDockerfile周りのコードを読んでみたところ、そもそもデフォルトでTCPに公開するための証明書の生成とかをやっていることがわかった。さらに DOCKER_TLS_CERTDIR='' にしておけばTLS無しでTCPの公開も可能らしい。(ちなみに試してみると、この方法は危険だしdeprecatedだから!というメッセージがログに出てきて、かなりの温度感で非推奨であることを伝えてくれる)
https://github.com/docker-library/docker/blob/679ff1a932a1bdf4341fb0d5cb06f088c77d44bf/24/dind/dockerd-entrypoint.sh#L117-L136

TCPを公開するポートが2376であることもこのコードが分かるため、compose.ymlでも2376を公開しておく。

Kesin11Kesin11

次はこの立ち上げたrootless dockerのサーバーに、dockerクライアント側のコンテナからTCPで接続させる。

サーバー側のdockerdはTLSの設定がされているため、クライアントから接続するときにはサーバー側で生成された証明書の情報が必要になる。
このあたりは先ほどのrootless dockerのドキュメントを見ても何も書いていなかったので、こちらの記事が大変参考になりました。
https://kazuhira-r.hatenablog.com/entry/2021/07/22/204449

サーバー側のコンテナで生成された証明書をクライアント側のコンテナに持っていくため、両方のコンテナで共通のボリュームをマウントしてそこ経由で証明書を受け渡す。compose.ymlでは rootless-certs というボリュームをマウントした。
サーバー側では /certs に証明書が生成されるのでこのパスに対してマウントする。クライアント側では自由なパスを指定できるのでどこでもいい。今回はわかりやすく /rootless-certs というパスにマウントした。
これでサーバー側では /certs/client に生成されたファイルがクライアント側では /rootless-certs/client として見えるようになる。

Kesin11Kesin11

あとは docker -H tcp://docker:2376 でサーバー側のrootless dockerに接続できるのだが、どうやらこの docker というホスト名であることが重要らしい。最初はサーバー側のコンテナは dind という別の名前にしていたのだがこういうエラーになった

      # ERROR: error during connect: Get "https://dind:2376/v1.24/info": tls: failed to verify certificate: x509: certificate is valid for docker, f54ac70a5c66, localhost, not dind

エラーによると証明書が有効な名前は docker , f54ac70a5c66(ちなみにこれはコンテナを再起動するたびに変わっていた) , localhost らしい。

自分は恥ずかしながらこのあたりの認証について詳しくないのでよく分かっていないのだが、サーバー側のdockerで証明書を作っているこのあたりのコードが関係してそう?
https://github.com/docker-library/docker/blob/679ff1a932a1bdf4341fb0d5cb06f088c77d44bf/24/dind/dockerd-entrypoint.sh#L21

とりあえず tcp://docker:2376 であればTLSも突破してサーバー側のdockerに接続できることはわかったので、compose.ymlの方でサーバーのコンテナは docker という名前にしておく。

Kesin11Kesin11

あとはこのあたりの接続先ホスト、TLS周りの設定をいちいちdockerのオプションとして設定しなくても済むようにクライアント側のコンテナの環境変数に登録して完成。

environment:
      DOCKER_TLS_VERIFY: "1"
      DOCKER_CERT_PATH: /rootless-certs/client/ 
      DOCKER_HOST: tcp://docker:2376

これでクライアント側のコンテナでは docker だけでサーバー側のrootless dockerにTCPで接続してくれる。
全てが上手く噛み合うと docker pull ubuntu をしたときにサーバー側のrootelss dockerのログが変化するのでちゃんと接続できている様子が確認できるはず。

次はこの単なるクライアント -> サーバーのdockerの接続から発展させて、クライアントのコンテナをgithub actionsのセルフホストランナーが動くコンテナに置き換える。

Kesin11Kesin11

クライアント側を https://github.com/myoung34/docker-github-actions-runner に置き換えた。
追加した環境変数は myoung34/docker-github-actions-runner に必要なものなので詳しくは向こうのREADMEを参照。

追加のvolumeで/_workをマウントしているのはランナー(dockerクライアント)でのジョブのディレクトリをdockerサーバー側にマウントさせるため。
myoung34/docker-github-actions-runner は RUNNER_WORKDIR: /_work の環境変数によってジョブ中のディレクトリを /_work に指定できる。dockerのクライアントとサーバーが別のマシンにある場合、bind mountをするにはディレクトリのパスが一致していないと意図した挙動にならないため、ジョブの中で docker run でbind mountを行ったり uses などでコンテナ型のactionを使えるようにするために /_work のパスでデータを共有している。
(これはかなり分かりにくい挙動だが、macOSのマシンでDocker DesktopではなくてlimaやRancher Desktopを使っているとmacOS <-> limaのVM間でディレクトリをマウントしている理由と同じはず)

services:
  docker: # クライアントから接続する際にtcp://docker:2376にする必要があるので `docker` である必要がある
    image: docker:24.0-dind-rootless
    privileged: true
    ports:
      - "2376:2376" # デフォルトで2376ポートがlistenされている
    networks:
      - dind-rootless
    volumes:
    # dind-rootlessが作成してくれる証明書をクライアント側に渡すためにボリュームをマウントする
    # https://github.com/docker-library/docker/blob/679ff1a932a1bdf4341fb0d5cb06f088c77d44bf/24/dind/dockerd-entrypoint.sh#LL130C1-L130C1
      - type: volume
        source: rootless-certs
        target: /certs
      # ジョブ内でbind mountを可能にするためにactions用のワークスペースをvolumeで共有して同じパスにマウントする
      - type: volume
        source: workspace
        target: /_work
  client:
    image: ghcr.io/myoung34/docker-github-actions-runner:2.304.0
    environment:
      DOCKER_TLS_VERIFY: "1"
      DOCKER_CERT_PATH: /rootless-certs/client/ 
      DOCKER_HOST: tcp://docker:2376 # ホスト名がdockerでないと証明書の検証が通らない。例えばtcp://dind:2376にするとこのようなエラーになる

      RUNNER_SCOPE: org
      ORG_NAME: ${ORG_NAME}
      LABELS: dind-rootless
      RUNNER_NAME_PREFIX: container
      DISABLE_AUTO_UPDATE: 1
      RUN_AS_ROOT: false
      ACCESS_TOKEN: ${GITHUB_PAT}
      RUNNER_WORKDIR: /_work
    networks:
      - dind-rootless
    volumes:
      # 同じボリュームをマウントすることで証明書を持ち込む
      - type: volume
        source: rootless-certs
        target: /rootless-certs
      # ジョブ内でbind mountを可能にするためにactions用のワークスペースをvolumeで共有して同じパスにマウントする
      - type: volume
        source: workspace
        target: /_work
    depends_on:
      - docker

networks:
  dind-rootless:
volumes:
  rootless-certs:
  workspace:
Kesin11Kesin11

このcompose.ymlの設定で立ち上げたランナーは以下のdockerやコンテナを使うワークフローのうち、 run_container を除いて完走できる。
つまり以下のdockerの使い方までは動くことを確認できた。

  • 単純にdocker info
  • bind mount込みのdocker run
    • 読み込みはできたが書き込みはpermission deniedで不可能だった。要調査
  • mysql, goのサーバー, nginxの組み合わせのdocker compose
    • ランナーを立ち上げたcompose側のネットワークの設定上、localhost:80ではアクセスできずdocker:80である必要がある
  • docker/setup-buildx-actiondocker/build-push-action が使える

唯一完走できない run_container は以下のエラーになる。
Container feature is not supported when runner is already running inside container.
これはdindやrootless dockerが原因ではなく、ランナーの actions/runnner の方でチェックされて制限されているもの。この問題を突破するにはdindだけでは不可能で https://github.com/actions/runner-container-hooks を利用する必要がある。

name: "Docker and container hooks test"
on:
  workflow_dispatch:

jobs:
  docker:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - run: env
      - name: Show docker info
        run: |
          docker -v
          docker info
          docker ps
      - run: ls
      - run: pwd
  docker-bind-mount:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
      # Bind mount to read
      - run: docker run --rm -v $(pwd):/workdir:ro -w /workdir busybox ls README.md
      # Bind mount to write
      # NOTE: Permission deniedになってしまう。rootlessだとrootユーザーが使えないからだと思われるが解決方法が分からないので保留
      # - run: docker run --rm -v $(pwd):/workdir    -w /workdir busybox touch foo
      # - run: ls -l foo
  run_container:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    services:
      redis:
        image: redis
    container:
      image: ubuntu
      options: --cpus 1
    timeout-minutes: 10
    steps:
      - run: env
      - run: ls
      - run: pwd
  docker-compose:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    concurrency: docker-compose
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
        with:
          repository: docker/awesome-compose
      - name: Show docker compose
        run: docker compose version
      # dindでdocker自体へtcp://docker:2376で接続しているため、同様にlocalhostではなくdocker:8080にする必要がある
      # compose.yml側でブリッジのネットワークにすればlocalhsotで接続できるようになる?
      - name: docker compose
        run: |
          cd nginx-golang-mysql
          docker compose up --wait
          curl http://docker:80
  docker-build-and-push:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      # NOTE: rootlessだと動かないので諦める
      # - name: Set up QEMU
      #   uses: docker/setup-qemu-action@v2

      # TCPで接続しているdockerの場合だと以下のエラーが出るっぽい?のでその対策
      # CircleCIであれば docker context create circleci && docker buildx create --use circleci で対応したワークアラウンド
      # ERROR: could not create a builder instance with TLS data loaded from environment.
      # Please use `docker context create <context-name>` to create a context for current environment and then create a builder instance with `docker buildx create <context-name>`
      - name: Setup docker context
        run: docker context create actions_context
        continue-on-error: true # 2回目以降ではエラーになるが毎回チェックが面倒なのでエラーを無視する
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
        with:
            endpoint: actions_context

      - name: Create simple Dockerfile
        run: |
          echo -e "FROM busybox\nRUN echo foobar > /hello /\nRUN cat /hello" > Dockerfile
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: "."
Kesin11Kesin11

https://github.com/actions/runner-container-hooks を使うにはこのリポジトリに含まれるファイルをダウンロードする必要があるため、ついに myoung34/docker-github-actions-runner のイメージをベースに独自のDockerfileを用意する。

runner-container-hooksのスクリプトと必要な環境変数については実は actions/runner のDockerfileにも書かれているのでそのやり方をほぼコピーしている。
https://github.com/actions/runner/blob/21b49c542cdb8caf3c6217db6286fdefecb5f025/images/Dockerfile#L15-L17

ただし actions/runner ではk8s用のスクリプトを使っているのに対してここではdocker用のスクリプトを使用する。
runner-container-hooks の仕組みについては今回は省略するがいずれ独立した記事にしたい。気になる人は runner-container-hooks のtsのコードを見てみると何をやってくれているのかだいたい分かると思う。

FROM myoung34/github-runner:2.304.0

ARG RUNNER_CONTAINER_HOOKS_VERSION=0.3.2

ARG RUNNER_DIR=/actions-runner
RUN cd "${RUNNER_DIR}" \
  && curl -fLo 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
ENV ACTIONS_RUNNER_CONTAINER_HOOKS="${RUNNER_DIR}/runner-container-hooks-docker/index.js"

このDockerfileを使うようにcompose.ymlの一部を書き換える

  client:
    # image: ghcr.io/myoung34/docker-github-actions-runner:2.304.0
    # runner-container-hooksを仕込むためにDockerfileを指定する
    build:
      context: .
      dockerfile: Dockerfile.container_hooks

container-hooksを設定すると run-container ジョブで先ほどはランナー側で止められてしまっていたエラーは発生しなくなり、代わりにちゃんとdockerのコマンドが実行されているログが出る。

Initialize Container 2s

Run '/actions-runner/runner-container-hooks-docker/index.js'
/usr/bin/docker ps --all --quiet --no-trunc --filter label=636f6e7461696e65722d4263665030306a425764514330
/usr/bin/docker network prune --force --filter label=636f6e7461696e65722d4263665030306a425764514330
/usr/bin/docker network create --label 636f6e7461696e65722d4263665030306a425764514330 github_network_926e2bff-5bc8-42ea-ab12-c7919d363f13
d2671a050a467e8b8ccf920bdd7e9c04de091ee3cce1a0a6637687ec1d882b75
/usr/bin/docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
Digest: sha256:dfd64a3b4296d8c9b62aa3309984f8620b98d87e47492599ee20739e8eb54fbf
Status: Image is up to date for ubuntu:latest
docker.io/library/ubuntu:latest
/usr/bin/docker create --label=636f6e7461696e65722d4263665030306a425764514330 --network=github_network_926e2bff-5bc8-42ea-ab12-c7919d363f13 --name 968c5e18201749c3a28f9f6927d2f341__471de0 --cpus 1 -e HOME -v=/var/run/docker.sock:/var/run/docker.sock -v=/_work:/__w -v=/actions-runner/externals:/__e -v=/_work/_temp:/__w/_temp -v=/_work/_actions:/__w/_actions -v=/opt/hostedtoolcache:/__t -v=/_work/_temp/_github_home:/github/home -v=/_work/_temp/_github_workflow:/github/workflow --entrypoint tail ubuntu -f /dev/null
c42c34280ece0c1e7703ac1609c730e37f415aa8582bca93a78c028c41379a79
/usr/bin/docker start c42c34280ece0c1e7703ac1609c730e37f415aa8582bca93a78c028c41379a79
Error response from daemon: error while creating mount source path '/_work/_actions': mkdir /_work/_actions: permission denied
Error: failed to start containers: c42c34280ece0c1e7703ac1609c730e37f415aa8582bca93a78c028c41379a79
Error: Error: The process '/usr/bin/docker' failed with exit code 1
Error: Process completed with exit code 1.
Error: Executing the custom container implementation failed. Please contact your self hosted runner administrator.

が、ダメ。 Error response from daemon: error while creating mount source path '/_work/_actions': mkdir /_work/_actions: permission denied このあたりが怪しいが /_work はすでにvolumeでランナー側とdocker側のコンテナで共有されているはずだし、それぞれのコンテナの中に入って調べてみても実際に /_work/_actions のディレクトリ自体は存在するのを確認した。
うーん、なぜ?

Kesin11Kesin11

コンテナの中に入って調べたところ、どうもランナー側のコンテナとdocker側のコンテナの一般ユーザーのuidが異なっているのが原因として考えられそう。

dind-rootlessは一般ユーザーの rootless はuid:1000だが、myoung34/docker-github-actions-runnerの一般ユーザーの runner はuid:1001になっている

どっちのuidに合わせるかなどを考えたが、dind-rootlessのDockerfileはかなり複雑なので自分で手を入れるのは難しそうだったため、myoung34/docker-github-actions-runnerをベースにしている独自のDockerfile側でuidを書き換えることにした。

具体的にはDockerfileの中でusermodでuidを変更し、その他にこのイメージがあらかじめrunnerユーザーで用意しているディレクトリの権限を設定し直す。

RUN usermod -u 1000 runner \
  && chown -R runner:runner /_work \
  && chown -R runner:runner /actions-runner \
  && chown -R runner:runner /opt/hostedtoolcache

(myoung34/docker-github-actions-runnerを使わずに自分で一からDockerfileを書いている人は関係無し。一般ユーザーを作るときにuid:1000にすることだけ注意すれば問題ないはず)

これでいけるかと再びジョブを実行したところ同じInitialize containerステップでエラーの内容が以下に変わった

Error response from daemon: error while creating mount source path '/opt/hostedtoolcache': mkdir /opt/hostedtoolcache: permission denied
Error: failed to start containers: 2d726105da2bb5aee2c038f131f506825507dcd6c39c80f4d103356d31b1d22f

/_work/_actions がエラーにならなくなったということは今までのPermission deniedの問題はuidを揃えたことでおそらく解消されたと思われる。
/opt/hostedtoolcache はたしかにランナー側のコンテナに存在するがdocker側にマウントしていなかったので /_work と同様にdocker側にもマウントが必要っぽい。

Kesin11Kesin11

というわけであとはcompose.ymlにvolumeを追加し、ランナー側とdocker側で共有するディレクトリを追加したら動いたの。各ファイルの最終的な完成形はこちら

compose.yml

services:
  docker: # クライアントから接続する際にtcp://docker:2376にする必要があるので `docker` である必要がある
    image: docker:24.0-dind-rootless
    privileged: true
    ports:
      - "2376:2376" # デフォルトで2376ポートがlistenされている
    networks:
      - dind-rootless
    volumes:
    # dind-rootlessが作成してくれる証明書をクライアント側に渡すためにボリュームをマウントする
    # https://github.com/docker-library/docker/blob/679ff1a932a1bdf4341fb0d5cb06f088c77d44bf/24/dind/dockerd-entrypoint.sh#LL130C1-L130C1
      - type: volume
        source: rootless-certs
        target: /certs
      # ジョブ内でbind mountを可能にするためにactions用のワークスペースをvolumeで共有して同じパスにマウントする
      - type: volume
        source: workspace
        target: /_work
      - type: volume
        source: actions-runner
        target: /actions-runner
      - type: volume
        source: hostedtoolcache
        target: /opt/hostedtoolcache
  client:
    # image: ghcr.io/myoung34/docker-github-actions-runner:2.304.0
    # runner-container-hooksを仕込むためにDockerfileを指定する
    build:
      context: .
      dockerfile: Dockerfile.with_rootless
    environment:
      DOCKER_TLS_VERIFY: "1"
      DOCKER_CERT_PATH: /rootless-certs/client/ 
      DOCKER_HOST: tcp://docker:2376 # ホスト名がdockerでないと証明書の検証が通らない。例えばtcp://dind:2376にするとこのようなエラーになる

      RUNNER_SCOPE: org
      ORG_NAME: ${ORG_NAME}
      LABELS: dind-rootless
      RUNNER_NAME_PREFIX: container
      DISABLE_AUTO_UPDATE: 1
      RUN_AS_ROOT: false
      ACCESS_TOKEN: ${GITHUB_PAT}
      RUNNER_WORKDIR: /_work
    networks:
      - dind-rootless
    volumes:
      # 同じボリュームをマウントすることで証明書を持ち込む
      - type: volume
        source: rootless-certs
        target: /rootless-certs
      # ジョブ内でbind mountを可能にするためにactions用のワークスペースをvolumeで共有して同じパスにマウントする
      - type: volume
        source: workspace
        target: /_work
      - type: volume
        source: actions-runner
        target: /actions-runner
      - type: volume
        source: hostedtoolcache
        target: /opt/hostedtoolcache
    depends_on:
      - docker

networks:
  dind-rootless:
volumes:
  rootless-certs:
  workspace:
  actions-runner:
  hostedtoolcache:

Dockerfile.with_rootless

# dind-rootlessをsidecarで立ててdockerをTCP経由で使う場合に必要な設定を追加したDockerfile
FROM myoung34/github-runner:2.304.0

ARG RUNNER_CONTAINER_HOOKS_VERSION=0.3.2
# https://github.com/actions/runner-container-hooks

ARG RUNNER_DIR=/actions-runner
RUN cd "${RUNNER_DIR}" \
  && curl -fLo 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
ENV ACTIONS_RUNNER_CONTAINER_HOOKS="${RUNNER_DIR}/runner-container-hooks-docker/index.js"

# ランナーとdockerのコンテナでuidが一致していないとpermission問題が発生するため、dind-rootlessイメージのrootlessユーザーuid=1000に合わせる
RUN usermod -u 1000 runner \
  && chown -R runner:runner /_work \
  && chown -R runner:runner /actions-runner \
  && chown -R runner:runner /opt/hostedtoolcache

このcompose.ymlとDockerfileで立ち上げたランナーは以下のワークフローを完走できる。
ちなみにuidを調整する前は bind mount込みのdocker run 読み込みはできたが書き込みはpermission deniedで不可能だった。要調査 としてスキップしていた run: docker run --rm -v $(pwd):/workdir -w /workdir busybox touch foo ステップもuidを揃えてからは完走できるようになったのでコメントアウトを解除した。

name: "Docker and container hooks test"
on:
  # push:
  workflow_dispatch:

jobs:
  docker:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - run: env
      - name: Show docker info
        run: |
          docker -v
          docker info
          docker ps
      - run: ls
      - run: pwd
  docker-bind-mount:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
      # Bind mount to read
      - run: docker run --rm -v $(pwd):/workdir:ro -w /workdir busybox ls README.md
      # Bind mount to write
      - run: docker run --rm -v $(pwd):/workdir    -w /workdir busybox touch foo
      - run: ls -l foo
  run_container:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    services:
      redis:
        image: redis
    container:
      image: ubuntu
      options: --cpus 1
    timeout-minutes: 10
    steps:
      - run: env
      - run: ls
      - run: pwd
  docker-compose:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    concurrency: docker-compose
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
        with:
          repository: docker/awesome-compose
      - name: Show docker compose
        run: docker compose version
      # dindでdocker自体へtcp://docker:2376で接続しているため、同様にlocalhostではなくdocker:8080にする必要がある
      # compose.yml側でブリッジのネットワークにすればlocalhsotで接続できるようになる?
      - name: docker compose
        run: |
          cd nginx-golang-mysql
          docker compose up --wait
          curl http://docker:80
  docker-build-and-push:
    strategy:
      fail-fast: false
      matrix:
        docker: [dind-rootless]
    runs-on:
      - self-hosted
      - ${{ matrix.docker }}
    timeout-minutes: 10
    steps:
      # NOTE: rootlessだと動かないので諦める
      # - name: Set up QEMU
      #   uses: docker/setup-qemu-action@v2

      # TCPで接続しているdockerの場合だと以下のエラーが出るっぽい?のでその対策
      # CircleCIであれば docker context create circleci && docker buildx create --use circleci で対応したワークアラウンド
      # ERROR: could not create a builder instance with TLS data loaded from environment.
      # Please use `docker context create <context-name>` to create a context for current environment and then create a builder instance with `docker buildx create <context-name>`
      - name: Setup docker context
        run: docker context create actions_context
        continue-on-error: true # 2回目以降ではエラーになるが毎回チェックが面倒なのでエラーを無視する
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
        with:
            endpoint: actions_context

      - name: Create simple Dockerfile
        run: |
          echo -e "FROM busybox\nRUN echo foobar > /hello /\nRUN cat /hello" > Dockerfile
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: "."
Kesin11Kesin11

まとめると、sidecarで立ち上げたrootless dockerを使ってdindする場合に注意するポイントは以下

  • rootless dockerは privileged: true で起動する必要がある
  • rootless docker側で生成された/certs/clientの証明書をクライアント側のコンテナにマウントして見えるようにする
  • クライアント側ではTCP経由でrootless dockerのコンテナのdockerに接続するように環境変数を設定しておく
  • github actionsランナーのワークスペースやその他の関係するディレクトリは全てrootless docker側にも共有されるようにマウントしておく
    • 自分の例だと必要なディレクトリのパスは myoung34/docker-github-actions-runner が設定しているパスなので、自分でセルフホストランナー用のDockerfileを作成している場合はマウントが必要なパスは異なるはずです
  • ランナー側とrootless docker側の一般ユーザーのuidは揃えておく。rootelss docker側がuid:1000なのでこちらに合わせる

あとはこのdocker composeをECSのタスク定義に翻訳できればs4ichiさんの構成を再現できそう。
ネットワークやボリュームはdocker composeとECSで多少異なるはずなので、主にそのあたりで微調整が必要そう。
https://www.docswell.com/s/s4ichi/5RXQLG-cookpad-self-hosted-runner-infra

そして、dockerが使えるところまで実現できればcontainer-hooksの導入はDockerfileにちょっと追加するだけで動く。container-hooksを入れると今まではコンテナで動かすランナーだと不可能だったジョブ中でコンテナを立ち上げる機能が使えるようになるため、VMの上でランナーを動かす場合と機能の差はなくなる。
container-hooksについてはそれ単独でのもう少し詳しい解説をいずれ書く。

おわり

Kesin11Kesin11

動作確認環境
Windows 11 WSL2
Docker Desktop v4.19.0

$ docker version
Client:
 Version:           20.10.21
 API version:       1.41
 Go version:        go1.18.1
 Git commit:        20.10.21-0ubuntu1~20.04.2
 Built:             Thu Apr 27 05:56:19 2023
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Desktop
 Engine:
  Version:          23.0.5
  API version:      1.42 (minimum version 1.12)
  Go version:       go1.19.8
  Git commit:       94d3ad6
  Built:            Wed Apr 26 16:17:45 2023
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.6.20
  GitCommit:        2806fc1057397dbaeefbea0e4e17bddfbd388f38
 runc:
  Version:          1.1.5
  GitCommit:        v1.1.5-0-gf19387a
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0