😃

EKSをARMインスタンスに対応するためにGitHub Actionsのself-hosted runnerを立てる

2023/02/07に公開

ここ数年でARMインスタンスを使う環境が業界的にも整備されてきたかと思います。
ARMインスタンスは処理によりますが速度的にはx86_64と同等かそれ以上となるケースがあるのと、
x86_64のインスタンスに比べると費用も安いので、そろそろEKSノードの各インスタンスもARMインスタンスへ移行したいと考えました。

EKSの各ノードをARMインスタンス対応するだけであればインスタンスタイプとAMIをARM対応のものに変更するだけで終わるのですが、
移行に関してはCI/CDを含めた環境についてもARM対応を考慮する必要があります。
そこで、今回移行時にCIで対応したことを紹介したいと思います。

GitHub Actionsの公式ランナーにはARM対応ランナーがない

EKSをARMインスタンス対応するために特に引っかかった点がCIです。
アプリケーションをEKS(k8s)で動かしているので、CIではコンテナイメージのビルドを行っています。
ただ、このコンテナイメージもARM対応する必要があり、ARM対応したコンテナイメージをビルドするためには
ホスト側もARMインスタンスで動く必要があります。

そして問題となったのは、CIに利用しているGitHub Actionsが2023年2月現在でもARM対応のランナーを提供していないということです。
そうなると、ARM対応したコンテナイメージのビルドがそのままでは出来ません。

正確にいうと、QEMUのアクションを使えばx86_64のランナーでもARM対応のコンテナイメージのビルドは出来ます。
しかし、QEMUはx86_64の状態でARM CPUの処理をエミュレーションしているために、処理速度が非常に遅いです。
処理によっては3〜5倍以上遅くなることがあります。

CIの速度を犠牲にしてしまってはさすがに意味がないと感じたため、
GitHub Actionsのself-hosted runnerを立てることで、ARM対応のランナーを自前で用意することにしました。

self-hosted runnerの候補

ではGitHub Actionsのself-hosted runnerはどのように構築するのかというと、
以下の公式ドキュメントで詳しく解説されています。

自分のランナーをホストする

通常は対象リポジトリまたはOrganizationの設定からRunnerを追加し、
追加後はself-hosted runnerをホストでセットアップするための手順が表示されるのでその通りに実行します。
これで構築自体はできるものの、この方法で構築できるself-hosted runnerはあくまで 1ホスト1ランナー となります。
一度に実行するタスクが1つしかないなら問題ないかもしれませんが、
複数リポジトリでビルドやテストを走らせるというケースには適していません。
かといって、1つ1つEC2インスタンスを立てて、そこでセットアップするというのも面倒なだけでなく、維持費もかかるかと思います。

この問題にうまく対処しているのがオートスケーリング型のself-hosted runnerシステムで、以下の2つが公式ドキュメントでも紹介されています。

actions/actions-runner-controller
Kubernetes上で self-hosted runner を構築できるコントローラー。

philips-labs/terraform-aws-github-runner
AWS上で self-hosted runnerを構築できるTerraformモジュール。

2023年2月現在だと、前者は執筆時点でも頻繁に更新されているようですが、後者の更新については既に止まっており、1年以上更新がありません。
また、普段EKSを使っていることもあって、k8sを使う方が個人的には慣れていたというのもあり、今回は actions-runner-controller を選択することにしました。

この actions-runner-controller は公式のドキュメントを見て導入するだけではいくつかハマりポイントがあったため、構築方法を踏まえて解説していきます。

1. EKSクラスタを構築する

actions-runner-controllerを利用する最初の準備として、EKSクラスタを構築します。
EKSクラスタの構築方法はeksctlやTerraformを使う方法が多いかと思いますので、好きな方法で作成します。
セットアップについてはAWS公式でも解説しており、eksctlを使う方法なら以下の記事が参考になるかと思います。
eksctlを使ったEKSクラスタ構築手順

次に、EKSクラスタのノードを Managed Node Groupで作成します。
以下の手順を参考に作成します。
eksctlを使ったマネージドノードグループ作成手順

このとき、--node-type にはARMインスタンス種別(t4g.large, m6g.largeなど)を、--node-ami-family にはARM対応のAMI種別を指定する必要があります。
AMI種別についてはEKSのAPIリファレンスを見ると指定可能な具体的な値が記載されています。(amiTypeという項目になります)
EKS API Reference

ここでは、セキュリティ面で強いと言われている Bottlerocket OSを搭載した BOTTLEROCKET_ARM_64 を指定する前提で進めます。
作成するノードグループとしては以下の 2 つを作成します。

・gh-actions-runner
m6g.largeで動くランナーを動かすノードグループ。
--nodes-minを 2、 --nodes-max を 4 として作成します。

・aws-system
t4g.mediumで動く比較的リソースを食うEKSアドオンを動かすためのノードグループ。
--nodes-minを 1、 --nodes-max を 1 として作成します。

以上で、EKSクラスタの準備が完了しました。

2. EKSクラスタへアドオンを導入する

EKSクラスタのKubernetesのバージョンにもよりますが、執筆時点では 1.24 が最新バージョンであるため、こちらを使う前提で解説していきます。

actions-runner-controller そのものを導入する前に、事前に追加でセットアップしておくアドオンがあります。

■ cluster-autoscaler
稼働中のPodとそのステータスを検知することで、ノードのスケールアウトやスケールダウンを行います。
要するに、ノードのオートスケーリングを行うために必要になります。
以下のドキュメントにある手順でインストールします。
cluster-autoscalerのインストール手順

■ aws-ebs-csi-driver
Kubernetes 1.23 以上で必須となります。
EKSでEBSを使ったボリュームを利用する場合に必要となります。
以下のドキュメントにある手順でインストールします。
aws-ebs-csi-dirverのインストール手順

aws-ebs-csi-driver を helm でインストールする際に、デフォルト設定から2点変更してインストールする必要があります。

具体的には、以下のnodeSelector と storageClasses の設定を values.yaml へ変更または追記してインストールします。

controller:
  # controller配下の他の設定値はそのままにして、nodeSelectorのみ変更する
  nodeSelector:
    eks.amazonaws.com/nodegroup: "aws-system"

# gp3を使うためのStorageClassの設定を追加する
storageClasses:
- name: ebs-gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
  volumeBindingMode: WaitForFirstConsumer
  reclaimPolicy: Delete
  parameters:
    encrypted: "false"
    type: "gp3"

aws-ebs-csi-driverをインストールすると、
ebs-csi-controllerというDeploymentと ebs-csi-node というDaemonSetが起動します。
この2つのワークロードリソースが割と曲者で、そこそこ多くのCPUとメモリを要求します。

・ebs-csi-controller … 最大で 500m のCPUと 1280Mi のメモリを要求し、これが 2 つ起動する
・ebs-csi-node … 最大で 300m のCPUと 768Mi のメモリを要求し、各ノードごとに 1 つずつ起動する
  
これを考慮せずにランナーで利用するCPUやメモリの設定(resourcesの設定)を行うと、オーバーコミットとなってオートスケーリングが上手くいかずにランナーのスケールに失敗してしまう可能性が高いです。そのため、予めランナーとは別のノードで ebs-csi-controller が動くように設定しておくことをお勧めします。

これで、各アドオンのセットアップは完了しました。

3. EKSクラスタへ actions-runner-controller をインストールする

アドオンをインストールしたら、actions-runner-controllerのインストールを行います。
以下のドキュメントにある手順でインストールします。
actions-runner-controllerのインストール手順

次に、GitHub APIの認証設定を行います。
他の記事を見ると、PAT(Personal Access Token)を使った例が紹介されているものが多いのですが、
PATはセキュリティ面で懸念があるため、GitHub Appsを使った認証を行うようにします。
GitHub Appsを使った認証設定は以下の公式ドキュメントで紹介されています。
GitHub Appsを使った actions-runner-controllerのGitHub API認証設定

これで actions-runner-controller を利用する準備が完了しました。

4. ランナーのコンテナイメージを作成する

これで actions-runner-controller を利用する準備自体は整いましたが、普段私は buildah を使ってコンテナイメージを作成しています。
actions-runner-controllerのデフォルトで設定されているランナーイメージ(summerwind/actions-runner)には buildah が入っていないため、buildah を使えるようにしたランナーイメージを予め作成しておき、ECRへPushしておきます。(もちろん、このランナーイメージもARM対応しておく必要があります)

以下に Containerfile (Dockerfile)の例を示します。

・Containerfile

FROM summerwind/actions-runner:ubuntu-22.04

ENV DEBIAN_FRONTEND="noninteractive" \
    BUILDAH_ISOLATION=chroot \
    BUILDAH_LAYERS=true

USER root

RUN BUILD_TOOLS="ca-certificates apt-transport-https curl lsof procps buildah podman gettext-base git openssh-client slirp4netns fuse-overlayfs super" \
  && apt-get update -o Acquire::Check-Valid-Until=false \
  && apt-get install -y --no-install-recommends ${BUILD_TOOLS} \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/* \
  && rm -rf /usr/share/doc /usr/share/man /var/log/* /tmp/*

RUN su - runner -c "git config --global --add safe.directory '*'" \
  && rm -f /etc/subuid /etc/subgid \
  && echo "runner:1:1000" >> /etc/subuid \
  && echo "runner:1002:64535" >> /etc/subuid \
  && echo "runner:1:1000" >> /etc/subgid \
  && echo "runner:1002:64535" >> /etc/subgid \
  && mkdir -p /home/runner/.local/share/containers \
  && chmod -R 775 /etc/containers/ \
  && chmod -R 755 /home/runner/.local/share/containers \
  && chown -R runner:runner /home/runner/.local

COPY containers.conf /etc/containers/
COPY registries.conf /etc/containers/
COPY storage.conf /etc/containers/

USER runner

以下、COPY対象ファイルの内容です。

・containers.conf

[containers]
netns="host"
userns="host"
ipcns="host"
utsns="host"
cgroupns="host"
cgroups="disabled"
log_driver = "k8s-file"
default_sysctls = [
  "net.ipv4.ping_group_range=0 20000",
]
default_ulimits = [
  "nofile=65535:65535",
]
[engine]
cgroup_manager = "cgroupfs"
events_logger="file"
runtime="crun"

・registries.conf

unqualified-search-registries = ["docker.io"]
short-name-mode="enforcing"

・storage.conf

[storage]
driver = "overlay"
runroot = "/run/containers/storage"
graphroot = "/var/lib/containers/storage"

[storage.options]
pull_options = {enable_partial_images = "false", use_hard_links = "false", ostree_repos=""}

[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
mountopt = "nodev,fsync=0,metacopy=on"
force_mask = "shared"

公式のGitHub Actionsのランナー(ubuntu-latest)は rootless で buildah が使えるようになっているため、
こちらも同じく rootless で buildah を使えるように構築しています。
このイメージでは最低限のツールのみ導入しているため、もし他にランナーで使いたいコマンド等があれば追加してください。

linux/arm64に対応したプラットフォームでイメージをビルドし、ECRへプッシュしておきます。

5. ランナーのマニフェストを作成する

利用する準備ができたので、具体的なマニフェストを作成していきます。
actions-runner-controllerをインストールしたことで、 RunnerDeploymentRunnerSet のワークロードリソースが使えるようになります。
公式GitHubリポジトリでは RunnerDeployment の例ばかりが紹介されているのですが、RunnerDeployment はPodが複数になったときに、ストレージ領域をランナーごとに分けることができず、オートスケーリング型のランナーにおいては相性が悪いため RunnerSet の方を使うようにします。
以下に例を示します。

apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerSet
metadata:
  name: example-runner
spec:
  serviceName: example-runner
  selector:
    matchLabels:
      app.kubernetes.io/name: example-runner
  # 利用するOrganizationを指定。単一リポジトリでよければ repository に対象リポジトリを指定
  organization: kkoudev
  #repository: kkoudev/example

  # GitHub Actionsのワークフローの runs-on で指定するラベル。今回はわかりやすく ubuntu-latest-arm としています
  labels:
    - ubuntu-latest-arm
  # 手順 4 で作成したランナーイメージをここで指定する。
  # AWS_ACCOUNT_ID と AWS_REGION は利用する環境に応じて置き換えてください。
  image: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/example/actions-runner:ubuntu-22.04
  template:
    metadata:
      labels:
        app.kubernetes.io/name: example-runner
      annotations:
        "cluster-autoscaler.kubernetes.io/safe-to-evict": "true"
    spec:
      # イメージを取得するために利用する認証情報。認証不要であればコメントアウトする
      imagePullSecrets:
      - name: ecr-login
      nodeSelector:
        # 手順 1 で作成したランナー用マネージドノードグループの名前を指定
        eks.amazonaws.com/nodegroup: gh-actions-runner
      affinity:
        # 以下の podAntiAffinity の指定により、1ホスト1ランナーで動作することを保証する
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
                - key: "app.kubernetes.io/name"
                  operator: In
                  values:
                  - example-runner
            topologyKey: "kubernetes.io/hostname"
      # デフォルトだとホストの user.max_user_namespaces や ulimit の上限値が低いため、initContainers で変更する
      # また、各ボリュームはマウント時にデフォルトで root になってしまっているため、ディレクトリの所有権限もここで変更する
      initContainers:
      - image: library/alpine:latest
        imagePullPolicy: IfNotPresent
        name: sysctl
        securityContext:
          privileged: true
        command:
        - sh
        - -c
        - >-
            ulimit -n 65536;
            sysctl -w "net.ipv4.ping_group_range=0 20000";
            sysctl -w user.max_user_namespaces=660000;
            chown 1001:1001 /home/runner/.local;
            chown 1001:1001 /home/runner/.cache;
        volumeMounts:
        - name: rootless-work
          mountPath: /home/runner/.local
        - name: runner-cache
          mountPath: /home/runner/.cache
      containers:
      - name: runner
        securityContext:
          privileged: true
        # cluster-autoscalerでノードのオートスケーリングを行うためには resources の指定が必須です。
        # 作成したノードグループのインスタンスタイプによってここは変更するようにしてください。
        # 以下の値は m6g.large (vCPU : 2, メモリ : 8GiB)を前提とした値となっています。
        resources:
          limits:
            cpu: 1600m
            memory: 6Gi
          requests:
            cpu: 1000m
            memory: 4Gi
        env:
        - name: RUNNER_FEATURE_FLAG_EPHEMERAL
          value: "true"
        # ボリュームをマウントしないとコンテナイメージをビルドするタスクであればすぐに容量不足となってしまう。
        # そのため、以下のディレクトリにEBSボリュームをマウントする。
        volumeMounts:
        - name: runner-work
          mountPath: /runner/_work
        - name: rootless-work
          mountPath: /home/runner/.local
        - name: var-lib-docker
          mountPath: /var/lib/docker
        - name: runner-cache
          mountPath: /home/runner/.cache
        - name: runner-tool-cache
          mountPath: /opt/hostedtoolcache
      - name: docker
        volumeMounts:
        - name: runner-cache
          mountPath: /home/runner/.cache
      # ボリュームはゴミが残らないようにするために、Generic Ephemeral Volumes として作成します。(k8s 1.23 から使えます)
      # ランナーが終了するときにボリュームも一緒に破棄されます。
      volumes:
      - name: runner-work
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes:
              - ReadWriteOnce
              storageClassName: "ebs-gp3"
              resources:
                requests:
                  storage: 10Gi
      - name: var-lib-docker
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes:
              - ReadWriteOnce
              storageClassName: "ebs-gp3"
              resources:
                requests:
                  storage: 50Gi
      - name: runner-cache
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes:
              - ReadWriteOnce
              storageClassName: "ebs-gp3"
              resources:
                requests:
                  storage: 50Gi
      - name: runner-tool-cache
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes:
              - ReadWriteOnce
              storageClassName: "ebs-gp3"
              resources:
                requests:
                  storage: 10Gi
      - name: rootless-work
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes:
              - ReadWriteOnce
              storageClassName: "ebs-gp3"
              resources:
                requests:
                  storage: 50Gi
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: example-runner
spec:
  scaleTargetRef:
    kind: RunnerSet
    name: example-runner
  # ランナーの最小数と最大数を指定する
  # EKSのマネージドノードグループの最小値と最大値と一致するようにしておく
  minReplicas: 2
  maxReplicas: 4
  metrics:
  - type: PercentageRunnersBusy
    scaleUpThreshold: "0.75"
    scaleDownThreshold: "0.25"
    scaleUpFactor: "2"
    scaleDownFactor: "0.5"
  - type: TotalNumberOfQueuedAndInProgressWorkflowRuns
    repositoryNames:
    # Organization単位でランナーを追加した場合は、ランナーの実行を許可するリポジトリ名(org名を除いたリポジトリ名)をここで指定する。
    - example

上記のマニフェストにおいて、特にパフォーマンス面で重要な設定は、runner-cache (/home/runner/.cache) のボリュームマウントです。
このディレクトリにEBSボリュームをマウントしておかないと、Node.jsプロジェクトのように大量のファイルI/O(具体的には node_modules のこと)が発生するプロジェクトのビルドにおいて、2倍以上の時間がかかってしまいます。
ここにボリュームをマウントすることで、公式のGitHub Actions同等かそれ以上のパフォーマンスが期待できますので、必ず設定するようにしてください。

これで self-hosted runner が利用できるようになりました。

6. 作成した self-hosted runner を使ってみる

それでは、作成した self-hosted runner を使ってみます。
以下にコンテナイメージをビルドするワークフローの例を示します。

name: kkoudev/example:arm64

on:
  push:
    paths-ignore:
      - "**.md"
    branches:
      - develop
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"

  workflow_dispatch:

env:
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
  AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}

jobs:
  build:
    # ここに RunnerSet の labels で指定した名前を設定する
    # 配列にしてラベルを複数指定する方向でも良いが、その辺りはお好みで
    runs-on: ubuntu-latest-arm

    timeout-minutes: 30

    steps:
      - name: clone repository
        uses: actions/checkout@v3

      # AWS CLIを使う場合は少し注意
      # unfor19/install-aws-cli-action は自動的にARCHを判定せず、amd64のバイナリをデフォルトでインストールしてしまうため、
      # 明示的に arch を指定します。
      # もしかすると同じようなアクションは他にもあるかもしれません。
      - name: install-aws-cli-action
        uses: unfor19/install-aws-cli-action@v1
        with:
          version: 2
          arch: arm64

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1-node16
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_DEFAULT_REGION }}

      - name: Amazon ECR "Login" Action for GitHub Actions
        run: |
          aws ecr get-login-password | buildah login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com

      - name: Build, tag, and push image to Amazon ECR
        env:
          ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_NAME }}
        run: |
          ECR_REGISTRY=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com
          IMAGE_URL=${ECR_REGISTRY}/${ECR_REPOSITORY}:latest

          envsubst "$(env | awk '!/^(HOME|PATH)/' | awk -F= '{print "$$" $1}')" < Containerfile | buildah bud --layers=true -t ${IMAGE_URL} -f - .
          buildah push ${IMAGE_URL} docker://${IMAGE_URL}

      - name: Slack Notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: all
          username: ${{ secrets.SLACK_USERNAME }}
          icon_emoji: ${{ secrets.SLACK_ICON_EMOJI }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
          MATRIX_CONTEXT: ${{ toJson(matrix) }}
        if: always()

これで作成した self-hosted runner でコンテナイメージのビルドができるようになりました。
実行すると、各ランナーごとにインスタンスが起動し、最大 4 ランナーまではスケールアウトするようになっているかと思います。
ランナーの実行が完了すると、インスタンスが削除されて設定した最小値までスケールダウンされます。

常時稼働しておくランナー(minReplicas)は今回は 2 にしていますが、この辺りはお好みで 1 にしてもらってもいいと思います。
実行するランナーが足りない場合でも、大体1〜2分前後あれば新しいランナーが起動するため、起動までのスタンバイ時間が気にならない人はminReplicas を 1 にしても良いかと思います。
(変更時は対応するマネージドノードグループの最小値、最大値も変更する必要があることに注意してください)

まとめ

というわけで、EKSでARMインスタンス対応するためにARM対応したGitHub Actionsの self-hosted runner の作成方法を紹介させていただきました。
EKSに限らず、GitHub ActionsをCIとして利用しており、これからARMインスタンスを使ってみようと考えている方の参考になれば幸いです。

Discussion