GitHub Actions: запуск Actions Runner Controller в Kubernetes

Автор |  24/09/2024
 

Ми користуємось GitHub Actions для деплоїв, і врешті-решт прийшли до того, що хочеться запускати Runners на своїх серверах в Kubernetes, бо:

  • self-hosted GitHub Runners дешевші – фактично, платимо тільки за сервери, на яких запускаються джоби
  • нам потрібно запускати SQL migrations на AWS RDS в приватних сабнетах
  • перформанс – ми можемо використовувати будь-який тип AWS EC2 та типи дисків, і не обмежувати себе в CPU/Memory та IOPS

Загальна документація – About self-hosted runners.

GitHub для цього має окремий контролер – Actions Runner Controller (ARC), який ми власне і будемо використовувати.

Запускати будемо на AWS Elastic Kubernetes Service v1.30, Karpenter 1.0 для скейлінгу EC2, та AWS Elastic Container Registry для Docker images.

У self-hosted раннерів є Usage limits, але навряд чи ми з ними зіткнемося.

Отже, що будемо робити:

  • з Helm встановимо Actions Runner Controller та Scale Set з Runners для тестового репозиторію, подивимось, як воно все взагалі працює
  • створимо окремий Karpenter NodePool з taints для запуску Runners на окремих інстансах
  • спробуємо реальні білди і деплої для нашого Backend API, подивимось, які будуть помилки
  • створимо власний Docker image для раннерів
  • спробуємо в роботі Docker in Docker mode
  • створимо окремий Kubernetes StorageClass з high IOPS, і подивимось, як це вплине на швидкість білдів-деплоїв

Спочатку зробимо все швиденько руками, аби побачити як воно працює – а потім будемо тюнити і запускати реальний білд-деплой.

Аутентифікація в GitHub

Документація – Authenticating to the GitHub API.

Тут є два варіанти – більш кошерний для production через GitHub App, або через персональний токен.

З GitHub App виглядає як більш правильне рішення, але ми невеликий стартап, і через персональний токен буде простіше – тому поки зробимо так, а “потім” (с) при потребі зробимо вже “як треба”.

Переходимо до свого профайлу, клікаємо Settings > Developer settings > Personal access tokens, клікаємо Generate new token (classic):

Self-hosted runners поки будемо використовувати тільки для одного репозиторію, тому задаємо права тільки на repo:

Expiration було б добре задати, але це PoC (який потім, як завжди, піде в production), тому поки ОК – нехай буде вічний.

Створюємо Kubernetes Namespace для раннерів:

$ kk create ns ops-github-runners-ns
namespace/ops-github-runners-ns created

Створюємо в ньому Kubernetes Secret з токеном:

$ kk -n ops-github-runners-ns create secret generic gh-runners-token --from-literal=github_token='ghp_FMT***5av'
secret/gh-runners-token created

Перевіряємо його:

$ kk -n ops-github-runners-ns get secret -o yaml
apiVersion: v1
items:
- apiVersion: v1
  data:
    github_token: Z2h***hdg==
  kind: Secret
...

Запуск Actions Runner Controller з Helm

Actions Runner Controller складається з двох частин:

  • gha-runner-scale-set-controller: власне сам контролер – його Helm-чарт створить необхідні Kubernetes CRD та запустить поди контролера
  • gha-runner-scale-set: відповідає за запуск Kubernetes Pods з GitHub Action Runners

Крім того, ще є легасі-версія – actions-runner-controller, але ми її використовувати не будемо.

Хоча в документації Scale Sets Controller теж називається Actions Runner Controller, і при цьому є ще й легасі Actions Runner Controller… Трохи плутає, майте на увазі, що частина нагуглених прикладів/документації може бути саме про легасі-версію.

Документація – Quickstart for Actions Runner Controller, або повний варіант – Deploying runner scale sets with Actions Runner Controller.

Встановлення Scale Set Controller

Зробимо окремий Namespace для контролера:

$ kk create ns ops-github-controller-ns
namespace/ops-github-controller-ns created

Встановлюємо чарт – там values доволі простий, ніяких апдейтів робити не треба:

$ helm -n ops-github-controller-ns upgrade --install github-runners-controller \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Перевіряємо поди:

$ kk -n ops-github-controller-ns get pod
NAME                                                           READY   STATUS    RESTARTS   AGE
github-runners-controller-gha-rs-controller-5d6c6b587d-fv8bz   1/1     Running   0          2m26s

Перевіряємо нові CRD:

$ kk get crd | grep github
autoscalinglisteners.actions.github.com                     2024-09-17T10:41:28Z
autoscalingrunnersets.actions.github.com                    2024-09-17T10:41:29Z
ephemeralrunners.actions.github.com                         2024-09-17T10:41:29Z
ephemeralrunnersets.actions.github.com                      2024-09-17T10:41:30Z

Встановлення Scale Set для Runners

Кожен Scale Set (ресурс AutoscalingRunnerSet) відповідає за конкретні Runners, які ми будемо використовувати через runs-on в workflow-файлах.

Задаємо дві змінні оточення – потім це передамо через власний файл values:

  • INSTALLATION_NAME: ім’я runners (в values задається через runnerScaleSetName)
  • GITHUB_CONFIG_URL: URL GitHub Organization або репозиторію в форматі https://github.com/<ORG_NAME>/<REPO_NAME>
$ INSTALLATION_NAME="test-runners"
$ GITHUB_CONFIG_URL="https://github.com/***/atlas-test"

Встановлюємо чарт, передаємо githubConfigUrl та githubConfigSecret – тут у нас вже є створений секрет, використовуємо його:

$ helm -n ops-github-runners-ns upgrade --install test-runners \
> --set githubConfigUrl="${GITHUB_CONFIG_URL}" \
> --set githubConfigSecret=gh-runners-token \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Ще раз перевіряємо поди в неймспейсі контролера – має додатись новий, з ім’ям test-runners-*-listener – він буде відповідати за запуск подів з ранерами для групи “test-runners“:

$ kk -n ops-github-controller-ns get pod
NAME                                                           READY   STATUS    RESTARTS   AGE
github-runners-controller-gha-rs-controller-5d6c6b587d-fv8bz   1/1     Running   0          8m38s
test-runners-694c8c97-listener                                 1/1     Running   0          40s

А створюється він з AutoscalingListeners:

$ kk -n ops-github-controller-ns get autoscalinglisteners 
NAME                             GITHUB CONFIGURE URL                            AUTOSCALINGRUNNERSET NAMESPACE   AUTOSCALINGRUNNERSET NAME
test-runners-694c8c97-listener   https://github.com/***/atlas-test   ops-github-runners-ns            test-runners

Перевіряємо поди в неймспейсі з самими ранерами – тут поки що пусто:

$ kk -n ops-github-runners-ns get pod
No resources found in ops-github-runners-ns namespace.

Власне, для початку на цьому і все – можна починати запускати джоби. А там по ходу діла будемо дивитись “де впало”, і додавати конфігурації.

Тест з GitHub Actions Workflow

Спробуємо запустити якийсь мінімальний білд, просто аби впевнитись, що в цілому схема працює.

В тестовому репозиторії створюємо файл .github/workflows/test-gh-runners.yml.

В runs-on задаємо ім’я нашого пулу раннерів – test-runners:

name: "Test GitHub Runners"

concurrency:
  group: github-test
  cancel-in-progress: false

on:
  workflow_dispatch:
      
permissions:
  # allow read repository's content by steps
  contents: read

jobs:

  aws-test:
    name: Test EKS Runners
    runs-on: test-runners
    steps:

      - name: Test Runner
        run: echo $HOSTNAME

Пушимо в репозиторій, запускаємо білд, чекаємо хвилину, і бачимо ім’я раннера:

Перевіряємо поди в Kubernetes:

$ kk -n ops-github-runners-ns get pod
NAME                              READY   STATUS    RESTARTS   AGE
test-runners-p7j9h-runner-xhb94   1/1     Running   0          6s

Цей жеж раннер і відповідний Runner Scale Set буде в Settings > Actions > Runners:

І джоба завершилась:

Окей – воно працює. Що далі?

  • треба створити Karpenter NodePool з серверів виключно під GitHub Runners
  • треба задати requests на поди
  • треба подивитись як раннери зможуть білдити Docker-образи

Створення Karpenter NodePool

Створимо окремий Karpenter NodePool з taints, аби на цих EC2 запускались тільки поди з GitHub Runners (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах):

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: github1abgt
spec:
  weight: 20
  template:
    metadata:
      labels:
        created-by: karpenter
        component: devops
    spec:
      taints:
        - key: GitHubOnly
          operator: Exists
          effect: NoSchedule
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: defaultv1a
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c5"]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ["large", "xlarge"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
  # total cluster limits
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 600s
    budgets:
      - nodes: "1"

Типи інстансів тут не тюнив, скопіював з NodePool нашого Backend API – потім подивимось, скільки ресурсів раннери будуть використовувати для роботи.

Ще є сенс потюнити disruptionconsolidationPolicy, consolidateAfter та budgets. Наприклад, якщо всі девелопери працюють за одною таймзоною – то WhenEmptyOrUnderutilized робити вночі, а вдень видаляти тільки по WhenEmpty, і задати вищий consolidateAfter, аби нові джоби не чекали зайвого часу на створення EC2. Див. Karpenter: використання Disruption budgets.

Автоматизація Helm deploy Scale Set

Тут варіантів кілька:

  • можемо використати Terraform resource helm_release
  • можемо створити власний чарт, і в ньому встановлювати чарти GitHub Runners через Helm Dependency

Або зробити ще простіше – створити репозиторій з конфігами-вальюсами, додати Makefile – і поки що деплоїти вручну.

Я скоріш за все заміксую схему:

  • сам контролер буде встановлюватись з Terraform коду, який розгортає весь Kubernetes кластер – там встановлюються інші контролери типу ExternalDNS, ALB Ingress Controller, etc
  • для створення Scale Sets з пулами раннерів під кожен репозиторій зроблю окремий Helm chart в окремому репозиторії, і в ньому у templates/ додам конфіг-файли для кожного пула раннерів
    • але поки це ще в PoC – то Scale Sets буде встановлюватись з Makefile який виконує helm install -f values.yaml

Створюємо власний values.yaml, задаємо runnerScaleSetName, requests та tolerations до tains з нашого NodePool:

githubConfigUrl: "https://github.com/***/atlas-test"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "test-runners"

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        command: ["/home/runner/run.sh"]    
        resources:
          requests:
            cpu: 1
            memory: 1Gi
    tolerations:
      - key: "GitHubOnly"
        effect: "NoSchedule"  
        operator: "Exists"

Додамо простенький Makefile:

deploy-helm-runners-test:
  helm -n ops-github-runners-ns upgrade --install test-eks-runners -f test-github-runners-values.yaml oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Деплоїмо:

$ make deploy-helm-runners-test

Перевіряємо чи змінився конфіг цього пулу раннерів:

$ kk -n ops-github-runners-ns describe autoscalingrunnerset test-runners
Name:         test-runners
Namespace:    ops-github-runners-ns
...
API Version:  actions.github.com/v1alpha1
Kind:         AutoscalingRunnerSet
...
Spec:
  Github Config Secret:   gh-runners-token
  Github Config URL:      https://github.com/***/atlas-test
  Runner Scale Set Name:  test-runners
  Template:
    Spec:
      Containers:
        Command:
          /home/runner/run.sh
        Image:  ghcr.io/actions/actions-runner:latest
        Name:   runner
        Resources:
          Requests:
            Cpu:             1
            Memory:          1Gi
      Restart Policy:        Never
      Service Account Name:  test-runners-gha-rs-no-permission
      Tolerations:
        Effect:    NoSchedule
        Key:       GitHubOnly
        Operator:  Exists

Аби перевірити, що буде використовуватись новий Karpenter NodePool – запускаємо тестовий білд, і перевіряємо NodeClaims:

$ kk get nodeclaim | grep git
github1abgt-dq8v5    c5.large    spot       us-east-1a                                 Unknown   20s

ОК, інстанс створюється, і под з раннером теж:

$ kk -n ops-github-runners-ns get pod
NAME                              READY   STATUS              RESTARTS   AGE
test-runners-6s8nd-runner-2s47n   0/1     ContainerCreating   0          45s

Тут все працює.

Білд Backend API

А тепер давайте спробуємо запустити білд і деплой нашого бекенду з реальним кодом і GitHub Actions Workflows.

Створюємо новий values для нового пула раннерів:

githubConfigUrl: "https://github.com/***/kraken"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "kraken-eks-runners"
...

Деплоїмо новий Scale Set:

$ helm -n ops-github-runners-ns upgrade --install kraken-eks-runners \
> -f kraken-github-runners-values.yaml \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Редагуємо workflow проекту – міняємо runs-on: ubuntu-latest на runs-on: kraken-eks-runners:

...
jobs:
  eks_build_deploy:
    name: "Build and/or deploy backend"
    runs-on: kraken-eks-runners
...

Запускаємо білд, новий Pod створився:

$ kk -n ops-github-runners-ns get pod
NAME                                    READY   STATUS    RESTARTS   AGE
kraken-eks-runners-pg29x-runner-xwjxx   1/1     Running   0          11s

І білд пішов:

Але тут жеж впав з помилками, що не може знайти make та git:

GitHub Runners image та “git: command not found”

Перевіримо вручну – запускаємо ghcr.io/actions/actions-runner:latest локально:

$ docker run -ti ghcr.io/actions/actions-runner:latest bash
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

runner@c8aa7e25c76c:~$ make
bash: make: command not found
runner@c8aa7e25c76c:~$ git
bash: git: command not found

Ну, те, що нема make – ще якось можна зрозуміти. Але на GitHub Runners не додати “в коробку” git?

Ось в цій GitHub Issue люди теж дивуються такому рішенню.

Але ок… Маємо, що маємо. Що ми можемо зробити – це створити власний образ, де за базу будемо брати ghcr.io/actions/actions-runner, і встановлювати все, що нам необхідно для щастя.

Див. Software installed in the ARC runner image. Також є інші образи, не від GitHub – Runners, але я їх не пробував.

Отже, наш базовий образ GitHub Runners використовує Ubuntu 22.04, тому можемо з apt встановити всі потрібні пакети.

Описуємо Dockerfile – я тут вже додав і AWS CLI, і кілька пакетів для Python:

FROM ghcr.io/actions/actions-runner:latest

RUN sudo curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash

RUN sudo apt update && \
  sudo apt -y install git make python3-pip awscli python3-venv

Але ще можливі warnings типу такого:

WARNING: The script gunicorn is installed in ‘/home/runner/.local/bin’ which is not on PATH.
Consider adding this directory to PATH or, if you prefer to suppress this warning, use –no-warn-script-location.

Тому в Dockerfile додав PATH:

FROM ghcr.io/actions/actions-runner:latest

ENV PATH="$PATH:/home/runner/.local/bin"

RUN sudo curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash

RUN sudo apt update && \
  sudo apt -y install git make python3-pip awscli python3-venv

Створюємо репозиторій в AWS ECR:

Білдимо образ:

$ docker build -t 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken -f Dockefile.kraken .

Логінимось в ECR:

$ aws --profile work ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 492***148.dkr.ecr.us-east-1.amazonaws.com

Пушимо:

$ docker push 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken

Міняємо image в нашому values:

...
runnerScaleSetName: "kraken-eks-runners"

template:
  spec:
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:latest
        command: ["/home/runner/run.sh"]  
...

Деплоїмо, запускаємо білд – і маємо нову проблему.

Помилка “Cannot connect to the Docker daemon” та Scale Set containerMode “Docker in Docker”

Тепер виникає проблема з Docker:

docker: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?.

Бо під час білду нашого бекенду запускається ще один Docker-контейнер для генерації OpenAPI docs.

Тому в нашому випадку нам потрібно використати Docker in Docker (хоча дуже не люблю цю схему).

Документація GitHub – Using Docker-in-Docker mode.

У Scale Sets для цього є окремий параметр containerMode.type=dind.

Додаємо в наш values:

...
runnerScaleSetName: "kraken-eks-runners"

containerMode:
  type: "dind"

template:
  spec:
...

Деплоїмо Helm, і тепер маємо два контейнери в поді з раннером – один сам runner, інший – dind:

==> New container [kraken-eks-runners-trb9h-runner-klfxk:dind]
==> New container [kraken-eks-runners-trb9h-runner-klfxk:runner]

Запускаємо білд, і… Маємо нову помилку 🙂

Привіт DinD та Docker volumes.

Помилка виглядає так:

Error: ENOENT: no such file or directory, open ‘/app/openapi.yml’

Docker in Docker та Docker volumes

Виникає вона через те, що в коді API створюється директорія в /tmp, в якій генерується файл openapi.yml, з якого потім генерується HTML з документацією:

...
def generate_openapi_html_definitions(yml_path: Path, html_path: Path):
    print("Running docker to generate HTML")
    
    app_volume_path = Path(tempfile.mkdtemp())
    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())

    if subprocess.call(
        [
            "docker",
            "run",
            "-v",
            f"{app_volume_path}:/app",
            "--platform",
            "linux/amd64",
            "-e",
            "yaml_path=/app/openapi.yml",
            "-e",
            "html_path=/app/openapi.html",
            "492***148.dkr.ecr.us-east-1.amazonaws.com/openapi-generator:latest",
        ]
    ):
...

Тут Path(tempfile.mkdtemp()) створює нову директорію в /tmp – але це виконується всередині контейнера kraken-eks-runners-trb9h-runner-klfxk:runner, а docker run -v f"{app_volume_path}:/app" запускається всередині контейнера kraken-eks-runners-trb9h-runner-klfxk:dind.

Давайте просто глянемо на маніфест поду:

$ kk -n ops-github-runners-ns describe autoscalingrunnerset kraken-eks-runners
...
  Template:
    Spec:
      Containers:
        ...
        Image:    492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.8
        Name:     runner
        ...
        Volume Mounts:
          Mount Path:  /home/runner/_work
          Name:        work
        ...
        Image:    docker:dind
        Name:     dind
        ...
        Volume Mounts:
          Mount Path:  /home/runner/_work
          Name:        work
        ...

Тобто, у обох контейнерів є спільний каталог /home/runner/_work, який створюється на хості/EC2, і маунтиться в Kubernetes Pod до обох Docker-контейнерів.

А каталог /tmp в контейнері runner – “локальний” для нього, і недоступний для контейнера з dind.

Тому як варіант – просто створювати новий каталог для файлу openapi.yml всередині /home/runner/_work:

...
    # get $HONE, fallback to the '/home/runner'
    home = os.environ.get('HOME', '/home/runner')
    # set app_volume_path == '/home/runner/_work/tmp/'
    app_volume_path = Path(home) / "_work/tmp/"

    # mkdir recursive, exist_ok=True in case the dir already created by openapi/asyncapi
    app_volume_path.mkdir(parents=True, exist_ok=True)
    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())
...

Або зробити ще краще – на випадок, якщо білд буде запускатись на GitHub hosted Runners, то додати перевірку того, на якому саме раннері запущена джоба, і відповідно вибирати де створювати каталог.

В values нашого Scale Set додаємо змінну RUNNER_EKS:

...
template:
  spec:
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.8
        command: ["/home/runner/run.sh"]
        env:
        - name: RUNNER_EKS
          value: "true"
...

А в коді – перевірку цієї змінної, і в залежності від неї задаємо каталог app_volume_path:

...
    # our runners will have the 'RUNNER_EKS=true'
    if os.environ.get('RUNNER_EKS', '').lower() == 'true':
        # Get $HOME, fallback to the '/home/runner'
        home = os.environ.get('HOME', '/home/runner')

        # Set app_volume_path to the '/home/runner/_work/tmp/'
        app_volume_path = Path(home) / "_work/tmp/"

        # mkdir recursive, exist_ok=True in case the dir already created by openapi/asyncapi
        app_volume_path.mkdir(parents=True, exist_ok=True)
    # otherwize if it's a GitHub hosted Runner without the 'RUNNER_EKS', use the old code
    else:
        app_volume_path = Path(tempfile.mkdtemp())

    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())
...

Запускаємо білд ще раз – і тепер все працює:

Помилка “Access to the path ‘/home/runner/_work/_temp/_github_home/.kube/cache’ is denied”

Ще іноді виникає проблема, коли в кінці білда-деплоя джоба завершується з повідомленням “Error: The opeation was canceled“:

В логах раннера при цьому є і причина – він не може видалити директорію _github_home/.kube/cache:

...
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z INFO TempDirectoryManager] Cleaning runner temp folder: /home/runner/_work/_temp
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager] System.AggregateException: One or more errors occurred. (Access to the path '/home/runner/_work/_temp/_github_home/.kube/cache' is denied.)
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager]  ---> System.UnauthorizedAccessException: Access to the path '/home/runner/_work/_temp/_github_home/.kube/cache' is denied.
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager]  ---> System.IO.IOException: Permission denied
...

І дійсно, якщо перевірити каталог /home/runner/_work/_temp/_github_home/ з контейнера runner – то він туди доступу не має:

runner@kraken-eks-runners-7pd5d-runner-frbbb:~$ ls -l /home/runner/_work/_temp/_github_home/.kube/cache
ls: cannot open directory '/home/runner/_work/_temp/_github_home/.kube/cache': Permission denied

Але доступ є з контейнера з dind, який цей каталог і створює:

/ # ls -l /home/runner/_work/_temp/_github_home/.kube/cache
total 0
drwxr-x---    3 root     root            78 Sep 24 08:36 discovery
drwxr-x---    3 root     root           313 Sep 24 08:36 http

При цьому створює його від root, хоча решта каталогів – від юзера 1001:

/ # ls -l /home/runner/_work/_temp/
total 40
-rw-r--r--    1 1001     1001            71 Sep 24 08:36 79b35fe7-ba51-47fc-b5a2-4e4cdf227076.sh
drwxr-xr-x    2 1001     1001            24 Sep 24 08:31 _github_workflow
...

А 1001 – це юзер runner з контейнера runner:

runner@kraken-eks-runners-7pd5d-runner-frbbb:~$ id runner
uid=1001(runner) gid=1001(runner) groups=1001(runner),27(sudo),123(docker)

Цікаво, що помилка виникає не постійно, а час від часу, хоча в самому workflow нічного не міняється.

Каталог .kube/config створюється з action bitovi/github-actions-deploy-eks-helm, який виконує aws eks update-kubeconfig з власного Docker-контейнера, і запускається від рута, бо запускається в Docker in Docker.

З варіантів приходить в голову два рішення:

  • або просто додати костиль у вигляді додаткової команди chown -r 1001:1001 /home/runner/_work/_temp/_github_home/.kube/cache в кінці деплою (хоча можна таким жеж костилем просто видаляти директорію)
  • або змінити GITHUB_HOME в іншу директорію – тоді aws eks update-kubeconfig буде створювати .kube/cache в іншому місці, і контейнер з runner зможе виконати Cleaning runner temp folder

Хоча я все одно не розумію, чому Cleaning runner temp folder виконується не кожного разу, і, відповідно, це “плаваючий баг”. Подивимось далі, як воно буде в роботі.

Підключення High IOPS Volume

Одна з причин, чому ми хочемо перейти на власні раннери – це пришвидшити білди-деплої.

Але велику частку часу займаються команди типу docker load && docker save.

Тому хочеться спробувати підключити AWS EBS з високим IOPS, бо дефолтний gp2 має 100 IOPS на кожен GB розміру – див. Amazon EBS volume types.

Створюємо новий Kubernetes StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-iops
provisioner: kubernetes.io/aws-ebs
parameters:
  type: gp3
  iopsPerGB: "16000"
  throughput: "1000"
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

У values нашого пула раннерів додаємо блок volumes, де перевизначаємо параметри для диска work, який по дефолту створюється з emptyDir: {} – задаємо новий storageClassName:

githubConfigUrl: "https://github.com/***/kraken"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "kraken-eks-runners"

containerMode:
  type: "dind"

template:
  spec: 
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.9
        command: ["/home/runner/run.sh"]
        env:
        - name: RUNNER_EKS
          value: "true"
        resources:
          requests:
            cpu: 2
            memory: 4Gi
    volumes:
      - name: work
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes: [ "ReadWriteOnce" ]
              storageClassName: "gp3-iops"
              resources:
                requests:
                  storage: 10Gi

Деплоїмо ці зміни AutoscalingRunnerSet, запускаємо наш деплой, і – поди з раннерами створюються, але тут жеж вбиваються, а сама джоба фейлиться.

Помилка “Access to the path ‘/home/runner/_work/_tool’ is denied”

Дивимось логи раннерів, і бачимо, що:

kraken-eks-runners-gz866-runner-nx89n:runner [RUNNER 2024-09-24 10:15:40Z ERR  JobDispatcher] System.UnauthorizedAccessException: Access to the path ‘/home/runner/_work/_tool’ is denied.

На документацію Error: Access to the path /home/runner/_work/_tool is denied я вже натикався, коли вище шукав рішення з помилкою “Access to the path ‘/home/runner/_work/_temp/_github_home/.kube/cache’ is denied“, ось воно і знадобилось.

Додаємо ще один initContainer, в якому виконуємо chown:

...
template:
  spec:
    initContainers:
    - name: kube-init
      image: ghcr.io/actions/actions-runner:latest
      command: ["sudo", "chown", "-R", "1001:123", "/home/runner/_work"]
      volumeMounts:
        - name: work
          mountPath: /home/runner/_work  
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.9
        command: ["/home/runner/run.sh"]
...

І тепер все працює.

Порівняємо результати.

Джоба “Build and/or deploy backend” займала 9 хвилин:

А стало 6 хвилин:

В цілому на цьому поки все.

Не скажу, що все прям працює з коробки, трохи повозитись 100% треба буде – але працює. Будемо пробувати переводити всі білди на свої раннери.