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

24 Вересня 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% треба буде – але працює. Будемо пробувати переводити всі білди на свої раннери.

Loading

VictoriaMetrics Cloud: інтеграція з AWS Data Firehose для CloudWatch метрик
0 (0)

22 Вересня 2024

Про саму VictoriaMetrics Cloud напишу окремо, а зараз хочу перевірити як можна писати CloudWatch Metrcis через AWS Firehose до VictoriaMetrics Cloud.

Власне, сам сервіс AWS Data Firehose дозволяє передачу потокових даних з різних джерел до сервісів Amazon на кшталт AWS S3, Redshift, OpenSearch, або до зовнішніх – Datadog, New Relic, і т.д.

Нещодавно VictoriaMetrics запустила (поки що в Beta) власну підтримку AWS Data Firehose, і тепер ми можемо стрімити дані до VictoriaMetrics Cloud.

Приємна особливість цього сетапу, що нам фактично не треба самим запускати якісь сервери або експортери для збору метрик – все повністю agentless та serverless, бо Data Firehose – це AWS Managed сервіс, який просто працює, а VictoriaMetrics Cloud працює повністю на інфраструктурі VictoriaMetrics, і не потребує від нас якихось особливих налаштувань.

Ще з цікавих моментів, це те, що CloudWatch віддає метрики а VictoriaMetrics приймає їх в форматі OpenTelemetry, хоча при бажанні у VictoriaMetrics можна їх конвертувати в формат Prometheus.

Власне, що будемо робити:

  • налаштуємо AWS Data Firehose Stream для передачі даних до VictoriaMetrics Cloud
  • налаштуємо CloudWatch Metrics Stream для передачі метрик в цей Firehose Stream

VictoriaMetrics Cloud Authentification

Перше, що потрібно зробити – це отримати URL ендпоінту, на який будуть відправлятись дані.

У VictoriaMetrics Cloud маємо створений Deployment (див. Creating deployments), в Overview якого маємо параметр Access Endpoint:

Друге – це отримати Access Token (див. Start writing and reading data).

Переходимо до вкладки Access, де маємо токен з правами read-write:

Тепер маємо дві частини, які будемо використовувати в AWS Firehose:

  • HTTP Endpoint URL: https://gw-c7-2b.cloud.victoriametrics.com
  • Bearer Access Token: ccbd4c8e-db49-463f-9813-371a09e549b6

З CloudWatch до VictoriaMetrics будемо писати в форматі OpenTelemetry, тому повний ендпоінт буде з URI /opentelemetry/api/v1/pushhttps://gw-c7-2b.cloud.victoriametrics.com/opentelemetry/api/v1/push.

Створення AWS Data Firehose Stream

Тут все доволі просто: нам потрібно задати Source, тобто – звідки і які дані будуть йти, і вказати Destination – куди ці дані відправляти.

При необхідності можна з AWS Lambda робити трансформації, але у випадку з метриками CloudWatch це не обов’язково.

Отже, переходимо до Amazon Data Firehose, клікаємо Create Firehose stream:

В Source вибираємо Direct PUT:

В Destination – HTTP Endpoint:

Задаємо Firehose stream name:

В Destination settings – вказуємо HTTP endpoint URL, який отримали в VictoriaMetrics Cloud + /opentelemetry/api/v1/push:

Токен аутентифікації задаємо в Access key у форматі “Bearer TOKEN_VALUE“:

Опціонально – включаємо GZIP.

Firehose потребує налаштування Backup storage для даних, які не зміг відправити до Destination – див. Handle data delivery failures.

Задаємо ім’я AWS S3 бакету:

Зберігаємо новий стрім – він готовий приймати дані.

Cloudwatch Metrics to AWS Data Firehose

Документація – Custom setup with Firehose.

Переходимо до CloudWatch > Metrcis > Streams, клікаємо Create metric stream:

Вибираємо Custom setup with Firehose, вибираємо створений вище стрім:

При необхідності – можна вибрати формат, але дефолтний OpenTelemetry 1.0 підтримується:

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

Останнім задаємо ім’я стріма:

Перевіряємо, що Status == Running:

Перевірка Firehose Stream

Тепер маємо CloudWatch Metrcis Stream, який пише метрики до Firehose Stream, який потім відправляє їх до HTTP Endpoint у VictoriaMetrcis Cloud.

Чекаємо хвилин 5, і спершу перевіряємо метрики в CloudWatch Metrcis Stream:

Якщо тут метрики є, то переходимо до Firehose Stream > Monitoring, де маємо побачити, що дані йдуть до VictoriaMetrics Cloud:

При проблемах з відправкою даних – дивимось вкладку Destination error logs:

Також можна перевірити вкладку Monitoring в VictoriaMetrics – на графіку Ingestion rate мають бути запити з {type="opentelemetry"}:

VictoriaMetrics Explore та метрики CloudWatch

Включаємо Autocomplete – і маємо отримати список метрик, які приходять з AWS CloudWatch:

І далі можемо вже робити запити, наприклад використовуючи лейблу __name__:

sum({__name__="amazonaws.com/AWS/EC2/CPUUtilization"}) by (Namespace, cloud.region)

А аби переключити формат метрик з OpenTelemetry на Prometheus – переходимо до Settings > Advanced Settings, і додаємо параметр -opentelemetry.usePrometheusNaming:

Готово.

Loading

Karpenter: використання Disruption budgets
0 (0)

17 Вересня 2024

Disruption budgets з’явились в версії 0.36, і виглядає як дуже цікавий інструмент для того, аби обмежити Karpenter в перестворенні WorkerNodes.

Наприклад в моєму випадку ми не хочемо, аби EC2 вбивались в робочі часи по США, бо там у нас клієнти, а тому зараз маємо consolidationPolicy=whenEmpty, аби запобігти “зайвому” видаленню серверів та Pods на них.

Натомість з Disruption budgets ми можемо налаштувати політики таким чином, що в один період часу будуть дозволені операції з WhenEmpty, а в інший – WhenEmptyOrUnderutilized.

Див. також Kubernetes: забезпечення High Availability для Pods – бо при використанні Karpenter навіть при налаштованих Disruption budgets необхідно мати відповідно налаштовані поди з Topology Spread та PodDisruptionBudget.

Типи Karpenter Disruption

Документація – Automated Graceful Methods.

Спочатку глянемо, в яких випадках Disruption взагалі відбувається:

  • Drift: виникає, коли є різниця між створеними конфігураціями NodePools або EC2NodeClass та існуючими WorkerNodes – тоді Karpenter почне перестворювати EC2 аби привести їх у відповідність до заданих параметрів
  • Interruption: якщо Karpenter отримує AWS Event, що інстанс буде виключено, наприклад – якщо це Spot
  • Consolidation: якщо маємо налаштування Consolidation на WhenEmptyOrUnderutilized або WhenEmpty, і Karpenter переносить наші Pods на інші WorkerNodes
    • у нас Karpenter 1.0, тому полісі WhenEmptyOrUnderutilized, для 0.37 це WhenUnderutilized

Karpenter Disruption Budgets

За допомогою Disruption budgets ми можемо дуже гнучко налаштувати в який час і які операції Karpenter може проводити, і задати ліміт на те, скільки WorkerNodes одночасно будуть видалятись.

Документація – NodePool Disruption Budgets.

Формат конфігурації доволі простий:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m

Тут ми задаємо:

  • дозволити видалення WorkerNodes для 20% від загальної кількості
  • для операції, коли Disruption викликаний умовою WhenEmpty
  • виконуємо це кожен день
  • на протязі 10 хвилин

Параметри тут можуть мати значення:

  • nodes: в процентах або просто кількості нод
  • reasons: Drifted, Underutilized або Empty
  • schedule: розклад, за яким правило застосовується, в UTC (інші таймзони поки не підтримуються), див. Kubernetes Schedule syntax
  • duration: і скільки часу правило діє, наприклад – 1h15m

При цьому не обов’язково задавати всі параметри.

Наприклад, ми можемо описати два таких бюджети:

- nodes: "25%"
- nodes: "10"

Тоді у нас постійно будуть працювати обидва правила, і перше обмежує кількість нод в 25% від загальної кількості, а друге – не більше як 10 інстансів – якщо у нас більш ніж 40 серверів.

Також, Budgets можна комбінувати, і якщо їх задано кілька – то ліміти будуть братись по найбільш суворому.

В першому прикладі ми застосовуємо правило на 20% нод і умові WhenEmpty, а решту часу будуть працювати дефолтні правила disruption – тобто, 10% від загальної кількості серверів із заданою consolidationPolicy.

Тому можемо записати правило так:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m
- nodes: 0

Тут останнє правило працює постійно, і буде таким собі запобіжником: ми забороняємо все, але дозоляємо виконувати disruption за політикою WhenEmpty на протязі 10 хвилин раз на добу починаючи з 00:00 UTC.

Приклад Disruption Budgets

Повертаючись до моєї задачі:

  • маємо Backend API в Kubernetes на окремому NodePool, а наші клієнти в основному з США, тому ми хочемо мінімізувати down-скейлінг WorkerNodes в робочий час по США
  • для цього ми хочемо заблокувати всі операції по WhenUnderutilized в період робочого часу по Central Time USA
    • в schedule Karpenter використовує зону UTC, тому початок робочого дня по Central Time USA 9:00 – це 15:00 UTC
  • операції з WhenEmpty дозволимо в будь-який час, але тільки по 1 WorkerNode одночасно
  • Drift – аналогічно, бо коли я деплою зміни – то хочу побачити результат відразу

Фактично, нам потрібно задати два бюджети:

  • по Underutilized – забороняємо все з понеділка по п’ятницю на протязі 9 годин починаючи з 15:00 по UTC
  • по Empty та Drifted – дозволяємо в будь-який час, але тільки по 1 ноді, а не дефолтні 10%

Тоді наш NodePool буде виглядати так:

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: backend1a
spec:
  template:
    metadata:
      labels:
        created-by: karpenter
        component: devops
    spec:
      taints:
        - key: BackendOnly
          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: "0"                   # block all
        reasons:
        - "Underutilized"            # if reason == underutilized
        schedule: "0 15 * * mon-fri" # starting at 15:00 UTC during weekdays
        duration: 9h                 # during 9 hours
      - nodes: "1"                   # allow by 1 WorkerNode at a time
        reasons:
        - "Empty"
        - "Drifted"

Деплоїмо, перевіряємо NodePool:

$ kk describe nodepool backend1a   
Name:         backend1a
...
API Version:  karpenter.sh/v1
Kind:         NodePool
...
Spec:
  Disruption:
    Budgets:
      Duration:  9h
      Nodes:     0
      Reasons:
        Underutilized
      Schedule:  0 15 * * mon-fri
      Nodes:     1
      Reasons:
        Empty
        Drifted
    Consolidate After:     600s
    Consolidation Policy:  WhenEmptyOrUnderutilized
...

І в логах бачимо, що спрацював Disruption по WhenUnderutilized:

karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:26.777Z","logger":"controller","message":"disrupting nodeclaim(s) via delete, terminating 1 nodes (2 pods) ip-10-0-42-250.ec2.internal/t3.small/spot","commit":"62a726c","controller":"disruption","namespace":"","name":"","reconcileID":"db2233c3-c64b-41f2-a656-d6a5addeda8a","command-id":"1cd3a8d8-57e9-4107-a701-bd167ed23686","reason":"underutilized"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:27.016Z","logger":"controller","message":"tainted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"f0815e43-94fb-4546-9663-377441677028","taint.Key":"karpenter.sh/disrupted","taint.Value":"","taint.Effect":"NoSchedule"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:50:35.212Z","logger":"controller","message":"deleted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"208e5ff7-8371-442a-9c02-919e3525001b"}

Готово.

Loading

VictoriaLogs: знайомство, запуск в Kubernetes, LogsQL та Grafana
0 (0)

5 Вересня 2024

VictoriaLogs – відносно нова система для збору та аналізу логів, схожа на Grafana Loki, але – як і VictoriaMetrics в порівнянні з “ванільним” Prometheus – менш вибаглива до ресурсів CPU/Memory.

Особисто я користуюсь Grafana Loki років 5, але до неї іноді буває дуже багато питань – і по документації, і по загальній складності системи, бо багато компонентів, і по перформансу – бо як я її не тюнив (див. Grafana Loki: оптимізація роботи – Recording Rules, кешування та паралельні запити), але іноді на відносно невеликих запитах Grafana повертає 504 від Loki Gateway, і я, якщо чесно, вже втомився з цим розбиратись.

Ну а оскільки у нас сам моніторинг побудований на VictoriaMetrics, і до VictoriaLogs вже “завезли” підтримку Grafana data source – то прийшов час спробувати її в роботі, і порівняти з Grafana Loki.

Чого у VictoriaLogs поки що нема:

  • підтримки AWS S3 бекенду – але обіцяють зробити в листопаді 2024 (до того, ж якоюсь “магічною” автоматизацією – коли старі дані з локального диску автоматично будуть перенесені до відповідного S3)
  • поки що нема аналога Loki RecordingRules – коли з логів створюємо звичайні метрики, їх записуємо в VictoriaMetrics/Prometheus, а потім робимо алерти в VMAlert та дашборди в Grafana, але знов-таки скоро має бути – жовтень-листопад 2024
  • Grafana data source теж ще в Beta, тому є складності з побудовою графіків в Grafana

І прям біда з всякими ChatGPT для генерації запитів – але про це поговоримо далі.

Документація – як завжди у VictoriaMetrcis чудова – VictoriaLogs.

Ще про останні апдейти VictoriaLogs говорили на мітапі VictoriaMetrics Meetup June 2024 – VictoriaLogs Update.

Цікаві скріншоти з бенчмарками VictoriaLogs vs ELK vs Grafana Loki – Benchmark for VictoriaLogs.

Roadmap по VictoriaLogs – тут>>>.

Тож що будемо сьогодні робити:

  • запустимо VictoriaLogs в Kubernetes
  • подивимось на можливості її LogsQL
  • підключимо Grafana data source
  • подивимось, як можна створити дашборду в Grafana

VictoriaLogs Helm chart

Деплоїти будемо з Helm-чарта vm/victoria-logs-single.

Такоє є підтримка в VictoriaMetrics Operator (див. VLogs).

Ми на проекті використовуємо власний чарт для нашого моніторингу (див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом), в якому через Helm dependency встановлюється чарт victoria-metrics-k8s-stack + всякі додаткові сервіси типу Promtail, k8s-event-logger etc. В цей же чарт додамо victoria-logs-single.

Для початку зробимо все руками, спочатку з якимись дефолтними values, потім подивимось, що воно нам встановить в Kubernetes і як воно працює – а потім будемо додавати в автоматизацію.

В чарті VictoriaLogs є можливість відразу запустити Fluetbit DaemonSet, але в нас вже є Promtail, тому будемо використовувати його.

Всі values є в документації до чарту, а з того, що може бути цікаве зараз:

  • extraVolumeMounts та extraVolumes: можемо створити власний окремий persistentVolume з AWS EBS, та підключати його до VictoriaLogs
  • persistentVolume.enabled та persistentVolume.storageClassName: або можемо просто вказати, що його треба створювати, і при потребі задати власний storageClass з ReclaimPolicy retain
  • ingress: в моєму випадку частина логів пишеться з AWS Lambda (див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda), тому потрібно буде створювати AWS ALB з типом Internal

Встановлення чарту

Додаємо репозиторій:

$ helm repo add vm https://victoriametrics.github.io/helm-charts/
$ helm repo update

Встановлюємо чарт в окремий Kubernetes Namespace ops-test-vmlogs-ns:

$ helm -n ops-test-vmlogs-ns upgrade --install vlsingle vm/victoria-logs-single

Перевіряємо Kubernetes Pod:

$ kk get pod
NAME                                     READY   STATUS    RESTARTS   AGE
vlsingle-victoria-logs-single-server-0   1/1     Running   0          36s

І глянемо на ресурси:

$ kk top pod
NAME                                     CPU(cores)   MEMORY(bytes)   
vlsingle-victoria-logs-single-server-0   1m           3Mi             

3 мегабайти пам’яті 🙂

Забігаючи наперед – після підключення запису логів з Promtail до VictoriaLogs ресурсів буде використовуватись не набагато більше.

Відкриваємо доступ до UI:

$ kk -n ops-test-vmlogs-ns port-forward svc/vlsingle-victoria-logs-single-server 9428

В браузері заходимо на http://localhost:9428.

Як і інші сервіси від VictoriaMetrics – попадаємо на сторінку з усіма необхідними посиланнями:

Переходимо на  http://localhost:9428/select/vmui/ – поки що тут пусто:

Додамо відправку логів з Promtail.

Налаштування Promtail

До VictoriaLogs можна писати логи в форматі Elasticsearch, ndjson або Loki – див. Data ingestion.

Власне нас цікавить саме Loki, і логи ми пишемо з Promtail. Приклад конфігурації Promtail для VictoriaLogs див. у Promtail setup.

У нас Promtail встановлюється з його власного чарту, який створює Kubernetes Secret з promtail.yml.

Оновлюємо values чарту, в config.clients додаємо ще один URL – в моєму випадку він буде неймспейсом з ops-test-vmlogs-ns.svc, бо VictoriaLogs запущена в іншому неймспейсі, ніж Loki:

...
promtail:
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://vlsingle-victoria-logs-single-server.ops-test-vmlogs-ns.svc:9428/insert/loki/api/v1/push
...

Деплоїмо зміни, чекаємо рестарту подів з Promtail, і ще раз перевіряємо логи в VictoriaLogs:

VictoriaLogs Log Streams

Під час запису логів до VictoriaLogs ми можемо задати додаткові параметри – див. HTTP parameters.

З того, що може бути цікавим зараз – це спробувати створити власні Log Stream, аби по ним потім робити фільтрацію логів для більш швидкої їх обробки. див. Stream fields.

Якщо лог-стрім не заданий – то VictoriaLogs пише все в один дефолтний стрім {}, як ми бачили на скріні вище.

Наприклад, у нас в кластері всі аплікейшени розбиті по власним Kubernetes Namespaces – dev-backend-api-ns, prod-backend-api-ns, ops-monitoring-ns і т.д.

Давайте створимо окремий стрім на кожен неймспейс – до url додаємо ?_stream_fields=namespace:

...
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://vlsingle-victoria-logs-single-server.ops-test-vmlogs-ns.svc:9428/insert/loki/api/v1/push?_stream_fields=namespace
...

Деплоїмо, і тепер маємо окремі стріми на кожен неймспейс:

VictoriaLogs vs Loki: ресурси CPU/Memory

Давайте просто глянемо на ресурси, які зараз в мене споживають всі поди для роботи Loki:

$ kk -n ops-monitoring-ns top pod | grep loki
atlas-victoriametrics-loki-chunks-cache-0                         2m           824Mi          
atlas-victoriametrics-loki-gateway-6bd7d496f5-9c2fh               1m           12Mi            
atlas-victoriametrics-loki-results-cache-0                        1m           32Mi            
loki-backend-0                                                    50m          202Mi           
loki-backend-1                                                    8m           214Mi           
loki-backend-2                                                    12m          248Mi           
loki-canary-gzjxh                                                 1m           15Mi            
loki-canary-h9d6s                                                 1m           17Mi            
loki-canary-hkh4f                                                 2m           17Mi            
loki-canary-nh9mf                                                 2m           16Mi            
loki-canary-pbs4x                                                 1m           17Mi            
loki-read-55bcffc9fb-7j4tg                                        12m          255Mi           
loki-read-55bcffc9fb-7qtns                                        45m          248Mi           
loki-read-55bcffc9fb-s7rpq                                        10m          244Mi           
loki-write-0                                                      42m          262Mi           
loki-write-1                                                      27m          261Mi           
loki-write-2                                                      26m          258Mi           

Та ресурси VictoriaLogs:

$ kk top pod
NAME                                     CPU(cores)   MEMORY(bytes)   
vlsingle-victoria-logs-single-server-0   2m           14Mi            

При тому, що пишеться однакова кількість логів.

Так – в Loki зараз є пачка RecordingRules, так – є пара дашборд в Grafana, які виконують запити напряму до Loki для графіків, але ж ну камон! Це небо і земля!

Можливо, це ще й мої криві руки, які не змогли нормально затюнити Loki – проте VictoriaLogs зараз запущена взагалі без всякого тюнингу.

LogsQL

Окей – маємо інстанс VictoriaLogs, маємо логи, які в неї пишуться.

Давайте спробуємо “покверяти” і розібратися з LogsQL взагалі, та трохи порівняти з LogQL від Loki.

Документація по LogsQL для VictoriaLogs – тут>>>.

Запити ми можемо робити з VM UI, з CLI та з Grafana – див. Querying.

Запити з HTTP API

У VictoriaLogs дуже приємний API, з яким можна отримати всі необхідні дані.

Наприклад, для пошуку по логам за допомогою curl можемо зробити запит до /select/logsql/query, а потім через unix pipe передати до jq.

Все ще маємо запущений kubectl port-forward, робимо запит з пошуком всіх логів зі словом “error“:

$ curl -s localhost:9428/select/logsql/query -d 'query=error' | head | jq
{
  "_time": "2024-09-02T12:23:40.890465823Z",
  "_stream_id": "0000000000000000195443555522d86dcbf56363e06426e2",
  "_stream": "{namespace=\"staging-backend-api-ns\"}",
  "_msg": "[2024-09-02 12:23:40,890: WARNING/ForkPoolWorker-6] {\"message\": \"Could not execute transaction\", \"error\": \"TransactionCanceledException('An error occurred (TransactionCanceledException) when calling the TransactWriteItems operation: Transaction cancelled, please refer cancellation reasons for specific reasons [None, None, ConditionalCheckFailed]')\", \"logger\": \"core.storage.engines.dynamodb_transactions\", \"level\": \"warning\", \"lineno\": 124, \"func_name\": \"_commit_transaction\", \"filename\": \"dynamodb_transactions.py\", \"pid\": 2660, \"timestamp\": \"2024-09-02T12:23:40.890294\"}",
  "app": "backend-celery-workers",
  "component": "backend",
  "container": "backend-celery-workers-container",
  "filename": "/var/log/pods/staging-backend-api-ns_backend-celery-workers-deployment-66b879bfcc-8pw52_46eaf32d-8956-4d44-8914-7f2afeda41ad/backend-celery-workers-container/0.log",
  "hostname": "ip-10-0-42-56.ec2.internal",
  "job": "staging-backend-api-ns/backend-celery-workers",
  "logtype": "kubernetes",
  "namespace": "staging-backend-api-ns",
  "node_name": "ip-10-0-42-56.ec2.internal",
  "pod": "backend-celery-workers-deployment-66b879bfcc-8pw52",
  "stream": "stderr"
}
...

І в результаті маємо всі поля та Log Stream, який задали вище – по полю Namespace.

Ще з цікавих ендпоінтів – можливість отримати всі стріми, в логах яких є ключове слово, наприклад:

$ curl -s localhost:9428/select/logsql/streams -d "query=error" | jq
{
  "values": [
    {
      "value": "{namespace=\"ops-monitoring-ns\"}",
      "hits": 5012
    },
    {
      "value": "{namespace=\"staging-backend-api-ns\"}",
      "hits": 542
    },
...

Запити з VM UI

Тут все просто – пишемо запит в полі Log queiry, отримуємо результат.

Результат можемо сформувати в форматі Group by, Table та JSON – його ми вже бачили в HTTP API.

В форматі Group by результат виводиться по кожному стріму:

А в форматі Table – колонками по іменам полей з логів:

Синтаксис LogsQL

Взагалі, можливостей прям дуже багато – див. всі в документації LogsQL.

Але давайте глянемо хоча б основні, аби мати уяву що ми можемо робити.

Самий простий приклад запитів з LogsQL ми вже бачили – просто по слову “error“.

Аби виконати пошук по фразі – “загортаємо” її в лапки:

Сортування

Важливий нюанс – результати повертаються у довільному порядку з метою покращення перформансу, тому рекомендується використовувати sort pipe по полю _time:

_time:5m error | sort by (_time)

Comments

Дуже прикольно, що ми в запити можемо додавати коментарі, наприклад:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" # this is a comment
| unpack_json | keep path, duration, _msg, _time # and an another one comment
| stats by(path) avg(duration) avg_duration | path:!"" | limit 10

Оператори

В LogsQL вони називаються Logical filter – AND, OR, NOT.

Наприклад, використати AND можемо так – шукаємо запис, в якому є строка “Received request” та ID “dada85f9246d4e788205ee1670cfbc6f“:

"Received request" AND "dada85f9246d4e788205ee1670cfbc6f"

Або зробити пошук по “Received request” тільки зі стриму namespace="prod-backend-api-ns":

"Received request" AND _stream:{namespace="prod-backend-api-ns"}

Або по полю pod:

"Received request" AND pod:="backend-api-deployment-98fcb6bcb-w9j26"

При чому оператор AND можна не задавати явно, тобто запит:

"Received request" pod:="backend-api-deployment-98fcb6bcb-w9j26"

Буде відпрацьований аналогічно попередньому.

Але в прикладах далі я все ж буду додавати AND  для ясності.

Фільтри

Будь-який запит LogsQL має містити хоча б один фільтр.

Коли ми робимо запит на кшталт “Received request” – то фактично ми використовуємо фільтр Phrase filter, який за замовченням застосовується до поля _msg.

А в запиті _stream:{namespace="prod-backend-api-ns"} ми використовуємо Stream filter.

Інші цікаві фільтри:

  • Time filter – можна задати проміжок часу в хвилинах/годинах або датах
  • Day та Week range filter – або виконати пошук по конкретним датам чи дням тижня
  • Prefix filter – пошук по неповному слову або фразі
  • Case-insensitive filter – пошук без урахування регістру
  • Regexp filter – регулярні вирази в пошуку
  • IPv4 range filter – це прям кілер-фіча – готовий фільтрі для IP-адрес

Давайте швиденько глянемо кілька прикладів.

Time filter

Вибрати всі записи за останню хвилину:

"Received request" AND _time:1m

Або за 1.09.2024:

"Received request" AND _time:2024-09-01

Або за проміжок часу – 30-го серпня по 2 вересня включно:

"Received request" AND _time:[2024-08-30, 2024-09-02]

Або без записів за 2024-08-30 – тобто, починаючи з 31-го числа – міняємо [ на (:

"Received request" AND _time:(2024-08-30, 2024-09-02]

Day range filter

Фільтри по годинам дня.

Наприклад, всі записи між 14:00 і 18:00 сьогодні:

"Received request" AND _time:day_range[14:00, 18:00]

Аналогічно до Time filter – використовуємо () та [] аби включити або виключити початок чи кінець range.

Week range filter

Подібний до Day range filter, але по днях тижня:

"Received request" AND _time:week_range[Mon, Fri]

Prefix filter

За допомогою “*” вказуємо, що нам потрібні всі логи, які починаються з фрази “ForkPoolWorker-1” – тобто, всі воркери з 1, 12, 19 і т.д:

"ForkPoolWorker-1"*

Аналогічно можемо використовувати цей фільтр для значень в полях записів.

Наприклад, вибрати всі записи, де поле container має значення “backend-celery“:

app:"backend-celery-"*

Або ж використати Substring filter:

app:~"backend-celery"

Regexp filter

Пошук з регуляркою, також можна комбінувати з Substring filter.

Наприклад, знайти всі записи з “Received request” АБО “ForkPoolWorker“:

~"Received request|ForkPoolWorker"

Pipes

Ще цікава можливість в LogsQL – використання pipes, через які можна виконувати додаткові операції.

Наприклад, в Grafana мені доволі часто потрібно було робити перейменування імені поля з метрики або лога.

З LogsQL це можна зробити за допомогою | copy або | rename:

  • є поле logtype: kubernetes
  • хочемо його зробити source: kubernetes

Виконуємо такий запит:

~"ForkPoolWorker" | rename logtype as source

Інші цікаві pipes:

  • delete pipe: видалити поле з результатів
  • extract pipe: створити нове поле зі значенням із записів в логах
  • field_names pipe: поверне всі поля з додавання кількості записів
  • fields pipe: повернути в результатах тільки обрані поля
  • filter pipe: фільтрувати результати з додатковими умовами
  • limit pipe: вивести тільки зазначені кількість результатів (див. також top)
  • math pipe: виконати математичні операції
  • offset pipe: теж прикольна штука – зробити “зміщення” на кількість результатів
  • pack_json pipe: “запакувати” всі поля з результатів в JSON (див. також pack_logfmt та unpack_json)
  • replace pipe: замінити слово/фразу в результатах на інше (маскувати паролі)
  • sort pipe: операції сортування в результатах
  • stats pipe: вивести статистику

Я вже не буду тут описувати приклади, бо в цілому вони – і багато іншого – є в документації, але давайте глянемо приклад запиту для Loki, і спробуємо переписати його для VictoriaLogs – і там як раз спробуємо pipes в ділі.

Приклад: Loki to VictoriaLogs query

Є у нас такий запит для Loki RecordingRules:

- record: eks:pod:backend:api:path_duration:avg
  expr: |
    topk (10,
        avg_over_time (
            {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
        ) by (domain, path, node_name)
    )

З логів Kubernetes Pods нашого бекенду створює метрику eks:pod:backend:api:path_duration:avg, в якій відображає середній час відповіді по ендпоінтам.

В ньому маємо:

  • вибираємо логи з лог-стріма app="backend-api"
  • логи пишуться в JSON, тому використовуємо json парсер
  • потім з regex parser створюємо поле domain зі значенням після “https://
  • з line_format отримуємо поля path та duration
  • з unwrap “витягуємо” значення з duration
  • рахуємо середнє значення з duration за допомогою оператора avg_over_time() за останні 5 хвилин, групуючи по полям domain, path, node_name – вони потім використовуються в алертах і графіках Grafana
  • збираємо інформацію по топ-10 записів

Як ми можемо щось схоже зробити з VictoriaLogs та LogsQL?

Почнемо з фільтра по полю:

app:="backend-api"

Отримуємо всі записи з подів цієї апки.

Пам’ятаємо, що можемо використати тут регулярку, і задати фільтр як app:~"backend" – тоді будуть результати з app="backend-celery-workers", app="backend-api" і т.д.

Можна додати фільтр по стріму – тільки з продакшена:

_stream:{namespace="prod-backend-api-ns"} AND app:="backend-api"

Або просто:

namespace:="prod-backend-api-ns" AND app:="backend-api"

В наших метриках Loki неймспейс не використовується, бо фільтри в алертах і Grafana використовують ім’я домену з поля domain, але тут для приклада най буде.

Далі нам треба створити поля domain, path та duration.

Тут можна використати або unpack_json – або extract.

unpack_json розпарсить JSON, і створить поля для запису з кожного ключа в JSON:

  • в документації до unpack_json говориться, що краще використовувати extract pipe
  • якщо використовувати його, то запит був би | extract '"duration": <duration>,'

Але нам всі поля не потрібні – тому можемо дропнути всі, і з фільтром keep залишити тільки duration, _msg та _time:

Далі, нам потрібно створити поле domain. Але просто взяти key url який створив unpack_json із {"url": "http://api.app.example.co/coach/notifications?limit=0" ...} нам не підходить, бо потрібен тільки домен – без строки “/coach/notifications?limit=0“.

Можемо додати фільтр extract_regexpextract_regexp "https?://(?P<domain>([^/]+))":

Тепер, маючи всі три поля, можемо використати stats by() і avg по полю duration:

А аби прибрати з результатів {"path":"","domain":"","avg(duration)":"NaN"} – додаємо фільтр path:!"".

Тепер весь запит буде:

app:="backend-api" | unpack_json | keep path, duration, _msg, _time | extract_regexp "https?://(?P<domain>([^/]+))" | stats by(path, domain) avg(duration) | path:!""

Останнім додаємо ліміт в останні 5 хвилин – _time:5m, і виводимо тільки топ-10 результатів.

Я тут приберу domain і додам фільтр по namespace, аби простіше було порівняти з результатами в Loki.

Результат avg(duration) будемо писати в нове поле avg_duration.

Тепер весь запит буде таким:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | stats by(path) avg(duration) avg_duration | path:!"" | limit 10

Результат:

Замість limit можна використати top pipe – бо limit просто обмежує кількість запитів, а top обмежує саме по значенню поля:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | stats by(path) avg(duration) avg_duration | path:!"" | top 10 by (path, duration)

І можемо додати sort(), а умову path:!"" винести перед викликом stats(), аби швидше оброблювався запит:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | path:!"" | stats by(path) avg(duration) avg_duration | sort by (_time, avg_duration) | top 10 by (path, avg_duration)

Порівняємо його з результатом з Loki, наприклад – API-ендпоінт /sprint-planning/backlog/challenges в результатах VictoriaLogs у нас тут має значення 160.464981 мілісекунд.

Виконуємо аналогічний запит в Loki:

topk (10,
    avg_over_time (
        {app="backend-api", namespace="prod-backend-api-ns"} | __error__="" | json | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
    ) by (path)
)

Все сходиться.

ChatGPT, Gemini, Claude та LogsQL (але Perplexity!)

Спробував з ними переписувати запити з Loki LogQL на VictoriaMetrics LogsQL – тут все дуже пєчально.

ChatGPT взагалі прям дуже глючить, і видає оператори типу SELECT, яких взагалі нема:

Gemini трохи краще, принаймні з більш-менш реальними операторами – але все одного не той випадок, коли можна просто скопіювати і використати:

І Claude – аналогічно до ChatGPT, нічого не знає – але пропонує “щось подібне”:

А от Perplexity відповів майже вірно:

Тільки спутав порядок – by() має бути після stats().

Helm, VictoriaLogs та Grafana data source

Репозиторій та документація – victorialogs-datasource.

VictoriaLogs sub-chart installation

Давайте відразу сюди ж додамо VictoriaLogs. Нагадаю, що у нас весь стек моніторинг встановлюється з нашого власного чарту, в якому через Helm dependency додаються victoria-metrics-k8s-stack, k8s-event-logger, aws-xray і т.д.

Видаляємо встановлений вручну чарт:

$ helm -n ops-test-vmlogs-ns uninstall vlsingle

В файлі Chart.yaml описуємо ще один dependency:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.25.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-metrics-auth
  version: ~0.6.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-logs-single
  version: ~0.6.0
  repository: https://victoriametrics.github.io/helm-charts  
...

Оновлюємо сабчарти:

$ helm dependency build

Оновлюємо свої values – додамо постійний сторейдж:

...
victoria-logs-single:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 3Gi # default value, to update later
...

Деплоїмо, і перевіряємо сервіс для VictoriaLogs:

$ kk get svc | grep logs
atlas-victoriametrics-victoria-logs-single-server      ClusterIP   None             <none>        9428/TCP                     2m32s

Редагуємо конфіг Promtail – задаємо новий URL:

...
promtail:
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/loki/api/v1/push?_stream_fields=namespace
...

Підключення Grafana data source

Трохи довелось повозитись з values для Grafana, але в результаті вийшло так:

...
  grafana:
    enabled: true

    env:
      GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "victorialogs-datasource"
    ...
    plugins:
      - grafana-sentry-datasource
      - grafana-clock-panel
      - grafana-redshift-datasource
      - https://github.com/VictoriaMetrics/victorialogs-datasource/releases/download/v0.4.0/victorialogs-datasource-v0.4.0.zip;victorialogs-datasource
    additionalDataSources:
      - name: Loki
        type: loki
        access: proxy
        url: http://atlas-victoriametrics-loki-gateway:80
        jsonData:
          maxLines: 1000
          timeout: 3m
      - name: VictoriaLogs
        type: victorialogs-datasource
        access: proxy
        url: http://atlas-victoriametrics-victoria-logs-single-server:9428

Версію шукаємо на сторінці Releases, зараз остання v0.4.0.

І зверніть увагу, що версія в URL задається два рази – /releases/download/v0.4.0/victorialogs-datasource-v0.4.0.zip.

Деплоїмо, при необхідності рестартимо поди з Grafana (якщо не використовуємо щось по типу Reloader), і перевіряємо датасорси:

Пробуємо з Explore:

Все працює.

Grafana dashboards та Time series visualization

З візуалізацією в Grafana поки що не все чудово, бо потрібно додавати transformations, аби Grafana panel коректно відобразила дані.

Довелось додавати їх як мінімум чотири:

  • Extract fields: результат від VictoriaLogs ми отримуємо в JSON, і з цією трансформацією з нього витягуємо всі поля
  • Convert field type: поле ‘duration’ в JSON приходить в string, тому його треба змінити на Number
  • Sort by: сортуємо по полю ‘avg_duration’
  • Prepare time series: для конвертації результатів в формат, який зрозуміє Time series visualization panel

Без цього будемо мати помилки типу “Data is missing a number field“, “Data is missing a time field” або “Data outside time range“.

Налаштування трансформацій:

Запит для графіка такий:

app:="backend-api" namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | path:!"" | stats by(_time:1m, path) avg(duration) avg_duration

Зверніть увагу, що тут _time переміщено у виклик stats() – робити статистку по останній хвилині для кожного path.

І результат такий:

Крім того, Data source поки не дає можливості переписати Options > Legend.

Висновки

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

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

По ресурсам CPU/Memory – тут взагалі жодних питань.

Grafana data source працює, чекаємо на його реліз.

Ну і чекаємо, коли завезуть підтримку AWS S3 та аналог Loki RecordingRules, бо на сьогодні VictoriaLogs можна використовувати виключно як систему для роботи з логами – але не для графіків чи алертів.

Біда, що всякі ChatGPT толком не можуть допомогти з запитами LogsQL, бо для Loki я ними користувався доволі часто, але згодом і вони цьому навчаться. Проте Perplexity відповідає майже без помилок.

Отже, з плюсів:

  • працює дійсно швидше, і дійсно НАБАГАТО менше споживає ресурсів
  • LogsQL приємний, багато можливостей
  • документація у VictoriaMetrics завжди досить детальна, з прикладами, добре структурована
  • підтримка у VictoriaMetrics теж чудова – і в GitHub Issues, і в Slack, і в Telegram – завжди можна поставити питання, і досить швидко отримати відповідь
  • на відміну від Grafana Loki – VictoriaLogs має власний Web UI, і як на мене – то це жирний плюс

З відносних мінусів:

  • і VictoriaLogs і Grafana data source все ще в Beta – тому можливі і якісь неочікувані проблеми, і не всі можливості поки що реалізовані
    • але знаючи команду VictoriaMetrics – вони досить швидко все роблять
  • відсутність RecordingRules та підтримки AWS S3 – це наразі те, що блокує особисто мене від того, аби повністю видалити Grafana Loki
    • але всі основні плюшки мають завезти до кінця 2024
  • ChatGPT/Gemini/Claude прям зовсім погано знають LogsQL, тому на їх допомогу очікувати не треба
    • але є допомога в Slack, і в Telegram самої VictoriaMetrics – і від комьюніті, і від команди розробників, ну і Perplexity непогано справляється

Loading

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager
0 (0)

21 Серпня 2024

Маємо на проекті новий EKS кластер 1.30, на якому хочемо повністю відмовитись від старого IRSA з OIDC і почати користуватись EKS Pod Identities – див. AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів.

І все наче працює чудово, але коли почав деплоїти наш Backend API – поди не стартують, і висять в статусі ContainerCreating.

Проблема: Kubernetes Secrets Store CSI Driver та “An IAM role must be associated with service account”

Сікрети для бекенду зберігаються в AWS Secrets Manager, звідки за допомогою Kubernetes Secrets Store CSI Driver синхронізуються в Kubernetes Secrets, з яких поди Бекенду створюють свої змінні оточення – див. AWS: Kubernetes – інтеграція AWS Secrets Manager та Parameter Store.

Перевіряємо статус поду – і бачимо чудове повідомлення “An IAM role must be associated with service account“:

Warning  FailedMount  43s (x2 over 2m45s)  kubelet  MountVolume.SetUp failed for volume "backend-api-secret-class-volume" : rpc error: code = Unknown desc = failed to mount secrets store objects for pod dev-backend-api-ns/backend-api-deployment-65c559d47-bb4dz, err: rpc error: code = Unknown desc = us-east-1: An IAM role must be associated with service account backend-api-sa (namespace: dev-backend-api-ns)

Google приводить нас до GitHub Issue Pod Identity Association not recognised by secrets store CSI driver, яка відкрита ще в грудні 2023.

В ній жеж є і пул-реквест Add support for Pod Identity Association, який наче має пофіксити цю проблему – але і він досі в Open, хоча декілька днів тому додали комент, що “We are conducting initial investigation on this feature request and will share updates soon“.

І драйвер зараз останньої версії – 0.3.9:

$ helm list -n kube-system | grep secret
secrets-store-csi-driver                kube-system     1               2024-07-18 12:46:20.945937022 +0300 EEST        deployed        secrets-store-csi-driver-1.4.4                  1.4.4      
secrets-store-csi-driver-provider-aws   kube-system     1               2024-07-18 12:46:23.287242734 +0300 EEST        deployed        secrets-store-csi-driver-provider-aws-0.3.9                

То що робити?

Варіант перший – це знов додавати OIDC і стару схему. Але цього прям зовсім не хочеться, бо на новому Kubernetes-кластері хотілося б вже і повністю нову аутентифікацію, а не ліпити костилі, які потім треба буде випилювати.

Варіант другий – спробувати перейти з  Kubernetes Secrets Store CSI Driver на External Secrets Operator, який вміє працювати з купою різних провайдерів – AWS Secrets Manager, Hashicorp Vault, Google Secrets Manager тощо.

Крім того, мені не дуже подобається те, що для створення Kubernetes Secret за допомогою Kubernetes Secrets Store CSI Driver, в його SecretProviderClass треба окремо описувати objects, а потім їх фактично дублювати в secretObjects.

External Secrets Operator: знайомство

Отже, External Secrets Operator (ESO) вміє отримувати сікрети із зовнішніх ресурсів і створювати звичайні Kubernetes Secrets.

Для доступу в AWS він використовує стандартну схему з ServiceAccount, а значить ми можемо створити EKS Pod Identity Association на AWS IAM Role, яка буде давати доступ до AWS Secrets Manager.

External Secrets Operator використовує два основні ресурси:

  • SecretStore: описує як саме отримати доступ до секретів – який провайдер (AWS, Google, Vault, etc) та аутентифікація, і створюється на рівні окремого Kubernetes Namespace для розподілення доступів
    • також є ClusterSecretStore, який можна створити глобально і доступний з будь-якого Namespace
  • ExternalSecret: описує які дані отримати від провайдера, і при потребі – які зміни зробити

External Secrets Operator має цілу купу зовнішніх провайдерів – див. Provider.

В AWS вміє працювати як з самим SecretsManager, та і ParameterStore.

Крім того, External Secrets Operator може навіть вносити зміни в AWS Secrets Manager – але ми будемо його використовувати тільки для створення Kubernetes Secrets, бо самі секрети в AWS Secrets Manager створюються з Terraform кожного проекту.

Отже, наша задача:

  • встановити External Secrets Operator
  • налаштувати йому доступ до AWS з AWS IAM Role та Kubernetes ServiceAccount використовуючи EKS Pod Identities
  • і створити Kubernetes Secret, який ми зможемо підключити в Kubernetes Pod, аби задати потрібні environment variables для роботи нашого сервісу

Запуск External Secrets Operator з Helm

Додаємо репозиторій:

$ helm repo add external-secrets https://charts.external-secrets.io
"external-secrets" has been added to your repositories

Сам Helm-чарт і доступні values – external-secrets.

Встановлюємо чарт з оператором в ops-external-secrets-ns Namespace:

$ helm install -n ops-external-secrets-ns --create-namespace external-secrets external-secrets/external-secrets

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

$ kk -n ops-external-secrets-ns get pod
NAME                                                READY   STATUS    RESTARTS   AGE
external-secrets-5859d8dc69-vxhjb                   1/1     Running   0          33s
external-secrets-cert-controller-5bbb8c4bb8-nmjn9   1/1     Running   0          33s
external-secrets-webhook-564cd5b69-r5mmb            1/1     Running   0          33s

Та ServiceAccounts:

$ kk -n ops-external-secrets-ns get sa 
NAME                               SECRETS   AGE
default                            0         10m
external-secrets                   0         9m59s
external-secrets-cert-controller   0         9m59s
external-secrets-webhook           0         9m59s

Нас тут цікавить ServiceAccount external-secrets – оператор буде використовувати його для доступу до Secrets Manager та Parameter Store, і його ми будемо підключати до EKS з Pod Indentity.

Аутентифікація з AWS IAM

Що нам потрібно:

  • IAM Policy, яка надає доступ до Secrets Manager та Parameter Store
  • IAM Role з Trust Policy для EKS Pod Indentity
    • і до цієї ролі підключимо IAM Policy

Тоді под з External Secrets Operator через Kubernetes ServiceAccount буде виконувати Assume цієї ролі, і отримувати доступ до секретів.

Поки зробимо найпростішою схемою, а далі подивимось, як додатково можна розділяти доступи через окремі IAM Roles для кожного SecretStore в різних неймспейсах.

Створення IAM Policy

Переходимо в IAM, створюємо нову IAM Policy, дозволяємо тільки read-операції:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAccessToSecretsManager",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:ListSecrets",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": [
                "arn:aws:secretsmanager:<AWS_REGION>:<AWS_ACCOUNT_ID>:secret:*"
            ]
        },
        {
            "Sid": "AllowAccessToParameterStore",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameters",
                "ssm:GetParameter",
                "ssm:GetParametersByPath"
            ],
            "Resource": [
                "arn:aws:ssm:<AWS_REGION>:<AWS_ACCOUNT_ID>:parameter/*"
            ]
        }
    ]
}

Зберігаємо з ім’ям external-secrets-operator-test-policy:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створення IAM Role для EKS Pod Identity

Переходимо в IAM Role, створюємо нову роль, в Use case вибираємо EKS – Pod Identity:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Підключаємо створену вище IAM Policy external-secrets-operator-test-policy:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Зберігаємо нову роль з ім’ям external-secrets-operator-test-role, в Trust policy маємо "Service": "pods.eks.amazonaws.com":

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створення EKS Pod Identity Association

Тепер підключаємо цю роль до нашого EKS-кластеру atlas-eks-ops-1-30-cluster в неймспейс ops-external-secrets-ns до Kubernetes ServiceAccount з ім’ям external-secrets:

$ aws --profile work eks create-pod-identity-association --cluster-name atlas-eks-ops-1-30-cluster \
> --role-arn arn:aws:iam::492***148:role/external-secrets-operator-test-role \
> --namespace ops-external-secrets-ns \
> --service-account external-secrets

Перевіряємо в EKS > Access:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Окей – тут все готово. Тепер ESO має отримати доступ до секретів та параметрів.

Далі нам потрібен SecretStore, який буде описувати як отримати доступ до AWS Secrets Manager або Parameter Store, та ExternalSecret, який власне буде відповідати за Kubernetes Secrets.

Створення Kubernetes Secrets з AWS Secrets Manager

Документація по всім значенням для ресурсів Operator – в API specification.

Створення SecretStore

Тестити будемо в окремому Namespace ops-test-ns.

Пишемо маніфест для SecretStore:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: test-secret-store
  namespace: ops-test-ns
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1

Створюємо ресурс:

$ kk apply -f test-secretstore.yml 
secretstore.external-secrets.io/test-secret-store created

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

$ kk get secretstore
NAME                AGE   STATUS   CAPABILITIES   READY
test-secret-store   29s   Valid    ReadWrite      True

Створення ExternalSecret

Тепер нам потрібен ExternalSecret, який буде використовувати створений вище SecretStore.

Створимо тестовий секрет в AWS Secrets Manager:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Зберігаємо його з ім’ям test-aws-secret:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

І маємо секрет зі значенням {"secret_key_1":"secret_value_1","secret_key_2":"secret_value_2"}:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Спочатку отримаємо всю строку, а потім подивимось, як її можна додавати в Kubernetes Secret.

Описуємо маніфест для ExternalSecret:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-external-secret
  namespace: ops-test-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
    creationPolicy: Owner
    deletionPolicy: Delete
    template:
      metadata:
        labels:
          app: test
  data:
    - secretKey: key_in_the_kubernetes_secret
      remoteRef:
        # AWS Secrets Manager secret's name
        key: test-aws-secret

Тут в spec:

  • refreshInterval: як часто перевіряти зміни в AWS Secrets Manager
  • secretStoreRef: який SecretStore використовувати для доступу в AWS Secrets Manager (можна вказати окремо в data.sourceRef.storeRef)
  • target:
    • name: ім’я Kubernetes Secret, який буде створено
    • creationPolicy: “власник” Kubernetes Secret:
      • Owner: видаляє Kubernetes Secret, якщо видаляється відповідний ExternalSecret
      • Merge: не створює Kubernetes Secret, а міняє записи в існуючому
      • Orphan: залишає Kubernetes Secret, якщо видаляється відповідний ExternalSecret
    • deletionPolicy:
      • Retain: залишає Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля
      • Delete: видаляє Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля
      • Merge: видаляє всі записи в Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля, але залишає сам Kubernetes Secret
    • template: описує структуру Kubernetes Secret, який буде створено – type, labels, annnotations, etc
  • data: описує зв’язок між секретом в AWS Secrets Manager та Kubernetes Secret:
    • secretKey: ім’я key в Kubernetes Secret
    • remoteRef:
      • key: ім’я секрету в AWS Secrets Manager

Створюємо ресурс:

$ kk apply -f test-externalsecret.yml 
externalsecret.external-secrets.io/test-external-secret created

Перевіряємо його статус:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS         READY
test-external-secret   test-secret-store   1h                 SecretSynced   True

STATUS == SecretSynced – тобто, External Secrets Operator зміг отримати значення з AWS Secrets Manager та створити Kubernetes Secret.

При проблемах можна глянути логи ESO:

$ ktail -n ops-external-secrets-ns -l app.kubernetes.io/instance=external-secrets

Перевіряємо сам Kubernetes Secret:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  key_in_the_kubernetes_secret: eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0=
...

І строка “eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0=” дає нам весь секрет з AWS Secrets Manager:

$ echo eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0= | base64 -d
{"secret_key_1":"secret_value_1","secret_key_2":"secret_value_2"}

Але в такому вигляді воно нам не дуже корисно, тому можемо зробити інакше – додати в data параметр property, в якому вказати конкретний ключ з секрету:

data:
  - secretKey: secret_key_1_value
    remoteRef:
      # AWS Secrets Manager secret's name
      key: test-aws-secret
      property: secret_key_1

Тоді наш Kubernetes Secret вже буде виглядати так:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  secret_key_1_value: c2VjcmV0X3ZhbHVlXzE=
...

Де “c2VjcmV0X3ZhbHVlXzE=” – це значення “secret_value_1“.

Або замість того, щоб описувати кожен ключ з AWS Secrets Manager – в нашому ExternalSercret замість spec.data можемо використати spec.dataFrom:

...
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
    creationPolicy: Owner
  ...     
  dataFrom:
  - extract:
      key: test-aws-secret
...

І тоді наш Kubernetes Secret буде виглядати так:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  secret_key_1: c2VjcmV0X3ZhbHVlXzE=
  secret_key_2: c2VjcmV0X3ZhbHVlXzI=
...

Де “c2VjcmV0X3ZhbHVlXzE=” – це значення “secret_value_1“, а “c2VjcmV0X3ZhbHVlXzI=” – “secret_value_2“.

Або можемо переіменувати ключі, наприклад:

...
  dataFrom:
  - extract:
      key: test-aws-secret
    rewrite:
    - regexp:
        source: "secret_key_([0-9])"
        target: "SECRET_KEY_${1}"

І результат:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  SECRET_KEY_1: c2VjcmV0X3ZhbHVlXzE=
  SECRET_KEY_2: c2VjcmV0X3ZhbHVlXzI=
...

Advanced IAM Permissions per SecretStore

Тепер давайте глянемо, як ми можемо використовувати IAM Role з окремою IAM Policy на рівні SecretStore.

Тобто замість того, аби мати одну IAM Role з IAM Policy, яка надає доступ до всіх секретів в AWS Secrets Manager, і яку використовує наш External Secrets Operator – ми можемо створити окрему IAM Role, її підключити до конкретного SecretStore в конкретному Kubernetes Namespace, і тоді цей SecretStore буде мати доступ тільки до тих секретів, які описані у відповідній IAM Policy.

Схематично це можна відобразити так:

AWS: Kubernetes та External Secrets Operator для AWS Secrets ManagerОтже, що зробимо:

  • в ролі external-secrets-operator-test-role, яка через EKS Pod Identity підключена до ServiceAccount external-secrets відключимо IAM Policy, яка дає доступ до всіх AWS Secrets
  • створимо нову IAM Policy external-secrets-operator-test-application-policy, яка буде надавати дозвіл тільки до конкретного секрету
  • створимо нову IAM Role external-secrets-operator-test-application-role:
    • їй в Trust Policy дозволимо виконувати Assume від імені ролі external-secrets-operator-test-role
    • і до цієї ролі підключимо IAM Policy external-secrets-operator-test-application-policy
  • а до SecretStore додамо параметр з role: external-secrets-operator-test-application-role

Тоді External Secrets буде працювати так:

  • Kubernets Pod з External Secrets через EKS Pod Identity виконує AssumeRole external-secrets-operator-test-role
  • при створенні ExternalSecret він використає SecretStore, в якому задана external-secrets-operator-test-application-role
    • External Secrets з external-secrets-operator-test-role виконає другий AssumeRole – “візьме” роль external-secrets-operator-test-application-role з її IAM Policy external-secrets-operator-test-application-policy
    • і вже з цієї роллю отримає доступ до секрету в AWS Secrets Manager

Поїхали.

В IAM Role external-secrets-operator-test-role видаляємо підключену IAM Policy, яка давала повний доступ до AWS Secrets Manager:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створимо нову IAM Policy external-secrets-operator-test-application-policy з доступом до одного конкретного секрету test-aws-secret:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAccessToSecretsManager",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:ListSecrets",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": [
                "arn:aws:secretsmanager:us-east-1:492***148:secret:test-aws-secret*"
            ]
        }
    ]
}

Зберігаємо:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створюємо нову IAM Role з Custom trust policy, де дозволяємо виконувати Assume від ролі external-secrets-operator-test-role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Statement1",
      "Effect": "Allow",
      "Action": [
          "sts:AssumeRole",
           "sts:TagSession"
         ],
 			"Principal": {
 			    "AWS": "arn:aws:iam::492***148:role/external-secrets-operator-test-role"
 			}
    }
  ]
}

До цієї ролі підключаємо нову політику:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

І зберігаємо нову роль як external-secrets-operator-test-application-role:

Тепер повертаємось до нашого SecretStore, і в spec.provider.aws додаємо параметр role:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: test-secret-store
  namespace: ops-test-ns
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      role: arn:aws:iam::492***148:role/external-secrets-operator-test-application-role

Оновлюємо SecretStore :

$ kk apply -f test-secretstore.yml 
secretstore.external-secrets.io/test-secret-store configured

Для перевірки видалимо старий ExternalSecret:

$ kk delete externalsecret test-external-secret
externalsecret.external-secrets.io "test-external-secret" deleted

Створимо ще раз:

$ kk apply -f test-externalsecret.yml 
externalsecret.external-secrets.io/test-external-secret created

І дивимось статус:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS         READY
test-external-secret   test-secret-store   1h                 SecretSynced   True

А тепер давайте спробуємо використати інший секрет з AWS Secrets Manager – “test/rds/kraken“, до якого ми не давали дозволу в IAM Policy external-secrets-operator-test-application-policy:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-external-secret
  namespace: ops-test-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
  ...
  dataFrom:
  - extract:
      key: test/rds/kraken

Деплоїмо, перевіряємо:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS              READY
test-external-secret   test-secret-store   1h                 SecretSyncedError   False

І тепер STATUS == SecretSyncedError.

В логах це добре видно – “external-secrets-operator-test-application-role is not authorized to perform: secretsmanager:GetSecretValue on resource: test/rds/kraken“:

external-secrets-5859d8dc69-2fgc8:external-secrets {... "msg":"could not get secret data from provider","ExternalSecret":{"name":"test-external-secret","namespace":"ops-test-ns"},"error":"AccessDeniedException: User: arn:aws:sts::492***148:assumed-role/external-secrets-operator-test-application-role/1724248118674412261 is not authorized to perform: secretsmanager:GetSecretValue on resource: test/rds/kraken because no identity-based policy allows the secretsmanager:GetSecretValue action\n\tstatus code: 400 ...}

Висновки

Поки що мені External Secrets Operator прям дуже сподобався.

По-перше – дійсно набагато менше коду маніфестів для створення ресурсів.

По-друге – він нормально працює з EKS Pod Identity.

Третє – це дуже гнучка система розподілення доступів до секретів.

Четверте – що за допомогою одного оператора можна створювати Kubernetes Secrets з різних провайдерів.

Та і взагалі простіша система, бо не потрібно мати DaemonSet з подами на кожній WorkerNode, як це реалізовано в secrets-store-csi-driver-provider-aws.

Виглядає дуже прикольно, тому будемо мігрувати на нього.

Loading

AWS: IAM Access Analyzer policy generation – створення IAM Policy
0 (0)

24 Липня 2024

Доволі частий кейс, коли на новому проекті, який тільки створює свою інфраструктуру і CI/CD, робиться це як MVP/PoC, і на початку на тюнінг AWS IAM Roles та IAM Policies час не витрачається, а просто підключається AdministratorAccess.

Власне, саме так відбувалось і в моєму проекті, але ми ростемо, і прийшов час навести лад в IAM.

Проблема і задача

Отже, маємо GitHub Actions джоби, які деплоять інфрастуктуру з Terraform.

Для доступу до AWS з GitHub використовується Identity Provider з IAM Role: GitHub Actions Worker при старті джоби виконує аутентифікацію та авторизацію в AWS з заданою IAM Role, і потім запускає власне деплой з Terraform.

Для IAM Role зараз підключена політика AdministratorAccess, і наша задача – написати нову fine grained політику, де б не було зайвих доступів.

Варіант перший – це створити пусту політику, підключити її до ролі замість AdministratorAccess, і раз за разом запускати джобу дивлячись на помилки в логах:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

А потім по черзі додавати дозволи, наприклад lambda:ListVersionsByFunction.

Варіант другий – це використати IAM Access Analyzer policy generation:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Він використає CloudTrail events для конкретної ролі та створить IAM Policy в якій будуть тільки ті API-виклики, які дійсно робились цією роллю.

Окрім IAM Access Analyzer є цікава тулза iann0036/iamlive, але в нашому випадку вона не дуже підходить, бо IAM Role використовується в GitHub Actions з AWS Indetity Provider.

Давайте глянемо, як налаштувати IAM Access Analyzer policy generation – створимо CloudTrail, IAM Role, напишемо Terraform-код який буде створювати ресурси, а потім перевіримо які політики нам запропонує  Access Analyzer.

Створення CloudTrail Trail

Перше, що нам буде потрібно – це створити CloudTrail Trail, який буде логувати дії. Детальніше про CloudTrail писав в AWS: CloudTrail – обзор и интеграция с CloudWatch и Opsgenie, але зараз нам цікаві тільки типи івентів, які він вміє записувати:

  • Management events: все, що стосується змін в ресурсах – створення EC2, VPC, зміни в SecurtyGroups тощо
  • Data events: все, що стосується даних – створення об’єктів в S3-бакетах, зміни в таблицях DynamoDB, виклики Lambda-функцій

Отже, якщо наш Terraform-код займається тільки створенням ресурсів в AWS – то має вистачити Management events, якщо ж він додатково виконує якісь дії з даними/об’єктами – то потрібні обидва. Можна включити всі, але майте на увазі, що CloudTrail trails не безкоштовний – див. AWS CloudTrail pricing.

Переходимо в CloudTrail > Trails, створюємо новий Trail:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Включаємо логування обох типів – просто для перевірки, в цьому випадку точно вистачило б тільки Management events:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Для Data events вибираємо які саме сервіси будемо логувати:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Переходимо до IAM.

Створення IAM Role

Додаємо нову роль з Trusted entity type == AWS Account, бо зараз тестувати будемо локально з AWS CLI від свого IAM-юзера, а не через GitHub OIDC Identity Provider:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Підключаємо AdministratorAccess:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Зберігаємо цю роль:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Налаштування AWS CLI

Тестити будемо локально, але імітуємо роботу GitHub Actions.

Що нам треба – це створити AWS CLI Profile, який буде виконувати AssumeRole, яку ми створили, а потім з цим профайлом Terraform буде створювати ресурси в AWS.

В файлі ~/.aws/config додаємо новий профайл:

[profile iam-test]
region = us-east-1
role_arn = arn:aws:iam::492***148:role/iam-generator-test-TO-DEL
source_profile = work

source_profile = work тут – це мій робочий профайл, в якому задані Access та Secrets keys.

Перевіряємо, чи працює IAM Role Assume:

$ aws --profile iam-test s3 ls
2023-02-01 13:29:34 amplify-staging-112927-deployment
2023-02-02 17:40:56 amplify-dev-174045-deployment
...

Окей – корзини бачимо, доступ працює.

Створення Terraform

Напишемо простий код, який буде створювати S3 бакет використовуючи створений вище IAM CLI Profile iam-test (пам’ятаємо, що ім’я бакету має бути унікальним для заданого AWS Region, інакше AWS спробує створити корзину в іншому регіоні):

provider "aws" {
  region = "us-east-1"
  profile = "iam-test"
}

resource "aws_s3_bucket" "my_bucket" {
  bucket = "blablabla-bucket-iam-test-to-del"

  force_destroy = true
}

Робимо terraform init та terraform plan:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

І запускаємо terraform apply:

...
aws_s3_bucket.my_bucket: Creating...
aws_s3_bucket.my_bucket: Creation complete after 3s [id=blablabla-bucket-iam-test-to-del]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Використання IAM Access Analyzer policy generation

Краще зачекати хвилин 5 після запуску Terraform, аби CloudTrail встиг записати всі події, а потім можемо згенерувати IAM Policy для цієї ролі:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Вибираємо період, регіон та створений раніше Trail:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Чекаємо 5-10 хвилин, поки проаналізуються логи CloudTrail (можна перезавантажувати сторінку з F5, бо іноді Status сам не оновлюється):

AWS: IAM Access Analyzer policy generation - створення IAM Policy

І дивимось які політики нам пропонуються:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Ціла купа, і основна для нашого тесту – s3:CreateBucket.

Клікаємо Next, і маємо саму політику в JSON:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Зверніть увагу, що Access Analyzer створив окремі правила на API-виклики, які стосуються всіх бакетів – s3:ListAllMyBuckets, і окремі правила для викликів, які стосуються конкретного бакету/бакетів – s3:CreateBucket.

При цьому в Resource використовується ${BucketName}, який ми можемо замінити на своє значення:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Зберігаємо та підключаємо цю політику:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

AWS: IAM Access Analyzer policy generation - створення IAM Policy

І тепер можемо відключити AdministratorAccess.

Але маємо на увазі, що ми виконували тільки створення ресурсів і, відповідно, виконувались API-виклики пов’язані тільки зі створенням корзини.

Тобто, якщо ми зараз приберемо AdministratorAccess і залишимо тільки цю нову політику – то виконати terraform destroy не зможемо, бо, по-перше – у нас нема права на s3:DeleteBucket, по-друге – при видаленні корзини AWS має перевірити чи нема в ній об’єктів, а для цього виконується операція s3:ListBucket – тому отримаємо помилку operation error S3: HeadBucket:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

Тож треба виконати всі дії з Terraform, а вже після цього генерувати політику:

AWS: IAM Access Analyzer policy generation - створення IAM Policy

І потім відключати AdministratorAccess. Але навіть в такому випадку s3:ListBucket (для S3: HeadBucket) треба додавати вручну.

Хоча це вже проблема більш специфічна саме до S3, але може бути подібна і з іншими ресурсами.

Loading

Terraform: менеджмент EKS Access Entries та EKS Pod Identities
5 (1)

15 Липня 2024

Отже, маємо кластер AWS Elastic Kubernetes Service з Authentication mode EKS API and ConfigMap, який ми включили під час апгрейду Terraform-модуля з версії 19.21 на 20.0.

Перед тим, як переключати EKS Authentication mode повністю на API – нам потрібно з aws-auth ConfgiMap перенести всіх юзерів і ролі в Access Entries EKS-кластера.

І ідея зараз така, щоб створити окремий проект Terraform, назвемо його “atlas-iam“, в якому ми будемо менеджити всі IAM-доступи – і для EKS з Access Entries та Pod Identities, і для RDS з IAM database authentication, і, можливо, потім сюди ж перенесемо і юзер-менеджмент взагалі.

Всі три схеми розбирав детальніше:

Не знаю, наскільки описана нижче схема зайде нам в майбутньому Production, але в цілому мені ідея поки що подобається – окрім проблеми з динамічними іменами для EKS Pod Identities.

Отже, поглянемо як ми з Terraform можемо реалізувати автоматизацію управління доступом для IAM Users та IAM Roles до EKS Cluster з EKS Access Entries, та як у Terraform можна створювати EKS Pod Identities для ServiceAccounts. Про додавання RDS сьогодні говорити не будемо – але його будемо мати на увазі при плануванні.

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

EKS Authentification та IAM: the current state

Зараз в aws-auth ConfgiMap зараз маємо:

  • IAM Users: звичайні юзери, які ходять в Kubernetes
  • IAM Roles: ролі для доступу в кластер з GitHub Actions

І всі з правами system:master – заодно наведемо трохи порядок в цьому.

Окремо зараз в проектах (окремі репозиторії для Backend API, моніторинг і т.д.) для відповідних Kubernetes Pods створюються IAM Roles та Kubernetes ServiceAccounts, які теж хочеться звідти винести в цей новий проект, і управляти з одного місця з EKS Pod Identity associations.

Тож наразі у нас по EKS дві задачі:

  • створити EKS Authentification API Access Entries для юзерів та GitHub ролей
  • створити Pod Identity associations для ServiceAccounts

Планування проекту

Головне питання тут – на якому рівні будемо менеджити? На рівні AWS-акаунтів – чи на рівні EKS-кластерів/RDS? А від цього будуть залежати і структура коду, і змінні.

Multiple AWS accounts з одним EKS та RDS оточеннями

Варіант 1 – якщо маємо декілька AWS-акаунтів, то можемо управляти на рівні акаунтів: тоді для кожного акаунта можемо створити змінну зі списком EKS-кластерів, змінну зі списком юзерів/ролей, і потім в циклах створювати відповідні ресурси.

Тоді структура файлів Terraform може бути такою:

  • providers.tf: описуємо AWS provider з assume_role для AWS-акаунту Dev/Staging/Prod (або використовуємо якийсь Terragrunt)
  • variables.tf: наші змінні з дефолтними значеннями
  • envs/: каталог з іменами AWS-акаунтів
    • aws-env-dev: каталог конкретного акаунту
      • dev.tfvars: значення для змінних з іменами EKS-кластерів і списком юзерів в цьому AWS-акаунті
  • eks_access_entires.tf: ресурси для EKS

І потім в коді в циклі проходимось по всім кластерам та всім юзерам, які задані в envs/aws-env-dev/dev.tfvars.

Але тут буде питання в тому, як створювати юзерів на кластерах:

  • або мати однакових юзерів і пермішени на всіх кластерах в акаунті, що, в принципі, ОК, якщо маємо AWS-мультіакаунт, і в кожному умовному AWS-Dev у нас тільки EKS-Dev
  • або створювати декілька ресурсів access_entries – під кожен кластер окремо, і вже в access_entries в циклі проходитись по групам юзерів для конкретного кластеру – якщо в AWS-Dev у нас окрім EKS-Dev якісь додаткові кластери EKS, де треба мати окремий набір юзерів

В моєму випадку у нас поки що один AWS-акаунт  з одним Kubernetes-кластером, але пізніше скоріш за все ми будемо їх розділяти та створювати окремі акаунти під Ops/Dev/Staging/Prod. Тоді просто додамо нове оточення в каталог envs/, і там опишемо новий EKS-кластер(и).

Multiple EKS та RDS clusters в одному AWS-акаунті

Варіант 2 – якщо AWS account один на всі EKS-оточення, то можна всім управляти на рівні кластерів, і тоді структура може бути такою:

  • providers.tf: описуємо AWS provider з assume_role для AWS-акаунту Main
  • variables.tf: наші змінні з дефолтними значеннями
  • envs/: каталог з іменами EKS-кластерів
    • dev/: каталог для EKS Dev
      • dev-eks-cluster.tfvars – зі списком юзерів EKS Dev
      • dev-rds-backend.tfvars – зі списком юзерів RDS Dev
    • prod/
      • prod-eks-cluster.tfvars – зі списком юзерів EKS Prod
      • prod-rds-backend.tfvars – зі списком юзерів RDS Prod
  • eks_access_entires.tf: ресурси для EKS

Можна взагалі все “огорнути” в модулі, і потім в циклах викликати саме модулі.

User permissions

Крім того, ще подумаємо про те, які права яким юзерам можуть знадобитись? Це треба, аби далі продумати структуру змінних і цикли for_each в ресурсах:

  • група devops: будуть кластер-адмінами
  • група backend:
    • права edit в усіх Namespaces з іменами “backend
    • права read-only на якісь обрані неймспейси
  • група qa:
    • права edit в усіх Namespaces з іменами “qa
    • права read-only на якісь обрані неймспейси

Тепер, маючи уявлення про те, що нам треба – можна починати створювати файли Terraform і формувати змінні.

Структура файлів Terraform

У нас вже склалась однакова схема на всіх проектах, і новий буде виглядати так – аби не ускладнювати поки що вирішив без модулів, далі побачимо:

$ tree terraform/
terraform/
|-- Makefile
|-- backend.tf
|-- envs
|   `-- ops
|       `-- atlas-iam.tfvars
|-- iam_eks_access_entires.tf
|-- iam_eks_pod_identities.tf
|-- providers.tf
|-- variables.tf
`-- versions.tf

Тут:

  • в Makefile: команди terraform init/plan/apply – і для виклику локально, і для CI/CD
  • backend.tf: S3 для стейт-файлів, DynamoDB для state-lock
  • envs/ops: файли tfvars зі списками кластерів та юзерів в цьому AWS-акаунті
  • providers.tf: тут provider "aws" з дефолтними тегами і необхідними параметрами
    • в моєму випадку provider "aws" бере значення змінної оточення AWS_PROFILE для визначення того, на який акаунт він має підключатись, а в AWS_PFOFILE задається регіон та необхідна IAM Role
  • variables.tf: змінні з дефолтними значеннями
  • versions.tf: версії самого Terraform та AWS Provider

І сюди ж потім можна буде додати файл типу iam_rds_auth.tf.

Terraform та EKS Access Management API

Почнемо з основного – доступ юзерів.

Для цього нам потрібно:

  • є юзер, який може належати до однії з груп – devops, backend, qa
  • йому потрібно задати тип прав доступу – admin, edit, read-only
  • і ці права видати або на весь кластер – для девопсів, або на конкретний неймспейс(и) – для backend та qa

Отже, будуть два типи ресурсів – aws_eks_access_entry та eks_access_policy_association:

  • в aws_eks_access_entry: описуємо EKS Access Entity – IAM User, для якого налаштовуємо доступ, та ім’я кластера, до якого доступ буде додаватись; параметри тут будуть:
    • cluster name
    • principal-arn
  • в eks_access_policy_association – описуємо тип доступу – admin/edit/etc та scope – cluster-wide, або конкретний неймспейс; параметри тут будуть:
    • cluster-name
    • principal-arn
    • policy-arn
    • access-scope

Створення variables

Див. Terraform: знайомство з типами даних – primitives та complex.

Тож які змінні нам будуть потрібні:

  • список кластерів – тут можна просто list():
    • atlas-eks-ops-1-28-cluster
    • atlas-eks-test-1-28-cluster
  • списки з юзерами, різні списки для різних груп – можна зробити однієї змінною типу map():
    • devops:
      • arn:aws:iam::111222333:user/user-1
      • arn:aws:iam::111222333:user/user-2
    • backend:
      • arn:aws:iam::111222333:user/user-3
      • arn:aws:iam::111222333:user/user-4
  • список з EKS Cluster Access Policies – хоча вони дефолтні, і змінюватись навряд чи будуть, але задля загального шаблону давайте теж зробимо окремою змінною
  • access-scope для aws_eks_access_policy_association – перепробував різні варіанти, але в результаті зробив без овер-інжинірінгу – можемо зробити змінну типу map(object):
    • група devops:
      • aws_eks_access_policy_association буде задаватись з access_scope = cluster
    • група backend:
      • список неймпспейсів, де всі члени групи будуть мати права edit
      • список неймпспейсів, де всі члени групи будуть мати права read-only
    • група qa:
      • список неймпспейсів, де всі члени групи будуть мати права edit
      • список неймпспейсів, де всі члени групи будуть мати права read-only

І потім створимо aws_eks_access_policy_association декількома ресурсами для кожної групи окремо. Далі побачимо як саме.

Поїхали – у файлі variables.tf описуємо змінні. Поки що всі значення запишемо в defaults, потім винесемо в tfvars оточень.

Змінна eks_clusters

Спочатку змінну з кластерами – поки тут буде один, тестовий:

variable "eks_clusters" {
  description = "List of EKS clusters to create records"
  type = set(string)
  default = [
    "atlas-eks-test-1-28-cluster"
  ]
}

Змінна eks_users

Додаємо список юзерів в трьох групах:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    devops = [
      "arn:aws:iam::492***148:user/arseny"
    ],
    backend = [
      "arn:aws:iam::492***148:user/oleksii",
      "arn:aws:iam::492***148:user/test-eks-acess-TO-DEL"
    ],
    qa = [
      "arn:aws:iam::492***148:user/yehor"
    ],
  }
}

Змінна eks_access_scope

Список для aws_eks_access_policy_association і access-scope, як це може виглядати:

  • ім’я команди
    • список неймспейсів на які будуть права admin
    • список неймспейсів на які будуть права edit
    • список неймспейсів на які будуть права read-only

Тож можна зробити щось накшталт такого:

variable "eks_access_scope" {
  description = "EKS Namespaces for teams to grant access with aws_eks_access_policy_association"
  type = map(object({
    namespaces_admin     = optional(set(string)),
    namespaces_edit      = optional(set(string)),
    namespaces_read_only = optional(set(string))
  }))
  default = {
    backend = {
      namespaces_edit      = ["*backend*", "*session-notes*"],
      namespaces_read_only = ["*ops*"]
    },
    qa = {
      namespaces_edit      = ["*qa*"],
      namespaces_read_only = ["*backend*"]
    }
  }
}

Тут:

  • backend мають права edit доступ на всі Namespaces і іменами “*backend*” та “*session-notes*“, і права read-only на неймспейси з “*ops*” – наприклад, доступ в Namespace “ops-monitoring-ns“, куди бекенд-тіма інколи заходить.
  • qa мають права edit на всі Namespaces і іменами “*qa*“, і права read-only на неймспейси Backend API

І тоді ми в принципі досить гнучко зможемо додавати першмішени для кожної групи юзерів.

Зверніть увагу, що в type ми задаємо optional(set()) – бо група юзерів може не мати якоїсь групи неймспейсів.

Змінна eks_access_policies

Останнім додаємо список з дефолтними політиками:

variable "eks_access_policies" {
  description = "List of EKS clusters to create records"
  type = map(string)
  default = {
    cluster_admin = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy",
    namespace_admin = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy",
    namespace_edit = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy",
    namespace_read_only = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy",
  }
}

Створення aws_eks_access_entry

Додаємо перший ресурс – aws_eks_access_entry.

Аби в одному циклі створювати aws_eks_access_entry і для кожного кластера зі списку eks_clusters, і для кожного юзерів з кожної групи – створимо три змінних в locals:

locals {
  eks_access_entries_devops = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.devops : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
  eks_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
  eks_access_entries_qa = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.qa : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

А потім їх використаємо в resource "aws_eks_access_entry" з for_each, яким сформуємо map() з key=idx та value=entry:

resource "aws_eks_access_entry" "devops" {
  for_each = { for idx, entry in local.eks_access_entries_devops : idx => entry }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

Тут у for_each буде формуватись список типу:

[
  { cluster_name = "atlas-eks-test-1-28-cluster", principal_arn = "arn:aws:iam::492***148:user/arseny" },
  { cluster_name = "atlas-eks-test-1-28-cluster", principal_arn = "arn:aws:iam::492***148:user/another.user" }
]

І аналогічно створюємо ресурси aws_eks_access_entry для груп backend з local.eks_access_entries_backend і для qa з local.eks_access_entries_qa:

...
resource "aws_eks_access_entry" "backend" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

resource "aws_eks_access_entry" "qa" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

Створення eks_access_policy_association

Наступний крок – надати цим юзерам пермішени.

Для групи devops це буде cluster-admin, а для бекенду – edit в одних неймспейсах і read-only в інших – по спискам неймспейсів в namespaces_edit та namespaces_read_only у змінній eks_access_scope.

Як і з aws_eks_access_entry – ресурси eks_access_policy_association для кожної групи юзерів зробимо трьома окремими сутностями.

Я вирішив не ускладнювати код, і зробити його більш читабельним, хоча можна було додати ще якийсь locals з flatten() і потім все робити в одному-двох ресурсах aws_eks_access_policy_association з циклами.

Тут знов використовуємо вже існуючі localseks_access_entries_devops, eks_access_entries_backend та eks_access_entries_qa з яких беремо кластери і юзерів, а потом для кожного задаємо права – аналогічно тому, як робили для aws_eks_access_entry.

Додаємо перший eks_access_policy_association – для девопсів, з Policy var.eks_access_policies.cluster_admin та access_scope = cluster:

# DEVOPS CLUSTER ADMIN
resource "aws_eks_access_policy_association" "devops" {
  for_each = { for cluser, user in local.eks_access_entries_devops : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.cluster_admin

  access_scope {
    type = "cluster"
  }
}

Група backend і права edit на неймспейси задані в списку namespaces_edit змінної eks_access_scope для групи “backend“:

# BACKEND EDIT
resource "aws_eks_access_policy_association" "backend_edit" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_edit

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["backend"].namespaces_edit
  }
}

Аналогічно – бекенди, але вже права read-only на групу неймспейсів зі списку namespaces_read_only:

# BACKEND READ ONLY
resource "aws_eks_access_policy_association" "backend_read_only" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_read_only

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["backend"].namespaces_read_only
  }
}

І аналогічно для QA:

# QA EDIT
resource "aws_eks_access_policy_association" "qa_edit" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_edit

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["qa"].namespaces_edit
  }
}

# QA READ ONLY
resource "aws_eks_access_policy_association" "qa_read_only" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_read_only

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["qa"].namespaces_read_only
  }
}

Робимо terraform plan – і маємо юзерів:

Виконуємо terraform apply, і перевіряємо EKS Access Entries:

І пермішени тестового юзера з групи backend:

Перевіряємо як все працює.

Створюємо kubectl context для тестового юзера (--profile test-eks):

$ aws --profile test-eks eks update-kubeconfig --name atlas-eks-test-1-28-cluster --alias test-cluster-test-profile
Updated context test-cluster-test-profile in /home/setevoy/.kube/config

Перевіряємо з kubectl auth can-i.

В default Namespace – не можемо нічого:

$ kk -n default auth can-i list pods
no

І неймспейс з іменем “*backend*” – тут можемо створювати поди:

$ kk -n dev-backend-api-ns auth can-i create pods
yes

Неймспейс з “*ops*” – можемо виконати list:

$ kk -n ops-monitoring-ns auth can-i list pods
yes

Але не можемо нічого створити:

$ kk -n ops-monitoring-ns auth can-i create pods
no

Окей – тут наче все готово.

AIM Roles для GitHub Actionsтут, аби ці ро нічим не відрізняються від звичайних юзерів – тому їх можна буде додати таким самим чином.

Terraform та EKS Pod Identities

Друге, що нам треба – це створити Pod Identity associations, аби замінити стару схему з EKS ServiceAccounts та OIDC.

Note: не забудьте додати eks-pod-identity-agent Add-On, приклад для terraform-aws-modules/eks – тут>>>.

Наприклад, на поточному кластері у нас є ServiceAccount для Yet Another Cloudwatch Exporter (YACE):

$ kk -n ops-monitoring-ns get sa yace-serviceaccount -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/atlas-monitoring-ops-1-28-yace-exporter-access-role
...

Що нам потрібно – це створити Pod Identity associations з такими параметрами:

  • cluster_name
  • namespace
  • service_account
  • role_arn

Зараз IAM Role для YACE створюється в коді Terraform в репозиторії з моніторингом, і тут є два варіанти:

  • перенести створення всіх IAM Roles для всіх проектів в цей новий, і потім тут жеж створювати aws_eks_pod_identity_association
    • але для цього доведеться міняти досить багато коду – випилювати створення ролей в інших проектах, і додавати їх в цей новий, плюс девелопери (якщо говорити про Backend API) вже якось звикли робити це в своїх проектах – потрібно буде писати документацію, як це робити в іншому проекті
  • або залишити створення ролей в кожному проекті окремо – а в новому просто мати змінну зі списками інших проектів та їхніх ролей (чи використати terraform_remote_state і terraform outputs з кожного проекту)
    • але тут будемо мати трохи геморою із запуском нових проектів, особливо, якщо там ролі будуть створюватись з якимись динамічними іменами – бо доведеться спочатку виконати terraform apply в новому проекті, отримати там ARN ролей, потім додавати їх в змінні нашого проекту “atlas-iam“, робити terraform apply тут аби ці ролі підключити до EKS, і тільки тоді робити умовний helm install зі створенням ServiceAccount та подів нового проекту

Але в обох варіантах нам для Pod Identity associations потрібно буде задавати такі параметри:

  • cluster_name: змінна вже є
  • імена ServiceAccount: вони будуть однакові на всіх кластерах
  • role_arn: залежить від того, як будемо створювати IAM Roles
  • namespace: а от тут питаннячко:
    • ім’я неймспейсу для моніторингу на всіх кластерах у нас однакове – “ops-monitoring-ns“, де Ops – це AWS або EKS оточення
    • а от для бекенду у нас на одному кластері є і Dev, і Staging, і Production – кожен у власному неймспейсі

А в неймспейсах для Pod Identity association ми вже не можемо використати “*“, як робили з Access Entries для юзерів, тобто маємо створювати окрему Pod Identity association на кожен конкретний неймспейс.

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

З використанням resource "aws_eks_pod_identity_association"

Варіант перший – використати “ванільний” aws_eks_pod_identity_association з Terraform AWS Provider.

Аби створити Pod Identity association для нашого умовного YACE-екпортеру, нам потрібно:

  • assume_role_policy: хто зможе виконувати IAM Role Assume
  • aws_iam_role: IAM Role з необхідними доступами до AWS API
  • aws_eks_pod_identity_association: підключити цю роль до EKS-кластеру і ServiceAccount в ньому

Давайте зробимо окремий файл для ролей – eks_pod_iam_roles.tf.

Описуємо aws_iam_policy_document – тепер ніяких OIDC, просто pods.eks.amazonaws.com:

# Trust Policy to be used by all IAM Roles
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
  }
}

Далі – сама роль.

Ролі треба буде створювати для кожного кластеру – тому знов візьмемо нашу змінну variable "eks_clusters", і потім в циклі створимо ролі з іменами під кожен кластер:

# Create an IAM Role to be assumed by Yet Another CloudWatch Exporter
resource "aws_iam_role" "yace_exporter_access" {
  for_each = var.eks_clusters

  name = "${each.key}-yace-exporter-role"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

  inline_policy {
    name = "${each.key}-yace-exporter-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action = [
            "cloudwatch:ListMetrics",
            "cloudwatch:GetMetricStatistics",
            "cloudwatch:GetMetricData",
            "tag:GetResources",
            "apigateway:GET"
          ]
          Effect   = "Allow"
          Resource = ["*"]
        },
      ]
    })
  }

  tags = {
    Name = "${each.key}-yace-exporter-role"
  }
}

І тепер в eks_pod_identities.tf можемо додати асоціацію:

resource "aws_eks_pod_identity_association" "yace" {
  for_each = var.eks_clusters

  cluster_name    = each.key
  namespace       = "example"
  service_account = "example-sa"
  role_arn        = aws_iam_role.yace_exporter_access["${each.key}"].arn
}

Де в "aws_iam_role.yace_exporter_access["${each.key}"].arn" посилаємось на IAM Role з конкретним іменем кожного кластеру:

...
  # aws_eks_pod_identity_association.yace["atlas-eks-test-1-28-cluster"] will be created
  + resource "aws_eks_pod_identity_association" "yace" {
      + association_arn = (known after apply)
      + association_id  = (known after apply)
      + cluster_name    = "atlas-eks-test-1-28-cluster"
      + id              = (known after apply)
      + namespace       = "example"
      + role_arn        = (known after apply)
      + service_account = "example-sa"
      + tags_all        = {
          + "component"   = "devops"
          + "created-by"  = "terraform"
          + "environment" = "ops"
        }
    }
...

З використанням модуля terraform-aws-eks-pod-identity

Інший варіант – робити через terraform-aws-eks-pod-identity. Тоді нам не потрібно окремо описувати роль – ми можемо задати IAM Policy прямо в модулі, і він все створить за нас. Крім того, він дозволяє в одному модулі створити кілька associations для різних кластерів.

Наприклад:

module "yace_pod_identity" {
  source   = "terraform-aws-modules/eks-pod-identity/aws"
  version  = "~> 1.2"
  for_each = var.eks_clusters

  name = "${each.key}-yace"

  attach_custom_policy = true
  source_policy_documents = [jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "cloudwatch:ListMetrics",
          "cloudwatch:GetMetricStatistics",
          "cloudwatch:GetMetricData",
          "tag:GetResources",
          "apigateway:GET"
        ]
        Effect   = "Allow"
        Resource = ["*"]
      },
    ]
  })]

  associations = {
    ex-one = {
      cluster_name    = each.key
      namespace       = "custom"
      service_account = "custom"
    }
  }
}

І для кожного кластера буде створено і роль, і асоціація:

Крім того, terraform-aws-eks-pod-identity вміє створювати всякі дефолтні IAM Roles – для ALB Ingress Controller, External DNS тощо.

Але в моєму випадку ці сервіси в EKS створюються з aws-ia/eks-blueprints-addons/aws, який сам створює ролі, і якихось змін відносно Pod Identity association я там не бачу (хоча GitHub Issue з обговоренням відкрита ще в листопаді 2023 – див. Switch to IRSAv2/pod identity).

Окей.

То як ми можемо зробити всю цю схему?

  • у нас будуть IAM Roles – створюються в інших проектах, або в нашому “atlas-iam
  • ми будемо знати в які неймспейси які ServiceAccounts нам треба підключати

Яку ми можемо придумати змінну для всього цього діла?

Можемо задати ім’я проекту – “backend-api“, “monitoring“, etc, і в кожному проекті мати списки з неймспейсами, ServiceAccount та IAM Roles.

А потім для кожного проекту мати окремий ресурс aws_eks_pod_identity_association, який в циклі буде проходитись по всім EKS-кластерам в AWS-акаунті.

Проблема: Pod Identity association та динамічні Namespaces

Але! Для Backend API в GitHub Actions у нас створюються динамічні оточення, і для них – Kubernetes Namespaces з іменами типу “pr-1212-backend-api-ns“. Тобто, є статичні неймспейси – “dev-backend-api-ns“, “staging-backend-api-ns” та “prod-backend-api-ns“, які ми знаємо – але будуть імена, які ми ніяк заздалегідь дізнатись не можемо.

На схожу тему є відкрита ще в грудні 2023 GitHub Issue – [EKS]: allow EKS Pod Identity association to accept a glob for the service account name (my-sa-*), але вона поки що без змін.

Тому як рішення поки що бачу тільки залишити старий IRSA для бекенду, а Pod Identity використовувати тільки для тих проектів, які мають статичні неймспейси.

Pod Identity association для Monitoring project

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

В проекті з моніторингом в мене є 5 кастомних IAM Roles – для Grafana Loki з доступами до S3, для звичайного CloudWatch Exporter, для Yet Another CloudWatch Exporter, для нашого власного Redshift Exporter, і для X-Ray Daemon.

Створення ролей все ж, мабуть, краще винести в цей проект, “atlas-iam“. Тоді при створенні нового проекту ми спочатку в цьому проекті описуємо його роль і асоціацію в потрібних неймспейсах з потрібним ServiceAccount, а потім в самому проекті в Helm-чарті вказуємо ім’я ServiceAccount.

Щодо terraform-aws-modules/eks-pod-identity/aws – як на мене, то він більш заточений під всякі дефолтні ролі, хоча можливість створення власних там є.

Але зараз простішим буде зробити так:

  • створювати IAM Roles для кожного сервісу зі звичайним resource "aws_iam_role"
  • і з resource "aws_eks_pod_identity_association" підключати ці ролі до кластерів

Отже, створимо нову змінну, де будуть проекти та їхні неймспейси і ServiceAccounts:

variable "eks_pod_identities" {
  description = "EKS ServiceAccounts for Pods to grant access with eks-pod-identity"
  type = map(object({
    namespace = string,
    projects = map(object({
      serviceaccount_name = string
    }))
  }))
  default = {
    monitoring = {
      namespace = "ops-monitoring-ns"
      projects = {
        loki = {
          serviceaccount_name = "loki-sa"
        },
        yace = {
          serviceaccount_name = "yace-sa"
        }
      }
    }
  }
}

Далі, у файлі eks_pod_iam_roles.tf зробимо роль – як робили вище, але без циклів, бо у нас одна роль на весь AWS-акаунт, яка буде підключатись до різних Kubernetes-кластерів:

# Trust Policy to be used by all IAM Roles
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
  }
}

# Create an IAM Role to be assumed by Yet Another CloudWatch Exporter
resource "aws_iam_role" "yace_exporter_access" {
  name = "monitoring-yace-exporter-role"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

  inline_policy {
    name = "monitoring-yace-exporter-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action = [
            "cloudwatch:ListMetrics",
            "cloudwatch:GetMetricStatistics",
            "cloudwatch:GetMetricData",
            "tag:GetResources",
            "apigateway:GET"
          ]
          Effect   = "Allow"
          Resource = ["*"]
        },
      ]
    })
  }

  tags = {
    Name = "monitoring-yace-exporter-role"
  }
}

Об’єкт data "aws_iam_policy_document" "assume_role" у нас тут буде один – і потім його використаємо в усіх IAM Roles.

Далі в файлі eks_pod_identities.tf описуємо власне aws_eks_pod_identity_association з використанням namespace та service_account зі змінної, яку описали вище, role_arn отримуємо з resource "aws_iam_role" "yace_exporter_access", а імена кластерів беремо в циклі зі змінної var.eks_clusters:

resource "aws_eks_pod_identity_association" "yace" {
  for_each = var.eks_clusters

  cluster_name    = each.key
  namespace       = var.eks_pod_identities.monitoring.namespace
  service_account = var.eks_pod_identities.monitoring.projects["yace"].serviceaccount_name
  role_arn        = aws_iam_role.yace_exporter_access.arn
}

Виконуємо terraform plan:

Робимо terraform apply, та перевіряємо Pod Identity associations в EKS:

Перевірка EKS Pod Identities

Ну і перевіримо, чи це працює.

Описуємо Kubernetes Pod з AWS CLI та ServiecAccount з іменем “yace-sa“:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: yace-sa
  namespace: ops-monitoring-ns
---
apiVersion: v1
kind: Pod
metadata:
  name: pod-identity-test
  namespace: ops-monitoring-ns
spec:
  containers:
    - name: aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
  restartPolicy: Never
  serviceAccountName: yace-sa

Створюємо їх:

$ kk apply -f irsa-sa.yaml 
serviceaccount/yace-sa created
pod/pod-identity-test created

Підключаємось в Pod:

$ kk -n ops-monitoring-ns exec -ti pod-identity-test -- bash

І пробуємо виконати запит до CloudWatch – наприклад, “cloudwatch:ListMetrics” на який ми давали права в IAM Role:

bash-4.2# aws cloudwatch list-metrics --namespace "AWS/SNS" | head
{
    "Metrics": [
        {
            "Namespace": "AWS/SNS",
            "MetricName": "NumberOfMessagesPublished",
            "Dimensions": [
                {
                    "Name": "TopicName",
                    "Value": "dev-stable-diffusion-running-opsgenie"

А на S3 у нас прав нема:

bash-4.2# aws s3 ls

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

Висновки

Отже, що ми маємо в результаті.

З EKS Access Entries все доволі ясно, і рішення має право на життя: можемо з одного Terraform-коду управляти юзерами для різних EKS кластерів і навіть в різних AWS-акаунтах.

Код, описаний в eks_access_entires.tf дозволяє нам досить гнучко створювати нові EKS Authentification API Access Entries для різних юзерів з різними правами, хоча я не торкався питання Kubernetes RBAC – створення окремих груп з власними RoleBindings. Але в моєму випадку це поки трохи overhead.

А от з EKS Pod Identities автоматизація “дала збій” – бо на цей час нема можливості використовувати “*” в іменах неймспейсів та/або ServiceAccounts – а тому ми маємо досить жорстко прив’язуватись до якихось постійних імен. Тому описане рішення може застосувати, коли у вас заздалегідь відомі імена об’єктів в Kubernetes – але для якихось динамічних рішень все ще доведеться використовувати стару схему з IRSA та OIDC.

Втім, сподіваюсь, цей момент пофіксять, і тоді можна буде всі наші проекти менеджити вже з одного коду.

Loading

Terraform: EKS та Karpenter – upgrade версії модуля з 19.21 на 20.0
5 (1)

10 Липня 2024

Наче звична задача – оновити версію модулю Terraform, але в terraform-aws-modules/eks версії 20.0 були досить великі зміни з breaking changes.

Зміни стосуються аутентифікації та авторизації в AWS IAM та AWS EKS, які розбирав в пості AWS: Kubernetes та Access Management API – нова схема авторизації в EKS.

Але там ми все робили руками, аби взагалі подивись на новий механізм – а тепер давайте це зробимо з Terraform.

Крім того, зміни відбулися в Karpenter відносно IRSA (IAM Roles for ServiceAccounts). Нову схему роботи з ServiceAccount описував в AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів, а апдейт конкретно в  модулі Karpenter глянемо в цьому пості.

Хоча я буду робити апгрейд версії самого AWS EKS і модуля Terraform через створення нового кластеру, а тому в принципі можу не морочити голову з “live-update” кластера, але хочеться спробувати зробити так, аби це можна було застосувати на живому кластері та не втратити до нього доступу і не поламати умовний Production.

Втім, майте на увазі, що те, що описано в цьому пості робиться на тестовому кластері (бо в мене взагалі один кластер для Dev/Staging/Prod). Тож не варто відразу робити апгрейд на продакшені, а краще спочатку протестувати на якомусь Dev-оточенні.

Про сетап самого кластеру детальніше писав у Terraform: створення EKS, частина 2 – EKS кластер, WorkerNodes та IAM, і в цьому пості приклади коду будуть саме звідти, хоча він вже трохи відрізняється від того, що було описано там, бо створення EKS зробив окремим власним модулем, щоб простіше було менеджити різні кластери-оточення.

В цілому, змін наче і дуже небагато – але пост вийшов довгий, бо намагався показати все детально і з реальними прикладами.

Що змінилося?

Повний опис є в GitHub – див. v20.0.0 та Upgrade from v19.x to v20.x.

Що саме нас цікавить (знов-таки – конкретно в моєму випадку):

  • EKS:
    • aws-auth: винесено в окремий модуль, в параметрах самого модуля terraform-aws-modules/eks його вже нема
      • з terraform-aws-modules/eks видалено параметри manage_aws_auth_configmap, create_aws_auth_configmap, aws_auth_roles, aws_auth_users, aws_auth_accounts
    • authentication_mode: додано значення API_AND_CONFIG_MAP
    • bootstrap_cluster_creator_admin_permissions: hardcoded у false
      • але можна передати enable_cluster_creator_admin_permissions зі значенням true, хоча тут наче треба додавати Access Entry
    • create_instance_profile: дефолтне значення змінилось з true на false, аби відповідати змінам в Karpenter v0.32 (але в мене Karpenter і так вже 0.32 і все працює, тому тут змін не має бути)
  • Karpenter:
    • irsa: видалено імена змінних з “irsa” – пачка перейменувань і декілька імен видалено взагалі
    • create_instance_profile: дефолтне значення з true стало false
    • enable_karpenter_instance_profile_creation: видалена
    • iam_role_arn: стало node_iam_role_arn
    • irsa_use_name_prefix: стало iam_role_name_prefix

В документації по апдейту описано ще досить багато змін по Karpenter – але в мене зараз  сам Karpenter версії 0.32 (див. Karpenter: Beta version – обзор змін та upgrade v0.30.0 на v0.32.1), модуль Terraform terraform-aws-modules/eks/aws//modules/karpenter зараз 19.21.0, і процес апгрейду самого EKS з 19 на 20 ніяк не вплинув на роботу Karpenter, тож їх можна оновлювати окремо – спочатку EKS, потім вже Karpenter.

План апгрейду

Що будемо робити:

  1. EKS: оновлення версії модулю з 19.21 => 20.0 з API_AND_CONFIG_MAP
    1. додавання aws-auth окремим модулем
  2. Karpenter: оновлення версії модулю з 19.21 => 20.0

EKS: upgrade 19.21 на 20.0

Нам потрібно:

  • видалити все, пов’язане з aws_auth, в моєму випадку це:
    • manage_aws_auth_configmap
    • aws_auth_users
    • aws_auth_roles
  • додати authentication_mode зі значенням API_AND_CONFIG_MAP (пізніше, для 21, треба буде замінити на API)
  • додати новий модуль для aws_auth_roles і перенести aws_auth_users та aws_auth_roles туди

Щодо bootstrap_cluster_creator_admin_permissions та enable_cluster_creator_admin_permissions – так як цей кластер створювався з 19.21, то root-юзер там вже є, і він буде доданий в Access Entries разом з WorkerNodes IAM Role, тому тут нічого робити не треба.

А як перенести в access_entries наших юзерів і ролі – подивимось мабуть вже в наступному пості, бо зараз будемо робити тільки оновлення версії модуля зі збереженням aws-auth ConfigMap.

Для тесту Karpenter – створив тестовий деплой з одним подом, який затригерить створення WorkerNode, і до нього Ingress/ALB, на який йде постійний ping, аби впевнитись, що все буде працювати без даунтаймів.

NodeClaims зараз:

$ kk get nodeclaim
NAME            TYPE       ZONE         NODE                          READY   AGE
default-7hjz7   t3.small   us-east-1a   ip-10-0-45-183.ec2.internal   True    53s

Окей, поїхали оновлювати EKS.

Поточний код Terraform та структура модулів

Аби далі краще розуміти момент з видаленням ресурс aws_auth з Terraform state – моя поточна структура файлів/модулів:

$ tree .
.
|-- Makefile
|-- backend.hcl
|-- backend.tf
|-- envs
|   `-- test-1-28
|       |-- VERSIONS.md
|       |-- backend.tf
|       |-- main.tf
|       |-- outputs.tf
|       |-- providers.tf
|       |-- test-1-28.tfvars
|       `-- variables.tf
|-- modules
|   `-- atlas-eks
|       |-- configs
|       |   `-- karpenter-nodepool.yaml.tmpl
|       |-- controllers.tf
|       |-- data.tf
|       |-- eks.tf
|       |-- iam.tf
|       |-- karpenter.tf
|       |-- outputs.tf
|       |-- providers.tf
|       `-- variables.tf
|-- outputs.tf
|-- providers.tf
|-- variables.tf
`-- versions.tf

Тут:

  • в envs/test-1-28/main.tf викликається модуль з modules/atlas-eks з необхідними параметрами – і виконувати terraform state rm ми будемо саме в envs/test-1-28
  • в modules/atlas-eks/eks.tf викликається модуль terraform-aws-modules/eks/aws потрібної версії – тут ми будемо робити зміни в коді

Виклик рутового модуля в файлі envs/test-1-28/main.tf виглядає так:

module "atlas_eks" {
  source = "../../modules/atlas-eks"

  # 'devops'
  component = var.component

  # 'ops'
  aws_environment = var.aws_environment

  # 'test'
  eks_environment = var.eks_environment

  env_name = local.env_name

  # '1.28'
  eks_version = var.eks_version

  # 'endpoint_public_access', 'enabled_log_types'
  eks_params = var.eks_params

  # 'coredns = v1.10.1-eksbuild.6', 'kube_proxy = v1.28.4-eksbuild.1', etc
  eks_addon_versions = var.eks_addon_versions

  # AWS IAM Roles to be added to EKS aws-auth as 'masters'
  eks_aws_auth_users = var.eks_aws_auth_users

  # GitHub IAM Roles used in Workflows
  eks_github_auth_roles = var.eks_github_auth_roles

  # 'vpc-0fbaffe234c0d81ea'
  vpc_id = var.vpc_id

  helm_release_versions = var.helm_release_versions

  # override default 'false' in the module's variables
  # will trigger a dedicated module like 'eks_blueprints_addons_external_dns_test'
  # with a domainFilters == variable.external_dns_zones.test == 'test.example.co'
  external_dns_zone_test_enabled = true

  # 'instance-family', 'instance-size', 'topology'
  karpenter_nodepool = var.karpenter_nodepool
}

А код основного модуля – так:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.21.0"

  # is set in `locals` per env
  # '${var.project_name}-${var.eks_environment}-${local.eks_version}-cluster'
  # 'atlas-eks-test-1-28-cluster'
  # passed from the root module
  cluster_name    = "${var.env_name}-cluster"

  # passed from the root module
  cluster_version = var.eks_version

  # 'eks_params' passed from the root module
  cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access

  # 'eks_params' passed from the root module
  cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types

  # 'eks_addons_version' passed from the root module
  cluster_addons = {
    coredns = {
      addon_version = var.eks_addon_versions.coredns
      configuration_values = jsonencode({
        replicaCount = 1
        resources = {
          requests = {
            cpu = "50m"
            memory = "50Mi"
          }
        }
      })
    }
    kube-proxy = {
      addon_version = var.eks_addon_versions.kube_proxy
      configuration_values = jsonencode({
        resources = {
          requests = {
            cpu = "20m"
            memory = "50Mi"
          }
        }
      })
    }    
    vpc-cni = {
      # old: eks_addons_version
      # new: eks_addon_versions
      addon_version = var.eks_addon_versions.vpc_cni
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET       = "1"
          AWS_VPC_K8S_CNI_EXTERNALSNAT = "true"
        }
      })      
    }
    aws-ebs-csi-driver = {
      addon_version            = var.eks_addon_versions.aws_ebs_csi_driver
      # iam.tf
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
  }

  # make as one complex var?
  # passed from the root module
  vpc_id                   = var.vpc_id
  # for WorkerNodes
  # passed from the root module
  subnet_ids               = data.aws_subnets.private.ids
  # for the Control Plane
  # passed from the root module
  control_plane_subnet_ids = data.aws_subnets.intra.ids

  manage_aws_auth_configmap = true

  # `env_name` make too long name causing issues with IAM Role (?) names
  # thus, use a dedicated `env_name_short` var
  eks_managed_node_groups = {
    # eks-default-dev-1-28
    "${local.env_name_short}-default" = {

      # `eks_managed_node_group_params` from defaults here

      # number, e.g. 2
      min_size = var.eks_managed_node_group_params.default_group.min_size
      # number, e.g. 6
      max_size = var.eks_managed_node_group_params.default_group.max_size
      # number, e.g. 2
      desired_size = var.eks_managed_node_group_params.default_group.desired_size
      # list, e.g. ["t3.medium"]
      instance_types = var.eks_managed_node_group_params.default_group.instance_types
      # string, e.g. "ON_DEMAND"
      capacity_type = var.eks_managed_node_group_params.default_group.capacity_type

      # allow SSM
      iam_role_additional_policies = {
        AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
      }

      taints = var.eks_managed_node_group_params.default_group.taints

      update_config = {
        max_unavailable_percentage = var.eks_managed_node_group_params.default_group.max_unavailable_percentage
      }
    }
  }

  # 'atlas-eks-test-1-28-node-sg'
  node_security_group_name    = "${var.env_name}-node-sg"
  # 'atlas-eks-test-1-28-cluster-sg'
  cluster_security_group_name = "${var.env_name}-cluster-sg"

  # to use with EC2 Instance Connect
  node_security_group_additional_rules = {
    ingress_ssh_vpc = {
      description = "SSH from VPC"
      protocol    = "tcp"
      from_port   = 22
      to_port     = 22
      cidr_blocks      = [data.aws_vpc.eks_vpc.cidr_block]
      type        = "ingress"
    }
  }

  # 'atlas-eks-test-1-28'
  node_security_group_tags = {
    "karpenter.sh/discovery" = var.env_name
  }

  cluster_identity_providers = {
    sts = {
      client_id = "sts.amazonaws.com"
    }
  }

  # passed from the root module
  aws_auth_users = var.eks_aws_auth_users
  
  # locals flatten() 'eks_masters_access_role' + 'eks_github_auth_roles'
  aws_auth_roles = local.aws_auth_roles
}

Трохи більше про модулі Terraform див. у Terraform: модулі, Outputs та Variables та Terraform: створення модулю для збору логів AWS ALB в Grafana Loki.

Ролі/юзери для aws-auth формуються в locals зі змінних у variables.tf та envs/test-1-28/test-1-28.tfvars, всі з system:masters – до RBAC ми ще не дійшли:

locals {
  # create a short name for node names in the 'eks_managed_node_groups'
  # 'test-1-28'
  env_name_short = "${var.eks_environment}-${replace(var.eks_version, ".", "-")}"

  # 'eks_github_auth_roles' passed from the root module
  github_roles = [for role in var.eks_github_auth_roles : {
    rolearn  = role
    username = role
    groups   = ["system:masters"]
  }]

  # 'eks_masters_access_role' + 'eks_github_auth_roles'
  # 'eks_github_auth_roles' from the root module
  # 'aws_iam_role.eks_masters_access' from the iam.tf here
  aws_auth_roles = flatten([
    {
      rolearn  = aws_iam_role.eks_masters_access_role.arn
      username = aws_iam_role.eks_masters_access_role.arn
      groups   = ["system:masters"]
    },
    local.github_roles
  ])
}

Антон в документації зробив класний Diff of Before (v19.21) vs After (v20.0) по змінам, то давайте спробуємо.

Зміни в authentication_mode та aws_auth

В модулі EKS найбільша зміна це переключення authentication_mode на API_AND_CONFIG_MAP.

Кластер вже є, створений з версією 19.21.0.

Вкладка Access зараз виглядає так:

Access configuration == ConfgiMap, і в IAM access entries пусто.

Тепер робимо зміни в коді файла modules/atlas-eks/eks.tf:

  1. міняємо версію на v20.0
  2. видаляємо все, що пов’язано з aws_auth
  3. додаємо authentication_mode зі значенням API_AND_CONFIG_MAP

Зміни поки виглядають так:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  #version = "~> 19.21.0"
  version = "~> v20.0"
  ...
  # removing for API_AND_CONFIG_MAP
  #manage_aws_auth_configmap = true

  # adding for API_AND_CONFIG_MAP
  authentication_mode = "API_AND_CONFIG_MAP"

  ...
  # removing for API_AND_CONFIG_MAP
  # passed from the root module
  #aws_auth_users = var.eks_aws_auth_users
  
  # removing for API_AND_CONFIG_MAP
  # locals flatten() 'eks_masters_access_role' + 'eks_github_auth_roles'
  #aws_auth_roles = local.aws_auth_roles
}

Виконуємо terraform init, аби оновити модуль EKS:

...
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/eks/aws 20.0.1 for atlas_eks.eks...
- atlas_eks.eks in .terraform/modules/atlas_eks.eks
- atlas_eks.eks.eks_managed_node_group in .terraform/modules/atlas_eks.eks/modules/eks-managed-node-group
- atlas_eks.eks.eks_managed_node_group.user_data in .terraform/modules/atlas_eks.eks/modules/_user_data
- atlas_eks.eks.fargate_profile in .terraform/modules/atlas_eks.eks/modules/fargate-profile
...

Глянемо aws-auth зараз – заодно будемо мати його “бекап” в YAML:

$ kk -n kube-system get cm aws-auth -o yaml
apiVersion: v1
data:
  mapAccounts: |
    []
  mapRoles: |
    - "groups":
      - "system:bootstrappers"
      - "system:nodes"
      "rolearn": "arn:aws:iam::492***148:role/test-1-28-default-eks-node-group-20240705095955197900000003"
      "username": "system:node:{{EC2PrivateDNSName}}"
    - "groups":
      - "system:masters"
      "rolearn": "arn:aws:iam::492***148:role/atlas-eks-test-1-28-masters-access-role"
      "username": "arn:aws:iam::492***148:role/atlas-eks-test-1-28-masters-access-role"
    - "groups":
      - "system:masters"
      "rolearn": "arn:aws:iam::492***148:role/atlas-test-ops-1-28-github-access-role"
      "username": "arn:aws:iam::492***148:role/atlas-test-ops-1-28-github-access-role"
...
  mapUsers: |
    - "groups":
      - "system:masters"
      "userarn": "arn:aws:iam::492***148:user/arseny"
      "username": "arseny"
...

Виконуємо terraform plan, і бачимо, що дійсно – aws-auth буде видалено:

Отже, якщо ми хочемо його залишити – то треба додати новий модуль з terraform-aws-modules/eks/aws//modules/aws-auth.

Приклад в документації виглядає так:

module "eks" {
  source  = "terraform-aws-modules/eks/aws//modules/aws-auth"
  version = "~> 20.0"

  manage_aws_auth_configmap = true

  aws_auth_roles = [
    {
      rolearn  = "arn:aws:iam::66666666666:role/role1"
      username = "role1"
      groups   = ["custom-role-group"]
    },
  ]

  aws_auth_users = [
    {
      userarn  = "arn:aws:iam::66666666666:user/user1"
      username = "user1"
      groups   = ["custom-users-group"]
    },
  ]
}

В моєму випадку – для aws_auth_roles та aws_auth_users використовуємо значення з locals:

module "aws_auth" {
  source  = "terraform-aws-modules/eks/aws//modules/aws-auth"
  version = "~> 20.0"

  manage_aws_auth_configmap = true

  aws_auth_roles = local.aws_auth_roles

  aws_auth_users = var.eks_aws_auth_users
}

Виконуємо ще раз terraform init, ще раз робимо terraform plan, і тепер маємо новий ресурс для aws-auth:

А старий все ще буде видалятись – тобто, спочатку Terraform його видалить, а потім створить заново, просто з новим іменем та ID в стейті:

Аби Terraform не видалив ресурс aws-auth з кластеру – нам потрібно видалити його зі state-файла: тоді Terraform не буде нічого знати про цей ConfgiMap, а при створенні з нашого module "aws_auth" – просто створить записи у своєму state file, але не буде нічого виконувати в Kubernetes.

Important: про всяк випадок – зробіть бекап відповідного state-file, бо будемо робити state rm.

Видалення aws_auth з Terraform State

В моєму випадку треба перейти в каталог з оточенням, envs/test-1-28, і вже звідти виконувати операції зі state.

Note: уточнюю, бо випадково все ж таки видалив aws-auth ConfigMap з production-кластеру. Але просто заново виконав terraform apply на ньому – і все без проблем відновилось.

Знаходимо ім’я модуля, як він записаний в стейті:

$ cd envs/test-1-28/
$ terraform state list | grep aws_auth
module.atlas_eks.module.eks.kubernetes_config_map_v1_data.aws_auth[0]

Можна перевірити з terraform plan output, який робили вище, аби впевнитись, що видаляємо саме його:

...
module.atlas_eks.module.eks.kubernetes_config_map_v1_data.aws_auth[0]: Refreshing state... [id=kube-system/aws-auth]
...

Тут ім’я module.atlas_eks – саме так, як задано в моєму модулі EKS у файлі envs/test-1-28/main.tf:

module "atlas_eks" {
  source = "../../modules/atlas-eks"
...

З terraform state rm видаляємо запис про ресурс:

$ terraform state rm 'module.atlas_eks.module.eks.kubernetes_config_map_v1_data.aws_auth[0]'
Acquiring state lock. This may take a few moments...
Removed module.atlas_eks.module.eks.kubernetes_config_map_v1_data.aws_auth[0]
Successfully removed 1 resource instance(s).
Releasing state lock. This may take a few moments...

Виконуємо terraform plan ще раз, і тепер нема ніяких “destroy” – тільки створення module.atlas_eks.module.aws_auth.kubernetes_config_map_v1_data.aws_auth[0]:

 # module.atlas_eks.module.aws_auth.kubernetes_config_map_v1_data.aws_auth[0] will be created
+ resource "kubernetes_config_map_v1_data" "aws_auth" {
...
Plan: 1 to add, 10 to change, 0 to destroy.

Виконуємо terraform apply:

...
module.atlas_eks.module.eks.aws_eks_cluster.this[0]: Still modifying... [id=atlas-eks-test-1-28-cluster, 50s elapsed]
module.atlas_eks.module.eks.aws_eks_cluster.this[0]: Modifications complete after 52s [id=atlas-eks-test-1-28-cluster]
...
module.atlas_eks.module.aws_auth.kubernetes_config_map_v1_data.aws_auth[0]: Creation complete after 6s [id=kube-system/aws-auth]
...

Перевіряємо сам ConfigMap – ніяк змін:

$ kk -n kube-system get cm aws-auth -o yaml
apiVersion: v1
data:
  mapAccounts: |
    []
  mapRoles: |
    - "groups":
      - "system:masters"
      "rolearn": "arn:aws:iam::492***148:role/atlas-eks-test-1-28-masters-access-role"
      "username": "arn:aws:iam::492***148:role/atlas-eks-test-1-28-masters-access-role"
...

І глянемо, що змінилось в AWS Console > EKS > ClusterName > Access:

Тепер у нас тут значення EKS API and ConfigMap та дві Access Entries – для WorkerNodes, та для рутового юзера.

Перевіряємо NodeClaims – Karpenter працює?

Можна поскейлити ворклоади, аби впевнитись:

$ kk -n test-fastapi-app-ns scale deploy fastapi-app --replicas=20
deployment.apps/fastapi-app scaled

Логи Karpenter – все добре:

...
karpenter-8444499996-9njx6:controller {"level":"INFO","time":"2024-07-05T12:27:41.527Z","logger":"controller.provisioner","message":"created nodeclaim","commit":"a70b39e","nodepool":"default","nodeclaim":"default-59ms4","requests":{"cpu":"1170m","memory":"942Mi","pods":"8"},"instance-types":"c5.large, r5.large, t3.large, t3.medium, t3.small"}
...

І нові NodeClaims створились:

$ kk get nodeclaim
NAME            TYPE       ZONE         NODE                          READY   AGE
default-59ms4   t3.small   us-east-1a   ip-10-0-46-72.ec2.internal    True    59s
default-5fc2p   t3.small   us-east-1a   ip-10-0-39-114.ec2.internal   True    7m6s

Тут в принципі все – можемо переходити до апгрейду модуля з Karpenter.

Karpenter: upgrade 19.21 на 20.0

Також є Karpenter Diff of Before (v19.21) vs After (v20.0), але дещо довелося міняти вручну.

Тут я пішов “методом тика” – робимо terraform plan, дивимось, що не так в результатах – фіксимо – ше раз plan. Пройшло без проблем, хоча з деякими помилками – подивимось на них.

Поточний код Terraform для Karpenter

Поточний код в modules/atlas-eks/karpenter.tf:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "19.21.0"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  create_iam_role      = false
  iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn
  irsa_use_name_prefix = false

  # In v0.32.0/v1beta1, Karpenter now creates the IAM instance profile
  # so we disable the Terraform creation and add the necessary permissions for Karpenter IRSA
  enable_karpenter_instance_profile_creation = true
}

Деякі outputs з модулю Karpenter використовуються в helm_releasemodule.karpenter.irsa_arn:

resource "helm_release" "karpenter" {
  namespace        = "karpenter"
  create_namespace = true

  name                = "karpenter"
  repository          = "oci://public.ecr.aws/karpenter"
  repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "karpenter"
  version             = var.helm_release_versions.karpenter

  values = [
    <<-EOT
    replicas: 1
    settings:
      clusterName: ${module.eks.cluster_name}
      clusterEndpoint: ${module.eks.cluster_endpoint}
      interruptionQueueName: ${module.karpenter.queue_name}
      featureGates: 
        drift: true      
    serviceAccount:
      annotations:
        eks.amazonaws.com/role-arn: ${module.karpenter.irsa_arn} 
    EOT
  ]

  depends_on = [
    helm_release.karpenter_crd
  ]
}

Апгрейд модулю Karpenter

Для початку міняємо версію на 20.0:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  #version = "19.21.0"
  version = "20.0"
...

Робимо terraform init та terraform plan, і дивимось на помилки:

...
 An argument named "iam_role_arn" is not expected here.
...
│ An argument named "irsa_use_name_prefix" is not expected here.
...
│ An argument named "enable_karpenter_instance_profile_creation" is not expected here.

Міняємо імена параметрів, і з  Karpenter Diff of Before (v19.21) vs After (v20.0) додаємо створення ресурсів для IRSA (IAM Role for ServiceAccounts – роль для Karpenter), бо вона у нас є в поточному сетапі:

  • iam_role_arn => node_iam_role_arn
  • irsa_use_name_prefix – тут трохи не зрозумів, бо по документації вона стала iam_role_name_prefix, але iam_role_name_prefix в inputs нема взагалі, тому просто закоментив
  • iam_role_name_prefix – теж не поняв – по документації вона стала node_iam_role_name_prefix, але знов-таки – такої нема, тому теж просто закоментив
  • enable_karpenter_instance_profile_creation: видалена

Тепер код виглядає так:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  #version = "19.21.0"
  version = "20.0"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  create_iam_role      = false

  # 19 > 20
  #iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn
  node_iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn

  # 19 > 20
  #irsa_use_name_prefix = false
  #iam_role_name_prefix = false
  #node_iam_role_name_prefix = false

  # 19 > 20
  #enable_karpenter_instance_profile_creation = true

  # 19 > 20
  enable_irsa             = true
  create_instance_profile = true

  # 19 > 20
  # To avoid any resource re-creation
  iam_role_name          = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_role_description   = "Karpenter IAM role for service account"
  iam_policy_name        = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_policy_description = "Karpenter IAM role for service account"
}

Ще раз робимо terraform plan, і тепер маємо іншу помилку, тепер вже в helm_release:

...
This object does not have an attribute named "irsa_arn".
...

Бо irsa_arn тепер стала iam_role_arn, міняємо теж:

...
    serviceAccount:
      annotations:
        eks.amazonaws.com/role-arn: ${module.karpenter.iam_role_arn} 
    EOT
...

Ще раз робимо terraform plan – тепер маємо іншу проблему, з довжиною імені ролі:

...
Plan: 1 to add, 3 to change, 0 to destroy.
╷
│ Error: expected length of name_prefix to be in the range (1 - 38), got KarpenterIRSA-atlas-eks-test-1-28-cluster-
│ 
│   with module.atlas_eks.module.karpenter.aws_iam_role.controller[0],
│   on .terraform/modules/atlas_eks.karpenter/modules/karpenter/main.tf line 69, in resource "aws_iam_role" "controller":
│   69:   name_prefix = var.iam_role_use_name_prefix ? "${var.iam_role_name}-" : null
...

Тому задав iam_role_use_name_prefix = false, і тепер весь оновлений код виглядає так:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  #version = "19.21.0"
  version = "20.0"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  # 19 > 20
  #create_iam_role      = false
  create_node_iam_role = false

  # 19 > 20
  #iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn
  node_iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn

  # 19 > 20
  #irsa_use_name_prefix = false
  #iam_role_name_prefix = false
  #node_iam_role_name_prefix = false

  # 19 > 20
  #enable_karpenter_instance_profile_creation = true

  # 19 > 20
  enable_irsa             = true
  create_instance_profile = true

  # To avoid any resource re-creation
  iam_role_name          = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_role_description   = "Karpenter IAM role for service account"
  iam_policy_name        = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_policy_description = "Karpenter IAM role for service account"

  # expected length of name_prefix to be in the range (1 - 38), got KarpenterIRSA-atlas-eks-test-1-28-cluster-
  iam_role_use_name_prefix = false
}

...
resource "helm_release" "karpenter" {
  ...
    serviceAccount:
      annotations:
        eks.amazonaws.com/role-arn: ${module.karpenter.iam_role_arn} 
    EOT
  ]
  ...
}

Виконуємо terraform plan – нічого не має видалитись:

...
Plan: 1 to add, 5 to change, 0 to destroy.

В плані у нас є:

  • буде додано module.atlas_eks.module.karpenter.aws_eks_access_entry.node: – трохи забігаючи наперед – це треба буде відключити, зараз побачимо, чому
  • в module.atlas_eks.module.karpenter.aws_iam_policy.controller: будуть оновлені політики – тут наче все ОК.
  • в module.atlas_eks.module.karpenter.aws_iam_role.controller: буде додано правило Allow з pods.eks.amazonaws.com – для роботи з EKS Pod Identities

Наче виглядає ОК – давайте деплоїти і тестити.

Логи Karpenter запущені, NodeClaim зараз вже є, пінги на тестовий Ingress/ALB йдуть.

EKS: CreateAccessEntry – access entry resource is already in use on this cluster

Робимо terraform apply, і – як неочікувано! – маємо помилку:

...
│ Error: creating EKS Access Entry (atlas-eks-test-1-28-cluster:arn:aws:iam::492***148:role/test-1-28-default-eks-node-group-20240710092944387500000003): operation error EKS: CreateAccessEntry, https response error StatusCode: 409, RequestID: 004e014d-ebbb-4c60-919b-fb79629bf1ff, ResourceInUseException: The specified access entry resource is already in use on this cluster.

Тому що “EKS automatically adds access entries for the roles used by EKS managed node groups and Fargate profiles“, див. authentication_mode = “API_AND_CONFIG_MAP”.

І ми вже бачили Access Entry для WorkerNodes, коли виконували апгрейд версії модулю EKS:

Якщо використовуються self-managed NodeGroups – то для них в terraform-aws-eks/modules/self-managed-node-group/variables.tf була додана змінна create_access_entry з дефолтним значенням true.

Для Kaprneter теж є нова змінна create_access_entry, і вона теж з дефолтним значенням true.

Задаємо її в false, бо в моєму випадку ноди створенні з Karpenter використовують ту саму IAM Role, що ноди з module.eks.eks_managed_node_groups:

...
  # To avoid any resource re-creation
  iam_role_name          = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_role_description   = "Karpenter IAM role for service account"
  iam_policy_name        = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_policy_description = "Karpenter IAM role for service account"

  # expected length of name_prefix to be in the range (1 - 38), got KarpenterIRSA-atlas-eks-test-1-28-cluster-
  iam_role_use_name_prefix = false

  # Error: creating EKS Access Entry ResourceInUseException: The specified access entry resource is already in use on this cluster.
  create_access_entry = false
}
...

Ще раз виконуємо terraform apply – і тепер все пройшло без помилок.

В логах Karpenter взагалі нічого, тобто Kubernetes Pod не перестворювався, пінги на тестову апку продовжують йти.

Можна її поскейлити, аби тригернути створення нових Karpenter NodeClaims:

$ kk -n test-fastapi-app-ns scale deploy fastapi-app --replicas=10
deployment.apps/fastapi-app scaled

І вони створились без проблем:

...
karpenter-649945c6c5-lj2xh:controller {"level":"INFO","time":"2024-07-10T11:33:34.507Z","logger":"controller.nodeclaim.lifecycle","message":"launched nodeclaim","commit":"a70b39e","nodeclaim":"default-pn64t","provider-id":"aws:///us-east-1a/i-0316a99cd2d6e172c","instance-type":"t3.small","zone":"us-east-1a","capacity-type":"spot","allocatable":{"cpu":"1930m","ephemeral-storage":"17Gi","memory":"1418Mi","pods":"110"}}

Наче виглядає як все працює.

Фінальний Terraform код для EKS та Karpenter

В результаті всіх змін код буде виглядати так.

Для EKS:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> v20.0"

  # is set in `locals` per env
  # '${var.project_name}-${var.eks_environment}-${local.eks_version}-cluster'
  # 'atlas-eks-test-1-28-cluster'
  # passed from a root module
  cluster_name    = "${var.env_name}-cluster"

  # passed from a root module
  cluster_version = var.eks_version

  # 'eks_params' passed from a root module
  cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access

  # 'eks_params' passed from a root module
  cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types

  # 'eks_addons_version' passed from a root module
  cluster_addons = {
    coredns = {
      addon_version = var.eks_addon_versions.coredns
      configuration_values = jsonencode({
        replicaCount = 1
        resources = {
          requests = {
            cpu = "50m"
            memory = "50Mi"
          }
        }
      })
    }
    kube-proxy = {
      addon_version = var.eks_addon_versions.kube_proxy
      configuration_values = jsonencode({
        resources = {
          requests = {
            cpu = "20m"
            memory = "50Mi"
          }
        }
      })
    }    
    vpc-cni = {
      # old: eks_addons_version
      # new: eks_addon_versions
      addon_version = var.eks_addon_versions.vpc_cni
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET       = "1"
          AWS_VPC_K8S_CNI_EXTERNALSNAT = "true"
        }
      })      
    }
    aws-ebs-csi-driver = {
      addon_version            = var.eks_addon_versions.aws_ebs_csi_driver
      # iam.tf
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
  }

  # make as one complex var?
  # passed from a root module
  vpc_id                   = var.vpc_id
  # for WorkerNodes
  # passed from a root module
  subnet_ids               = data.aws_subnets.private.ids
  # for the ControlPlane
  # passed from a root module
  control_plane_subnet_ids = data.aws_subnets.intra.ids

  # adding for API_AND_CONFIG_MAP
  # TODO: change to the "API" only after adding aws_eks_access_entry && aws_eks_access_policy_association
  authentication_mode = "API_AND_CONFIG_MAP"

  # `env_name` make too long name causing issues with IAM Role (?) names
  # thus, use a dedicated `env_name_short` var
  eks_managed_node_groups = {
    # eks-default-dev-1-28
    "${local.env_name_short}-default" = {

      # `eks_managed_node_group_params` from defaults here

      # number, e.g. 2
      min_size = var.eks_managed_node_group_params.default_group.min_size
      # number, e.g. 6
      max_size = var.eks_managed_node_group_params.default_group.max_size
      # number, e.g. 2
      desired_size = var.eks_managed_node_group_params.default_group.desired_size
      # list, e.g. ["t3.medium"]
      instance_types = var.eks_managed_node_group_params.default_group.instance_types
      # string, e.g. "ON_DEMAND"
      capacity_type = var.eks_managed_node_group_params.default_group.capacity_type

      # allow SSM
      iam_role_additional_policies = {
        AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
      }

      taints = var.eks_managed_node_group_params.default_group.taints

      update_config = {
        max_unavailable_percentage = var.eks_managed_node_group_params.default_group.max_unavailable_percentage
      }
    }
  }

  # 'atlas-eks-test-1-28-node-sg'
  node_security_group_name    = "${var.env_name}-node-sg"
  # 'atlas-eks-test-1-28-cluster-sg'
  cluster_security_group_name = "${var.env_name}-cluster-sg"

  # to use with EC2 Instance Connect
  node_security_group_additional_rules = {
    ingress_ssh_vpc = {
      description = "SSH from VPC"
      protocol    = "tcp"
      from_port   = 22
      to_port     = 22
      cidr_blocks      = [data.aws_vpc.eks_vpc.cidr_block]
      type        = "ingress"
    }
  }

  # 'atlas-eks-test-1-28'
  node_security_group_tags = {
    "karpenter.sh/discovery" = var.env_name
  }

  cluster_identity_providers = {
    sts = {
      client_id = "sts.amazonaws.com"
    }
  }
}

Для Karpenter:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "20.0"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  create_node_iam_role = false

  node_iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn

  enable_irsa             = true
  create_instance_profile = true

  # backward compatibility with 19.21.0
  # see https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/docs/UPGRADE-20.0.md#karpenter-diff-of-before-v1921-vs-after-v200
  iam_role_name          = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_role_description   = "Karpenter IAM role for service account"
  iam_policy_name        = "KarpenterIRSA-${module.eks.cluster_name}"
  iam_policy_description = "Karpenter IAM role for service account"

  iam_role_use_name_prefix = false

  # already created during EKS 19 > 20 upgrade with 'authentication_mode = "API_AND_CONFIG_MAP"'
  create_access_entry = false
}

...

resource "helm_release" "karpenter" {
  namespace        = "karpenter"
  create_namespace = true

  name                = "karpenter"
  repository          = "oci://public.ecr.aws/karpenter"
  repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "karpenter"
  version             = var.helm_release_versions.karpenter

  values = [
    <<-EOT
    replicas: 1
    settings:
      clusterName: ${module.eks.cluster_name}
      clusterEndpoint: ${module.eks.cluster_endpoint}
      interruptionQueueName: ${module.karpenter.queue_name}
      featureGates: 
        drift: true
    serviceAccount:
      annotations:
        eks.amazonaws.com/role-arn: ${module.karpenter.iam_role_arn} 
    EOT
  ]

  depends_on = [
    helm_release.karpenter_crd
  ]
}

Ще треба буде оновити сам Karpenter з 0.32 на 0.37.

Підготовка до апгрейду з v20.0 на v21.0

Див. Upcoming Changes Planned in v21.0.

Що зміниться?

Основне – це зміни з aws-auth: модуль буде видалено, тому варто відразу вже переходити на authentication_mode = API, а для цього нам треба перенести всіх наших юзерів та ролі з aws-auth ConfigMap в EKS Access Entries.

Крім того, варто вже переходити на нову систему роботи з ServiceAccounts – EKS Pod Identities.

І виходить, що зміни буде дві:

  • в aws-auth є записи для IAM Roles та IAM Users: їх треба створити як EKS Access Entries
  • окремо маємо кілька IAM Roles, які використовуються в ServiceAccounts – їх треба перенести в Pod Identity associations
    • в Terraform для цього маємо новий ресурс aws_eks_pod_identity_association
    • а OIDC схоже не потрібен буде зовсім

Але все це я вже буду робити новим проектом в окремому репозиторії, який буде займатись IAM та доступом до EKS і RDS. Можливо, опишу в наступному пості.

Loading

EcoFlow: моніторинг з Prometheus та Grafana
5 (1)

6 Липня 2024

В продовження теми з Підготовка до зими 2024-2025: ДБЖ, інвертори, та акумулятори.

Сюрпрайз – для EcoFlow навіть є Prometheus експортер – berezhinskiy/ecoflow_exporter.

Виглядає прям дуже круто. Запустив, подивився на це діло – і побіг писати цей пост.

Запускається фактично в пару кліків з Docker Compose, файл є “в комплекті” експортера.

Як це працює?

Виявляється, у EcoFlow є сервіс mqtt.ecoflow.com, куди девайси відправляють телеметрію. В моєму мобільному застосунку я цього не знайшов, але нагуглив ось такий скрін в треді Home Automation – My Journey:

Тобто, коли ми реєструємо девайс в мобільному застосунку – EcoFlow починає відправляти метрики, використовуючи логін/пароль, з яким ми реєструємось в самому застосунку.

Ну і за такою ж логікою працює сам експорт – див. код у ecoflow_exporter.py – ми йому в параметрах задаємо свій логін-пароль (не банкінг, можна робити), він з цим логіном-паролем підключається до api.ecoflow.com/auth/login, отримує токен, і вже з цим токеном йде на mqtt.ecoflow.com:8883.

А з вже mqtt.ecoflow.com отримує прям купу всяких цікавих метрик, на які ми сьогодні і подивимось.

Запуск EcoFlow Prometheus Exporter

Тут все прям дуже просто, і описано в документації Quick Start.

Клонуємо репозиторій:

$ git clone https://github.com/berezhinskiy/ecoflow_exporter
$ cd ecoflow_exporter/docker-compose/

В файлі compose.yaml задаємо логіни-паролі – для Grafana та для EcoFlow API:

...
  grafana:
    image: grafana/grafana
    container_name: grafana
    ports:
      - 3000:3000
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: "admin"
    volumes:
      - ./grafana:/etc/grafana/provisioning/datasources
      - grafana_data:/var/lib/grafana

  ecoflow_exporter:
    image: ghcr.io/berezhinskiy/ecoflow_exporter
    container_name: ecoflow_exporter
    ports:
      - 9091:9091
    restart: unless-stopped
    environment:
      DEVICE_SN: DAEBZ5KF1183072
      ECOFLOW_USERNAME: [email protected]
      ECOFLOW_PASSWORD: "MyPassword"
      EXPORTER_PORT: 9091
...

В DEVICE_SN вказуємо серійний номер девайсу – є в мобільному застосунку:

І запускаємо контейнери:

$ docker-compose up

Grafana dashboard

Логінимось в Grafana, переходимо до http://localhost:3000/dashboards, імпортуємо дашборду, ID 17812:

І маємо купу цікавих графіків:

Наприклад, ось момент, коли я відключив холодильник – запасу батареї відразу стало з 8 до 16 годин:

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

Як ми і рахували в попередньому пості – навантаження в 1270 ват/годину, батареї 56 вольт – маємо 22.6 ампер струму – 19 в метриках плати BMS (Battery Management System), і ще 3 ампери, мабуть, на інвертор і інші системи станції.

При цьому з розетки струм 5.7 ампери (графік Current):

>>> 1270/220
5.7

Цікаві метрики і по температурі:

Інвертор під час роботи від батарей гріється аж 80 градусів – хоча на балконі +25 (стоїть окремий термометр біля зарядних).

А як тільки світло з’явилось, і станція відключила роботу з батарей (і, відповідно, інвертор) – то температура впала.

Хоча, можливо, інвертор працює постійно, якщо EcoFlow є Online UPS (див. Типи ДБЖ), але з меншим навантаженням. Але мені здається, що EcoFlow все ж є Line-Interactive системою.

Повний список метрик є в документації експортера, правда без деталей. Метрики дефолті від самого EcoFlow, просто конвертується ім’я: bms_bmsStatus.maxCellTemp -> bms_bms_status_max_cell_temp.

Алерти в Telegram

Алерти відправляються через Telegram API та бота, токен якого можемо задати в файлі конфігурації Alertmanager alertmanager/alertmanager.yml, а самі алерти описані в файлі prometheus/alerts/ecoflow.yml – можемо тут їх потюнити, чи написати власні.

З @BotFather і командою /newbot створюємо бота:

Створюємо канал:

Додаємо бота в канал:

Знаходимо Telegram Group Chat ID.

Самий простий спосіб, який я знайшов – це через web.telegram.org – відкриваємо цей канал, і зверху маємо ID:

Відправляти можемо напряму від бота до нашого юзера – тоді в chat_id вказуємо свій ID.

Якщо все ж використовуємо групу – то ID вказуємо разом зі знаком “-“, тобто в моєму випадку це “-1002162514981“.

Редагуємо файл alertmanager.yml, додаємо параметри для Телеграму:

...
receivers:
  - name: telegram
    telegram_configs:
    - bot_token: "745***AJM"
      chat_id: -1002162514981
      api_url: https://api.telegram.org
      message: '{{ template "telegram.template" . }}'
      parse_mode: MarkdownV2

Для перевірки алертів можемо змінити умову в ecoflow.yml:

groups:
- name: EcoFlow
  rules:
    - alert: EcoFlowOffline
      expr: ecoflow_online == 0
...

На ecoflow_online != 0.

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

Алерт затригерився.

Якщо відправку робили від бота до себе, як юзера – то знаходимо нашого бота, клікаємо Send Message, аби ініціювати чат, бо сам він першим вам писати не зможе – в логах Alertmanager буде помилка “bot can’t initiate conversation with a user“:

І відразу маємо від нього повідомлення з алертом:

Якщо робили через групу – то алерт відразу прийде туди:

Взагалі є сенс перевірити алерти, до, наприклад, в EcoFlowHalfBattery використовується метрика ecoflow_bms_bms_status_f32_show_soc, яка в мене порожня – перевіряємо на сторінці http://localhost:9090/graph:

Проте є метрика ecoflow_bms_master_soc з Grafana:

Тому алерт можна переписати як:

- alert: EcoFlowHalfBattery
  expr: ecoflow_bms_master_soc < 50
  ...

Ну і в принципі на цьому все.

Далі це все можна запускати локально з автостартом через systemd, або запустити десь на Raspberry PI.

Loading

AWS: Kubernetes та Access Management API – нова схема авторизації в EKS
0 (0)

4 Липня 2024

Ще одна крута фіча, яку Амазон показав ще на минулому re:Invent в листопаді 2023 – це зміни в тому, як AWS Elastic Kubernetes Service виконує аутентифікацію та авторизацію юзерів. При чому це стосується не тільки саме користувачів кластеру, а і WorkerNodes.

Тобто, не дуже-то нова схема – але в мене ось тільки зараз дійшли руки до апгрейду кластеру з 1.28 на 1.30, заодно буду оновлювати версію модулю terraform-aws-modules/eks зі змінами ESK Access Management API, бо зараз ми на версії 19, а зміни були додані в версії 20 (див. v20.0.0 Release notes),

Про Terraform, мабуть, поговоримо в наступному пості, а сьогодні давайте глянемо як нова система працює, і що вона нам дозволяє. А вже знаючи це – візьмемо Terraform, і взагалі подумаємо про те, як організувати роботу з IAM з урахування змін в EKS Access Management API та EKS Pod Identities.

Загалом по аутентифікації/авторизації в Kubernetes можна ще глянути старі пости:

Як це працює?

Раніше ми мали спеціальний aws-auth ConfigMap, в якому описувались WorkerNodes IAM Roles, всі наші юзери та їхні групи.

Відтепер, ми можемо керувати доступами в EKS напряму через його API використовуючи AWS IAM в ролі аутентифікатора. Тобто, юзер логіниться в AWS, AWS виконує аутентифікацію – перевіряє, що це саме той юзер, за якого він себе видає, а потім, коли юзер підключається до Kubernetes – то виконується його авторизація – перевірка прав доступу до кластеру і в самому кластері.

При цьому ця схема чудово працює з RBAC самого Kubernetes.

І ще одна дуже важлива деталь – що ми нарешті можемо позбутись “дефолтного root-юзера” – прихованого адміністратора кластера, від імені якого він створювався. При чому раніше ми не мали змоги ніде його побачити або змінити, що іноді спричиняло проблеми.

Отже, якщо раніше нам потрібно було самим керувати записами в aws-auth ConfigMap, і не дай боже його зламати (а в мене траплялось через чи то кривий маніфест, чи то не дуже прямі руки) – то тепер ми можемо винести управління доступами в окремий Terraform-код, і управляти доступами набагато простіше і з меншим ризиком.

Зміни в IAM та EKS

Тепер у нас в EKS є дві нові сутності – Access entries та Access policies:

  • Amazon EKS Access Entries – запис в EKS про об’єкт, який пов’язаний з AWS IAM роллю чи юзером
    • описує тип (звичайний юзер, EC2, etc), Kubernetes RBAC Groups, або EKS Access Policy
  • Amazon EKS Access Policy – політика в EKS, яка описує права для EKS Access Entries. І це політики саме EKS – ви не знайдете їх в IAM.

Наразі є 4 EKS Access Policy, які ми можемо використати, і вони аналогічні дефолтним User-facing ClusterRoles в Kubernetes :

  • AmazonEKSClusterAdminPolicy – cluster-admin
  • AmazonEKSAdminPolicy – admin
  • AmazonEKSEditPolicy – edit
  • AmazonEKSViewPolicy – view

Підозрюю, що десь під капотом ці EKS Access Policy просто мапляться на Kubernetes ClusterRoles.

Ці політики ми підключаємо до IAM Role чи IAM user, і під час підключення до Kubernetes-кластеру EKS Autorizer перевіряє які саме права є у цього користувача.

Схематично це можна відобразити так:

Або така схема, з блогу AWS A deep dive into simplified Amazon EKS access management controls:

Замість дефолтних AWS managed IAM Policy ми при створенні EKS Access Policy можемо вказати ім’я Kubernetes RBAC Group – і тоді замість EKS Autorizer буде використано механізм Kubernetes RBAC – далі подивимось, як це працює.

Для Terraform в terraform-provider-aws версії 5.33.0 були додані два нових відповідних типи resource – aws_eks_access_entry та aws_eks_access_policy_association. Але зараз все будемо робити руками.

Налаштування Cluster access management API

Перевіряти як воно працює будемо на існуючому кластері версії 1.28.

Відкриваємо налаштування кластера, вкладка Access, клікаємо Manage access:

Зараз у нас включено ConfigMap (той самий aws-auth) – міняємо на EKS API and ConfigMap – так ми залишимо і старий механізм, і протестуємо новий (в Terraform це також можна зробити):

Звертаємо увагу на попередження “Once you modify a cluster to use EKS access entry API, you cannot change it back to ConfigMap only” – але terraform-aws-modules/eks версії 19.21 ці зміни ігнорує і нормально працює далі, тож можна міняти руками.

Тепер кластер буде виконувати авторизацію юзерів і з aws-auth ConfigMap, і з EKS Access Entry API, з перевагою до Access Entry API.

Після переключення на EKS Access Entry API відразу маємо нові EKS Access Entries:

І як раз тепер ми можемо побачити того самого “прихованого root-юзера” – assumed-role/tf-admin, бо Teraform працює саме від цієї IAM-ролі, і в моєму сетапі це робилось як через цей механізм EKS, від якого тепер можна буде позбутись.

Але з поточного aws-auth ConfigMap взято не все – роль для WorkerNodes є, а от решта записів (юзери з mapUsers та ролі з mapRoles) автоматично не додались. Хоча під час зміни параметра API_AND_CONFIG_MAP через Teraform це наче має відбутись – потім перевіримо.

Підключення нового IAM User до кластеру EKS

Перевірити існуючі EKS Access Entries з AWS CLI можна командою aws eks list-access-entries:

$ aws --profile work eks list-access-entries --cluster-name atlas-eks-test-1-28-cluster
{
    "accessEntries": [
        "arn:aws:iam::492***148:role/test-1-28-default-eks-node-group-20240702094849283200000002",
        "arn:aws:iam::492***148:role/tf-admin"
    ]
}

Давайте додамо нового IAM User з доступом до кластеру.

Створюємо юзера:

В Set permissions не вибираємо нічого, просто клікаємо Next:

Створюємо Access Key – будемо використовувати цього юзера з AWS CLI, аби згенерувати kubectl config:

Додаємо дозвіл на eks:DescribeCluster:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Statement1",
      "Effect": "Allow",
      "Action": ["eks:DescribeCluster"],
      "Resource": ["*"]
    }
  ]
}

Зберігаємо, і створюємо новий AWS CLI профайл:

$ vim -p ~/.aws/config ~/.aws/credentials

Додаємо профайл до ~/.aws/config:

[profile test-eks]
region = us-east-1
output = json

І ключі до ~/.aws/credentials:

[test-eks]
aws_access_key_id = AKI***IMN
aws_secret_access_key = Kdh***7wP

Створюємо новий kubectl context:

$ aws --profile test-eks eks update-kubeconfig --name atlas-eks-test-1-28-cluster --alias test-cluster-test-user
Updated context test-cluster-test-user in /home/setevoy/.kube/config

З юзером закінчили – тепер треба з ним підключитись до кластеру.

EKS: створення Access Entry

Можемо зробити або через AWS Console:

Або з AWS CLI (з робочим профайлом, а не новим, бо в нього ніяких прав нема) і командою aws eks create-access-entry.

В параметрах передаємо ім’я кластера та ARN юзера чи ролі, яких підключаємо до кластеру (але, мабуть, більш коректно буде сказати “для яких створюємо точку входу на кластер“, бо сутність називається Access Entry):

$ aws --profile work eks create-access-entry --cluster-name atlas-eks-test-1-28-cluster --principal-arn arn:aws:iam::492***148:user/test-eks-acess-TO-DEL
{
    "accessEntry": {
        "clusterName": "atlas-eks-test-1-28-cluster",
        "principalArn": "arn:aws:iam::492***148:user/test-eks-acess-TO-DEL",
        "kubernetesGroups": [],
        "accessEntryArn": "arn:aws:eks:us-east-1:492***148:access-entry/atlas-eks-test-1-28-cluster/user/492***148/test-eks-acess-TO-DEL/98c8398d-9494-c9f3-2bfc-86e07086c655",
        ...
        "username": "arn:aws:iam::492***148:user/test-eks-acess-TO-DEL",
        "type": "STANDARD"
    }
}

Ще раз глянемо в AWS Console:

Новий Enrty додано, йдемо далі.

Підключення EKS Access Policy

Зараз доступу до кластеру з новим юзером ми все ще не маємо, бо до нього не підключена політика – поле Access policies пусте.

Перевіряємо з kubectl auth can-i:

$ kubectl auth can-i get pod
no

Додати EKS Access Policy можемо або в AWS Console:

Або знов-таки з AWS CLI і командою aws eks associate-access-policy.

Давайте поки додамо AmazonEKSViewPolicy:

$ aws --profile work eks associate-access-policy --cluster-name atlas-eks-test-1-28-cluster \                                                                    
> --principal-arn arn:aws:iam::492***148:user/test-eks-acess-TO-DEL \
> --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy \
> --access-scope type=cluster
{
    "clusterName": "atlas-eks-test-1-28-cluster",
    "principalArn": "arn:aws:iam::492***148:user/test-eks-acess-TO-DEL",
    "associatedAccessPolicy": {
        "policyArn": "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy",
        "accessScope": {
            "type": "cluster",
            "namespaces": []
        },
    ...
}

Зверніть увагу на --access-scope type=cluster – зараз ми видали ReadOnly права на весь кластер, але можемо обмежити конкретним неймспейсом(ми) – далі спробуємо.

Глянемо ще раз в AWS Console:

Access Policy додано.

Пробуємо kubectl:

$ kubectl auth can-i get pod
yes

Але не можемо створити под – бо маємо ReadOnly права:

$ kubectl auth can-i create pod
no

Інші корисні команди для AWS CLI:

Видалення default root user

На майбутнє, відключити створення такого юзеру при створенні кластера з aws eks create-cluster можна параметром bootstrapClusterCreatorAdminPermissions=false.

А зараз давайте замінимо його – додамо нашому тестовому юзеру адмін-права і видалимо дефолтного root.

Повторяємо aws eks associate-access-policy, але тепер в --policy-arn вказуємо AmazonEKSClusterAdminPolicy:

$ aws --profile work eks associate-access-policy --cluster-name atlas-eks-test-1-28-cluster \
> --principal-arn arn:aws:iam::492***148:user/test-eks-acess-TO-DEL \
> --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \
> --access-scope type=cluster

І що з правами тепер?

$ kubectl auth can-i create pod
yes

Тепер маємо двох cluster-admin:

І можемо видалити старого:

$ aws --profile work eks delete-access-entry --cluster-name atlas-eks-test-1-28-cluster --principal-arn arn:aws:iam::492***148:role/tf-admin

Namespaced EKS Access Entry

Замість того, аби видавати права на весь кластер з --access-scope type=cluster – ми можемо зробити юзера адміном тільки у конкретних неймспейсах.

Не відключаємо нашого тестового юзера – бо наразі це наш єдиний адмін. Давайте візьмемо звичайного IAM User, то зробимо його адміном тільки в одному Kubernetes Namespace.

Створюємо новий неймспейс:

$ kk create ns test-ns
namespace/test-ns created

Створюємо нову EKS Access Entry для мого AWS IAM User:

$ aws --profile work eks create-access-entry --cluster-name atlas-eks-test-1-28-cluster --principal-arn arn:aws:iam::492***148:user/arseny

І підключаємо AmazonEKSEditPolicy, але в --access-scope задаємо тип namespace та вказуємо ім’я цього NS:

$ aws --profile work eks associate-access-policy --cluster-name atlas-eks-test-1-28-cluster \
> --principal-arn arn:aws:iam::492***148:user/arseny \
> --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy \        
> --access-scope type=namespace,namespaces=test-ns

В access-scope ми можемо задати або clutser, або неймспейс. Не дуже гнучко – але для гнучкості у нас є RBAC.

Генеруємо новий kubectl context з --profile work, де profile work – мій звичайний AWS User, для якого ми створювали EKS Access Entry з AmazonEKSEditPolicy:

$ aws --profile work eks update-kubeconfig --name atlas-eks-test-1-28-cluster --alias test-cluster-arseny-user
Updated context test-cluster-arseny-user in /home/setevoy/.kube/config

Перевіряємо активний kubectl context:

$ kubectl config current-context
test-cluster-arseny-user

І перевіряємо права – спочатку в default Namespace:

$ kubectl --namespace default auth can-i create pod
no

І в тестовому неймспейсі:

$ kubectl --namespace test-ns auth can-i create pod
yes

Nice!

Все працює.

EKS Access Entry та Kubernetes RBAC

Замість того, щоб підключати EKS Access Policy від AWS, яких всього чотири – ми можемо використати звичайний механізм Kubernetes Role-Based Access Control, RBAC.

Це виглядає так:

  1. в EKS створюємо Access Entry
    1. в параметрах Access Entry вказуємо Kubernetes RBAC Group
  2. а далі за звичною схемою – використовуємо RBAC Group та Kubernetes RoleBinding

Тоді ми пройдемо аутентифікацію в AWS, після чого AWS “передасть” нас до Kubernetes, а той вже виконає авторизацію – перевірку наших прав в кластері – на основі нашої RBAC-групи.

Видаляємо створену Access Entry для мого IAM User:

$ aws --profile work eks delete-access-entry --cluster-name atlas-eks-test-1-28-cluster --principal-arn arn:aws:iam::492***148:user/arseny

Створюємо його заново, але тепер додаємо --kubernetes-groups:

$ aws --profile work eks create-access-entry --cluster-name atlas-eks-test-1-28-cluster \                                                              
> --principal-arn arn:aws:iam::492***148:user/arseny \
> --kubernetes-groups test-eks-rbac-group

Глянемо в AWS Console:

Пробуємо перевірити права з kubectl:

$ kubectl --namespace test-ns auth can-i create pod
no

Бо EKS Access Policy ми не додавали, і в RBAC нічого не робили.

Опишемо RoleBinding та зв’яжемо RBAC групу test-eks-rbac-group з дефолтною Kubernetes edit ClusterRole:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: test-eks-rbac-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: edit
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: test-eks-rbac-group

Переключаємо контекст на нашого адміна (я для цього користуюсь kubectx):

$ kx
✔ Switched to context "test-cluster-test-user".

І створюємо RoleBinding в неймспейсі test-ns, аби дати юзеру права Edit тільки в цьому NS:

$ kubectl --namespace test-ns apply -f test-rbac.yml 
rolebinding.rbac.authorization.k8s.io/test-eks-rbac-binding created

Переключаємось на юзера arseny:

$ kx
✔ Switched to context "test-cluster-arseny-user".

І знову перевіряємо права в двох неймспейсах:

$ kubectl --namespace default auth can-i create pod
no

$ kubectl --namespace test-ns auth can-i create pod
yes

RBAC працює.

Ну і в принципі на цьому все.

Механізм працює, основні його сутності в роботі побачили – тепер можна переходити до Terraform.

Loading