Scale-to-zero async worker with dead-letter
A worker processes messages from a queue, scales on queue length (down to zero), stores results in object storage and sets aside failing messages.
Problem
You have background work with irregular load (file processing, notifications, integrations). You want to pay close to zero when there are no messages, scale only when the queue grows, not lose failing messages and use no keys or connection strings: the worker authenticates with its identity against the queue and storage.
The topology is the same on every cloud; what changes is what implements each role.
Queue
AWS
Amazon SQS (Standard)
SQS-managed SSE
Coming from Azure: there this role is Azure Service Bus (Standard) — queue. The visibility timeout plays the role of the peek-lock lock, and it must be at least 6 times the Lambda timeout.
Coming from GCP: there this role is Pub/Sub topic + push subscription.
1 gotcha in this role
Azure
Azure Service Bus (Standard) — queue
local_auth_enabled = false (Entra ID only)
Coming from AWS: there this role is Amazon SQS (Standard). Peek-lock is the same model as the visibility timeout (at-least-once). The big difference: private networking requires the Premium tier.
Coming from GCP: there this role is Pub/Sub topic + push subscription.
3 gotchas in this role
Worker
AWS
AWS Lambda
Event source mapping with ReportBatchItemFailures
Coming from Azure: there this role is Container Apps Job (event-driven).
Coming from GCP: there this role is Cloud Run (service).
3 gotchas in this role
Azure
Container Apps Job (event-driven)
KEDA azure-servicebus scaler with managed identity
Coming from AWS: there this role is AWS Lambda.
Coming from GCP: there this role is Cloud Run (service).
2 gotchas in this role
Secretless identity
AWS
IAM Role (Lambda execution role)
sqs:ReceiveMessage/DeleteMessage + scoped s3:PutObject
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from GCP: there this role is Service Accounts (worker + push invoker).
No fine print in this role.
Azure
User-assigned Managed Identity
Service Bus Data Receiver + Storage Blob Data Contributor
Coming from AWS: there this role is IAM Role (Lambda execution role).
Coming from GCP: there this role is Service Accounts (worker + push invoker).
1 gotcha in this role
Results
AWS
S3 bucket
Block Public Access + BucketOwnerEnforced
Coming from Azure: there this role is Storage Account + container.
Coming from GCP: there this role is Cloud Storage bucket.
No fine print in this role.
Azure
Storage Account + container
Shared Key disabled
Coming from AWS: there this role is S3 bucket.
Coming from GCP: there this role is Cloud Storage bucket.
No fine print in this role.
Failed messages
AWS
Separate SQS queue
redrive_policy with maxReceiveCount
Coming from Azure: there this role is $DeadLetterQueue subqueue. It isn't a subqueue: it's another queue, and in Standard the message expires based on when it entered the original queue. Give it longer retention than the source.
Coming from GCP: there this role is Dead-letter topic + pull subscription.
1 gotcha in this role
Azure
$DeadLetterQueue subqueue
max_delivery_count on the queue
Coming from AWS: there this role is Separate SQS queue.
Coming from GCP: there this role is Dead-letter topic + pull subscription.
1 gotcha in this role
Amazon Web Services
Implementation
- Queue
- Amazon SQS (Standard)SQS-managed SSEComing from Azure: there this role is Azure Service Bus (Standard) — queue. The visibility timeout plays the role of the peek-lock lock, and it must be at least 6 times the Lambda timeout.Coming from GCP: there this role is Pub/Sub topic + push subscription.
- Worker
- AWS LambdaEvent source mapping with ReportBatchItemFailuresComing from Azure: there this role is Container Apps Job (event-driven).Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- IAM Role (Lambda execution role)sqs:ReceiveMessage/DeleteMessage + scoped s3:PutObjectComing from Azure: there this role is User-assigned Managed Identity.Coming from GCP: there this role is Service Accounts (worker + push invoker).
- Results
- S3 bucketBlock Public Access + BucketOwnerEnforcedComing from Azure: there this role is Storage Account + container.Coming from GCP: there this role is Cloud Storage bucket.
- Failed messages
- Separate SQS queueredrive_policy with maxReceiveCountComing from Azure: there this role is $DeadLetterQueue subqueue. It isn't a subqueue: it's another queue, and in Standard the message expires based on when it entered the original queue. Give it longer retention than the source.Coming from GCP: there this role is Dead-letter topic + pull subscription.
Cost model
Scales to zero
No fixed cost
Zero fixed cost: SQS, Lambda and S3 are pay-per-use. Note the event source mapping's long polling: SQS polling requests are billed even when the queue is empty (few, but not zero).
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 4 gotchas
The queue's visibility timeout must be at least 6 times the Lambda timeout
If the function takes longer than the visibility timeout, the message becomes visible again and another invocation picks it up while the first is still processing: guaranteed duplicates. AWS recommends a visibility timeout ≥ 6× the function timeout. The module computes and validates it.
Without ReportBatchItemFailures, one failed message retries the whole batch
By default, if the function fails the whole batch goes back to the queue, including already processed messages. With function_response_types = ["ReportBatchItemFailures"] the function returns only the failed IDs. In Azure and GCP acks are per message, so this problem doesn't exist in the same way.
In Standard queues, the message expires in the DLQ based on when it entered the original queue
When moving a message to the DLQ, SQS keeps the original enqueue timestamp (in FIFO it resets). If the DLQ has the same retention as the source queue, messages can vanish from the DLQ almost immediately. Set the DLQ retention longer than the source (the module uses the maximum, 14 days).
Limit the event source mapping's concurrency, not just the function's
scaling_config.maximum_concurrency limits how many concurrent instances of the function the queue can invoke. It's an event source mapping setting, independent of the function's reserved concurrency and free of charge, and it keeps one queue from using all of the reserved concurrency or the account's concurrency quota. Note: the reserved concurrency must be greater than or equal to the sum of the maximums of all the function's event sources; otherwise Lambda might throttle your messages.
Terraform
main.tf
# Worker asíncrono con escala a cero — AWS
#
# Lambda disparada por SQS con respuestas parciales de lote, DLQ con retención
# mayor que la cola de origen y resultados en S3. Sin claves: el execution role
# de la función es la identidad.
data "aws_partition" "current" {}
locals {
# AWS recomienda visibility timeout >= 6× el timeout de la función.
visibility_timeout_seconds = var.function_timeout_seconds * 6
results_prefix = "results/"
}
# --- Colas ----------------------------------------------------------------------
resource "aws_sqs_queue" "dlq" {
name = "${var.name}-jobs-dlq"
sqs_managed_sse_enabled = true
# Máximo (14 días): en colas Standard el vencimiento cuenta desde que el mensaje
# entró a la cola original (gotcha: dlq-retention-original-timestamp).
message_retention_seconds = 1209600
}
resource "aws_sqs_queue" "jobs" {
name = "${var.name}-jobs"
sqs_managed_sse_enabled = true
visibility_timeout_seconds = local.visibility_timeout_seconds
message_retention_seconds = 345600
receive_wait_time_seconds = 20
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = var.max_receive_count
})
}
# Solo la cola de trabajos puede usar esta DLQ.
resource "aws_sqs_queue_redrive_allow_policy" "dlq" {
queue_url = aws_sqs_queue.dlq.id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.jobs.arn]
})
}
# --- Bucket de resultados -------------------------------------------------------
resource "aws_s3_bucket" "results" {
#checkov:skip=CKV_AWS_21:Resultados regenerables con TTL; versioning duplicaría costo (ver patrón object-storage-ttl).
#checkov:skip=CKV_AWS_144:Replicación cross-region no aplica a resultados regenerables.
#checkov:skip=CKV_AWS_18:Access logging fuera de alcance; usar CloudTrail data events si se requiere.
#checkov:skip=CKV2_AWS_62:Notificaciones de eventos fuera de alcance del patrón.
bucket = var.results_bucket_name
}
resource "aws_s3_bucket_public_access_block" "results" {
bucket = aws_s3_bucket.results.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "results" {
bucket = aws_s3_bucket.results.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "results" {
#checkov:skip=CKV2_AWS_67:Clave administrada por AWS (aws/s3).
bucket = aws_s3_bucket.results.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_lifecycle_configuration" "results" {
bucket = aws_s3_bucket.results.id
rule {
id = "expire-results"
status = "Enabled"
filter {
prefix = local.results_prefix
}
expiration {
days = var.results_ttl_days
}
}
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
# --- Identidad de la función ----------------------------------------------------
resource "aws_iam_role" "worker" {
name = "${var.name}-worker"
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" "worker_managed" {
for_each = toset([
"service-role/AWSLambdaBasicExecutionRole",
"AWSXRayDaemonWriteAccess",
])
role = aws_iam_role.worker.name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/${each.value}"
}
resource "aws_iam_role_policy" "worker" {
name = "consume-jobs-write-results"
role = aws_iam_role.worker.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", "sqs:ChangeMessageVisibility"]
Resource = aws_sqs_queue.jobs.arn
},
{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${aws_s3_bucket.results.arn}/${local.results_prefix}*"
},
]
})
}
# --- Lambda + event source mapping ----------------------------------------------
resource "aws_cloudwatch_log_group" "worker" {
#checkov:skip=CKV_AWS_158:Cifrado con la clave administrada por CloudWatch; CMK opcional.
name = "/aws/lambda/${var.name}-worker"
retention_in_days = 365
}
resource "aws_lambda_function" "worker" {
#checkov:skip=CKV_AWS_115:La concurrencia se limita en el event source mapping (scaling_config), no con reserved concurrency (gotcha: max-concurrency-protects-downstream).
#checkov:skip=CKV_AWS_116:Invocación síncrona desde SQS: la DLQ vive en la cola, no en la función.
#checkov:skip=CKV_AWS_117:Sin VPC: SQS y S3 se acceden por sus endpoints de AWS; ver private-web-api para red privada.
#checkov:skip=CKV_AWS_173:Variables de entorno sin secretos; cifradas con la clave administrada por Lambda.
#checkov:skip=CKV_AWS_272:Code signing no aplica a funciones empaquetadas como imagen.
function_name = "${var.name}-worker"
role = aws_iam_role.worker.arn
package_type = "Image"
image_uri = var.image_uri
timeout = var.function_timeout_seconds
memory_size = 512
tracing_config {
mode = "Active"
}
environment {
variables = {
RESULTS_BUCKET = aws_s3_bucket.results.bucket
RESULTS_PREFIX = local.results_prefix
}
}
depends_on = [aws_cloudwatch_log_group.worker, aws_iam_role_policy_attachment.worker_managed]
}
resource "aws_lambda_event_source_mapping" "jobs" {
event_source_arn = aws_sqs_queue.jobs.arn
function_name = aws_lambda_function.worker.arn
batch_size = var.batch_size
maximum_batching_window_in_seconds = 5
# Solo los mensajes que fallaron vuelven a la cola (gotcha: partial-batch-failures).
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = var.max_concurrency
}
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"worker-demo\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,20}$", var.name))
error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
}
}
variable "region" {
description = "Región de AWS."
type = string
default = "us-east-1"
}
variable "results_bucket_name" {
description = "Nombre global único del bucket de resultados."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.results_bucket_name))
error_message = "results_bucket_name: 3-63 caracteres, minúsculas, números, puntos o guiones."
}
}
variable "image_uri" {
description = "Imagen de la función en ECR."
type = string
}
variable "function_timeout_seconds" {
description = "Timeout de la Lambda. El visibility timeout de la cola se fija en 6× este valor (gotcha: visibility-timeout-6x)."
type = number
default = 60
validation {
condition = var.function_timeout_seconds >= 1 && var.function_timeout_seconds <= 900
error_message = "function_timeout_seconds: entre 1 y 900 (límite de Lambda)."
}
}
variable "max_receive_count" {
description = "Entregas antes de mandar el mensaje a la DLQ."
type = number
default = 5
}
variable "batch_size" {
description = "Mensajes por invocación."
type = number
default = 10
}
variable "max_concurrency" {
description = "Invocaciones concurrentes máximas disparadas por la cola (gotcha: max-concurrency-protects-downstream)."
type = number
default = 10
validation {
condition = var.max_concurrency >= 2
error_message = "maximum_concurrency de un event source mapping de SQS es como mínimo 2."
}
}
variable "results_ttl_days" {
description = "Días que se conservan los resultados."
type = number
default = 30
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "queue_url" {
description = "URL de la cola de trabajos (los productores necesitan sqs:SendMessage)."
value = aws_sqs_queue.jobs.id
}
output "dlq_url" {
description = "URL de la dead-letter queue."
value = aws_sqs_queue.dlq.id
}
output "results_bucket" {
description = "Bucket de resultados."
value = aws_s3_bucket.results.bucket
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.67"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = var.tags
}
}Microsoft Azure
Implementation
- Queue
- Azure Service Bus (Standard) — queuelocal_auth_enabled = false (Entra ID only)Coming from AWS: there this role is Amazon SQS (Standard). Peek-lock is the same model as the visibility timeout (at-least-once). The big difference: private networking requires the Premium tier.Coming from GCP: there this role is Pub/Sub topic + push subscription.
- Worker
- Container Apps Job (event-driven)KEDA azure-servicebus scaler with managed identityComing from AWS: there this role is AWS Lambda.Coming from GCP: there this role is Cloud Run (service).
- Secretless identity
- User-assigned Managed IdentityService Bus Data Receiver + Storage Blob Data ContributorComing from AWS: there this role is IAM Role (Lambda execution role).Coming from GCP: there this role is Service Accounts (worker + push invoker).
- Results
- Storage Account + containerShared Key disabledComing from AWS: there this role is S3 bucket.Coming from GCP: there this role is Cloud Storage bucket.
- Failed messages
- $DeadLetterQueue subqueuemax_delivery_count on the queueComing from AWS: there this role is Separate SQS queue.Coming from GCP: there this role is Dead-letter topic + pull subscription.
Cost model
Partially scales to zero
Fixed cost even with zero traffic:
- Service Bus Standard (monthly base charge)
The job consumes nothing without messages and storage is pay-per-use. The only fixed cost is the Service Bus Standard base charge, small compared to Premium (see gotcha).
Monthly floor with no traffic
USD 9.81/ mo
With the Terraform module’s default configuration. Good for comparing orders of magnitude, not for budgeting.
- Service Bus Standard (base charge) × 1 · 0.0134 USD/hUSD 9.81
List price in eastus as of 2026-10-24, in USD, with no discounts or taxes.
Fine print · 4 gotchas
The scale rule authenticates with the identity property, not auth
For the scale rule to use a managed identity, set the identity property instead of auth. The portal doesn't support it: use IaC or CLI.
Private networking on Service Bus requires Premium, which has a high fixed cost
Private Endpoints, VNet rules and CMK require Premium-tier namespaces, which are billed per messaging unit. Standard supports IP firewall rules. This pattern uses Standard with Entra ID-only auth: if the requirement is private networking, you have to move to Premium. In AWS, SQS connects through an interface VPC endpoint.
Peek-lock is at-least-once, not exactly-once
If the worker dies after processing but before completing the message, the lock expires and the message is redelivered. It's the same model as SQS's visibility timeout. Service Bus duplicate detection dedupes repeated sends, not processing: the worker must be idempotent.
The dead-letter queue uses the queue's quota if nobody drains it
After max_delivery_count deliveries (10 by default) the message goes to the $DeadLetterQueue subqueue, which counts towards the entity size. If it piles up, the main queue starts rejecting sends with QuotaExceeded. You need alerts on the dead-letter count and a process to reprocess or discard.
Terraform
main.tf
# Worker asíncrono con escala a cero — Azure
#
# Container Apps Job disparado por KEDA según el largo de una cola de
# Service Bus Standard, con identidad administrada para la cola, el scaler y
# el storage. Sin connection strings: el namespace no acepta SAS.
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
tags = var.tags
}
# --- Service Bus --------------------------------------------------------------
resource "azurerm_servicebus_namespace" "this" {
#checkov:skip=CKV_AZURE_204:Standard no soporta Private Endpoint ni reglas de VNet; requiere Premium (gotcha: sb-private-endpoint-premium).
#checkov:skip=CKV_AZURE_199:Doble cifrado solo disponible en Premium.
#checkov:skip=CKV_AZURE_201:CMK solo disponible en Premium.
#checkov:skip=CKV_AZURE_202:La identidad del namespace solo sirve para CMK (Premium); el acceso usa la identidad del worker.
name = var.servicebus_namespace_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
sku = "Standard"
local_auth_enabled = false
minimum_tls_version = "1.2"
tags = var.tags
}
resource "azurerm_servicebus_queue" "jobs" {
name = "jobs"
namespace_id = azurerm_servicebus_namespace.this.id
lock_duration = "PT1M"
max_delivery_count = var.max_delivery_count
default_message_ttl = "P14D"
dead_lettering_on_message_expiration = true
}
# --- Storage de resultados ----------------------------------------------------
resource "azurerm_storage_account" "results" {
#checkov:skip=CKV_AZURE_59:Acceso público de red con auth solo Entra ID: el job de Container Apps sin VNet no tiene IP de salida fija.
#checkov:skip=CKV_AZURE_35:Ídem: sin VNet no hay subnet ni IP estable para reglas de red.
#checkov:skip=CKV2_AZURE_33:Private Endpoint fuera de alcance de este patrón (ver private-web-api).
#checkov:skip=CKV2_AZURE_1:CMK fuera de alcance del patrón.
#checkov:skip=CKV_AZURE_206:ZRS suficiente para resultados regenerables.
#checkov:skip=CKV_AZURE_33:No se usa Queue Storage.
name = var.storage_account_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_kind = "StorageV2"
account_tier = "Standard"
account_replication_type = "ZRS"
shared_access_key_enabled = false
default_to_oauth_authentication = true
local_user_enabled = false
allow_nested_items_to_be_public = false
cross_tenant_replication_enabled = false
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
infrastructure_encryption_enabled = true
blob_properties {
delete_retention_policy {
days = 7
}
container_delete_retention_policy {
days = 7
}
}
tags = var.tags
}
resource "azurerm_storage_container" "results" {
#checkov:skip=CKV2_AZURE_21:Logging de lectura vía diagnostic settings, fuera de alcance del patrón.
name = "results"
storage_account_id = azurerm_storage_account.results.id
container_access_type = "private"
}
# --- Identidad del worker (contenedor + scaler) -------------------------------
resource "azurerm_user_assigned_identity" "worker" {
name = "id-${var.name}-worker"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
# Receiver alcanza para leer, completar y para que KEDA consulte el largo de la cola.
resource "azurerm_role_assignment" "worker_queue" {
scope = azurerm_servicebus_queue.jobs.id
role_definition_name = "Azure Service Bus Data Receiver"
principal_id = azurerm_user_assigned_identity.worker.principal_id
principal_type = "ServicePrincipal"
}
resource "azurerm_role_assignment" "worker_results" {
scope = azurerm_storage_container.results.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.worker.principal_id
principal_type = "ServicePrincipal"
}
# --- Container Apps Job (event-driven) ----------------------------------------
resource "azurerm_container_app_environment" "this" {
name = "cae-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
workload_profile {
name = "Consumption"
workload_profile_type = "Consumption"
}
tags = var.tags
}
resource "azurerm_container_app_job" "worker" {
name = "caj-${var.name}-worker"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
container_app_environment_id = azurerm_container_app_environment.this.id
workload_profile_name = "Consumption"
replica_timeout_in_seconds = var.replica_timeout_seconds
replica_retry_limit = 1
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.worker.id]
}
event_trigger_config {
parallelism = 1
replica_completion_count = 1
scale {
min_executions = 0
max_executions = var.max_executions
polling_interval_in_seconds = 30
rules {
name = "servicebus-jobs"
custom_rule_type = "azure-servicebus"
# El scaler se autentica con la identidad, no con un connection string
# (gotcha: keda-scaler-identity).
identity_id = azurerm_user_assigned_identity.worker.id
metadata = {
namespace = azurerm_servicebus_namespace.this.name
queueName = azurerm_servicebus_queue.jobs.name
messageCount = tostring(var.messages_per_execution)
}
}
}
}
template {
container {
name = "worker"
image = var.container_image
cpu = 0.25
memory = "0.5Gi"
env {
name = "AZURE_CLIENT_ID"
value = azurerm_user_assigned_identity.worker.client_id
}
env {
name = "SERVICEBUS_FQDN"
value = "${azurerm_servicebus_namespace.this.name}.servicebus.windows.net"
}
env {
name = "SERVICEBUS_QUEUE"
value = azurerm_servicebus_queue.jobs.name
}
env {
name = "RESULTS_CONTAINER_URL"
value = "${azurerm_storage_account.results.primary_blob_endpoint}${azurerm_storage_container.results.name}"
}
}
}
tags = var.tags
depends_on = [azurerm_role_assignment.worker_queue, azurerm_role_assignment.worker_results]
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"worker-demo\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,20}$", var.name))
error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
}
}
variable "location" {
description = "Región de Azure."
type = string
default = "eastus2"
}
variable "servicebus_namespace_name" {
description = "Nombre global único del namespace de Service Bus."
type = string
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$", var.servicebus_namespace_name))
error_message = "servicebus_namespace_name: 6-50 caracteres, empieza con letra."
}
}
variable "storage_account_name" {
description = "Nombre global único de la Storage Account de resultados."
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.storage_account_name))
error_message = "storage_account_name: 3-24 caracteres, solo minúsculas y números."
}
}
variable "max_delivery_count" {
description = "Entregas antes de mandar el mensaje a $DeadLetterQueue."
type = number
default = 10
validation {
condition = var.max_delivery_count >= 1
error_message = "max_delivery_count debe ser >= 1."
}
}
variable "messages_per_execution" {
description = "Mensajes en cola por ejecución del job (messageCount del scaler KEDA)."
type = number
default = 5
}
variable "max_executions" {
description = "Máximo de ejecuciones concurrentes del job."
type = number
default = 10
}
variable "replica_timeout_seconds" {
description = "Tiempo máximo de cada ejecución."
type = number
default = 300
}
variable "container_image" {
description = "Imagen del worker: procesa un lote de mensajes y termina."
type = string
default = "mcr.microsoft.com/k8se/quickstart-jobs:latest"
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "servicebus_fqdn" {
description = "FQDN del namespace (los productores se autentican con Entra ID)."
value = "${azurerm_servicebus_namespace.this.name}.servicebus.windows.net"
}
output "queue_name" {
description = "Cola de trabajos. La dead-letter es <cola>/$DeadLetterQueue."
value = azurerm_servicebus_queue.jobs.name
}
output "worker_identity_client_id" {
description = "Client ID de la identidad del worker."
value = azurerm_user_assigned_identity.worker.client_id
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# Con shared_access_key_enabled = false el provider tiene que usar Entra ID
# para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
storage_use_azuread = true
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}