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








