Ogni organizzazione AWS matura separa prima o poi i workload dai servizi condivisi in account distinti. Compute nell’Account A, OpenSearch nell’Account B. La sfida e far attraversare i log di CloudWatch quel confine tra account in modo affidabile, con IAM a minimo privilegio e senza credenziali hardcoded.
Il pattern canonico e: Lambda scrive log strutturati su CloudWatch, un subscription filter li inoltra a Kinesis Data Firehose tramite una destination cross-account, e Firehose consegna a OpenSearch. Kinesis fornisce buffering, retry e un percorso di dead-letter che le chiamate dirette Lambda-OpenSearch non hanno.
Architettura#
flowchart LR
subgraph AccountA["Account A (Workloads)"]
Lambda["Lambda Function\nPython 3.12"]
CWL["CloudWatch\nLog Group\n14-day retention"]
Sub["Subscription Filter"]
Dest["CW Logs\nDestination ARN"]
Lambda --> CWL
CWL --> Sub
Sub --> Dest
end
subgraph AccountB["Account B (Shared Services)"]
Firehose["Kinesis Firehose\nbuffer: 5MB / 300s"]
OS["OpenSearch Domain\nos 2.11"]
DLQ["S3 Bucket\nFailed records"]
Firehose --> OS
Firehose --> DLQ
end
Dest -->|"cross-account IAM role"| Firehose
La CloudWatch Logs destination e una risorsa nell’Account B che accetta dati di log dall’Account A. E il ponte tra i subscription filter e Kinesis. L’Account A non scrive mai direttamente su OpenSearch.
Account A: Lambda, Log Group, Subscription Filter#
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-central-1"
}
resource "aws_iam_role" "lambda_exec" {
name = "lambda-exec-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "app" {
function_name = "my-service"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
handler = "handler.lambda_handler"
runtime = "python3.12"
role = aws_iam_role.lambda_exec.arn
environment {
variables = {
LOG_LEVEL = "INFO"
}
}
}
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/src/handler.py"
output_path = "${path.module}/build/handler.zip"
}
resource "aws_cloudwatch_log_group" "app" {
name = "/aws/lambda/${aws_lambda_function.app.function_name}"
retention_in_days = 14
}
resource "aws_cloudwatch_log_subscription_filter" "to_opensearch" {
name = "forward-to-opensearch"
log_group_name = aws_cloudwatch_log_group.app.name
filter_pattern = "" # stringa vuota = invia tutti gli eventi
destination_arn = var.cloudwatch_destination_arn # ARN destination Account B
depends_on = [aws_cloudwatch_log_group.app]
}La variabile destination_arn e la CloudWatch Logs destination creata nell’Account B (mostrata sotto). Un filter_pattern vuoto inoltra tutto; si puo usare "ERROR" o un filtro JSON come { $.level = "ERROR" } per ridurre il volume.
Account B: OpenSearch Domain e Consegna Cross-Account#
provider "aws" {
region = "eu-central-1"
}
data "aws_caller_identity" "b" {}
resource "aws_opensearch_domain" "logs" {
domain_name = "platform-logs"
engine_version = "OpenSearch_2.11"
cluster_config {
instance_type = "t3.small.search"
instance_count = 1
}
ebs_options {
ebs_enabled = true
volume_size = 20
volume_type = "gp3"
}
encrypt_at_rest { enabled = true }
node_to_node_encryption { enabled = true }
domain_endpoint_options {
enforce_https = true
tls_security_policy = "Policy-Min-TLS-1-2-2019-07"
}
tags = { Environment = "shared" }
}
# Minimo privilegio: solo le due azioni di cui Firehose ha effettivamente bisogno
resource "aws_opensearch_domain_policy" "firehose_access" {
domain_name = aws_opensearch_domain.logs.domain_name
access_policies = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = aws_iam_role.firehose_delivery.arn }
Action = ["es:ESHttpPut", "es:ESHttpPost"]
Resource = "${aws_opensearch_domain.logs.arn}/*"
}]
})
}
# Ruolo IAM cross-account trusted dal servizio CloudWatch Logs dell'Account A
resource "aws_iam_role" "cloudwatch_destination" {
name = "cloudwatch-logs-cross-account-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "logs.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"aws:SourceAccount" = var.account_a_id
}
}
}]
})
}
resource "aws_iam_role_policy" "cloudwatch_destination_firehose" {
name = "put-firehose-records"
role = aws_iam_role.cloudwatch_destination.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["firehose:PutRecord", "firehose:PutRecordBatch"]
Resource = aws_kinesis_firehose_delivery_stream.logs.arn
}]
})
}
resource "aws_cloudwatch_log_destination" "logs" {
name = "lambda-logs-to-opensearch"
role_arn = aws_iam_role.cloudwatch_destination.arn
target_arn = aws_kinesis_firehose_delivery_stream.logs.arn
}
resource "aws_cloudwatch_log_destination_policy" "logs" {
destination_name = aws_cloudwatch_log_destination.logs.name
access_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.account_a_id}:root" }
Action = "logs:PutSubscriptionFilter"
Resource = aws_cloudwatch_log_destination.logs.arn
}]
})
}Kinesis Data Firehose: Consegna con Buffer a OpenSearch#
La consegna diretta da CloudWatch a OpenSearch esiste ma non ha logica di retry ne dead-letter. Firehose risolve entrambi i problemi.
resource "aws_s3_bucket" "firehose_dlq" {
bucket = "platform-logs-firehose-dlq-${data.aws_caller_identity.b.account_id}"
}
resource "aws_iam_role" "firehose_delivery" {
name = "firehose-opensearch-delivery"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "firehose.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "firehose_delivery" {
name = "firehose-delivery-policy"
role = aws_iam_role.firehose_delivery.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["es:ESHttpPut", "es:ESHttpPost", "es:DescribeDomain"]
Resource = [aws_opensearch_domain.logs.arn, "${aws_opensearch_domain.logs.arn}/*"]
},
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:GetBucketLocation"]
Resource = [aws_s3_bucket.firehose_dlq.arn, "${aws_s3_bucket.firehose_dlq.arn}/*"]
}
]
})
}
resource "aws_kinesis_firehose_delivery_stream" "logs" {
name = "lambda-logs-to-opensearch"
destination = "opensearch"
opensearch_configuration {
domain_arn = aws_opensearch_domain.logs.arn
role_arn = aws_iam_role.firehose_delivery.arn
index_name = "lambda-logs"
index_rotation_period = "OneDay"
buffering_interval = 300 # secondi
buffering_size = 5 # MB
retry_duration = 300
s3_backup_mode = "FailedDocumentsOnly"
s3_configuration {
role_arn = aws_iam_role.firehose_delivery.arn
bucket_arn = aws_s3_bucket.firehose_dlq.arn
prefix = "failed-logs/"
}
}
}Impostare buffering_interval e buffering_size in base al volume di ingestione reale. I valori predefiniti (300s / 5MB) sono ragionevoli per volumi medio-bassi. Per servizi ad alto traffico, ridurre l’intervallo per evitare latenza nei dashboard.
CI/CD con OIDC (Nessuna Credenziale Hardcoded)#
La versione originale di questo articolo memorizzava AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY direttamente nelle variabili CI. Si tratta di un anti-pattern di sicurezza. GitHub Actions supporta lo scambio di token OIDC: il runner dimostra la propria identita ad AWS e riceve una sessione di ruolo temporanea, senza credenziali a lunga durata memorizzate da nessuna parte.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions_terraform" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
}name: Terraform Apply
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_B_ID:role/github-actions-terraform
aws-region: eu-central-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform validate
- run: terraform plan
- run: terraform apply -auto-approveVincola la condizione di trust OIDC a un subject specifico come repo:org/repo:ref:refs/heads/main invece di un wildcard. Un wildcard consente a qualsiasi branch o fork di assumere il ruolo.
Usare i Log in OpenSearch Dashboards#
Una volta che i dati fluiscono, crea un index pattern che corrisponde a lambda-logs-* in OpenSearch Dashboards. Firehose ruota l’indice ogni giorno per impostazione predefinita.
Una query utile nella tab Discover per trovare errori raggruppati per funzione:
{
"query": {
"bool": {
"must": [
{ "match": { "level": "ERROR" } },
{ "range": { "@timestamp": { "gte": "now-1h" } } }
]
}
},
"aggs": {
"errors_per_function": {
"terms": { "field": "function_name.keyword", "size": 10 }
}
}
}Costruisci un dashboard con quattro pannelli: error rate nel tempo (grafico a linee su level:ERROR), durata p99 (dal campo duration emesso da Lambda nelle righe REPORT), tasso di cold start (filtro su Init Duration presente) e messaggi di errore piu frequenti (aggregazione terms su errorMessage.keyword).
Errori comuni e come evitarli
Usare aws_elasticsearch_domain invece di aws_opensearch_domain
La risorsa provider Elasticsearch e stata deprecata nel provider Terraform AWS 4.x. Funziona ancora ma crea risorse OpenSearch con un’API compatibile Elasticsearch e verra rimossa in futuro. Usa aws_opensearch_domain per tutto il nuovo codice.
Credenziali CI hardcoded
Memorizzare AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY nelle variabili CI significa che quelle credenziali vivono nel secret store del CI provider per sempre, ruotano manualmente e possono apparire nei log quando il mascheramento e mal configurato. OIDC elimina completamente le credenziali a lunga durata.
IAM troppo permissivo (es:*)
Il ruolo di consegna Firehose ha bisogno solo di es:ESHttpPut e es:ESHttpPost sul dominio specifico. es:* concede la possibilita di eliminare il dominio, modificare le access policy e triggerare snapshot – nulla di cio e richiesto dal percorso di consegna.
Nessuna retention policy sul log group CloudWatch
Senza retention_in_days, CloudWatch conserva i log per sempre e il costo di storage cresce indefinitamente. 14 giorni copre la maggior parte delle finestre di indagine degli incidenti. Adatta il valore ai requisiti di compliance.
Nessuna configurazione buffer per Firehose Il buffer predefinito di Firehose e 300s o 5MB, il primo che si raggiunge. Per servizi a volume molto basso questo significa gap di 5 minuti nei dashboard. Imposta sempre valori espliciti e testali rispetto al tasso di ingestione atteso.
depends_on mancante tra log group e subscription filter
Se Terraform crea il subscription filter prima che esista il log group, l’apply fallisce. Aggiungi sempre depends_on = [aws_cloudwatch_log_group.app] alla risorsa subscription filter.
Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.