Lab práctico · Semana 6: Despliegue de modelos, integración empresarial y herramientas de desarrollo

Pipeline CI/CD para una aplicación de IA generativa

⏱ 75-100 minDificultad: avanzadaTask statements: 2.32.5

Qué vas a construir

Un pipeline mínimo pero realista para la API de resúmenes del lab 11, con lo que diferencia el CI/CD de una aplicación de IA generativa del de una aplicación normal: una puerta de calidad de prompts que llama al modelo real con casos de referencia y bloquea el despliegue si el comportamiento empeora.

flowchart LR
    DEV["git push a main"] --> CC["AWS CodeCommit"]
    CC -->|"evento"| EB["EventBridge"]
    EB --> CP["AWS CodePipeline V2"]
    CP --> CB["CodeBuild: tests unitarios, puerta de calidad, package"]
    CB -->|"packaged.yaml"| CFN["Acción CloudFormation: CREATE_UPDATE"]
    CFN --> APP["Stack lab12-app: API Gateway + Lambda"]
    CB -.->|"Converse"| BR["Bedrock: Nova Micro"]

Todo el pipeline se define como código en una plantilla de CloudFormation (repositorio, proyecto de CodeBuild, roles, pipeline y regla de EventBridge).

Antes de empezar

  • Haber hecho el lab 11 y conservar la carpeta ~/lab11 (código, pruebas y app.yaml). Si la borraste, repite los pasos 1 a 3 del lab 11 (solo crear ficheros, sin desplegar).
  • Región eu-central-1 (Fráncfort), identidad con permisos de administración en tu cuenta de estudio (el despliegue crea roles IAM).
  • AWS CodeCommit vuelve a estar abierto a clientes nuevos desde noviembre de 2025, así que puedes usarlo como repositorio. En una empresa lo habitual es GitHub, GitLab o Bitbucket conectados mediante AWS CodeConnections; el resto del pipeline sería igual.
  • Coste esperado (estimación a 1/10/2026): CodePipeline V2 cobra 0,002 USD por minuto de ejecución de acción (con 100 minutos gratuitos al mes); CodeBuild general1.small cuesta del orden de 0,005 USD por minuto de build (con 100 minutos gratuitos al mes según las condiciones de la capa gratuita); cada ejecución dura pocos minutos. Tokens de la puerta de calidad: céntimos de céntimo. Máximo razonable: 0,20 USD. Consulta CodePipeline pricing y CodeBuild pricing.

Paso 1: añade la puerta de calidad y el buildspec

En CloudShell:

cd ~/lab11

La puerta de calidad llama al handler real (y por tanto a Bedrock) con casos de referencia (golden dataset) y comprueba propiedades objetivas: respuesta 200, stopReason igual a end_turn (no cortada por max_tokens), que el resumen no sea más largo que el original y que conserve los datos clave. Si algún caso falla, termina con código 1 y el build falla.

cat > tests/prompt_gate.py <<'EOF'
"""Puerta de calidad de prompts: llama al modelo real con casos de referencia."""
import json
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import app  # noqa: E402

CASOS = [
    {
        "texto": "La factura 2024-118 vence el 30 de noviembre. Si no se paga, "
                 "se aplicará un recargo del 5 % y se suspenderá el servicio.",
        "debe_contener": ["30 de noviembre", "5"],
    },
    {
        "texto": "El equipo de soporte atiende de lunes a viernes de 9:00 a 18:00. "
                 "Los fines de semana solo hay atención por correo electrónico.",
        "debe_contener": ["lunes", "correo"],
    },
]

fallos = 0
for caso in CASOS:
    r = app.handler({"body": json.dumps({"texto": caso["texto"]})}, None)
    cuerpo = json.loads(r["body"])
    resumen = cuerpo.get("resumen", "")
    ok = (
        r["statusCode"] == 200
        and cuerpo.get("stopReason") == "end_turn"
        and len(resumen) < len(caso["texto"]) * 1.2
        and all(t.lower() in resumen.lower() for t in caso["debe_contener"])
    )
    print(("OK   " if ok else "FALLO"), "|", resumen.replace("\n", " ")[:120])
    fallos += 0 if ok else 1

if fallos:
    print(f"{fallos} caso(s) no superan la puerta de calidad")
    sys.exit(1)
print("Puerta de calidad superada")
EOF

El buildspec le dice a CodeBuild qué hacer:

cat > buildspec.yml <<'EOF'
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.13
    commands:
      - pip install --quiet pytest boto3
  build:
    commands:
      - echo "1/3 Tests unitarios con Bedrock simulado"
      - python -m pytest -q tests/unit
      - echo "2/3 Puerta de calidad de prompts contra el modelo real"
      - python tests/prompt_gate.py
      - echo "3/3 Empaquetado de la plantilla"
      - aws cloudformation package --template-file app.yaml --s3-bucket "$ARTIFACT_BUCKET" --s3-prefix lambda --output-template-file packaged.yaml

artifacts:
  files:
    - packaged.yaml
EOF

Pruébalo en local antes de automatizarlo (sí llama al modelo, con coste insignificante):

AWS_DEFAULT_REGION=eu-central-1 python3 -m pytest -q tests/unit
AWS_REGION=eu-central-1 python3 tests/prompt_gate.py

Debes ver Puerta de calidad superada. Si con el prompt correcto algún caso falla, lee el resumen impreso: un modelo pequeño puede parafrasear (por ejemplo, escribir la fecha de otra forma). Ajusta los términos esperados a lo que es obligatorio conservar; una puerta de calidad que falla sin motivo acaba ignorándose.

Paso 2: despliega la infraestructura del pipeline

Guarda la plantilla del pipeline:

mkdir -p ~/lab12 && cd ~/lab12
cat > pipeline.yaml <<'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Description: Lab 12 - Pipeline CI/CD minimo para la API de resumen del lab 11

Parameters:
  RepoName:
    Type: String
    Default: lab12-genai-app
  AppStackName:
    Type: String
    Default: lab12-app
  ModelId:
    Type: String
    Default: eu.amazon.nova-micro-v1:0
  BaseModelId:
    Type: String
    Default: amazon.nova-micro-v1:0

Resources:
  ArtifactBucket:
    Type: AWS::S3::Bucket
    Properties:
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          - Id: borrar-artefactos-antiguos
            Status: Enabled
            ExpirationInDays: 7

  Repo:
    Type: AWS::CodeCommit::Repository
    Properties:
      RepositoryName: !Ref RepoName
      RepositoryDescription: Codigo de la API de resumen del lab 12

  BuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: build
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: '*'
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                Resource: !Sub ${ArtifactBucket.Arn}/*
              - Effect: Allow
                Action:
                  - s3:ListBucket
                  - s3:GetBucketAcl
                  - s3:GetBucketLocation
                Resource: !GetAtt ArtifactBucket.Arn
              - Effect: Allow
                Action: bedrock:InvokeModel
                Resource:
                  - !Sub arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/${ModelId}
                  - !Sub arn:aws:bedrock:*::foundation-model/${BaseModelId}

  BuildProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Sub ${RepoName}-build
      ServiceRole: !GetAtt BuildRole.Arn
      TimeoutInMinutes: 15
      Source:
        Type: CODEPIPELINE
        BuildSpec: buildspec.yml
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/amazonlinux-x86_64-standard:5.0
        EnvironmentVariables:
          - Name: ARTIFACT_BUCKET
            Value: !Ref ArtifactBucket
          - Name: MODEL_ID
            Value: !Ref ModelId

  DeployRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudformation.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: desplegar-app
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - lambda:*
                  - apigateway:*
                Resource: '*'
              - Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:GetRole
                  - iam:PassRole
                  - iam:TagRole
                  - iam:UntagRole
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:GetRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:ListRolePolicies
                  - iam:ListAttachedRolePolicies
                Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/${AppStackName}-*
              - Effect: Allow
                Action: s3:GetObject
                Resource: !Sub ${ArtifactBucket.Arn}/*

  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codepipeline.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: pipeline
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:GetObjectVersion
                  - s3:PutObject
                  - s3:GetBucketVersioning
                Resource:
                  - !GetAtt ArtifactBucket.Arn
                  - !Sub ${ArtifactBucket.Arn}/*
              - Effect: Allow
                Action:
                  - codecommit:GetBranch
                  - codecommit:GetCommit
                  - codecommit:GetRepository
                  - codecommit:UploadArchive
                  - codecommit:GetUploadArchiveStatus
                  - codecommit:CancelUploadArchive
                Resource: !GetAtt Repo.Arn
              - Effect: Allow
                Action:
                  - codebuild:StartBuild
                  - codebuild:BatchGetBuilds
                Resource: !GetAtt BuildProject.Arn
              - Effect: Allow
                Action:
                  - cloudformation:CreateStack
                  - cloudformation:UpdateStack
                  - cloudformation:DeleteStack
                  - cloudformation:DescribeStacks
                  - cloudformation:DescribeStackResources
                  - cloudformation:DescribeStackEvents
                  - cloudformation:GetTemplate
                  - cloudformation:CreateChangeSet
                  - cloudformation:DescribeChangeSet
                  - cloudformation:DeleteChangeSet
                  - cloudformation:ExecuteChangeSet
                Resource: !Sub arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AppStackName}/*
              - Effect: Allow
                Action: cloudformation:ValidateTemplate
                Resource: '*'
              - Effect: Allow
                Action: iam:PassRole
                Resource: !GetAtt DeployRole.Arn

  Pipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: !Sub ${RepoName}-pipeline
      PipelineType: V2
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore:
        Type: S3
        Location: !Ref ArtifactBucket
      Stages:
        - Name: Source
          Actions:
            - Name: Codigo
              ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: '1'
              Configuration:
                RepositoryName: !GetAtt Repo.Name
                BranchName: main
                PollForSourceChanges: false
              OutputArtifacts:
                - Name: SourceOutput
        - Name: Build
          Actions:
            - Name: TestsYEmpaquetado
              ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: '1'
              Configuration:
                ProjectName: !Ref BuildProject
              InputArtifacts:
                - Name: SourceOutput
              OutputArtifacts:
                - Name: BuildOutput
        - Name: Deploy
          Actions:
            - Name: DesplegarStack
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Provider: CloudFormation
                Version: '1'
              Configuration:
                ActionMode: CREATE_UPDATE
                StackName: !Ref AppStackName
                TemplatePath: BuildOutput::packaged.yaml
                Capabilities: CAPABILITY_IAM
                RoleArn: !GetAtt DeployRole.Arn
                ParameterOverrides: !Sub '{"ModelId": "${ModelId}", "BaseModelId": "${BaseModelId}"}'
              InputArtifacts:
                - Name: BuildOutput

  TriggerRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: arrancar-pipeline
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: codepipeline:StartPipelineExecution
                Resource: !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}

  PushRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Arranca el pipeline con cada push a main
      EventPattern:
        source:
          - aws.codecommit
        detail-type:
          - CodeCommit Repository State Change
        resources:
          - !GetAtt Repo.Arn
        detail:
          event:
            - referenceCreated
            - referenceUpdated
          referenceType:
            - branch
          referenceName:
            - main
      Targets:
        - Id: pipeline
          Arn: !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}
          RoleArn: !GetAtt TriggerRole.Arn

Outputs:
  CloneUrl:
    Value: !GetAtt Repo.CloneUrlHttp
  PipelineName:
    Value: !Ref Pipeline
  ArtifactBucket:
    Value: !Ref ArtifactBucket
EOF

Qué define:

Recurso Para qué
ArtifactBucket Artefactos del pipeline y zip de la Lambda; bloqueo de acceso público y expiración a 7 días
Repo Repositorio CodeCommit lab12-genai-app
BuildRole + BuildProject CodeBuild con imagen Amazon Linux 2023 estándar 5.0 y Python 3.13; su rol puede invocar solo Nova Micro (para la puerta de calidad) y escribir en el bucket
DeployRole Rol que CloudFormation asume para crear la app: permisos limitados a Lambda, API Gateway y roles cuyo nombre empiece por lab12-app-
PipelineRole + Pipeline Pipeline V2 con tres etapas: Source (CodeCommit), Build (CodeBuild) y Deploy (acción CloudFormation CREATE_UPDATE)
TriggerRole + PushRule Regla de EventBridge que arranca el pipeline con cada push a main (en lugar de sondear el repositorio)

Despliégala:

aws cloudformation deploy \
  --region eu-central-1 \
  --template-file pipeline.yaml \
  --stack-name lab12-pipeline \
  --capabilities CAPABILITY_IAM

aws cloudformation describe-stacks --region eu-central-1 --stack-name lab12-pipeline \
  --query 'Stacks[0].Outputs' --output table

Al crearse, el pipeline intenta una primera ejecución y falla en Source porque el repositorio está vacío. Es normal.

Paso 3: sube el código al repositorio

Configura Git para autenticarse en CodeCommit con tus credenciales de CloudShell (el credential helper de la AWS CLI) y sube el código:

git config --global credential.helper '!aws codecommit credential-helper $@'
git config --global credential.UseHttpPath true
git config --global user.name "Borja"
git config --global user.email "borja@example.com"

CLONE=$(aws cloudformation describe-stacks --region eu-central-1 --stack-name lab12-pipeline \
  --query "Stacks[0].Outputs[?OutputKey=='CloneUrl'].OutputValue" --output text)
cd ~ && git clone "$CLONE" lab12-repo && cd lab12-repo
git checkout -b main

cp -r ~/lab11/src ~/lab11/tests ~/lab11/app.yaml ~/lab11/buildspec.yml .
find . -name '__pycache__' -prune -exec rm -rf {} +
printf "__pycache__/\n*.pyc\npackaged.yaml\n" > .gitignore

git add .
git commit -m "API de resumen con puerta de calidad de prompts"
git push -u origin main

El push genera un evento de CodeCommit; la regla de EventBridge arranca el pipeline.

Paso 4: sigue la ejecución

PIPE=$(aws cloudformation describe-stacks --region eu-central-1 --stack-name lab12-pipeline \
  --query "Stacks[0].Outputs[?OutputKey=='PipelineName'].OutputValue" --output text)

aws codepipeline get-pipeline-state --region eu-central-1 --name "$PIPE" \
  --query 'stageStates[].[stageName,latestExecution.status]' --output table

Repite el comando cada minuto (o mira la consola de CodePipeline, más visual). En la etapa Build, abre los logs de CodeBuild: verás los tests unitarios, las líneas OK | ... de la puerta de calidad con los resúmenes reales y el package. En Deploy, CloudFormation crea el stack lab12-app.

Cuando las tres etapas estén en Succeeded, prueba la API desplegada por el pipeline:

URL=$(aws cloudformation describe-stacks --region eu-central-1 --stack-name lab12-app \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text)
KEY_ID=$(aws cloudformation describe-stacks --region eu-central-1 --stack-name lab12-app \
  --query "Stacks[0].Outputs[?OutputKey=='ApiKeyId'].OutputValue" --output text)
API_KEY=$(aws apigateway get-api-key --region eu-central-1 --api-key "$KEY_ID" \
  --include-value --query value --output text)

curl -s -X POST "$URL" -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"texto": "La factura 2024-118 vence el 30 de noviembre. Si no se paga, se aplicará un recargo del 5 % y se suspenderá el servicio."}' | python3 -m json.tool

Paso 5: rompe el prompt y deja que el pipeline te proteja

Simula un cambio «inocente» de un compañero que estropea el comportamiento: pedir el resumen en inglés.

cd ~/lab12-repo
sed -i 's/Eres un asistente que resume textos en español de España. /You are an assistant that summarizes texts in English only. /' src/app.py
git diff --stat
git commit -am "Cambio de idioma del prompt"
git push

Los tests unitarios pasan (el modelo está simulado y el formato de la petición no cambia), pero la puerta de calidad falla: los resúmenes ya no contienen «lunes», «correo» o «30 de noviembre». El build termina en error, Deploy no se ejecuta y la API sigue sirviendo la versión buena. Compruébalo:

aws codepipeline get-pipeline-state --region eu-central-1 --name "$PIPE" \
  --query 'stageStates[].[stageName,latestExecution.status]' --output table

Revierte el cambio:

git revert --no-edit HEAD
git push

El pipeline vuelve a ejecutarse y termina en verde. Este es el valor de una puerta de calidad específica de IA generativa: los tests clásicos no detectan una regresión de prompt.

Comprueba que funciona

  • El primer push ejecuta las tres etapas en verde y crea el stack lab12-app.
  • La API desplegada por el pipeline responde con un resumen en español.
  • El cambio de idioma pasa los tests unitarios pero falla la puerta de calidad y no se despliega.
  • Tras git revert, el pipeline vuelve a verde.

Limpieza

El orden importa: el stack lab12-app lo gestiona CloudFormation con el DeployRole del stack del pipeline, así que bórralo primero.

REGION=eu-central-1
FUNCION=$(aws cloudformation describe-stacks --region $REGION --stack-name lab12-app \
  --query "Stacks[0].Outputs[?OutputKey=='FunctionName'].OutputValue" --output text)

# 1) La aplicación desplegada por el pipeline
aws cloudformation delete-stack --region $REGION --stack-name lab12-app
aws cloudformation wait stack-delete-complete --region $REGION --stack-name lab12-app
aws logs delete-log-group --region $REGION --log-group-name "/aws/lambda/$FUNCION"

# 2) Vaciar el bucket de artefactos (si no, el stack no puede borrarlo)
BUCKET=$(aws cloudformation describe-stacks --region $REGION --stack-name lab12-pipeline \
  --query "Stacks[0].Outputs[?OutputKey=='ArtifactBucket'].OutputValue" --output text)
aws s3 rm "s3://$BUCKET" --recursive

# 3) El pipeline, CodeBuild, CodeCommit, roles y regla de EventBridge
aws cloudformation delete-stack --region $REGION --stack-name lab12-pipeline
aws cloudformation wait stack-delete-complete --region $REGION --stack-name lab12-pipeline

# 4) Logs de CodeBuild y ficheros locales
aws logs delete-log-group --region $REGION --log-group-name "/aws/codebuild/lab12-genai-app-build"
rm -rf ~/lab12 ~/lab12-repo ~/lab11

Al borrar el stack del pipeline también se borra el repositorio CodeCommit con su historial. Si algún comando de logs dice que el grupo no existe, no pasa nada.

Preguntas para pensar como arquitecto

1. ¿Por qué no basta con tests unitarios para una aplicación de IA generativa?

Porque el componente crítico (el modelo con su prompt) es no determinista y está fuera del código que simulas. Un cambio de prompt, de parámetros o de versión del modelo puede degradar la calidad sin romper ningún contrato de software. Hacen falta pruebas de regresión de prompts contra el modelo real, con conjuntos de referencia y umbrales (propiedades objetivas, evaluación automática o LLM-as-a-judge), como puerta del pipeline (skills 2.3.5 y 5.1.4).

2. El proveedor publica una versión nueva del modelo. ¿Cómo la adoptarías con seguridad?

Tratando el cambio de modelo como un cambio de código o de configuración gobernado: actualizar el identificador (en AppConfig o en el repositorio), ejecutar la evaluación sobre el conjunto de referencia (calidad, formato, latencia y coste por petición), aprobar manualmente si procede y desplegar de forma gradual (canario con CodeDeploy o despliegue gradual de AppConfig) con rollback automático ligado a alarmas. Mantener el modelo anterior disponible hasta confirmar.

3. ¿Por qué el pipeline usa un DeployRole distinto del rol de CodeBuild?

Por mínimo privilegio y separación de funciones. CodeBuild ejecuta código del repositorio (tests, scripts): si alguien mete algo malicioso, solo puede leer y escribir en el bucket e invocar un modelo. La creación de recursos (Lambda, API Gateway, roles IAM) la hace CloudFormation con un rol acotado a los recursos de la app (lab12-app-*). Nadie tiene permisos de administrador «por si acaso».

4. ¿Qué cambiaría si varios equipos consumen modelos a través de un gateway de IA central?

El pipeline del gateway desplegaría la capa común (autenticación, planes de uso, guardrails, enrutado de modelos, logging), y las políticas (modelos aprobados, límites por equipo) vivirían en configuración versionada (AppConfig) con su propio flujo de aprobación. Cada equipo desplegaría sus aplicaciones consumiendo el gateway, sin permisos directos sobre Bedrock. La observabilidad central permite atribuir coste y detectar abusos por consumidor.

5. Kiro y Amazon Q Developer, ¿en qué parte de este flujo ayudarían?

En la productividad del desarrollador (skill 2.5.4): generar o refactorizar el código de la Lambda y las plantillas, proponer tests (incluidos casos de la puerta de calidad) y explicar errores del pipeline. Kiro, además, puede planificar el cambio con specs, aplicar reglas del proyecto con steering y ejecutarse en modo headless dentro de la CI. Q Developer en la consola ayuda a diagnosticar fallos de CloudFormation o CodeBuild. Ninguno sustituye a la puerta de calidad: generan código, no garantizan comportamiento.


Volver al módulo