Приклад створення модулю 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, з якого буде створюватись Lambdaloki_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.
Все готово.