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

Автор |  20/02/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.

Все готово.