Recursos personalizados de CloudFormation con Lambda: guía práctica

Un recurso personalizado permite ejecutar lógica propia durante una operación de CloudFormation. En el caso respaldado por Lambda, CloudFormation envía a la función un evento Create, Update o Delete. La función debe enviar una respuesta JSON a la URL prefirmada ResponseURL; devolver un diccionario desde el handler no completa la operación.

Esta guía explica cuándo conviene el recurso, cómo responder y qué hacer con el PhysicalResourceId para que una actualización o un borrado no deje objetos externos sin administrar. La guía de AWS para recursos personalizados respaldados por Lambda describe la integración con el handler. Los fragmentos de este artículo ilustran el contrato: no forman una pila lista para desplegar ni ejecutan cambios en una cuenta.

Cuándo conviene un recurso personalizado

Úsalo cuando la pila necesita una acción de aprovisionamiento que no cubre un tipo de recurso nativo de CloudFormation, por ejemplo, llamar a una API externa o mantener una configuración en otro sistema. Si AWS ya ofrece un recurso nativo para ese trabajo, suele ser más sencillo dejar que CloudFormation lo administre directamente.

Los recursos personalizados admiten las operaciones Create, Update y Delete. Si necesitas un tipo de recurso con lectura, listado y detección de drift, compara esa opción con los tipos de recurso del CloudFormation Registry. Este artículo se centra en Lambda como proveedor; CloudFormation también puede enviar solicitudes a un tema de Amazon SNS.

Declara el recurso y sus propiedades

El recurso puede usar el tipo Custom:: o AWS::CloudFormation::CustomResource. En ambos casos, ServiceToken identifica al proveedor. Este fragmento supone que ya existe una función Lambda en la misma región de la pila y que proporcionas su ARN mediante el parámetro ProviderFunctionArn:

Parameters:
  ProviderFunctionArn:
    Type: String

Resources:
  ExternalSetting:
    Type: Custom::ExternalSetting
    Properties:
      ServiceToken: !Ref ProviderFunctionArn
      ServiceTimeout: 120
      Key: app-version

Outputs:
  ResolvedValue:
    Value: !GetAtt ExternalSetting.Value

CloudFormation pasa Key y las otras propiedades como ResourceProperties en el evento. El proveedor decide qué significan y qué valores acepta. En este fragmento, el parámetro debe recibir el ARN de una función Lambda de la misma región que la pila. Si declaras la función en esta misma plantilla como un recurso AWS::Lambda::Function con ID lógico ProviderFunction, puedes usar !GetAtt ProviderFunction.Arn en ServiceToken. ServiceTimeout limita cuánto espera CloudFormation una respuesta: acepta de 1 a 3600 segundos y su valor predeterminado es 3600, según la referencia del recurso.

La función que muestra el ejemplo no está definida aquí: debes implementarla y concederle los permisos que necesita para administrar el sistema externo. La guía de CloudFormation con AWS CLI explica cómo validar plantillas, revisar cambios de pila y gestionar su ciclo de vida.

Si estás definiendo los accesos del proveedor, la guía de seguridad en AWS: por dónde empezar y en qué orden explica cómo limitar permisos y usar roles para las cargas de trabajo.

Qué recibe y qué debe responder Lambda

CloudFormation invoca la función de forma asíncrona y envía campos como estos:

Campo Para qué sirve
RequestType Indica Create, Update o Delete.
RequestId, StackId y LogicalResourceId Identifican la solicitud, la pila y el recurso lógico. Debes copiar sus valores exactamente en la respuesta.
ResponseURL URL prefirmada de S3 a la que el proveedor carga la respuesta.
ResourceProperties Propiedades actuales de la plantilla. En un evento Update también llega OldResourceProperties.
PhysicalResourceId Identificador del recurso administrado; aparece en las solicitudes Update y Delete.

La respuesta no vuelve en el valor de retorno del handler. El proveedor debe enviar por HTTP PUT un documento JSON a ResponseURL antes de que venza ServiceTimeout. Para una actualización que termina correctamente, la respuesta tiene esta forma:

{
  "Status": "SUCCESS",
  "RequestId": "copiar desde el evento",
  "StackId": "copiar desde el evento",
  "LogicalResourceId": "ExternalSetting",
  "PhysicalResourceId": "id estable del proveedor",
  "Data": {
    "Value": "1.2.3"
  }
}

Data es opcional. CloudFormation expone sus valores como atributos del recurso, por lo que la plantilla puede leer el ejemplo con !GetAtt ExternalSetting.Value. Si la operación falla, responde con Status: FAILED y un Reason breve que ayude a encontrar el error. La respuesta completa no puede superar 4096 bytes. Data y NoEcho no se admiten en respuestas de Delete.

Evita incluir secretos en Data. NoEcho: true enmascara los valores del recurso que se recuperan con Fn::GetAtt, pero no cifra el dato ni redacta copias en las secciones Metadata o Outputs de la plantilla, ni las que hayas escrito en los logs. Si necesitas entregar un secreto a otro recurso, devuelve una referencia y guarda el valor en un servicio diseñado para administrarlo.

Este helper Python, usando solo la biblioteca estándar, implementa el envío de la respuesta. No crea ni elimina el recurso externo:

import json
from urllib.request import Request, urlopen

MAX_RESPONSE_BYTES = 4096

def send_response(event, status, physical_resource_id, data=None, reason=None):
    if status not in {"SUCCESS", "FAILED"}:
        raise ValueError("status debe ser SUCCESS o FAILED")
    if not physical_resource_id:
        raise ValueError("physical_resource_id no puede estar vacío")
    if len(physical_resource_id.encode("utf-8")) > 1024:
        raise ValueError("physical_resource_id supera 1 KB")
    if status == "FAILED" and not reason:
        raise ValueError("reason es obligatorio cuando status es FAILED")

    payload = {
        "Status": status,
        "RequestId": event["RequestId"],
        "StackId": event["StackId"],
        "LogicalResourceId": event["LogicalResourceId"],
        "PhysicalResourceId": physical_resource_id,
    }
    if reason:
        payload["Reason"] = reason[:512]
    if event["RequestType"] != "Delete" and data is not None:
        payload["Data"] = data

    body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
    if len(body) > MAX_RESPONSE_BYTES:
        payload = {
            "Status": "FAILED",
            "RequestId": event["RequestId"],
            "StackId": event["StackId"],
            "LogicalResourceId": event["LogicalResourceId"],
            "PhysicalResourceId": physical_resource_id,
            "Reason": "La respuesta supera 4096 bytes; reduce Data.",
        }
        body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
    if len(body) > MAX_RESPONSE_BYTES:
        raise ValueError("la respuesta mínima supera el límite de CloudFormation")

    request = Request(
        event["ResponseURL"],
        data=body,
        headers={
            "content-type": "",
            "content-length": str(len(body)),
        },
        method="PUT",
    )
    with urlopen(request, timeout=10) as response:
        response.read()

Después de completar la operación externa, el handler llama una vez a send_response con SUCCESS y el identificador físico correcto. Si falla la operación externa, debe enviar FAILED y conservar el error completo en los logs de Lambda. Si falla el propio envío, no envíes una segunda respuesta sin comprobar el estado externo: el primer PUT pudo haber llegado aunque Lambda no haya recibido su confirmación. El motivo de la respuesta debe ser corto y no contener credenciales ni otros datos sensibles.

AWS también proporciona el módulo cfn-response, pero CloudFormation solo lo incluye cuando el código de Lambda está definido en la propiedad ZipFile de una plantilla. Para código empaquetado en S3 debes implementar el envío, como en el helper anterior, o usar una biblioteca de proveedor compatible. Si usas cfn-response, pasa un identificador físico estable: por defecto usa el nombre del log stream, y un valor distinto en una actualización provoca un reemplazo.

Diseña Create, Update y Delete alrededor del identificador físico

El PhysicalResourceId identifica el objeto que el proveedor administra. Debe ser una cadena no vacía, única para ese objeto y de hasta 1 KB. Mantén un identificador estable para el mismo objeto; evita generar uno aleatorio en cada actualización.

Evento Acción del proveedor
Create Crea o encuentra el objeto externo y devuelve su identificador físico. Si además devuelve datos, la plantilla puede obtenerlos con Fn::GetAtt.
Update Compara las propiedades actuales con OldResourceProperties y aplica el cambio. Si modifica el mismo objeto, devuelve el mismo identificador físico.
Delete Elimina el objeto identificado por PhysicalResourceId y envía una respuesta. Si el objeto ya no existe, trata esa condición como una eliminación completada.

Si una actualización necesita reemplazar el objeto, crea el nuevo, responde con su nuevo PhysicalResourceId y deja que CloudFormation envíe después un evento Delete para el objeto anterior. Si devuelves un identificador distinto por error, CloudFormation también interpreta que hubo reemplazo y puede borrar el objeto previo. La documentación explica este comportamiento en la referencia de respuestas de recursos personalizados.

La lógica del proveedor no forma una transacción con el resto de la pila. Si crea un objeto externo y luego falla antes de responder, CloudFormation puede marcar la operación como fallida aunque el efecto externo haya ocurrido. Usa un nombre o clave estable, verifica si el objeto ya existe antes de crearlo y haz que las operaciones de actualización converjan al estado deseado, en vez de sumar efectos cada vez que se procesan.

Para identificar una repetición del mismo evento puedes usar RequestId junto con StackId; AWS documenta esa combinación como identificador de solicitud. Eso no cubre por sí solo una nueva operación de pila, que tendrá otra solicitud. Para esas repeticiones, haz que la creación sea idempotente con una clave propia del objeto, registra el resultado de la operación si el sistema externo lo necesita y haz que borrar un objeto ausente termine correctamente. En una sustitución, conserva la capacidad de borrar específicamente el identificador viejo.

Reintentos, errores y limpieza

Lambda recibe las solicitudes de CloudFormation de forma asíncrona. En las invocaciones asíncronas estándar, Lambda reintenta por defecto hasta dos veces los errores del handler; también puede entregar un mismo evento más de una vez. La documentación de reintentos asíncronos de Lambda describe este comportamiento. Si el handler alcanzó a crear el objeto externo antes de fallar o de perder la respuesta, el reintento no debe crear un duplicado.

Hay dos resultados distintos que conviene registrar:

Cuando CloudFormation debe borrar el recurso al eliminar la pila o quitarlo de la plantilla, envía Delete; durante un reemplazo envía Delete para el objeto anterior. Una política de retención puede omitir esa limpieza, así que comprueba si el recurso se conservará y quién gestionará luego el objeto externo. La función debe usar el PhysicalResourceId que llega en la solicitud para limpiar el objeto correcto y siempre responder, incluso si ya no existe. Incluye una estrategia de compensación para los fallos que ocurren después de crear un objeto externo, porque ese efecto no desaparece automáticamente al fallar la pila.

Timeouts y resolución de errores

ServiceTimeout es el tiempo máximo de espera de CloudFormation, no el timeout de la función Lambda. Lambda tiene su propio límite de ejecución. Ajusta ambos a la duración esperada: configura el timeout de Lambda para poder diagnosticar la operación y reservar tiempo para cargar la respuesta antes de que venza el timeout del recurso.

Si la pila se queda esperando o marca el recurso como fallido:

  1. En Events de la pila, localiza el LogicalResourceId y lee ResourceStatusReason. La documentación de eventos de CloudFormation explica esos estados y motivos.
  2. En los logs de CloudWatch de la función, busca el mismo evento y comprueba si el handler recibió el tipo de solicitud correcto, terminó la acción y cargó la respuesta.
  3. Verifica que la respuesta use PUT, incluya RequestId, StackId, LogicalResourceId y un PhysicalResourceId, y que el JSON no supere 4096 bytes.
  4. Comprueba que ServiceToken y la pila estén en la misma región. Si Lambda está en una VPC, asegúrate de que pueda alcanzar la URL prefirmada de S3. Con AWS PrivateLink, el recurso necesita acceso a los buckets de S3 específicos de CloudFormation para entregar esa respuesta; consulta las consideraciones de AWS para recursos personalizados en una VPC.

Un timeout de CloudFormation puede indicar que el trabajo externo sí ocurrió, pero la respuesta no llegó. Antes de reintentar una operación fallida, consulta el objeto externo mediante su clave estable para evitar duplicarlo. Para profundizar en pilas, change sets y validación, continúa con Infraestructura como código en AWS con CloudFormation: guía práctica. Si necesitas repasar invocaciones, permisos e idempotencia en Lambda, consulta AWS Lambda: qué es, cómo funciona y cuándo usarlo.

Recursos, canales y comunidades para seguir aprendiendo