GitHub Actions: деплой Terraform з review запланованих змін
0 (0)

7 Березня 2024

У пості GitHub Actions: деплой Dev/Prod оточень з Terraform я вже описував те, як можна реалізувати CI/CD для Terraform з GitHub Actions, але в тому рішенні є один суттєвий недолік: немає можливості зробити review змін перед тим, як їх застосувати з terraform apply.

GitHub Actions має можливість використання Reviewing deployments для approve/reject, проте ця можливість доступна тільки на GitHub Enterprise.

Тож як ми можемо вирішити цю проблему?

Звісно, ми взагалі можемо використати рішення на кшталт Atlatis або Gaia – але коли на проекті всього 4-5 репозиторіїв з Terraform, то такі утілити будуть трохи overkill.

Як варіант – продовжити використання GitHub Actions з hashicorp/setup-terraform, але робити terraform plan під час створення Pull Request, а його output виносити в коментарі до PR. Тоді перед тим, як підтвердити Merge – ми зможемо подивитись на те, які зміни будуть застосовані, і виконувати terraform apply після того, як feature/fix branch буде вмержено в master.

Хоча це теж не ідеальне рішення – бо між створенням Pull Request і виконанням Terraform Plan та мержем і виконанням Terraform Apply може пройти час, за який в інфраструктурі щось зміниться, і той Plan вже буде не актуальний – тож треба розібратись і з цим.

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

  • створимо тестовий код Terraform
  • створимо два GitHub Actions Workflow:
    • test on Pull Request created
    • deploy on Pull Request merged
  • і подивимось на варіанти того, як можна вирішити проблему з неактуальним Terraform Plan

Підготовка

Створимо проект Terraform, потім підготуємо репозиторій GitHub.

Terraform – тестові ресурси

В тестовому репозиторії створюємо каталог terraform, і в ньому файл terraform.tf:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-test"
    key            = "atlas-test.tfstate"
    region         = "us-east-1"
    encrypt        = true
  }
}

terraform {
  required_version = "~> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component  = "devops"
      created-by = "terraform"
      environment = "test"
      project     = "atlas-test"
    }
  }
}

Для тесту будемо створювати S3-бакет – додаємо main.tf:

resource "aws_s3_bucket" "example" {
  bucket = "atlas-test-bucket-ololo"

  tags = {
    Name = "atlas-test-bucket-ololo"
  }
}

Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket – AuthorizationHeaderMalformed

Трохи забігаючи наперед, і трохи “оффтопу”: вже коли робив terraform apply, то зловив помилку:

Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket, https response error StatusCode: 400, api error AuthorizationHeaderMalformed: The authorization header is malformed; the region ‘us-east-1’ is wrong; expecting ‘us-west-2’

Текст трохи вводить в оману, бо – при чому тут регіони? В provider "aws" у нас явно задано us-east-1, з AIM Role теж в порядку. Звідки взагалі береться us-west-2?

Якщо включити debug з export TF_LOG=debug, то бачимо, що дійсно:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-west-2″.

А “лікується” це тим, що – як ми знаємо – ім’я корзини має ж бути унікальним на весь AWS Region, тобто якщо додати отой суфікс “ololo” – то все працює, як треба:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-east-1

Схоже, що чи то сам AWS, чи Terraform намагається зробити корзину в іншому регіоні, якщо в заданому така вже у когось є.

Окей, йдемо далі.

Робимо cd terraform/ && terraform fmt && terraform init && terraform plan:

Тут все готово, можемо переходити до налаштувань GitHub Actions.

GitHub Actions – змінні оточення репозиторію

Для запуску Terraform в GitHub Actions нам потрібна одна змінна, в якій ми будемо передавати AWS IAM Role, за допомогою якої GitHub зможе виконувати дії в нашому AWS акаунті – див. Configuring OpenID Connect in Amazon Web Services.

Переходимо в Settings > Actions secrets and variables, переходимо в Variables:

В Repository variables додаємо нову змінну TF_AWS_ROLE – вона буде використовуватись в aws-actions/configure-aws-credentials:

І в принципі наразі це все, що нам потрібно – можемо починати створювати наші GitHub Actions Workflow.

GitHub Actions – створення Workflow

Для того, щоб Actions зміг запустити наш код Terrform, нам потрібно:

  • виконати checkout коду на GitHub Runner, на якому буде запускатись флоу: використаємо actions/checkout
  • виконати аутентифікацію в AWS: використаємо aws-actions/configure-aws-credentials – він виконає AssumeRole зі змінної TF_AWS_ROLE та створить змінні оточення з ключами та AWS_SESSION_TOKEN
  • запустити terraform init та інші: використаємо hashicorp/setup-terraform – він додасть сам Terraform

Тут в принципі можна використати той же підхід з окремими Actions для кожного step, як описувалось в Створення test-on-push Workflow, але зараз для наочності та простоти все опишемо прям у файлі workflow.

Workflow з Terraform Validate та Plan

Першим створимо Workflow, який буде запускатись при створенні PR та буде виконувати перевірки з terraform validate, tflint і т.д, а потім буде виконувати terraform plan, результат якого буде додавати в коментарі до Pull Request, який тригернув цей workflow.

В корні репозиторію створюємо каталог .github/workflows, і в ньому файл terraform-test-on-pr.yml:

name: "Terraform: test on PR open"

# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false

on: 
  # can be ran manually
  workflow_dispatch:
  # run on pull-requests
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    # only if changes were in the 'terraform' directory
    paths:
      - terraform/**
      
permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read
  # allow adding comments in a Pull Request
  pull-requests: write

jobs:
  
  terraform:

    name: "Test: Terraform"
    runs-on: ubuntu-latest
    # to avoid using GitHub Runners time
    timeout-minutes: 10
    defaults:
      run:
        # run all steps in the 'terraform' directory
        working-directory: ./terraform

    steps:

      # get repository's content
      - name: "Setup: checkout"
        uses: actions/checkout@v4

      # setup 'env.AWS_*' variables to run Terraform
      - name: "Setup: Configure AWS credentials"
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 900
          aws-region: us-east-1

      # terraform formatting check
      - name: "Test: Terraform fmt"
        id: fmt
        run: terraform fmt -check -no-color
        # do not throw error, just warn
        continue-on-error: true

      # use TFLint to check the code
      - name: "Setup: TFLint"
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0

      - name: "Test: Terraform linter"
        run: tflint -f compact
        shell: bash

      # use official Action
      - name: "Setup: Terraform"
        uses: hashicorp/setup-terraform@v3

      # get modules, configure backend
      - name: "Test: Terraform Init"
        id: init
        run: terraform init -no-color

      # verify whether a configuration is syntactically valid
      - name: "Test: Terraform Validate"
        id: validate
        run: terraform validate -no-color

      # create a Plan to see what will be changed
      - name: "Test: Terraform Plan"
        id: plan
        run: terraform plan -no-color

В concurrency забороняємо одночасний запуск кількох workflows, див. Using concurrency, і це нам знадобиться далі, коли будемо робити terraform apply.

Для команд Terraform задаємо параметр -no-color, щоб потім в коментарях до Pull Request не мати зайвих символів:

Додаємо .gitignore:

**/.terraform/*

Див. повний приклад в Terraform.gitignore.

Комітимо всі зміни, пушимо в репозиторій, якщо зміни робили в окремому бранчі – то мержимо в master, щоб GitHub Actions “побачив” новий Workflow файл, і маємо запущений Workflow:

Додавання Terraform Plan в коментарі до Pull request

Далі використаємо actions/github-script, який через GitHub API може додавати коментарі до нашого PR.

А в коментарі використаємо outputs з нашого steps.plan.

Додаємо ще один step:

...
      # generate comment to the Pull Request using 'steps.plan.outputs'
      - name: "Misc: Post Terraform summary to PR"
        uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>
      
            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`
      
            </details>
      
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
      
            <details><summary>Show Plan</summary>
      
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
      
            </details>
      
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

Тут:

  • виконуємо step якщо github.event_name == 'pull_request'
  • створюємо змінну оточення PLAN зі змістом із steps.plan.outputs.stdout
  • запускаємо actions/github-script, якому:
    • передаємо GITHUB_TOKEN для аутентифікації (використає permissions.pull-requests: write)
    • передаємо аргумент script, в якому:
      • створюємо const output, де ми формуємо текст для коментаря
      • викликаємо функцію github.rest.issues.createComment, яка:

Мене трохи засмутило, що викликається github.rest.issues.createComment – бо чому issues, коли ми маємо справу з Pull Request?

Але в документації Sending requests to the GitHub API сказано, що “issues and PRs are treated as one concept by the Octokit client“.

І в документації самого клієнта:

You can use the REST API to create comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.

При потребі, можна отримати всі дані з контекстів так:

...
      - name: Dump Job Context
        env: 
          JOB_CONTEXT: ${{toJson(github)}}
        run: echo "$JOB_CONTEXT"

      - name: View context attributes
        uses: actions/github-script@v7
        with:
          script: console.log(context)  
...

Окей.

Пушимо зміни, чекаємо, поки виконається джоба (пам’ятаємо, що тригер у нас – зміни в каталозі terraform):

І маємо новий коментар в Pull Request:

Що тут ще можна “затюнити” – це замість створення нового коментаря в Pull Request оновлювати вже існуючий.

Для цього можна використати функцію github.rest.issues.listComments, за допомогою якої знайти всі коментарі comment.body.includes(‘Terraform Format and Style’), і якщо такий знайдеться – то виконати github.rest.issues.updateComment, а якщо ні – то github.rest.issues.createComment, як робили вище:

...
      - name: "Misc: Post Terraform summary to PR"
        uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // 1. Retrieve existing bot comments for the PR
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            })
            const botComment = comments.find(comment => {
              return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
            })
      
            // 2. Prepare format of the comment
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>
      
            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`
      
            </details>
      
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
      
            <details><summary>Show Plan</summary>
      
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
      
            </details>
      
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            // 3. If we have a comment, update it, otherwise create a new one
            if (botComment) {
              github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: output
              })
            } else {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: output
              })
            }

Хоча тут кому-як, бо може більш інформативно буде мати історію планів.

Окей.

Тепер ми маємо GitHub Actions Workflow, який виконує перевірки Terraform, і постить результат Plan в коментарі до Pull Request.

Єдине, що ще треба на увазі – це ліміт на кількість символів в коментарі до Pull Request – 65535 – і деякі Plan можуть не влізти.

Останнім нам треба додати Workflow, який буде виконувати Terraform Apply, коли PR змержено.

Workflow з Terraform Apply

Створюємо файл terraform-deploy-on-pr-merge.yml:

name: "Terraform: Apply on push to master"

# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false

on:
  # run on PR merge, e.g. 'push' changes to the 'master'
  push:
    branches:
      - master
    # only if changes were in the 'terraform' directory
    paths:
      - terraform/**

permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read

jobs:

  deploy:

    name: "Deploy: Terraform"
    runs-on: ubuntu-latest
    # to avoid using GitHub Runners time
    timeout-minutes: 30
    defaults:
      run:
        # run all steps in the 'terraform' directory
        working-directory: ./terraform

    steps:

      # get repository's content
      - name: "Setup: checkout"
        uses: actions/checkout@v4

      # setup 'env.AWS_*' variables to run Terraform
      - name: "Setup: Configure AWS credentials"
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 900
          aws-region: us-east-1

      # use TFLint to check the code
      - name: "Setup: TFLint"
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0
          
      - name: "Test: Terraform lint"
        run: tflint -f compact
        shell: bash

      # use official Action
      - name: "Setup: Terraform"
        uses: hashicorp/setup-terraform@v3

      # get modules, configure backend
      - name: "Setup: Terraform Init"
        id: init
        run: terraform init -no-color

      # verify whether a configuration is syntactically valid
      - name: "Test: Terraform Validate"
        id: validate
        run: terraform validate -no-color

      # create a Plan to use with the 'apply'
      - name: "Deploy: Terraform Plan"
        id: plan
        run: terraform plan -no-color -out tf.plan

      # deploy changes using Plan's file
      - name: "Deploy: Terraform Apply"
        id: apply
        run: terraform apply -no-color tf.plan

Тут все майже те саме, що і попередньому workflow, тільки тригером буде push в master, а результат виконання terraform plan ми зберігаємо у файл tf.plan, і з нього ж потім виконуємо terraform apply.

Пушимо в репозиторій, мержимо – і маємо виконаний apply:

Добре. Виглядає начебто чудово?

Але є проблема.

What if? Outdated Terraform Plan

А проблема полягає в тому, що за той час, коли ми подивились результат terraform plan в коментарях до PR і до моменту, коли він буде змержений і виконаний – пройде якийсь час, за який в ресурсах можливі зміни – чи то хтось запустить deploy-джобу з іншого PR, чи хтось задеплоїть власні зміни зі своєї машини (у нас це поки що допускається).

І тоді той Plan, який ми бачили в результатах виконання workflow Terraform Test вже буде неактуальний.

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

Тож що ми можемо зробити, щоб запобігти такому?

Terraform: “Saved plan is stale”

Як варіант – це зберігати результати виконання terraform plan в файл, і потім цей же файл передавати в terraform apply.

Тоді, якщо в Terraform State відбулися зміни, то файл з Plan, який ми передамо на apply, сфейлиться, див. Manual State Pull/Push.

Як можемо перевірити те, як воно працює (тобто фейлиться):

  1. деплоїмо нашу корзину
  2. додаємо тег
  3. робимо terraform plan -out test.plan
  4. додаємо ще один тег
  5. робимо terraform apply
  6. а потім ще раз terraform apply, але вже з файлу test.plan – симулюємо зміни, які відбулися між запуском наших workflow

Отже – маємо корзину:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
  }
}

Деплоїмо її з terraform apply:

$ terraform apply
...
aws_s3_bucket.example: Creating...
aws_s3_bucket.example: Creation complete after 3s [id=atlas-test-bucket-ololo]

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

Додаємо тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
  }
}

Виконуємо terraform plan, результат зберігаємо у файл:

$ terraform plan -out=test.plan
...
Plan: 0 to add, 1 to change, 0 to destroy.

Saved the plan to: test.plan

Додаємо ще один тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
    NewTag2 = "NewTag2"
  }
}

Деплоїмо без test.plan:

$ terraform apply
...
aws_s3_bucket.example: Modifying... [id=atlas-test-bucket-ololo]
aws_s3_bucket.example: Modifications complete after 3s [id=atlas-test-bucket-ololo]

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

А тепер пробуємо задеплоїти з файлу:

$ terraform apply test.plan
Acquiring state lock. This may take a few moments...
╷
│ Error: Saved plan is stale
│ 
│ The given plan file can no longer be applied because the state was changed by another operation after the plan was created.

Чудово – наш деплой обламався.

GitHub Actions та Artifacts: передача файлу з Terraform Plan

Тепер спробуємо реалізувати механізм передачі plan-файлу між workflow. Спочатку зробимо його збереження в artifact.

Note: тут ще треба враховувати питання Security, бо в plan можуть бути конфіденційні дані. З іншого боку, якщо хтось отримав доступ до вашого CI – то ви й так маєте проблеми.

Save Plan output as an Artifact

Тож що нам треба зробити:

  1. при створенні Pull Request виконуємо terraform plan -out name.tfplan
  2. зберігаємо файл name.tfplan як артефакт
  3. при мержі Pull Request завантажуємо цей name.tfplan на GitHub Runner
  4. і виконуємо terraform apply name.tfplan

Виглядає наче досить просто? Якби ж то…

Почнемо з вигрузки артефакту після тесту – тут все дійсно просто.

Задля того, щоб при виконанні terraform apply взяти артефакт саме з цього PR – додамо в ім’я файлу номер PR.

Оновлюємо файл terraform-test-on-pr.yml:

...
      # create a Plan to see what will be changed
      # save it to the file with a PR number
      - name: "Test: Terraform Plan"
        id: plan
        run: terraform plan -no-color -out env-test-${{ github.event.pull_request.number }}.tfplan

      # save as an artifact to this workflow run
      - name: Upload Terraform Plan
        uses: actions/upload-artifact@v4
        with:
          name: env-test-${{ github.event.pull_request.number }}.tfplan
          path: "terraform/env-test-${{ github.event.pull_request.number }}.tfplan"
          # throw an error if we can't find the Plan's file
          if-no-files-found: error
          # replace if an existing one is found
          overwrite: true
...

(задаємо тут в path каталог terraform/, бо “upload-artifact action does not use the working-directory setting”, див. No files were found with the provided path: build. No artifacts will be uploaded.)

Пушимо, і перевіряємо джобу:

Use Terraform Plan’s Artifact for the Terraform Apply

Далі, нам треба цей файл використати в іншому workflow, де відбувається деплой – і тут маємо дві проблеми:

  1. офіційний actions/download-artifact не підтримує завантаження файлів з інших workflow
  2. наш workflow з terraform apply запускається при івенті push, а не pull_request – і в контексті github у нас вже нема github.event.pull_request.number

Першу проблеми ми можемо вирішити за допомогою іншого Action – dawidd6/action-download-artifact, якому можна передати ім’я файлу іншого workflow, в якому буде потрібний артефакт.

А другу проблему можемо вирішити за допомогою Action jwalton/gh-find-current-pr, який виконує запит до GitHub API, і повертає номер PR, з якого був зроблений Merge.

Отже, оновлюємо наш terraform-deploy-on-pr-merge.yml – додаємо permissions.actions: read, permissions.pull-requests: read та два нових step – прибираємо Terraform Plan, отримуємо PR number, та оновлюємо Terraform Apply – передаємо файл з планом:

...
permissions:
  # create OpenID Connect (OIDC) ID token
  id-token: write
  # allow read repository's content by steps
  contents: read
  # get PR number
  pull-requests: read
  # allow download artifacts
  actions: read
...
      # get a PR number used to make the 'push' when merging
      - name: "Misc: get PR number"
        uses: jwalton/gh-find-current-pr@master
        id: findpr
        with:
          state: all

      # download artifact witjh the Terraform Plan file of the 'Terraform Test' workflow         
      - name: "Misc: Download Terraform Plan"
        uses: dawidd6/action-download-artifact@v3
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          # the Workflow to look for the artifact
          workflow: terraform-test-on-pr.yml
          # PR number used to generate the artifact and trigger this workflow
          pr: ${{ steps.findpr.outputs.pr }}
          # artifact's name
          name: env-test-${{ steps.findpr.outputs.pr }}.tfplan
          path: terraform/
          # ensure we have the file in the workflow
          check_artifacts: true

      - name: "Deploy: Terraform Apply"
        id: apply
        run: terraform apply -no-color env-test-${{ steps.findpr.outputs.pr }}.tfplan

Все пушимо, мержимо – і маємо виконаний Terraform Apply з використанням файлу з Terraform Plan попереднього workflow:

Також ще один важливий нюанс – завдяки однаковому значенню в concurrency.group в обох Workflow – наш деплой завжди буде чекати виконання Plan, що корисно, коли PR створюється і тут же мержиться:

...
# set other jobs with the same 'group' in a queue
concurrency:
  group: deploy-test
  cancel-in-progress: false
...

Рішення виглядає цілком робочим, і перевірки з кількома одночасними PR пройшло. Хоча мене трохи напрягає те, що тут маємо “too many moving parts”, ще й покладаємось на thirdparty GitHub Actions.

Використання GitHub Branch protection rule

Є ще один варіант, і можна або використати тільки його – або варіант, писаний вище + цей: це задати Branch protection rule, де буде вимога в бранчі, з якого робиться PR, мати всі зміни, які вже є в master.

Якщо використовувати тільки такий підхід – то прибираємо steps з uploads/download артефакту, і натомість використовуємо terraform plan -out file.tfplan та terraform apply file.tfplan в одній джобі, як робили до рішення з artifacts.

Хоча при такому підході ви все одно покладаєтесь на те, що Plan, який буде створено під час виконання джоби буде == тому плану, який ви дивились в коментарях, бо “запобіжник” з “Saved plan is stale” тут не спрацює.

Втім, налаштувати Branch protection rule все одно буде корисно:

Включаємо “Require status checks to pass before merging” та “Require branches to be up to date before merging“:

Тоді, якщо в master бранч був зроблений merge з іншого бранчу – GitHub не дозволить виконати PR Merge, поки ви не оновите свій бранч, а це викличе ще один запуск workflow з Terraform Test і генерацію нового Plan, який буде додано в коментарі:

Клікаємо Update branch – запускається нова перевірка, генерується новий артефакт з новим Plan в артефакті (якщо комбінуємо обидва рішення):

І тепер можемо мержити, і деплоїти:

Готово.

P.S. І в будь-якому разі завжди майте S3 Bucket Versioning, щоб мати бекап ваших state-файлів.

Loading

Kubernetes: tracing запитів з AWS X-Ray та Grafana data source
0 (0)

2 Березня 2024

Tracing (“трасування”) дозволяє відстежувати запити між компонентами, тобто, наприклад, при використанні AWS і у Kubernetes  ми можемо прослідкувати весь шлях запиту від AWS Load Balancer – до Kubernetes Pod – і до DynamoDB або RDS.

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

В AWS для трейсінгу існує є сервіс X-Ray, куди ми можемо відправляти дані за допомогою AWS X-Ray SDK for Python або AWS Distro for OpenTelemetry Python (або інших мов, але тут будемо говорити про Python).

AWS X-Ray до кожного запиту додає унікальний X-Ray ID і дозволяє будувати картину повного “маршруту” запиту.

Окрім X-Ray, в Kubernetes ми можемо трейсити за допомогою таких рішень як Jaeger або Zipkin, і потім будувати картину в Grafana Tempo.

Інше рішення – використовувати X-Ray Daemon, який ми можемо запустити в Kubernetes, і додати в Grafana плагін X-Ray. Див. приклади в Introducing the AWS X-Ray integration with Grafana.

Крім того, AWS Distro for OpenTelemetry теж працює з Trace ID, сумісними з AWS X-Ray – див. AWS Distro for OpenTelemetry and AWS X-Ray та Collecting traces from EKS with ADOT.

Проте сьогодні ми будемо додавати саме X-Ray коллектор, який створить Kubernetes DaemonSet та Kubernetes Service, в який Kubernetes Pods зможуть слати дані, які ми потім зможемо побачити або в AWS Console X-Ray, або в Grafana.

AWS IAM

IAM Policy

Для доступу в AWS з подів з X-Ray нам потрібно створити IAM Role, яку ми потім будемо використовувати в ServiceAccount для X-Ray.

Ми все ще користуємось старим варіантом додавання IAM Role через ServiceAccounts, див. Kubernetes: ServiceAccount з AWS IAM Role для Kubernetes Pod, хоча нещодавно AWS анонсували Amazon EKS Pod Identity Agent add-on – див. AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів.

Отже, створюємо IAM Policy з дозволами для запису в X-Ray:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "xray:PutTraceSegments",
                "xray:PutTelemetryRecords"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

Зберігаємо:

IAM Role

Далі додаємо IAM Role, яку зможе використовувати Kubernetes ServiceAccount.

Знаходимо Identity provider нашого EKS-кластеру:

Переходимо в IAM Role, додаємо нову роль.

В Trusted entity type вибираємо Web Identity, і в Web identity вибираємо Identity provider нашого EKS, в Audience – AWS STS:

Підключаємо створену вище політику:

Зберігаємо:

Запуск X-Ray Daemon в Kubernetes

Використаємо Helm-чарт okgolove/aws-xray.

Створюємо файл x-ray-values.yaml – див. дефолтні значення у values.yaml:

serviceAccount:
  annotations: 
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/XRayAccessRole-test
xray:
  region: us-east-1
  loglevel: prod

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

$ helm repo add okgolove https://okgolove.github.io/helm-charts/

Встановлюємо чарт в кластер, який створить DaemonSet і Service:

$ helm -n ops-monitoring-ns install aws-xray okgolove/aws-xray -f x-ray-values.yaml

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

$ kk get pod -l app.kubernetes.io/name=aws-xray
NAME             READY   STATUS    RESTARTS   AGE
aws-xray-5n2kt   0/1     Pending   0          41s
aws-xray-6cwwf   1/1     Running   0          41s
aws-xray-7dk67   1/1     Running   0          41s
aws-xray-cq7xc   1/1     Running   0          41s
aws-xray-cs54v   1/1     Running   0          41s
aws-xray-mjxlm   0/1     Pending   0          41s
aws-xray-rzcsz   1/1     Running   0          41s
aws-xray-x5kb4   1/1     Running   0          41s
aws-xray-xm9fk   1/1     Running   0          41s

Та Kubernetes Service:

$ kk get svc -l app.kubernetes.io/name=aws-xray
NAME       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)             AGE
aws-xray   ClusterIP   None         <none>        2000/UDP,2000/TCP   77s

Перевірка та робота з X-Ray

Створення Python Flask HTTP App з X-Ray

Створимо сервіс на Python Flask, який буде відповідати на HTTP-запити і логувати X-ray ID (ChatGPT промт – “Create a simple Python App with AWS X-Ray SDK for Python to run in Kubernetes. Add X-Ray ID output to requests“):

from flask import Flask
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.ext.flask.middleware import XRayMiddleware
import logging

app = Flask(__name__)

# Configure AWS X-Ray
xray_recorder.configure(service='SimpleApp')
XRayMiddleware(app, xray_recorder)

# Set up basic logging
logging.basicConfig(level=logging.INFO)

@app.route('/')
def hello():
    # Retrieve the current X-Ray segment
    segment = xray_recorder.current_segment()
    # Get the trace ID from the current segment
    trace_id = segment.trace_id if segment else 'No segment'
    # Log the trace ID
    logging.info(f"Responding to request with X-Ray trace ID: {trace_id}")
    
    return f"Hello, X-Ray! Trace ID: {trace_id}\n"

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

Створюємо requirements.txt:

flask==2.0.1
werkzeug==2.0.0
aws-xray-sdk==2.7.0

Додаємо Dockerfile:

FROM python:3.8-slim

COPY requirements.txt .
RUN pip install --force-reinstall -r requirements.txt

COPY app.py .

CMD ["python", "app.py"]

Збираємо Docker-образ – тут використовується репозиторій в AWS ECR:

$ docker build -t 492***148.dkr.ecr.us-east-1.amazonaws.com/x-ray-test .

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

$ aws 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/x-ray-test

Запуск Flask App в Kubernetes

Створюємо маніфест з Kubernetes Deployment, Service та Ingress.

Для Ingress включаємо логування в AWS S3 бакет – з нього логи будуть збиратись до  Grafana Loki, див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda.

Для Deployment задаємо змінну оточення AWS_XRAY_DAEMON_ADDRESS, в якій вказуємо Kubernetes Service нашого X-Ray Daemon:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: flask-app
  template:
    metadata:
      labels:
        app: flask-app
    spec:
      containers:
      - name: flask-app
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/x-ray-test
        ports:
        - containerPort: 5000
        env: 
          - name: AWS_XRAY_DAEMON_ADDRESS
            value: "aws-xray.ops-monitoring-ns.svc.cluster.local:2000"
          - name: AWS_REGION
            value: "us-east-1"
---
apiVersion: v1 
kind: Service
metadata:
  name: flask-app-service
spec:
  selector:
    app: flask-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: flask-app-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: "internet-facing"
    alb.ingress.kubernetes.io/target-type: "ip"
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-28-devops-monitoring-ops-alb-logs
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: flask-app-service
            port:
              number: 80

Деплоїмо, та перевіряємо Ingress/ALB:

$ kk get ingress
NAME                CLASS   HOSTS   ADDRESS                                                                 PORTS   AGE
flask-app-ingress   alb     *       k8s-default-flaskapp-25042181e0-298318111.us-east-1.elb.amazonaws.com   80      10m

Робимо запит до ендпоінту:

$ curl k8s-default-flaskapp-25042181e0-298318111.us-east-1.elb.amazonaws.com
Hello, X-Ray! Trace ID: 1-65e1d287-5fc6f0f34b4fb2120da8bbec

І бачимо X-Ray ID. Чудово.

Його ж бачимо в логах Load Balancer:

І в самому X-Ray:

Правда, я все ж очікував, що Load Balancer теж буде в мапі запиту – але ні.

Grafana X-Ray data source

Додаємо новий Data source:

Налаштовуємо доступ до AWS – тут просто з ACCESS та SECRET ключами (див. документацію X-Ray):

І тепер маємо новий data source в Explore:

Та новий тип візуалізації – Traces:

І десь вже окремим постом мабуть опишу побудову реальної дашборди з використанням X-Ray.

Loading

AWS: VPC Prefix та максимальна кількість подів на Kubernetes WorkerNodes
0 (0)

28 Лютого 2024

Кожна WorkerNode в Kubernetes може мати обмежену кількість подів, і цей ліміт визначається трьома параметрами:

  • CPU: загальна кількість requests.cpu не може бути більше, ніж є CPU на Node
  • Memory: загальна кількість requests.memory не може бути більше, ніж є Memory на Node
  • IP: загальна кількість подів не може бути більшою, ніж є IP-адрес у ноди

І якщо перші два ліміти такі собі “soft” – бо ми можемо просто не задавати requests взагалі – то ліміт по кількості IP-адрес на ноді це вже “hard” ліміт, бо кожному поду, який запускається на ноді, потрібно видати власну адресу з пула Secondary IP його ноди.

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

Наприклад, одна з наших нод t3.medium виглядає так:

В неї є вільні CPU, не вся пам’ять requested, але Pods Allocation вже 100% – бо до ноди t3.medium може бути додано 17 Secondary IP для подів, і вони всі вже зайняті.

Максимум Secondary IP на AWS EC2

Кількість же додаткових (secondary) IP на ЕС2 залежить від кількості ENI (Elastic network Interface) та кількості IP на кожен інтерфейс, і ці параметри залежать від типу EC2.

Наприклад, t3.medium може мати 3 інтерфейси, і на кожному може бути до 6 IP (див. IP addresses per network interface per instance type):

Тобто всього 18 адрес, але мінус по 1 Private IP роботу ENI самого інстансу – і для подів на такій ноді буде доступно 17 адрес.

Amazon VPC Prefix Assignment Mode

Щоб вирішити проблему з кількістью Secondary IP на EC2 можна використати VPC Prefix Assignment Mode – коли на інтерфейс підключається не окремий IP, а цілий блок /28, див. Assign prefixes to Amazon EC2 network interfaces.

Наприклад, ми можемо створити новий ENI і йому присвоїти CIDR (Classless Inter-Domain Routing) префікс:

$ aws --profile work ec2 create-network-interface --subnet-id subnet-01de26778bea10395 --ipv4-prefix-count 1

Перевіряємо цей ENI:

Єдиний момент, який варто мати на увазі це те, що VPC Prefix Assignment Mode доступний тільки для інстансів на AWS Nitro System – останнє покоління гіпервізорів AWS, на якому працюють інстанси T3, M5, C5, R5 і т.д. – див. Instances built on the Nitro System.

What is: CIDR /28

Кожна IPv4-адреса складається з 32 бітів, поділених на 4 октети (групи по 8 біт). Ці біти можуть бути представлені у двійковій системі (0 або 1) або у десятковій формі (значення між 0 та 255 для кожного октету). Ми будемо оперувати саме 0 та 1.

Маска підмережі /28 вказує на те, що перші 28 бітів IP-адреси зарезервовані для ідентифікації мережі – тоді 4 біти (32 всього мінус 28 зарезервованих) залишаються для визначення індивідуальних хостів в мережі:

Знаючи, що у нас є 4 вільні біти, а кожен біт може мати значення 0 або 1, ми можемо порахувати загальну кількість комбінацій: 2 в ступені 4, або 2×2×2×2=16 – тобто в мережі /28 може бути загалом 16 адрес, включаючи як адресу мережі (перший IP), так і broadcast адресу (останній IP), отже саме для хостів буде доступно 14 адрес.

Тож замість того, щоб на ENI підключати один Secondary IP – ми підключаємо відразу 16.

При цьому варто враховувати скільки ваша VPC Subnet зможе мати таких блоків, бо це буде визначати кільікість WorkerNodes, які ви зможете запустити.

Тут вже простіше використати утіліти накшалт ipcalc.

Наприклад, в мене Private Subnets мають префікс /20, і якщо всю цю мережу розбити на блоки по /28, то будемо мати 256 підмереж і 3584 адрес:

$ ipcalc 10.0.16.0/20 /28
...
Subnets:   256
Hosts:     3584

Або можна використати калькулятор онлайн – ipcalc.

VPC Prefix та AWS EKS VPC CNI

Добре – ми побачили, як ми можемо виділити блок адрес на інтерфейс, який підключений до EC2.

Що далі? Як з цього пулу адрес видається окрема адреса поду, який ми запускаємо в Kubernetes?

Цим займається VPC Container Networking Interface (CNI) Plugin, який складається з двох основних компонентів:

  • L-IPAM daemon (IPAMD): відповідає за створення та підключення ENI до EC2-інстансів, призначення блоків адрес до цих інтерфейсів та “прогрів” IP-префіксів для пришвидшення запуску подів (поговоримо далі)
  • CNI plugin: відповідає за налаштування мережевих інтерфейсів на ноді – як ethernet, та і віртуальних, і комунікує з IPAMD через RPC (Remote Procedure Call)

Як саме це реалізовано чудово описано в пості Amazon VPC CNI plugin increases pods per node limits > How it works:

Тож процес виглядає таким чином:

  1. Kubernetes Pod при запуску виконує запит до IPAMD на виділення IP
  2. IPAMD перевіряє доступні адреси, якщо вільні адреси є – виділяє один для поду
  3. якщо вільних адрес в підключених до ЕС2 префіксах нема – то IPAMD робить запит на підключення до ENI нового префіксу
    1. якщо до існуючого ENI вже не можна додавати нові префікси – робиться запит на підключення нового ENI
      1. якщо нода вже має максимальну кількість ENI – то запит поду на новий IP фейлиться
    2. якщо новий префікс додано (на існуючий або новий ENI) – то з нього вибирається IP для поду

WARM_PREFIX_TARGET, WARM_IP_TARGET та MINIMUM_IP_TARGET

Див. WARM_PREFIX_TARGET, WARM_IP_TARGET and MINIMUM_IP_TARGET.

Для конфігурації виділення префіксів нодам та IP подам VPC CNI має три додаткові опції – WARM_PREFIX_TARGET, WARM_IP_TARGET та MINIMUM_IP_TARGET:

  • WARM_PREFIX_TARGET: скільки підключених /28 префіксів тримати “в запасі”, тобто вони будуть підключені до ENI, але адреси з них ще не використовуються
  • WARM_IP_TARGET: скільки мінімально IP адрес підключати при створенні ноди
  • MINIMUM_IP_TARGET: скільки мінімально IP адрес тримати “в запасі”

При використанні VPC Prefix Assignment Mode ви не можете задати всі три параметри в нуль – як мінімум або WARM_PREFIX_TARGET або WARM_IP_TARGET мають бути задані хоча б в 1.

Якщо заданий WARM_IP_TARGET та/або MINIMUM_IP_TARGET – вони будуть мати перевагу над WARM_PREFIX_TARGET, тобто значення з WARM_PREFIX_TARGET буде ігноруватись.

Subnet CIDR reservations

Документація – Subnet CIDR reservations.

При використанні Prefix IP, адреси в префіксу мають бути суміжні, тобто в одному префіксу не можуть бути адреси “10.0.31.162” (блок 10.0.31.160/28) та “10.0.31.178” (блок 10.0.31.176/28).

Якщо сабнет активно використовується, і в ньому немає безперервного блоку адрес для виділння цілого префіксу, то ви отримаєте помилку:

failed to allocate a private IP/Prefix address: InsufficientCidrBlocks: The specified subnet does not have enough free cidr blocks to satisfy the request

Щоб запобігти цьому, можна використати функцію резервації блоків – VPC Subnet CIDR reservations для створення єдиного блоку, з якого потім будуть “нарізатись” блоки по /28. Такий блок не буде використовуватись для виділення Private IP для EC2, натомість VPC CNI буде створювати префікси саме з цієї “резервації”.

При цьому ви можете створити таку резервацію навіть якщо окремі IP в цьому блоку вже використовуються на EC2 – як тільки такі адреси звільняться, вони більше не будуть виділятись окремим інстансам EC2, а будуть зберігатись для формування префіксів /28.

Отже, якщо в мене є VPC Subnet з блоком /20 – я можу розбити її на два CIDR Reservation блоки по /21, і в кожному /21 блоці мати:

$ ipcalc 10.0.24.0/21 /28
...
Subnets:   128
Hosts:     1792

128 блоків /28 по 14 IP для хостів – разом 1792 IP для подів.

Активація VPC CNI Prefix Assignment Mode в AWS EKS

Документація – Increase the amount of available IP addresses for your Amazon EC2 nodes.

Все, що треба зробити – це змінити значення змінної ENABLE_PREFIX_DELEGATION в aws-node DaemonSet:

$ kubectl set env daemonset aws-node -n kube-system ENABLE_PREFIX_DELEGATION=true

При використанні Terraform модулю terraform-aws-modules/eks/aws це можна зробити через configuration_values для vpc-cni AddOn:

...
module "eks" {
  ...
  cluster_addons = {
    coredns = {
      most_recent = true
    }
    kube-proxy = {
      most_recent = true
    }
    vpc-cni = {
      most_recent    = true
      before_compute = true
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET       = "1"
        }
      })
    }
  }
...

Див. examples.

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

$ kubectl describe daemonset aws-node -n kube-system
...
    Environment:
      ...
      VPC_ID:                                 vpc-0fbaffe234c0d81ea
      WARM_ENI_TARGET:                        1
      WARM_PREFIX_TARGET:                     1
...

При використанні AWS Managed NodeGroups новий ліміт буде заданий автоматично.

В цілому, максимальна кількість подів буде залежати від типу інстансу і кількості vCPU на ньому – 110 подів на кожні 10 ядер (див. Kubernetes scalability thresholds). Але є ще ї ліміти, які задані самим AWS.

Наприклад для t3.nano з 2 vCPU це буде 34 поди – перевіримо скриптом max-pod-calculator.sh:

$ ./max-pods-calculator.sh --instance-type t3.nano --cni-version 1.9.0 --cni-prefix-delegation-enabled
34

На c5.4xlarge з 16 vCPU – 110 подів:

$ ./max-pods-calculator.sh --instance-type c5.4xlarge --cni-version 1.9.0 --cni-prefix-delegation-enabled 
110

А на c5.24xlarge з 96 ядрами – 250 подів, бо це вже обмеження від AWS:

$ ./max-pods-calculator.sh --instance-type c5.24xlarge --cni-version 1.9.0 --cni-prefix-delegation-enabled
250

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

Для того, щоб задати максимальну кількість подів на WorkerNodes, які створює Karpenter – використовуємо опцію maxPods для NodePool:

...
        - key: karpenter.sh/capacity-type
          operator: In 
          values: ["spot", "on-demand"]
      kubelet:
        maxPods: 110
...

Тестую на тестовому кластері, де зараз є тільки одна нода для CiritcalAddons, тобто звичайні поди на ній не запускаються:

$ kk get node
NAME                          STATUS   ROLES    AGE   VERSION
ip-10-0-61-176.ec2.internal   Ready    <none>   2d    v1.28.5-eks-5e0fdde

Для перевірки створимо Deployment з 3 подами:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest
        ports:
        - containerPort: 80

Деплоїмо, і Karpenter створює один NodeClaim з t3.small:

$ kk get nodeclaim
NAME            TYPE       ZONE         NODE   READY   AGE
default-w7g9l   t3.small   us-east-1a          False   3s

Пара хвилин – і поди на ньому запустились:

Тепер скейлимо Deployment до, наприклад, 50 подів:

$ kk scale deployment nginx-deployment --replicas=50
deployment.apps/nginx-deployment scaled

І все ще маємо один NodeClaim з тим же t3.small, але тепер на ньому запущено 50 подів:

Звісно, при такому підході треба завжди задавати Pod requests, щоб кожен под мав доступ до CPU та Memory – саме requests для нас тепер будуть лімітами на кількість подів на нодах.

Loading

Terraform: створення модулю для збору логів AWS ALB в Grafana Loki
0 (0)

20 Лютого 2024

Приклад створення модулю Terraform для автоматизації збору логів з AWS Load Balancers у Grafana Loki.

Як працює сама схема див. у Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda – ALB пише логи в S3-бакет, звідки їх забирає Lambda-функція з Promtail і пересилає у Grafana Loki.

В чому ідея з модулем Terraform:

  • є EKS оточення – наразі один кластер, але пізніше може бути декілька
  • є аплікейшени – API у бекенда, моніторинг у девопсів тощо
    • у кожного аплікейшена може бути один або декілька власних оточень – Dev, Staging, Prod
  • для аплікейшенів є AWS ALB, з яких треба збирати логи

Код Terraform для збору логів досить великий – aws_s3_bucket, aws_s3_bucket_public_access_block, aws_s3_bucket_policy, aws_s3_bucket_notification, ще й Lambda-функції.

При цьому ми маємо декілька проектів у різних команд, і у кожного проекту можуть бути декілька оточень – у когось тільки Ops або тільки Dev, у когось – Dev, Staging, Prod.

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

Але головна причина – це трохи mess при створені ресурсів для логування через декілька оточень – одне оточення це сам EKS-кластер, а інше оточення – це самі сервіси на кшталт моніторингу або Backend API

Тобто:

  • в рутовому модулі проекту маємо змінну для EKS-кластеру – $environment зі значенням ops/dev/prod (наразі маємо один кластер і, відповідно, один environment == "ops")
  • в модуль логів з рутового модулю будемо передавати іншу змінну – app_environments зі значеннями dev/staging/prod, плюс імена сервісів, команди тощо – це оточення самих сервісів/applications

Отже, в рутовому модулі, тобто в проекті, будемо викликати новий модуль ALB Logs в циклі для кожного значення з environment, а в середині модуля – в циклі створювати ресурси по кожному з app_environments.

Спочатку зробимо все локально в існуючому проекті, а потім винесемо новий модуль в GitHub-репозиторій, і підключимо його в проекті з репозиторію.

Створення модулю

В репозиторії проекту зараз маємо таку структуру файлів – тестувати будемо в проекті, який створює ресурси для моніторингу, але не принципово, просто тут вже налаштований backend і інші параметри для Terraform:

$ tree .
.
|-- Makefile
|-- acm.tf
|-- backend.hcl
|-- backend.tf
|-- envs
|   |-- ops
|   |   `-- ops-1-28.tfvars
|-- iam.tf
|-- lambda.tf
|-- outputs.tf
|-- providers.tf
|-- s3.tf
|-- variables.tf
`-- versions.tf

Створюємо директорію для модулів і каталог для самого модулю:

$ mkdir -p modules/alb-s3-logs

Створення S3 bucket

Почнемо з простого бакету – описуємо його в файлі modules/alb-s3-logs/s3.tf:

resource "aws_s3_bucket" "alb_s3_logs" {
  bucket = "test-module-alb-logs"
}

Далі, включаємо його в основному модулі, в самому проекті в main.tf:

module "alb_logs_test" {
  source = "./modules/alb-s3-logs"
}

Виконуємо terraform init і перевіряємо з terraform plan:

Добре.

Далі в наш новий модуль треба додати декілька inputs (див. Terraform: модулі, Outputs та Variables) – щоб формувати ім’я корзини, і мати значення для app_environments.

Створюємо файл modules/alb-s3-logs/variables.tf:

variable "eks_env" {
  type        = string
  description = "EKS environment passed from a root module (the 'environment' variable)"
}

variable "eks_version" {
  type        = string
  description = "EKS version passed from a root module"
}

variable "component" {
  type        = string
  description = "A component passed from a root module"
}

variable "application" {
  type        = string
  description = "An application passed from a root module"
}

variable "app_environments" {
  type        = set(string)
  description = "An application's environments"
  default  = [
    "dev",
    "prod"
  ]
}

Далі в модулі оновлюємо ресурс aws_s3_bucket – додаємо for_each (див. Terraform: цикли count, for_each та for) по всім значенням з app_environments:

resource "aws_s3_bucket" "alb_s3_logs" {
  # ops-1-28-backend-api-dev-alb-logs
  # <eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs
  for_each = var.app_environments

  bucket = "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${each.value}-alb-logs"

  # to drop a bucket, set to `true` first
  # apply
  # then remove the block
  force_destroy = false
}

Або можемо зробити краще – винести формування імен корзин в locals:

locals {
  # ops-1-28-backend-api-dev-alb-logs
  # <eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs
  bucket_names = { for env in var.app_environments : env => "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${env}-alb-logs" }
}

resource "aws_s3_bucket" "alb_s3_logs" {
  for_each = local.bucket_names

  bucket = each.value

  # to drop a bucket, set to `true` first
  # run `terraform apply`
  # then remove the block
  # and run `terraform apply` again
  force_destroy = false
}

Тут беремо кожний елемент зі списку app_environments, формуємо змінну env, і формуємо map[] з іменем bucket_names, де в key у нас буде значення з env, а в value – ім’я корзини.

Оновлюємо виклик модулю в проекті – додаємо передачу параметрів:

module "alb_logs_test" {
  source = "./modules/alb-s3-logs"

  #bucket = "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${each.value}-alb-logs"
  # i.e. 'ops-1-28-backend-api-dev-alb-logs'
  eks_env     = var.environment
  eks_version = local.env_version
  component   = "backend"
  application = "api"  
}

Перевіряємо ще раз:

Створення aws_s3_bucket_public_access_block

До файлу modules/alb-s3-logs/s3.tf додамо ресурс aws_s3_bucket_public_access_block – проходимось в циклі по всім бакетам:

...
# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "alb_s3_logs_backend_acl" {
  for_each = aws_s3_bucket.alb_s3_logs

  bucket = each.value.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Створення Promtail Lambda

Далі додамо створення Lambda-функцій – на кожну корзину буде своя функція зі своїми змінними для лейбл в Loki.

Тобто для корзини “ops-1-28-backend-api-dev-alb-logs” ми створимо інстанс Promtail Lambda, у якої в змінних EXTRA_LABELS будуть значення “component=backend, logtype=alb, environment=dev“.

Для створення функцій нам потрібні нові змінні:

  • vpc_id: для Lambda Security Group
  • vpc_private_subnets_cidrs: для правил в Security Group – куди буде дозволено доступ
  • vpc_private_subnets_ids: для самих функцій – в яких сабнетах їх запускати
  • promtail_image: Docker image URL з AWS ECR, з якого буде створюватись Lambda
  • loki_write_address: для Promtail – куди слати логи

Дані по VPC ми отримуємо в самому проекті з ресурсу data "terraform_remote_state" (див. Terraform: terraform_remote_state – отримання outputs інших state-файлів), який бере їх з іншого проекту, який менеджить наші VPC:

# connect to the atlas-vpc Remote State to get the 'outputs' data
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = var.aws_region
    dynamodb_table = "tf-state-lock-atlas-vpc"
  }
}

І потім в locals створюється об’єкт vpc_out з даними по VPC, і там жеж формується URL для Loki:

locals {
  ...
  # get VPC info
  vpc_out = data.terraform_remote_state.vpc.outputs

  # will be used in Lambda Promtail 'LOKI_WRITE_ADDRESS' env. variable
  # will create an URL: 'https://logger.1-28.ops.example.co:443/loki/api/v1/push'
  loki_write_address = "https://logger.${replace(var.eks_version, ".", "-")}.${var.environment}.example.co:443/loki/api/v1/push"
}

Додаємо нові змінні в variables.tf нашого модуля:

...
variable "vpc_id" {
  type        = string
  description = "ID of the VPC where to create security group"
}

variable "vpc_private_subnets_cidrs" {
  type        = list(string)
  description = "List of IPv4 CIDR ranges to use in Security Group rules and for Lambda functions"
}

variable "vpc_private_subnets_ids" {
  type        = list(string)
  description = "List of subnet ids when Lambda Function should run in the VPC. Usually private or intra subnets"
}

variable "promtail_image" {
  type        = string
  description = "Loki URL to push logs from Promtail Lambda"
  default = "492***148.dkr.ecr.us-east-1.amazonaws.com/lambda-promtail:latest"
}

variable "loki_write_address" {
  type        = string
  description = "Loki URL to push logs from Promtail Lambda"
}

Створення security_group_lambda

Створюємо файл modules/alb-logs/lambda.tf, і почнемо з модуля security_group_lambda з модулю terraform-aws-modules/security-group/aws, який створить нам Security Group – вона у нас одна на всі функції для логування:

data "aws_prefix_list" "s3" {
  filter {
    name   = "prefix-list-name"
    values = ["com.amazonaws.us-east-1.s3"]
  }
}

module "security_group_lambda" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 5.1.0"

  name        = "${var.eks_env}-${var.eks_version}-loki-logger-lambda-sg"
  description = "Security Group for Lambda Egress"

  vpc_id = var.vpc_id

  egress_cidr_blocks      = var.vpc_private_subnets_cidrs
  egress_ipv6_cidr_blocks = []
  egress_prefix_list_ids  = [data.aws_prefix_list.s3.id]

  ingress_cidr_blocks      = var.vpc_private_subnets_cidrs
  ingress_ipv6_cidr_blocks = []

  egress_rules  = ["https-443-tcp"]
  ingress_rules = ["https-443-tcp"]
}

В файлі main.tf проекту додаємо передачу нових параметрів в модуль:

module "alb_logs_test" {
  source = "./modules/alb-s3-logs"

  #bucket = "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${each.value}-alb-logs"
  # i.e. 'ops-1-28-backend-api-dev-alb-logs'
  eks_env     = var.environment
  eks_version = local.env_version
  component   = "backend"
  application = "api"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  loki_write_address        = local.loki_write_address
}

Виконуємо terraform init та terraform plan:

Тепер можемо додавати Lambda.

Створення модулю promtail_lambda

Далі – сама функція.

В ній нам потрібно буде вказати allowed_triggers – ім’я корзини, з якої можна виконувати нотифікацію про створення в корзині нових об’єктів, і для кожної корзини ми хочемо створити окрему функцію з власними змінними для labels в Loki.

Для цього описуємо модуль module "promtail_lambda", де знов зробимо цикл по всім корзинам – як робили з aws_s3_bucket_public_access_block.

Але в параметрах функції нам потрібно передати поточне значення з app_environments – “dev” або “prod“.

Для цього ми можемо використати each.key, бо коли ми створюємо resource “aws_s3_bucket” “alb_s3_logs” з for_each = var.app_environments або for_each = local.bucket_names – то отримуємо обє’кт, в якому в key буде кожне значення з var.app_environments, а в value – деталі корзини.

Давайте глянемо як це виглядає.

В нашому модулі додамо output – можна прямо в файлі modules/alb-s3-logs/s3.tf:

output "buckets" {
  value       = aws_s3_bucket.alb_s3_logs
}

В рутовому модулі, в самому проекті в файлі main.tf – теж output, який використовує output модулю:

...
output "alb_logs_buckets" {
  value = module.alb_logs_test.buckets
}

Робимо terraform plan, і маємо такий результат:

Тож повністю наша Lambda-функція буде такою:

...
module "promtail_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "~> 7.2.1"
  # key: dev
  # value:  ops-1-28-backend-api-dev-alb-logs
  for_each = aws_s3_bucket.alb_s3_logs

  # <eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs-logger
  # bucket name: ops-1-28-backend-api-dev-alb-logs
  # lambda name: ops-1-28-backend-api-dev-alb-logs-loki-logger
  function_name = "${each.value.id}-loki-logger"
  description   = "Promtail instance to collect logs from ALB Logs in S3"

  create_package = false
  # https://github.com/terraform-aws-modules/terraform-aws-lambda/issues/36
  publish = true

  image_uri     = var.promtail_image
  package_type  = "Image"
  architectures = ["x86_64"]

  # labels: "component,backend,logtype,alb,environment,dev"
  # will create: component=backend, logtype=alb, environment=dev
  environment_variables = {
    EXTRA_LABELS             = "component,${var.component},logtype,alb,environment,${each.key}"
    KEEP_STREAM              = "true"
    OMIT_EXTRA_LABELS_PREFIX = "true"
    PRINT_LOG_LINE           = "true"
    WRITE_ADDRESS            = var.loki_write_address
  }

  vpc_subnet_ids         = var.vpc_private_subnets_ids
  vpc_security_group_ids = [module.security_group_lambda.security_group_id]
  attach_network_policy  = true

  # bucket name: ops-1-28-backend-api-dev-alb-logs
  allowed_triggers = {
    S3 = {
      principal  = "s3.amazonaws.com"
      source_arn = "arn:aws:s3:::${each.value.id}"
    }
  }
}

Тут в each.value.id ми будемо мати ім’я корзини, а в environment,${each.key}" – значення “dev” або “prod“.

Перевіряємо – terraform init && terraform plan:

Створення aws_s3_bucket_policy

Наступним ресурсом нам потрібна політика для S3, яка буде дозволяти писати ALB та читати нашій Lambda-функції.

Тут у нас будуть дві нові змінні:

  • aws_account_id: передамо з рутового модулю
  • elb_account_id: поки можемо задати дефолтне значення, бо ми тільки в одному регіоні

Додаємо в variables.tf модулю:

...
variable "aws_account_id" {
  type        = string
  description = "AWS account ID"
}

variable "elb_account_id" {
  type        = string
  description = "AWS ELB Account ID to be used in the ALB Logs S3 Bucket Policy"
  default     = 127311923021
}

І в файлі modules/alb-s3-logs/s3.tf описуємо сам aws_s3_bucket_policy:

...
resource "aws_s3_bucket_policy" "s3_logs_alb_lambda_allow" {
  for_each = aws_s3_bucket.alb_s3_logs

  bucket = each.value.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.elb_account_id}:root"
        }
        Action = "s3:PutObject"
        Resource = "arn:aws:s3:::${each.value.id}/AWSLogs/${var.aws_account_id}/*"
      },
      {
        Effect = "Allow"
        Principal = {
          AWS = module.promtail_lambda[each.key].lambda_role_arn
        }
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::${each.value.id}/*"
      }
    ]
  })
}

Тут ми знову використовуємо each.key з наших корзин, де будемо мати значення “dev” або “prod“.

І, відповідно, можемо звернутись до кожного ресурсу module "promtail_lambda" – бо вони теж створюються в циклі – module.alb_logs_test.module.promtail_lambda["dev"].aws_lambda_function.this[0].

Додаємо передачу aws_account_id в корневому модулі:

module "alb_logs_test" {
  source = "./modules/alb-s3-logs"
  ...
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  loki_write_address        = local.loki_write_address
  aws_account_id            = data.aws_caller_identity.current.account_id
}

Перевіряємо з terraform plan:

Створення aws_s3_bucket_notification

Останній ресурс – aws_s3_bucket_notification, який створить нотифікацію в Lambda при появі нового об’єкту в корзині.

Тут теж самий принцип – цикл по корзинам, і по env через each.key:

...
resource "aws_s3_bucket_notification" "s3_logs_notification" {
  for_each = aws_s3_bucket.alb_s3_logs

  bucket = each.value.id

  lambda_function {
    lambda_function_arn = module.promtail_lambda[each.key].lambda_function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "AWSLogs/${var.aws_account_id}/"
  }
}

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

І тепер у нас все готово – давайте деплоїти.

Перевірка роботи Promtail Lambda

Деплоїмо з terraform apply, перевіряємо корзини:

$ aws --profile work s3api list-buckets | grep ops-1-28-backend-api-dev-alb-logs
            "Name": "ops-1-28-backend-api-dev-alb-logs",

Створимо Ingress з s3.bucket=ops-1-28-backend-api-dev-alb-logs:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx
          ports:
            - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo-service
spec:
  selector:
    app: nginx-demo
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-28-backend-api-dev-alb-logs
spec:
  ingressClassName: alb
  rules:
    - host: test-logs.ops.example.co
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-demo-service
                port:
                  number: 80

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

$ kk get ingress example-ingress
NAME              CLASS   HOSTS                     ADDRESS                                                                   PORTS   AGE
example-ingress   alb     test-logs.ops.example.co   k8s-opsmonit-examplei-8f89ccef47-1782090491.us-east-1.elb.amazonaws.com   80      39s

Перевіряємо зміст корзини:

$ aws s3 ls ops-1-28-backend-api-dev-alb-logs/AWSLogs/492***148/
2024-02-20 16:56:54        107 ELBAccessLogTestFile

Тестовий файл є – значить ALB може писати логи.

Робимо запити до ендпоінту:

$ curl -I http://test-logs.ops.example.co
HTTP/1.1 200 OK

За пару хвилин перевіряємо відповідну Lambda-функцію:

Виклики пошли, добре.

І перевіряємо логи в Loki:

Все працює.

Залишилось винести наш модуль в репозиторій, а потім використати в якомусь проекті.

Terraform модуль з GitHub репозиторію

Створюмо новий репозиторій, копіюємо в нього всю директорію модулю – alb-s3-logs:

$ cp -r ../atlas-monitoring/terraform/modules/alb-s3-logs/ .
$  tree .
.
|-- README.md
`-- alb-s3-logs
    |-- lambda.tf
    |-- s3.tf
    `-- variables.tf

2 directories, 4 files

Комітимо, пушимо:

$ ga -A
$ gm "feat: module for ALB logs collect"
$ git push

І оновлюємо його виклик в проекті:

module "alb_logs_test" {
  #source = "./modules/alb-s3-logs"
  source = "[email protected]:org-name/atlas-tf-modules//alb-s3-logs"
  ...
  loki_write_address        = local.loki_write_address
  aws_account_id            = data.aws_caller_identity.current.account_id
}

Робимо terraform init:

$ terraform init
...
Downloading git::ssh://[email protected]/org-name/atlas-tf-modules for alb_logs_test...
- alb_logs_test in .terraform/modules/alb_logs_test/alb-s3-logs

...

І перевіряємо ресурси:

$ terraform plan
...
No changes. Your infrastructure matches the configuration.

Все готово.

Loading

Grafana Loki: LogQL та Recoding Rules для метрик з логів AWS Load Balancer
0 (0)

10 Лютого 2024

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

Отже, про що мова: у нас є AWS Load Balancers, логи з яких збираються до Grafana Loki, див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda.

В Loki маємо Recoding Rules і remote write, за допомогою чого генеруємо деякі метрики, і записуємо їх до VictoriaMetrics, звідки їх використовують VMAlert та Grafana – див. Grafana Loki: оптимізація роботи – Recording Rules, кешування та паралельні запити.

Що я хочу, це зробити графіки по Load Balancers не з метрик CloudWatch – а генерувати їх з логів, по, по-перше – збір метрик з CloudWatch у Prometheus/VictoriaMetrics коштує грошей за запити до CloudWatch, по-друге – з логів ми можемо отримати набагато більше інформації, по-третє – метрики CloudWatch мають обмеження, які ми можемо обійти з логами.

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

Також деякі метрики не дають тієї картини, котру хочеться, як-от метрика ProcessedBytes, яка має значення по “traffic to and from clients“, але немає інформації по recieved та sent bytes.

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

  • розгорнемо тестове оточення в Kubernetes – Pod та Ingress/ALB з логуванням в S3
  • налаштуємо збір логів з S3 через Lambda-функцію з Promtail
  • подивимось на поля в ALB Access Logs – які можуть бути нам цікаві
  • подумаємо, які графіки хотілося б мати для дашборди Графани і, відповідно, які метрики нам для цього можуть знадобитись
  • створимо запити для Loki, які будуть формувати дані з логу AWS LoadBalancer
  • і напишемо кілька Loki Recording Rules, які будуть нам генерувати потрібні метрики

Тестове оточення в Kubernetes

Нам потрібен Pod, який буде генерувати HTTP-відповіді, та Ingress, який створить AWS ALB, з якого ми будемо збирати логи.

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

Створюємо корзину для логів:

$ aws --profile work s3api create-bucket --bucket eks-alb-logs-test --region us-east-1

Створюємо файл з Policy:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::127311923021:root"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::eks-alb-logs-test/AWSLogs/492***148/*"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::492***148:role/grafana-atlas-monitoring-ops-1-28-loki-logger-devops-alb"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::eks-alb-logs-test/*"
        }
    ]
}

Підключаємо цю політику до корзини:

$ aws --profile work s3api put-bucket-policy --bucket eks-alb-logs-test --policy file://s3-alb-logs-policy.json

В бакеті налаштовуємо Properties > Event notifications на івенти ObjectCreated:

Задаємо Destination до Lambda з Promtail:

Описуємо ресурси для Kubernetes – Deployment, Service та Ingress.

В Ingress annotations задаємо access_logs.

Крім того, потрібен TLS – бо записи про імена доменів в запитах ми будемо брати саме з нього:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx
          ports:
            - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo-service
spec:
  selector:
    app: nginx-demo
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}, {"HTTP":80}]'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=eks-alb-logs-test
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/bab1c48a-d11e-4572-940e-2cd43aafb615
spec:
  ingressClassName: alb
  rules:
    - host: test-alb-logs-1.setevoy.org.ua
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-demo-service
                port:
                  number: 80

Деплоїмо:

$ kk apply -f ingress-svc-deploy.yaml 
deployment.apps/nginx-demo-deployment created
service/nginx-demo-service created
ingress.networking.k8s.io/example-ingress created

Перевіряємо Ingress та Load Balancer:

$ kk get ingress example-ingress
NAME              CLASS   HOSTS                          ADDRESS                                                                   PORTS   AGE
example-ingress   alb     test-alb-logs-1.setevoy.org.ua   k8s-opsmonit-examplei-8f89ccef47-1707845441.us-east-1.elb.amazonaws.com   80      2m40s

Перевіряємо з cURL, що все працює:

$ curl -I https://test-alb-logs.setevoy.org.ua
HTTP/1.1 200 OK

Є сенс додати Security Group, де закрити доступ усім крім вашого IP – щоб під час тестування бути впевненим в результатах.

Application Load Balancer: корисні поля в Access Log

Документація – Access logs for your Application Load Balancer.

Що нам може бути цікавим в логах?

Враховуємо, що в нашому випадку Load Balancer target – це буде Kubernetes Pod:

  • type: у нас деякі ALB використовують WebSockets, то було добре мати змогу виділяти їх
  • time: час створення відповіді клієнту – не те, щоб знадобилось в графіках, але най буде
  • elb: Load Balancer ID – корисно в алертах і можна використати в фільтрах графіків Grafana
  • client:port та target:port: звіки запит прийшов, і куди пішов; у нашому випадку target:port буде Pod IP
  • request_processing_time: час від отримання запиту від клієнта до передачі його до Pod
  • target_processing_time: час від відправки запиту до Pod до початку отримання відповіді від Pod
  • response_processing_time: час від отримання відповіді від Pod до початку передачі відповіді клієнту
  • elb_status_code: HTTP код від ALB до клієнта
  • target_status_code: HTTP код від Pod до ALB
  • received_bytes: отримано байт від клієнта – для HTTP включає розмір headers, для WebSockets – загальний об’єм байт переданий в рамках connection
  • sent_bytes: аналогічно, але для відповіді клієнту
  • request: повний URL запиту від клієнта у формі HTTP method + protocol://host:port/uri + HTTP version
  • user_agent: User Agent
  • target_group_arn: Amazon Resource Name (ARN) для Target Group, на яку віправляються запити
  • trace_id: X-Ray ID – корисно, якщо ним користуєтесь (я робив data links в Grafana на дашборду з X-Ray)
  • domain_name: на HTTPS Listener – ім’я хосту в запиті під час TLS handshake (власне домен, за яким звернувся клієнт)
  • actions_executed: може бути корисним особливо якщо використовується AWS WAF, див. Actions taken
  • error_reason: причина помилки, якщо запит сфейливсся – я не користуюсь, але може бути корисно, див. Error reason codes
  • target:port_list: аналогічно target:port
  • target_status_code_list: аналогічно target_status_code

Grafana Loki: логи Application Load Balancer та LogQL pattern

Знаючи існуючі поля ми можемо створити запит, який буде формувати Fields з записів в логах. А далі ці поля ми зможемо використовувати або для створення labels, або для побудови графіків.

Офіційна документація по темі:

І мій пост Grafana Loki: можливості LogQL для роботи з логами та створення метрик для алертів.

(я все збираєсь спробувати VictoriaLogs, цікаво, чи вміє вона в Recodring Rules та генерацію метрик, як доберусь – напишу пост)

При роботі з Load Balancer логами в Loki враховуйте, що від моменту запиту до самої Loki дані дойдуть через 5 хвилин – і це окрім того, що ми бачимо дані з затримкою, створить ще деякі проблеми, які далі розберемо.

Використуємо парсер pattern, якому задамо імена полів, які хочемо створити: на кожне поле в логу зробимо окремий <field> в Loki:

pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`

В кінці через <_> пропускаємо два поля – classification та classification_reason, бо я поки не бачу, де б воно могло знадобитись.

Тепер повний запит в Loki разом з Stream Selector буде виглядати так:

{logtype="alb", component="devops"} |= "test-alb-logs.setevoy.org.ua" | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`

Або, як ми вже маємо поле domain, то замість Line filter expression додамо Label filter expressiondomain="test-alb-logs.setevoy.org.ua":

{logtype="alb", component="devops"} 
| pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
| domain="test-alb-logs.setevoy.org.ua"

HTTP Load testing tools

Далі потрібно чимось виконувати запити до нашого Load Balancer, щоб генерувати логи, і мати змогу задавати конкретні параметри на кшалт кількості запитів в секунду.

Спробував декілька різних утиліт, наприклад Vegeta:

$ echo "GET https://test-alb-logs.setevoy.org.ua/" | vegeta attack -rate=1 -duration=60s | vegeta report
Requests      [total, rate, throughput]         60, 1.02, 1.01
Duration      [total, attack, wait]             59.126s, 59s, 125.756ms
Latencies     [min, mean, 50, 90, 95, 99, max]  124.946ms, 133.825ms, 126.062ms, 127.709ms, 128.203ms, 542.437ms, 588.428ms
Bytes In      [total, mean]                     36900, 615.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      200:60  
Error Set:

Або wrk2:

$ wrk2 -c1 -t1 -d60s -R1 https://test-alb-logs.setevoy.org.ua/
Running 1m test @ https://test-alb-logs.setevoy.org.ua/
  1 threads and 1 connections
  Thread calibration: mean lat.: 166.406ms, rate sampling interval: 266ms
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   127.77ms    0.94ms 131.97ms   80.00%
    Req/Sec     0.80      1.33     3.00    100.00%
  60 requests in 1.00m, 49.98KB read
Requests/sec:      1.00
Transfer/sec:     852.93B

wrk2 якось більше зайшов, бо простіше.

Ще, звісно, можно взяти Bash та Apache Benchmark:

#!/bin/bash
for i in {1..60}
do
  ab -n 1 -c 1 https://test-alb-logs.setevoy.org.ua/
  sleep 1
done

Але це трохи зайві костилі.

Або JMeter – проте це вже зовсім overkill для наших потреб

Дашборда Grafana: планування

Для ALB є готові дашборди, наприклад – AWS ELB Application Load Balancer by Target Group та AWS ALB Cloudwatch Metrics, тож з них ми можемо взяти собі ідеї для графіків, які будемо будувати.

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

Детальніше про створення борди можна почитати у, наприклад, Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes, а тут зосередимось на запитах Loki.

Фільтри:

  • domain – візьмемо з поля domain (забігаючи наперед – запит label_values(aws:alb:requests:sum_by:rate:1m,domain))
  • alb_id – з фільтром по $domain (запит label_values(aws:alb:requests:sum_by:rate:1m{domain=~"$domain"),domain))

Далі – на самій борді зверху будуть всякі Stats, а під ними – графіки.

Зверху – stats:

  • requests/sec: per-second rate запитів на секунду
  • response time: загальний час відповіді клієнту
  • 4xx rate та 5xx rate: per-second rate помилок
  • received та transmitted throughput: скільки байт в секунду отримуємо та відправляємо
  • client connections: загальна кількість connections

І власне графіки кожному обраному в фільтрі Load Balancer:

  • HTTP codes by ALB: загальна картина по кодам відповідей клієнтам, per-second rate
  • HTTP code by targets: загальна картина по кодам відповідей від Pods, per-second rate
  • 4xx/5xx graph: графік по помилкам, per-second rate
  • requests total: графік по загальній кількості запитів від клієнтів
  • ALB response time: загальний час відповіді клієнту
  • target response time: час відповіді від подів
  • received та transmitted throughput: скільки байт в секунду отримуємо та відправляємо

Створення метрик з Loki Recording Rules

Запускаємо тест – 1 запит в секунду:

$ wrk2 -c1 -t1 -R1 -H "User-Agent: wrk2" -d30m https://test-alb-logs-1.setevoy.org.ua/

І поїхали творити запити.

LogQL для requests/sec

Перше, що приходить в голову для отримання per-second rate запитів – це використати функцію rate():

rate(log-range): calculates the number of entries per second

В rate() використовуємо наш stream selector {logtype="alb", component="devops"} (в мене ці лейбли задаються на Lambda Promtail) та парсер pattern, щоб створити поля зі значеннями, які потім будемо використовувати:

Щоб отримати загальний результат по всім знайденим записам – “огорнемо” наш запит в функцію sum():

sum(
    rate(
        {logtype="alb", component="devops"} 
            | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
            | domain="test-alb-logs.setevoy.org.ua"
            [1m]
    )
)

Якщо ж ми хочемо робити метрику, яка буде в лейблах мати ім’я ALB та ім’я домену, щоб потім використовувати ці лейбли в фільтрах дашборди Grafana – то додаємо sum by (elb_id, domain):

І тепер маємо значення по кількості всіх унікальних пар elb_id, domain – 1 запит на секунду, як ми з задавали в wrk2 -c1 -t1 -R1.

Наче виглядає чудово?

Але давайте спробуємо цей запит використати в Loki Recording Rules та створити на його основі метрику.

Loki Recording Rule для метрик та проблема з AWS Load Balancer Logs EmitInterval

Додаємо запис для створення метрики aws:alb:requests:sum_by:rate:1m:

kind: ConfigMap
apiVersion: v1
metadata:
  name: loki-alert-rules
data:
  rules.yaml: |-
    groups:
      - name: AWS-ALB-Metrics-1
        rules:
        - record: aws:alb:requests:sum_by:rate:1m
          expr: |
            sum by (elb_id, domain) (
                rate(
                    {logtype="alb", component="devops"} 
                        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                        | domain="test-alb-logs-1.setevoy.org.ua"
                        [1m]
                )
            )

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

WTF?!?

По-перше – чому значення 0.3, а не 1?

По-друге – чому ми в результаті маємо розриви в лінії?

AWS Load Balancer та logs EmitInterval

Давайте повернемось до Loki, і збільшемо інтервал для rate() до 5 хвилин:

І в результаті бачимо, що значення поступово знижується з 1 до нуля.

Чому?

Тому що логи з LoadBalancer до S3 збираються раз на 5 хвилин – див. Access log files:

Elastic Load Balancing publishes a log file for each load balancer node every 5 minutes

В Classic Load Balancer є можливість налаштувати частоту передачі логів до S3 з параметром EmitInterval – але в Application Load Balancer це значення фіксоване 5 хвилинами.

А як працює функція rate()?

Вона в момент виклику бере всі значення за заданий проміжок часу – останні 5 хвилин в нашому випадку, rate({...}[5m]) – і формує середнє значення за секунду:

calculates per second rate of all values in the specified interval

Тобто картина виходить така:

Тож коли ми викликаємо rate() в 12:28 – вона бере дані за проміжок 12:23-12:28, але на цей момент остані логи в нас є тільки до 12:25, а тому і значення, яке повертає rate() з кожною хвилиною зменшується.

Коли ж нові логи приходять до Loki – то rate() обробляє їх всіх, і лінія вирівнюється до значення 1.

Окей, тут більш-менш ясно-понятно.

А що з графіками в Prometheus/VictoriaMetrics, які ми будуємо з метрики aws:alb:requests:sum_by:rate:1m?

Тут картина ще цікавіша, бо Loki Ruler виконує запити раз на хвилину:

# How frequently to evaluate rules.
# CLI flag: -ruler.evaluation-interval
[evaluation_interval: <duration> | default = 1m]

Тобто, запит був виконаний у 12:46:42:

(час на сервері UTC, -2 години від часу в VictoriaMetrics VMUI, тому тут 10:46:42, а не 12:46:42)

І отримав значення “0.3” – бо нових логів ще було:

І навіть якщо ми в Recording Rule збільшемо час в rate() до 5 хвилин – ми все одно не тримаємо правильної картини, бо частина проміжку часу ще не буде мати логів.

Біда.

LogQL та offset

То що ми можемо зробити?

Я тут досить довго намагався знайти рішення, і пробував варіант з count_over_time, думав спробувати рішення з ruler_evaluation_delay_duration, як описано у Loki recording rule range query, і думав пробувати погратись з Ruler evaluation_interval, але придумалось набагато простіше і, мабуть, найбільш правильне – чому б не використати offset?

Не був впевнений, що LogQL його підтримує, але як виявилось – таки є:

З offset 5m ми “зсуваємо” початок нашого запиту: замість того, щоб брати дані за останню хвилину – ми беремо дані за 5 хвилин раніше + наша хвилина для rate(), і тоді в результаті ми вже маємо всі логи.

Це, звісно, трохи портить загальну картину, бо ми не отримаємо самі актуаільні дані, але ми і так маємо їх з затримкою в 5 хвилин, і для графіків та алертів по Load Balancer такі затримки не є критичними, тож, imho, рішення цілком валідне.

Додамо нову метрику aws:alb:requests:sum_by:rate:1m:offset:5m:

...
        - record: aws:alb:requests:sum_by:rate:1m:offset:5m
          expr: |
            sum by (elb_id, domain) (
                rate(
                    {logtype="alb", component="devops"} 
                        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                        | domain="test-alb-logs-1.setevoy.org.ua"
                        [1m] offset 5m
                )
            )

І перевіримо результат тепер:

Щоб впевнитись – запустимо тест на 2 запити в секунду:

$ wrk2 -c2 -t2 -R2 -H "User-Agent: wrk2" -d120m https://test-alb-logs-1.setevoy.org.ua/

Чекаємо 5-7 хвилин, і перевіряємо графік ще раз:

Окей, з цим розібрались – поїхали далі.

LogQL для response time

Що потрібно: мати уяву скільки часу займає весь процес відповіді клієнту – від отримання запиту на ALB, до початку відправки йому даних.

Для цього у нас в логах є три поля:

  • request_processing_time: час від отримання запиту від клієнта до передачі його до Pod
  • target_processing_time: час від відправки запиту до Pod до початку отримання відповіді від нього
  • response_processing_time: час від отримання відповіді від Pod до початку передачі відповіді клієнту

Тож ми можемо зробити три метрики, потім отримати загальну суму – і отримаємо загальний час.

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

  • вибрати логи з Log Selector
  • із запису взяти значення з поля target_processing_time
  • і отрмати середнє значення за, наприклад, 1 хвилину

Перевіряти будемо саме з target_processing_time, бо у нас є CloudWatch метрика TargetResponseTime, з якою ми можемо порівняти наші результати.

Запит буде виглядати таким:

avg_over_time (
    {logtype="alb"} 
        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
        | __error__="" 
        | unwrap target_processing_time
        | target_processing_time >= 0
        | domain="test-alb-logs-1.setevoy.org.ua"
    [1m] offset 5m
) by (elb_id)

target_processing_time >= 0 використовуємо, бо деякі записи можуть мати значення -1:

This value is set to -1 if the load balancer can’t dispatch the request to a target

Фактично, avg_over_time() виконує sum_over_time() (сума з unwrap target_processing_time всіх значень за 1 хвилину) / на count_over_time() (загальна кількість записів з Log Selector {logtype="alb"}):

sum(sum_over_time (
    {logtype="alb"} 
        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
        | __error__="" 
        | unwrap target_processing_time
        | target_processing_time >= 0
        | domain="test-alb-logs-1.setevoy.org.ua"
    [1m] offset 5m
))
/
sum(count_over_time (
    {logtype="alb"} 
        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
        | __error__="" 
        | target_processing_time >= 0
        | domain="test-alb-logs-1.setevoy.org.ua"
    [1m] offset 5m
))

І порівняємо результат з CloudWatch – 14:21 на нашому графіку це 14:16 в CloudWatch, бо ми використовуємо offset 5m:

0.0008 CloudWatch – і 0.0007 у нас. В принципі, плюс-мінус воно.

Або можемо взяти 99-й перцентиль:

І теж саме в CloudWatch:

Додаємо Recording Rule aws:alb:target_processing_time:percentile:99:

- record: aws:alb:target_processing_time:percentile:99
  expr: |
    quantile_over_time (0.99,
        {logtype="alb"} 
            | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
            | __error__="" 
            | unwrap target_processing_time
            | target_processing_time >= 0
            | domain="test-alb-logs-1.setevoy.org.ua" [1m] offset 5m
    ) by (elb_id)

І перевіряємо в VictoriaMetrics:

Ще раз порівняємо з CloudWatch:

Добре, з цим теж розібрались.

Далі – статистика по 4хх, 5хх, та й взагалі всім кодам.

LogQL для 4xx та 5xx errors rate

Тут все просто – можемо взяти наш самий перший запит, і додати в sum by (elb_code):

sum by (elb_id, domain, elb_code) (
    rate(
        {logtype="alb", component="devops"} 
            | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
            | domain="test-alb-logs-1.setevoy.org.ua"
            [1m] offset 5m
    )
)

У нас все ще запущений wrk2 на 2 запити в секунду – ось ми їх і бачимо.

Давайте додамо ще один виклик на тіж 2 запити в секунду, але на неіснуючу сторінку, шоб отримати код 404:

$ wrk2 -c2 -t2 -R2 -H "User-Agent: wrk2" -d120m https://test-alb-logs-1.setevoy.org.ua/404-page

І поки воно дійде до логів – створимо метрику:

- record: aws:alb:requests:sum_by:elb_http_codes_rate:1m:offset:5m
  expr: |
    sum by (elb_id, domain, elb_code) (
        rate(
            {logtype="alb", component="devops"} 
                | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                | domain="test-alb-logs-1.setevoy.org.ua"
                [1m] offset 5m
        )
    )

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

Аналогічно робимо, якщо треба мати метрики/графіки по кодам з TargetGroups, тільки замість sum by (elb_id, domain, elb_code) робимо sum by (elb_id, domain, target_code).

LogQL для received та transmitted bytes

Метрика CloudWatch ProcessedBytes має загальне значення для отримано/передано, а я хотів б бачити їх окремо.

Отже, зараз у нас виконується 2 запити в секунду.

Глянемо в лог на значення поля sent_bytes:

На кожен запит з кодом 200 ми відправляємо відповідь у 853 байти, тобто на два запити в секунду – 1706 байт/секунду.

Давайте робити запит:

sum by (elb_id, domain) (              
    sum_over_time(
        (
            {logtype="alb"} 
            | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
            | __error__="" 
            | domain="test-alb-logs-1.setevoy.org.ua"
            | unwrap sent_bytes
        )[1s]
    )
)

Тут ми з unwrap sent_bytes беремо значення поля sent_bytes, передаємо його до sum_over_time[1s] – і отримуємо наші 1706 байт в секунду:

Додаємо Recording Rule:

- record: aws:alb:sent_bytes:sum
  expr: |
    sum by (elb_id, domain) (              
        sum_over_time(
            (
                {logtype="alb"} 
                | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                | __error__="" 
                | domain="test-alb-logs-1.setevoy.org.ua"
                | unwrap sent_bytes
            )[1s] offset 5m
        )
    )

І маємо дані в VictoriaMetrcis:

Або можна зробити теж саме, але використовуючи функцію rate():

sum by (elb_id, domain) (
  rate(
    (
      {logtype="alb"} 
      | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
      | __error__="" 
      | domain="test-alb-logs-1.setevoy.org.ua"
      | unwrap sent_bytes
    )[5m]
  )
)

Для Recording Rules – додаємо offset 5m:

- record: aws:alb:sent_bytes:rate:5m:offset:5m
  expr: |
    sum by (elb_id, domain) (
      rate(
        (
          {logtype="alb"} 
          | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
          | __error__="" 
          | domain="test-alb-logs-1.setevoy.org.ua"
          | unwrap sent_bytes
        )[1m] offset 5m
      )
    )

Аналогічно робимо для received_bytes.

LogQL для Client Connections

Як ми можемо порахувати кількість саме connections до ALB? Бо в CloudWatch такої метрики нема.

Кожен TCP Connection формується з пари Client_IP:Client_Port – Destination_IP:Destination_Port – див. A brief overview of TCP/IP communications.

В логах ми маємо поле client_ip зі значеннями типу “178.158.203.100:41426“, де 41426 – це локальний TCP-порт на моєму ноуті, який використовується в рамках цього підключення до destination Load Balancer.

Тож ми можемо взяти кількість унікальних client_ip в логах за секунду – і отримати кількість TCP-сессій, тобто підключень.

Отже, зробимо такий запит:

sum by (elb_id, domain) (
    sum by (elb_id, domain, client_ip) (
        count_over_time(
            (
                {logtype="alb"} 
                | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                | __error__="" 
                | domain="test-alb-logs-1.setevoy.org.ua"
            )[1s]
        )
    )
)

Тут:

  • за допомогою count_over_time()[1s] ми беремо кількість записів в логах за останню секунду
  • отриманий результат “огортаємо” в sum by (elb_id, domain, client_ip) – тут client_ip використовуємо, щоб отримати унікальні значення для кожного Client_IP:Client_Port, і отримуємо кількість унікальних значень по полям elb_id, domain, client_ip
  • а потім ще раз огортаємо в sum by (elb_id, domain) – щоб отримати загальну кількість таких унікальних записів по кожному Load Balancer та Domain

Для перевірки запускаємо ще один інстанс wrk2, через пару хвилин ще один – і маємо 4 connection:

(спайки, мабуть додаткові підключеня, які робить wrk2 при запуску)

Або теж саме, але з rate() за 1 хвилину – щоб графік був більш рівномірний:

sum by (elb_id, domain) (
    sum by (elb_id, domain, client_ip) (
        rate(
            (
                {logtype="alb"} 
                | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                | __error__="" 
                | domain="test-alb-logs-1.setevoy.org.ua"
            )[1m] offset 5m
        )
    )
)

Ну і в принципі на цьому все. Маючі такі запити ми можемо побудувати борду.

Loki Recording Rules, Prometheus/VictoriaMetrics та High Cardinality

Окремо додам на тему створення labels: було б дуже прикольно зробити метрику, яка в своїх labels мала б, наприклад, target/Pod IP, або IP клієнтів.

Втім, це призведе до такого явища як “High Cardinality“:

Any additional log lines that match those combinations of label/values would be added to the existing stream. If another unique combination of labels comes in (e.g. status_code=”500”) another new stream is created.

Imagine now if you set a label for ip. Not only does every request from a user become a unique stream. Every request with a different action or status_code from the same user will get its own stream.

Doing some quick math, if there are maybe four common actions (GET, PUT, POST, DELETE) and maybe four common status codes (although there could be more than four!), this would be 16 streams and 16 separate chunks. Now multiply this by every user if we use a label for ip. You can quickly have thousands or tens of thousands of streams.

This is high cardinality. This can kill Loki.

Див. How labels in Loki can make log queries faster and easier.

В нашому випадку ми генеруємо метрику, яку записуємо в TSDB VictoriaMetrics, і кожна додаткова лейбла з унікальним значенням буде створювати додаткові time-series.

Див. Understanding Cardinality in Prometheus Monitoring.

Loading

Arduino: запуск Arduino IDE на Linux, та перший “Hello, World!”
0 (0)

3 Лютого 2024

Продовжуємо бавитись з Ардуінкою 🙂

В попередньому пості трохи поговорили про Starter Kit – тепер хочеться вже ж щось зробити.

Тож встановимо IDE на Arch Linux, і спробуємо виконати перший код.

Запуск Arduino IDE на Linux

Документація для Arch Linux є тут>>>.

Встановлюємо IDE:

$ yay -S arduino-ide-bin

Перший запуск:

$ arduino-ide

Додамо CLI:

$ sudo pacman -S arduino-cli

Приклади в моїй офіційній документації є тут>>>, але там прям біда з форматуванням коду, тож я взяв інший приклад ось тут – Turn an LED on and off every second.

Arduino IDE, Linux та “No ports discovered”

Тепер нам в IDE треба вибрати board, тобто плату Arduino, з якою будемо працювати, але – “No ports discovered

Ну, Linux жеж 🙂

“Нєльзя просто так взять, і…” (с)

У CLI аналогічно:

$ arduino-cli board list
No boards found.

Окей, а якщо..

 

Але ні – після ребуту системи теж нічого не змінилося.

Судячи з документації, має з’явитись новий девайс – /dev/ttyACM0 або /dev/ttyUSBx – проте в мене нічого. Та й lsusb не виводить ніяких нових девайсів.

Почав гуглити, знайшов ось цей тред, і подумав – може і в мене проблема з USB-кабелем? А як перевірити? Бо іншого кабелю під рукою нема.

Проте є ігровий ПК з Віндою – давайте спробуємо там.

Встановлюємо IDE на Вінду, і – о чудо!

Але ж працювати під Віндою не хочеться, тож пішов знову пробувати з Linux, переключив Arduino назад до ноута з Arch, і – о, знову чудо!

Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: new full-speed USB device number 5 using xhci_hcd
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: New USB device found, idVendor=10c4, idProduct=ea60, bcdDevice= 1.00
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: Product: CP2102 USB to UART Bridge Controller
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: Manufacturer: Silicon Labs
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: SerialNumber: 0001
Feb 03 16:14:41 setevoy-wrk-laptop kernel: cp210x 4-2:1.0: cp210x converter detected
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: cp210x converter now attached to ttyUSB0
Feb 03 16:14:41 setevoy-wrk-laptop mtp-probe[48870]: checking bus 4, device 5: "/sys/devices/pci0000:00/0000:00:08.1/0000:06:00.4/usb4/4-2"
Feb 03 16:14:41 setevoy-wrk-laptop mtp-probe[48870]: bus: 4, device: 5 was not an MTP device
Feb 03 16:14:42 setevoy-wrk-laptop mtp-probe[48931]: checking bus 4, device 5: "/sys/devices/pci0000:00/0000:00:08.1/0000:06:00.4/usb4/4-2"
Feb 03 16:14:42 setevoy-wrk-laptop mtp-probe[48931]: bus: 4, device: 5 was not an MTP device

І тепер є новий девайс в lsusb:

$ lsusb
...
Bus 004 Device 005: ID 10c4:ea60 Silicon Labs CP210x UART Bridge
...

І tty:

$ ll /dev/ttyUSB0 
crw-rw---- 1 root uucp 188, 0 Feb  3 16:14 /dev/ttyUSB0

І тепер є порт в IDE під Linux:

 

Не знаю, чому так вийшло. Можливо, Ардуінка якось ініциалізувалася, коли нормально підключилась на Windows.

Але це ще не все 🙂

“/dev/ttyUSB0”: Permission denied

Потрібно додатково дозволити доступ до /dev/ttyUSB0 – тут трохи забігаю наперед, коли вже пробував заливати код:

Додаємо файл /etc/udev/rules.d/01-ttyusb.rules:

SUBSYSTEMS=="usb-serial", TAG+="uaccess"

Робимо релоад udev:

$ sudo udevadm control --reload

Перепідключаємо ардуінку, і клікаємо Upload:

І тепер все завантажилось:

Arduino “Hello, World!”

Окей, наче все готово?

Спробуємо зробити з прикладу Turn an LED on and off every second.

IDE та код

Тут просто копіюємо код з тієї сторінки:

// the setup function runs once when you press reset or power the board
void setup() {
  // initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);
}

// the loop function runs over and over again forever
void loop() {
  digitalWrite(LED_BUILTIN, HIGH);  // turn the LED on (HIGH is the voltage level)
  delay(1000);                      // wait for a second
  digitalWrite(LED_BUILTIN, LOW);   // turn the LED off by making the voltage LOW
  delay(1000);                      // wait for a second
}

Підключаємо Arduino, клікаємо Upload:

В коді все наче досить просто:

  • виконуємо ініціалізацію LED_BUILTIN (13 pin на платі)
  • запускаємо цикл, в якому
    • подаємо живлення на LED_BUILTIN
    • чекаємо 1000 мілісекунд
    • відключаємо живлення

Тепер зберемо сам “девайс”, який буде нам блимкати лампочкою.

LED та резистор

Нам потрібен LED і резистор на 220 Ом.

Якщо з LED все зрозуміло, то з резистором я трохи покопався.

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

Тож просто взяв мультиметр, та поміряв ним:

Коли ж вже це все зробив, то стала зрозуміла і ця схема:

Тож резистор на фотці має:

  • червоний
  • червоний
  • чорний
  • чорний
  • коричневий

Тобто відповідно до схеми це буде “2 2 0 х1 1%” – 220 Ом, начебто вірно?

Тепер спробуємо це все підключити.

Breadboard для Arduino

Дістаємо breadboard (“макетна плата”).

Під капотом вона має такі з’єднання:

І шини для підключення живлення та компонентів:

Отже, нам потрібно таке підключення:

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

Сам LED довгою ніжкою підключаємо на сторону живлення (“+”), до резистора, короткою – до “землі”:

Далі – підключаємо на самій платі Arduino: чорний провід до GRD (Ground), червоний – до 13 pin – він у нас відповідає за роботу з LED (але з портами детальніше будемо розбиратись вже далі, див. Digital Pins):

Підключаємо Arduino до ноута (або підключаємо блок живлення), і…

Wow! It works! 🙂

Окей.

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

Loading

Arduino: перше знайомство та мій Arduino Starter Kit
0 (0)

3 Лютого 2024

Бути девопсом – то, звичайно круто – всі ці клауди, Терраформи, сесуріті і прочі дуже цікаві штуки.

Але я давно хотів спробувати і щось більш “реальне”, щось таке, щоб можна було потримати в руках, і цими ж руками зібрати.

В минулому році, коли думав про чергову підготовку до зими (див. Підготовка до зими 2023-2024: електрохарчування), знов згадав свою ідею мати вдома пожежну сигналізацію – бо на балконі стоять акумулятори. І, звісно, можна купити готові рішення від Ajax Systems, але ж можна і зробити самому!

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

Тож врешті-решт я таки вирішив почати знайомитись з Arduino. До того ж досвід роботи з мікроконтролерами може знадобитись, якщо доведеться мати справу з дронами (if you know what I mean 😉 ).

Ну і само собою – хіба ж можна займатись такими штуками, і не написати про на RTFM? 🙂 Тож додаю нову рубрику, і сподіваюсь, що буду її періодично оновлювати.

Arduino: the very beginning

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

Тож самим першим кроком було загуглити якось на кшталт “adruino що можна зробити” і, чесно кажучи, навіть здивувався – скільки ж всього є під цю платформу.

Тож з того, що можна зробити – і що було б корисно вдома, аби мати більше мотивації:

  • система відеоспостереження
  • сигналізація пожежі/потопу
  • система автополиву квітів
  • робот-пилосос
  • система управління акваріумом

Та безліч всього іншого, бо датчиків для Arduino – просто море.

Як познайомитись з Arduino?

Знову-таки – я ніколи з ним не мав справу, тож треба було якось почати освоюватись в новому для себе світі.

Мені з цим допомагають два основні матеріали:

Взагалі стартових наборів теж дуже багато, я вибирав по принципу “побільше всього відразу”. Враховуючи вартість в 2-3 тисячі гривень – можна собі дозволити брати “максимальний” набір (в мене ще не самий максимальний), бо краще відразу мати під рукою все, аніж почати щось робити, потім зрозуміти, що тобі не вистачає якоїсь деталі, і чекати, поки на пошту прийде замовлення.

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

Ще, звісно, є купа матеріалів у всяких блогах, на Youtube тощо – але до них поки не добрався.

Насправді я тільки от сьогодні дістав цей набір із шафи, хоча почав читати книгу і купив набір ще восени, але потім якось те-це, якісь справи-робота, і трохи підзабив на це діло.

Тож  час розпаковувати його – і пробувати щось сконектити.

Arduino Starter Kit

Список компонентів в моєму наборі прям дуже широкий:

  • 5 * Синій світлодіод
  • 5 * Червоний світлодіод
  • 5 * Жовтий світлодіод
  • 1 * RGB світлодіод
  • 8 * Резистор 220 Ом
  • 5 * Резистор 10 кОм
  • 5 * Резистор 1кОм
  • 1 * 10K потенціометр
  • 1 * Зумер (активний)
  • 1 * Зумер (пасивний)
  • 4 * Великий кнопковий перемикач
  • 2 * Датчик нахилу
  • 3 * Фоторезистор
  • 1 * Датчик полум’я
  • 1 * Датчик температури LM35
  • 1 * Регістр зсуву 74HC595N
  • 1 * 7-сегментний світлодіодний 1x модуль
  • 1 * 7-сегментний світлодіодний 4х модуль
  • 1 * 8 * 8 Світлодіодна матриця
  • 1 * 2×16 РК-дисплей
  • 1 * ІЧ-приймач
  • 1 * ІЧ-пульт дистанційного керування
  • 1 * Серводвигун
  • 1 * Кроковий модуль драйвера
  • 1 * Кроковий двигун
  • 1 * Модуль джойстика
  • 1 * Релейний модуль
  • 1 * Датчик руху PIR
  • 1 * Аналоговий датчик газу
  • 1 * Модуль акселерометра ADXL345
  • 1 * Ультразвуковий датчик HC-SR04
  • 1 * Модуль годинника реального часу на DS3231
  • 1 * Датчик температури і вологості DHT11
  • 1 * Датчик вологості грунту
  • 1 * RFID-модуль RC522
  • 1 * RFID-карта
  • 1 * RFID-ключ
  • 40 * Конектор
  • 1 * Макетна плата
  • 10 * Перемички мама-мама
  • 30 * Перемички тато-тато
  • 1 * 6-елементна AA акумуляторна батарея
  • 1 * Кабель USB

Arduino controller

Власне сама “ардуінка”, контролер – головна плата:

Хоча їх теж є багато і від різних виробників:

Але принципіально вони всі майже однакові:

Або в моєму випадку, з документації:

Головна його частина – мікроконтролер ATmega328P:

Для його програмування є Arduino IDE, але її ми встановимо на Linux та подивимось в наступній частині – Arduino: запуск Arduino IDE на Linux та “Hello, World!”.

Забігаючи наперед – для Arduino використовується Wiring – фреймворк зі спрощеним C++, але начебто можна писати і на самій С++ (буде привід згадати її).

Підключення Arduino до комп’ютера

Ну, що – спробуємо його включити?)

Живлення може бути прямо від USB:

Ваааау!)))

“It works!” (c)

Тепер можна починати щось робити.

Тож в наступній частині ми встановимо IDE на Linux, і заставимо нашу ардуінку блимкати LED.

Корисні посилання

Loading

Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes
0 (0)

1 Лютого 2024

Маємо AWS EKS з Karpenter, який займається автоскелінгом EC2 – див. AWS: знайомство з Karpenter для автоскейлінгу в EKS, та встановлення з Helm-чарту.

В цілому проблем з ним поки не маємо, але в будь-якому разі потрібен його моніторинг, для чого Karpeneter “з коробки” надає метрики, які можемо використати в Grafana та Prometheus/VictoriaMetrics алертах.

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

  • додамо збір метрик до VictoriaMetrics
  • подивимось які метрики нам можуть бути корисні
  • додамо Grafana Dashboard для WorkerNodes + Karpenter

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

Окремо треба буде створити алерти – але це вже іншим разом. Маючи уяву про доступні метрики Karpenter та Prometheus-запити для графіків в Grafana проблем з алертами не має бути.

Поїхали.

Збір метрик Karpenter з VictoriaMetrics VMAgent

Наша VictoriaMetrics деплоїться власним чартом, див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом.

Для додавання нового target оновлюємо values цього чарту – додаємо ендпоінт karpenter.karpenter.svc.cluster.local:8000:

...
  vmagent:
    enabled: true
    spec: 
      replicaCount: 1
      inlineScrapeConfig: |
        - job_name: yace-exporter
          metrics_path: /metrics
          static_configs:
            - targets: ["yace-service:5000"]
        - job_name: github-exporter
          metrics_path: /
          static_configs:
            - targets: ["github-exporter-service:8000"]     
        - job_name: karpenter
          metrics_path: /metrics
          static_configs:
            - targets: ["karpenter.karpenter.svc.cluster.local:8000"]  
...

Деплоїмо, і для перевірки таргету відкриваємо порт до VMAgent:

$ kk port-forward svc/vmagent-vm-k8s-stack 8429

Перевіряємо таргети:

Для перевірки метрик відкриваємо порт до VMSingle:

$ kk port-forward svc/vmsingle-vm-k8s-stack 8429

І шукаємо дані по запиту {job="karpenter"}:

Корисні метрики Karpenter

Тепер глянемо, які саме метрики нам можуть бути корисні.

Але перед тим, як розбиратись з метриками – давайте проговоримо основні поняття в Karpenter (окрім очевидних типу Node, NodeClaim або Pod):

  • controller: компонент Karpenter, який віподвідає за певний аспект його роботи, наприклад, Pricing controller відповідає за перевірку вартості інстансів, а Disruption Controller відповідає за керування процесом зміни стану WorkerNodes
  • reconciliation (“узгодження”): процес, коли Karpenter виконує узгодження бажаного стану (desired state) и реального (current state), наприклад – при появі Pod, для якого нема вільних ресурсів на існуючих WorkerNodes, Karpenter створить нову Node, на якій зможе запустись Pod, і його статус стане Running – тоді reconciliation процес статусу цього поду завершиться
  • consistency (“когерентність” або “узгодженість”): процес внутрішнього контролю і забезпечення відповідності необхідним параметрам (наприклад, перевірка того, що створена WorkerNode має диск розміром саме 30 GB)
  • disruption: процес зміни WorkerNodes в кластері, наприклад перестворення WorkerNode (для заміни на інстанс з більшою кількістю CPU або Memory), або видалення існуючої ноди, на якій нема запущених Pods
  • interruption: випадки, коли EC2 буде зупинено у зв’язку з помилками на hardware, виключення інстансу (коли робиться Stop або Terminate instance), або у випадку зі Spot – коли AWS “відкликає” інстанс; ці евенти йдуть на віподвідну SQS, звідки їх отримує Karpenter, щоб запустити новий інстанс на заміну
  • provisioner: компонент, який аналізує поточні потреби кластера, такі як запити на створення нових Pod, визначає, які ресурси потрібно створити (WorkerNodes), і ініціює створення нових (взагалі, Provisioner був замінений на NodePool, але окремі метрики по ньому залишились)

Тут я зібрав тільки ті метрики, які мені вважаються найбільш корисними в даний момент, але варто самому передивитись документацію Inspect Karpenter Metrics і є трохи більше деталей у документації Datadog:

Controller:

  • controller_runtime_reconcile_errors_total: кількість помилок при оновленні WorkerNodes (тобто в роботі Disruption Controller при виконанні операцій по Expiration, Drift, Interruption та Consolidation) – корисно мати графік або алерт 
  • controller_runtime_reconcile_total: загальна кількість таких операції – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто

Сonsistency:

  • karpenter_consistency_errors: виглядає як корисна метрика, але в мене вона пуста (принаймні поки що)

Disruption:

  • karpenter_disruption_actions_performed_total: загальна кількість дій по disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто
  • karpenter_disruption_eligible_nodes: загальна кількість WorkerNodes для виконання disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method
  • karpenter_disruption_replacement_nodeclaim_failures_total: загальна кількість помилок при створенні нових WorkerNodes на заміну старим, в лейблах метрик вказується disruption method

Interruption:

  • karpenter_interruption_actions_performed: кількість дій за повідомленнями про EC2 Interruption (з SQS) – можливо має сенс, але в мене за тиждень збору метрик такого не траплялось

Nodeclaims:

  • karpenter_nodeclaims_created: загальна кількість створенних NodeClaims з лейблами по причині створення та відповідним NodePool
  • karpenter_nodeclaims_terminated: аналогічно, але по видаленним NodeClaims

Provisioner:

  • karpenter_provisioner_scheduling_duration_seconds: можливо, має сенс моніторити, бо якщо цей показник буде рости але буде надто великим – то це може бути ознакою проблем; проте, в мене за тиждень хістограма karpenter_provisioner_scheduling_duration_seconds_bucket незмінна

Nodepool:

  • karpenter_nodepool_limit: ліміт CPU/Memory NodePool, заданий в його Provisioner (spec.limits)
  • karpenter_nodepool_usage: використання ресурсів NodePool – CPU, Memory, Volumes, Pods

Nodes:

  • karpenter_nodes_allocatable: інформація по існуючим WorkerNodes – тип, кількість CPU/Memory, Spot/On-Demand, Availability Zone, etc
    • можна мати графік по кількості Spot/On-Demand інстансів
    • можна використовувати для отримання даних по доступних ресурсах ЦПУ/пам’яті – sum(karpenter_nodes_allocatable) by (resource_type)
  • karpenter_nodes_created: загальна кількість створенних нод
  • karpenter_nodes_terminated: загальна кількість видалених нод
  • karpenter_nodes_total_pod_limits: загальна кількість всіх Pod Limits (окрім DaemonSet) на кожній WorkerNode
  • karpenter_nodes_total_pod_requests: загальна кількість всіх Pod Requests (окрім DaemonSet) на кожній WorkerNode

Pods:

  • karpenter_pods_startup_time_seconds: час від сторення поду до його переходу в статус Running (сума по всім подам)
  • karpenter_pods_state: досить корисна метрика, бо в лейблах має статус поду, на якій він ноді запущений, неймспейс тощо

Cloudprovider:

  • karpenter_cloudprovider_errors_total: кількість помилок від AWS
  • karpenter_cloudprovider_instance_type_price_estimate: вартість інстансів по типам – можна на дашборді виводити вартість compute-потужностей кластеру

Створення Grafana dashboard

Для Grafana є готовий дашборд – Display all useful Karpenter metrics, але він якось зовсім не інформативний. Втім, з нього можна взяти деякі графіки та/або запити.

Зараз в мене є власна борда для перевірки статусу та ресурів по кожній окремій WorkerNode:

В графіках цієї борди є Data Links на дашборду з деталями по на дашборду з інформацією по конкретному Pod:

Графіки ALB внизу будуються з логів в Loki.

Тож що зробимо: нову борду, на якій будуть всі WorkerNodes, а в Data Links графіків цієї борди зробимо лінки на перший дашборд.

Тоді буде непогана навігація:

  1. загальна борда по всім WorkerNodes з можливістю перейти на дашборду з більш детальною інформацією по конкретній ноді
  2. на борді по конкретній ноді вже буде інформація по подах на цій ноді, і data links на борду по конкретному поду

Планування дашборди

Давайте поміркуємо, що саме ми б хотіли бачити на новій борді.

Фільтри/змінні:

  • мати змогу бачити всі WorkerNodes разом, або обрати одну чи кілька окремо
  • мати змогу бачити ресурси по конкретним Namespaces або Applications (в моєму випадку кожен сервіс має власний неймспейс, тому використаємо їх)

Далі, інформація по нодам:

  • загальна інформаця по нодам:
    • кількість нод
    • кількість подів
    • кількість ЦПУ
    • кількість Мем
    • spot vs on-deman ratio
    • вартість всіх нод за добу
  • відсотки від allocatable використано:
    • cpu – від pods requested
    • mem – від pods requested
    • pods allocation
  • реальне використання ресурсів – графіки по нодам:
    • CPU та Memory подами
    • кількість подів – процент від максимума на ноді
    • створено-видалено нод (by Karpenter)
    • вартість нод
    • процент EBS used
    • network – in/our byes/sec

По Karpenter:

  • controller_runtime_reconcile_errors_total – загальна кількість помилок
  • karpenter_provisioner_scheduling_duration_seconds – час створення подів
  • karpenter_cloudprovider_errors_total – загальна кількість помилок

Looks like a plan?

Поїхали творити.

Створення дашборди

Робимо нову борду, задаємо основні параметри:

Grafana variables

Нам потрібні дві змінні – по нодам і неймпспейсам.

Ноди можемо вибрати з karpenter_nodes_allocatable, неймспейси отримати з karpenter_pods_state.

Створюємо першу змінну – node_name, включаємо можливість вибору All або Multi-value:

Створюємо другу змінну – $namespace.

Щоб вибирати неймспейси тільки з обраних в фільтрі нод – додаємо можливість фільтру по $node_name яку створили вище і використовуємо регулярку “=~” – якщо нод буде обрано кілька:

Переходимо до графіків.

Кількість нод в кластері

Запит – використовуємо фільтр под обраним нодам:

count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name))

Кількість подів в кластері

Запит – тут фільтр і по нодам, і по неймспейсу:

sum(karpenter_pods_state{node=~"$node_name", namespace=~"$namespace"})

Кількість ядер CPU на всіх нодах

Частина ресурсів зайнята системою то ДемонСетами – вони у karpenter_nodes_allocatable не враховються. Можна перевірити запитом sum(karpenter_nodes_system_overhead{resource_type="cpu"}).

Тому можемо вивести або загальну кількість – karpenter_nodes_allocatable{resource_type="cpu"} + karpenter_nodes_system_overhead{resource_type="cpu"}, або тільки дійсно доступну для наших workloads – karpenter_nodes_allocatable{resource_type="cpu"}.

Так як тут ми хочемо бачити саме загальну кількість – то давайте використаємо суму:

sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="cpu"}) + sum(karpenter_nodes_system_overhead{node_name=~"$node_name", resource_type="cpu"})

Загальний доступний об’єм пам’яті

JFYI:

  • SI standard: 1000 bytes in a kilobyte.
  • IEC standart: 1024 bytes in a kibibyte

Але давайте просто самі зробимо / 1024, і використаємо Кілобайти:

sum(sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="memory"}) + sum(karpenter_nodes_system_overhead{node_name=~"$node_name", resource_type="memory"})) / 1024

Spot instances – % від загальної кількості Nodes

Окрім нод створенних самим Karpenter у нас є окрема “дефолтна” нода, яка створються при створенні кластеру – для всяких controllers. Вона теж Spot (поки що), тож рахуймо і її.

Формула буде такою:

загальна сума нод = всі спот від karpenter + 1 дефолтна / на загальну кількість нод

Сам запит:

sum(count(sum(karpenter_nodes_allocatable{node_name=~"$node_name", nodepool!="", capacity_type="spot"}) by (node_name)) + 1) / count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name)) * 100

CPU requested – % від загального allocatable

Запит беремо з дефолтної борди Karpenter, трохи підпилюємо під свої фільтри:

sum(karpenter_nodes_total_pod_requests{node_name=~"$node_name", resource_type="cpu"}) / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="cpu"})

Memory requested – % від загального allocatable

Аналогічно:

sum(karpenter_nodes_total_pod_requests{node_name=~"$node_name", resource_type="memory"}) / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="memory"})

Pods allocation – % від загального allocatable

Скільки подів маємо від загальної ємності:

sum(karpenter_pods_state{node=~"$node_name", namespace=~"$namespace"} / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="pods"})) * 100

Controller errors

Метрика controller_runtime_reconcile_errors_total включає в себе і контролери від VictoriaMetrcis, тож виключаємо їх через {container!~".*victoria.*"}:

sum(controller_runtime_reconcile_errors_total{container!~".*victoria.*"})

Cloudprovider errors

Рахуємо як рейт в секунду (з контролерами мабуть теж краще рейт, подивимось, як будуть помилки):

sum(rate(karpenter_cloudprovider_errors_total[15m]))

Nodes cost 24h – вартість всіх Nodes за добу

А от тут прям дуже цікаво вийшло.

По-перше – у AWS є дефолтні метрики білінгу від CloudWatch, але наш проект користується кредитами від AWS і ці метрики пусті.

Тому скористаємось метриками від Karpenter – karpenter_cloudprovider_instance_type_price_estimate.

Щоб відобразити вартість серверверів нам треба вибрати кожен тип інстансу які використовуються і потім порахувати загальну вартість по кожному типу і іх кількості.

Що ми маємо:

  • дефолта нода: з типом Spot, але створюється не Karpenter – можемо її ігнорувати
  • ноди, створені Karpenter: можуть бути або spot або on-demand, і можуть бути різних типів (t3.medium. c5.large, etc)

Спочатку нам треба отримати кількість нод по кожному типу:

count(sum(karpenter_nodes_allocatable) by (node_name, instance_type,capacity_type)) by (instance_type, capacity_type)

Отримуємо 4 spot, і один інстанс без лейбли capacity_type – бо це з дефолтної нод-групи:

Можемо його виключити з {capacity_type!=""} – він у нас один, без скейлінгу, можемо не враховувати, бо це тільки для CriticalAddons.

Для кращої картини візьмемо більший проміжок часу, бо там був ще t3.small:

Далі, у нас є метрика karpenter_cloudprovider_instance_type_price_estimate, використовуючи яку нам треба порахувати вартість всіх інстансів по кожному instance_type і capacity_type.

Запит буде виглядати так (дякую ChatGPT):

sum by (instance_type, capacity_type) (
    count(sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
    * on(instance_type, capacity_type) group_left 
    avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
)

Тут:

  1. “внутрішній” запит sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type): рахується сума всіх CPU, memory тощо для кожної комбінації node_name, instance_type, capacity_type
  2. “зовнішній” count(...) by (instance_type, capacity_type): результат попереднього запиту рахуємо з count, щоб отримати кількість кожної комбінації – отримуємо кількіть WorkerNodes кожного instance_type та capacity_type
  3. другий запит – avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type): повертає нам середню ціну по кожному instance_type та capacity_type
  4. використовуючи * on(instance_type, capacity_type): множимо кількість нод с запита номер 2 (count(...)) на результат с запита номер 3 (avg(...)) по співпадаючим комбінаціям метрик instance_type та capacity_type
  5. і самий перший “зовнішній” запит sum by (instance_type, capacity_type) (...): повертає нам суму по кожій комбінації

В результаті маємо такий графік:

Отже, що ми тут маємо:

  • 4 інстанси t3.medium та 2 t3.small
  • загальна вартість всіх t3.medium на годину виходить 0.074, всіх t3.small – 0.017

Для перевірки порахуємо вручну.

Спочатку по t3.small:

{instance_type="t3.small", capacity_type="spot"}

Виходить 0.008:

І по t3.medium:

{instance_type="t3.medium", capacity_type="spot"}

Виходить 0.018:

Тож:

  • 4 інстанси t3.medium по 0.018 == 0.072 usd/година
  • 2 інстанси t3.small по 0.008 == 0.016 usd/година

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

Залишилось все це зібрати разом, і вивести загальную вартість всіх серверів за 24 години – використаємо avg() і результат помножимо на 24 години:

avg(
  sum by (instance_type, capacity_type) (
    count(sum(karpenter_nodes_allocatable{capacity_type!=""}) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
    * on(instance_type, capacity_type) group_left 
    avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
  )
) * 24

І в результаті все у нас зараз виглядає так:

Йдемо далі – до графіків.

CPU % use by Node

Тут вже використаємо дефолтні метрики від Node Exporter – node_cpu_seconds_total, але вони мають лейбли instance у вигляді instance="10.0.32.185:9100", а не node_name або node як у  метриках від Karpenter (karpenter_pods_state{node="ip-10-0-46-221.ec2.internal"}).

Тож щоб їх застосувати node_cpu_seconds_total з нашою змінною $node_name – додамо нову змінну node_ip, яку будемо формувати з метрики kube_pod_info з фільтром по лейблі node, де використовуємо нашу стару змінну node_name – щоб вибирати поди тільки з обраних в фільтрах нод.

Додаємо нову змінну, поки для перевірки не вимикаємо “Show on dashboard“:

І тепер можемо створити графік с запитом:

100 * avg(1 - rate(node_cpu_seconds_total{instance=~"$node_ip:9100", mode="idle"}[5m])) by (instance)

Але в такому випадку instance нам поверне результати як “10.0.38.127:9100” – а ми всюди використовуємо “ip-10-0-38-127.ec2.internal“. До того ж ми не зможемо додати data links, бо друга панель використовує формат ip-10-0-38-127.ec2.internal.

Тож ми можемо використати label_replace(), і переписати запит так:

100 * avg by (instance) (
    label_replace(
        rate(node_cpu_seconds_total{instance=~"$node_ip:9100", mode="idle"}[5m]), 
        "instance", 
        "ip-${1}-${2}-${3}-${4}.ec2.internal", 
        "instance", 
        "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
    )
)

Тут label_replace отримує 4 агрументи:

  1. перший – метрика, над якою будемо виконувати трансформацію (результат rate(node_cpu_seconds_total))
  2. другий – лейбла, над якою ми будемо виконувати трансформацію – instance
  3. третій – новий формат value для лейбли – “ip-${1}-${2}-${3}-${4}.ec2.internal
  4. четвертий – ім’я лейбли, з якої ми будемо тримувати дані за допомогою regex

І останнім описуємо сам regex “(.*)\\.(.*)\\.(.*)\\.(.*):9100“, за яким треба отрмати кожен октет з IP 10.0.38.127, а потім кожен результат відповідно записати у ${1}-${2}-${3}-${4}.

Тепер маємо графік в такому вигляді:

Memory used by Node

Тут все аналогічно:

sum by (instance) (
  label_replace(
    (
      1 - (
        node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
      )
    ) * 100,
    "instance", "ip-${1}-${2}-${3}-${4}.ec2.internal", "instance", "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
  )
)

Pods use % by Node

Тут нам треба виконати запит між двома метриками – karpenter_pods_state та karpenter_nodes_allocatable:

(
  sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
  / 
  sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100

Або ми можемо виключити нашу дефолтну ноду “ip-10-0-41-2.ec2.internal” і відобразити тільки ноди самого Карпентеру додавши вибірку по karpenter_nodes_allocatable{capacity_type!=""} – бо нам тут більше цікаво наскільки зайняті ноди, які створено самим Karpenter під наші аплікейшени.

Але для цього нам знабиться метрика karpenter_nodes_allocatable, в якій ми можемо перевірити наявність лейбли capacity_typecapacity_type!="".

Проте karpenter_nodes_allocatable має лейблу node_name а не node як в попередніх двух, тому ми знову можемо додати label_replace, і зробити такий запит:

(
  sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
  / 
  on(node) group_left
  sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100
and on(node)
label_replace(karpenter_nodes_allocatable{capacity_type!=""}, "node", "$1", "node_name", "(.*)")

Тут в and on(node) ми використовуємо лейблу node в результатах запиту зліва (sum by()) і в результаті справа, щоб зі списку нод в karpenter_nodes_allocatable{capacity_type!=""} (тобто всі ноди, окрім нашої “дефолтної”) вибрати тільки ті, які є в результатх першого запиту:

EBS use % by Node

Тут вже простіше:

sum(kubelet_volume_stats_used_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance) 
/ 
sum(kubelet_volume_stats_capacity_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance) 
* 100

Nodes created/terminated by Karpenter

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

increase(karpenter_nodes_created[1h])

Та:

- increase(karpenter_nodes_terminated[1h])

Тут в функції increase() перевіряємо наскільки змінилося значення за годину:

А щоб позбутися цих “сходинок” – можемо додатково загорнути результат у функцію avg_over_time():

Grafana dashboard: фінальний результат

І все разом у нас тепер виглядає так:

Додавання Data links

Останім кроком буде додавання Data Links на графіки: потрібно додати лінку на іншу дашборду, по конкретній ноді.

Ця борда має такий URL: https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-41-2.ec2.internal

Де в var-node_name=ip-10-0-41-2.ec2.internal задається ім’я ноди, по якій треба вивести дані:

Тож відкриваємо графік, знаходимо Data links:

Задаємо ім’я та URL – список всіх полів можна отримати по Ctrl+Space:

__field візьме дані з labels.node з результату запиту в панелі:

І сформує посилання у вигляді “https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-38-110.ec2.internal“.

Ну і на цьому начебто все.

Loading

AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів
5 (1)

15 Грудня 2023

Ще з дуже цікавих новинок останнього re:Invent – це EKS Pod Identities: нова можливість керувати доступами подів до ресурсів AWS.

The current state: IAM Roles for Service Accounts

До цього ми використовували модель IAM Roles for Service Accounts, IRSA, де для того, щоб якомусь поду дати доступ до, наприклад, S3, ми створювали IAM Role з відповідною IAM Policy, налаштовували її Trust Policy – щоб дозволити виконувати AssumeRole тільки з відповідвідного кластеру, потім створювали Kubernetes ServiceAccount, в annotations якого вказували ARN цієї ролі.

За такою схемою ми мали декілька “error prone” моментів:

  • найбільш розповсюджена проблема, з якою і я стикався прям ну дуже багато раз – помилки в Trust Policy, де треба було вказувати OIDC кластеру
  • помилки в самому ServiceAccount, де можна було помилитись в ARN ролі

Див. AWS: EKS, OpenID Connect та ServiceAccounts.

The f(ea)uture state: EKS Pod Identities

Проте тепер EKS Pod Identities дозволяє нам один раз створити IAM Role, ніяк її не обмежувати конкретним кластером, і підключати цю роль до подів (знову-таки – через ServiceAccount) прямо з AWS CLI, AWS Console чи через AWS API (Terraform, CDK, etc).

Як це виглядає:

  • в EKS додаємо новий контроллер – Amazon EKS Pod Identity Agent add-on
  • створюємо IAM Role, в Trust Policy якої тепер використовуємо Principal: pods.eks.amazonaws.com
  • і з AWS CLI, AWS Console чи через AWS API підключаємо цю роль напряму до потрібного ServiceAccount

Го тестити!

Створення IAM Role

Переходимо в IAM, створюємо роль.

В Trusted entity type вибираємо EKS і новий тип – EKS – Pod Identity:

В Permissions візьмемо вже існуючу політику на S3ReadOnly:

Задаємо ім’я ролі, і як раз тут і бачимо нову Trust Policy:

І давайте порівняємо її з Trust Policy для IRSA ролі:

Набагато простіше, а значить – менше варіантів для помилок, і взагалі простіше менеджити. До того ж, ми більше не зав’язані на Cluster OIDC Provider.

До речі, з EKS Pod Identities ми можемо використовувати і role session tags.

Окей, йдемо далі.

Amazon EKS Pod Identity Agent add-on

Переходимо до нашого кластеру, встановлюємо новий компонент – Amazon EKS Pod Identity Agent add-on:

Чекаємо хвилину – готово:

І поди цього контролера:

$ kk -n kube-system get pod | grep pod
eks-pod-identity-agent-d7448                    1/1     Running   0               91s
eks-pod-identity-agent-m46px                    1/1     Running   0               91s
eks-pod-identity-agent-nd2xn                    1/1     Running   0               91s

Підключення IAM Role до ServiceAccount

Переходимо в Access, і клікаємо Create Pod Identity Association:

Вибираємо роль, яку створили вище.

Далі задаємо ім’я неймспейсу – або вибираємо зі списку існуючих, або вказуємо нове.

Аналогічно з ім’ям ServiceAccount – можна задати вже створенний SA, можно задати нове ім’я:

Створення Pod та ServiceAccount

Описуємо маніфест:

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

Деплоїмо:

$ kubectl apply -f iam-sa.yaml    
namespace/ops-iam-test-ns created
serviceaccount/ops-iam-test-sa created
pod/ops-iam-test-pod created

І пробуємо доступ:

$ kk -n ops-iam-test-ns exec -ti ops-iam-test-pod -- bash
bash-4.2# aws s3 ls
2023-02-01 11:29:34 amplify-staging-112927-deployment
2023-02-02 15:40:56 amplify-dev-174045-deployment
...

Але якщо ми спробуємо іншу операцію, на яку ми не підключали політику, наприклад – EKS, то отримаємо 403:

bash-4.2# aws eks list-clusters

An error occurred (AccessDeniedException) when calling the ListClusters operation: User: arn:aws:sts::492***148:assumed-role/EKS-Pod-Identities-test-TO-DEL/eks-atlas-eks--ops-iam-te-cc662c4d-6c87-44b0-99ab-58c1dd6aa60f is not authorized to perform: eks:ListClusters on resource: arn:aws:eks:us-east-1:492***148:cluster/*

Проблеми?

Наразі я бачу одну потенційну не проблему, але питання, яке варто мати на увазі: якщо раніьше ми налаштовували доступ на рівні сервісу, то з EKS Pod Identities це робиться на рівні управління кластером.

Тобо: в мене є сервіс, Backend API. В нього є власний репозиторій, в якому є каталог terrafrom, в якому створюються необхідні IAM-ролі.

Далі, є каталог helm, в якому маємо маніфест з ServiceAccount, в якому в анотаціях через змінні передається ARN цієї IAM ролі.

І на цьому все – мені (точніше – CI/CD пайплайну, який виконує деплой) потрібен доступ тільки до IAM, потрібен доступ в EKS на створення Ingress, Deployment та ServiceAccount.

Але тепер треба буде думати як давати доступ ще й до EKS на рівні AWS, бо треба буде виконувати додаткову операцію в AWS API на Create Pod Identity Assosiaction.

До речі, в Terraform вже новий ресурс для цього – aws_eks_pod_identity_association.

Проте виглядає дійсно класно, і може дуже спростити життя по менеджменту EKS та IAM.

EKS Pod Identity restrictions

Варто звернути увагу на документацію, бо в EKS Pod Identity restrictions говориться, що EKS Pod Identities доступна тількина Amazon Linux:

EKS Pod Identities are available on the following:

Loading

AWS: CloudWatch – Multi source query: збираємо метрики із зовнішнього Prometheus
0 (0)

13 Грудня 2023

Ще один цікавий анонс з останнього re:Invent – це те, що в CloudWatch додали можливість збирати метрики із зовнішніх ресурсів (див. дуже цікавий доклад AWS re:Invent 2023 – Cloud operations for today, tomorrow, and beyond (COP227)).

Тобто тепер ми можемо створювати графіки та/або алерти не тільки з дефолтних метрик самого CloudWatch – але й за допомогою конекторів для CloudWatch підключити збір метрик з Amazon Managed Service for Prometheus, звичайного Prometheus, Amazon OpenSearch Service, Amazon RDS для MySQL та PostgreSQL, CSV файлів з S3 бакетів і навіть з Microsoft Azure Monitor.

Виглядає це прям дуже круто, бо тепер можна буду переосмислили взагалі всю свою концепцію побудови моніторингу и observability на проекті.

Підключення метрик з vanilla Prometheus

Є Prometheus на голому EC2 інстансі, на якому відкрито порт 9090 – спочатку спробуємо тут, потім глянемо на VictoriaMetrcis в EKS.

Переходимо в CloudWatch Metrics, тепер маємо нову вкладку Multi source query:

Вибираємо Prometheus:

Задаємо параметри.

Поля логін-пароль обов’язкові, тож навіть якщо Prometheues не потребує аутентифікації – задаємо тут якісь значення.

Нижче можна налаштувати параметри мережі. Наприклад, якщо Prometheus доступний тільки всередені VPC – то тут можемо вибрати VPC і сабнети:

Клілкаємо Create data source – в CloudWatch запустить створення CloudFormation стеку, в якому створить Lambda-функції, які власне і будуть збирати дані з нашого дата-сорсу:

Повертаємось до CloudWatch, де тепер маємо новий дата-сорс:

І можемо з нього отримати метрики:

VictoriaMetrics, EKS та VPC

Зараз маємо VictoriaMetrics в Kubernetes, див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом.

Щоб CloudWatch міг збирати метрики – нам треба відкрити доступ до VMSingle.

Тут маємо два варіанти – або звичайний Ingress/ALB, див. values.yaml, або через VMAuth з аутентифікацією, див. VictoriaMetrics: VMAuth – проксі, аутентифиікація та авторизація.

І при додаванні data source в CloudWatch єдина відмінність від звичайного Prometheus – це URI:

  • у Prometheues ми ходимо на URI hostname:9090/api/v1/labels
  • у VicrotiaMetrcis з VMSingle – через hostname:8429/prometheus/api/v1/labels

Тож в data source додаємо як https://vmsingle.ops.example.co/prometheus/.

Редагування і видалення data source

Не побачив, де і як можна змінити якісь параметри або видалити дата-сорс з панелі самого CloudWatch.

Схоже що поки що видаляється дата-сорс тільки через CloudFormation – видалення стеку, а змінити якісь параметри можна тільки в самій Lambda-функції.

Наприклад, щоб відредагувати Prometheus URL – він задається в змінних оточення функції:

Але anyway – виглядає це все дуже прикольно.

Loading