Salta al contenuto principale

Inviare i log Lambda a OpenSearch tra account AWS diversi con Terraform

Indice dei contenuti
La centralizzazione dei log su piu account e un requisito fondamentale per qualsiasi platform team. Se le funzioni Lambda si trovano in un account AWS e gli strumenti di osservabilita in un altro, serve una pipeline production-grade che trasferisca i log tra account senza compromettere la sicurezza. Ecco la configurazione Terraform completa.

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
Nota

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
#

account_a/lambda.tf
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
#

account_b/opensearch.tf
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.

account_b/firehose.tf
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/"
    }
  }
}
Importante

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.

account_b/github_oidc.tf
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"
        }
      }
    }]
  })
}
.github/workflows/terraform.yml
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-approve
Avviso

Vincola 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.