Está en la página 1de 486

Stratospheric (Edición Española)

De Cero a Producción con Spring Boot y AWS

Philip Riecks, Tom Hombergs y Björn Wilmsmann

This book is for sale at http://leanpub.com/stratospheric-es

Esta versión se publicó en 2023-10-03

Éste es un libro de Leanpub. Leanpub anima a los autores y publicadoras con el


proceso de publicación. Lean Publishing es el acto de publicar un libro en
progreso usando herramientas sencillas y muchas iteraciones para obtener
retroalimentación del lector hasta conseguir el libro adecuado.

© 2023 Philip Riecks, Tom Hombergs y Björn Wilmsmann


También por estos autores
Libros por Philip Riecks
Stratospheric

Java Testing Toolbox

Testing Spring Boot Applications Demystified

Libros por Tom Hombergs


Get Your Hands Dirty on Clean Architecture (2nd edition)

Stratospheric

Libros por Björn Wilmsmann


Stratospheric
Índice general

Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
¿Por qué Spring Boot & AWS? . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
¿Quién debería leer este libro? . . . . . . . . . . . . . . . . . . . . . . . . . . 4
Prerrequisitos para los ejemplos prácticos . . . . . . . . . . . . . . . . . . . 5
¿Qué esperar de este libro? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Poniéndonos en contacto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
Recursos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
Acerca de los Autores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8

Parte I: Desplegando con AWS . . . . . . . . . . . . . 10

1. Familiarizándonos con AWS . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11


Preparándonos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
Inspeccionando la aplicación Todo de “Hello World” . . . . . . . . . . . . 14
Publicando la Aplicación “Hello World” en Docker Hub . . . . . . . . . . 15
Comenzando con los Recursos de AWS . . . . . . . . . . . . . . . . . . . . . 17
Inspeccionando las Plantillas de CloudFormation . . . . . . . . . . . . . . 19
Inspeccionando los Scripts de Despliegue . . . . . . . . . . . . . . . . . . . 27
Inspeccionando la Consola AWS . . . . . . . . . . . . . . . . . . . . . . . . . 30

2. Una visión general de los servicios de AWS . . . . . . . . . . . . . . . . . . 34


AWS CloudFormation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
AWS Cloud Development Kit (CDK) . . . . . . . . . . . . . . . . . . . . . . . 35
ÍNDICE GENERAL

Amazon CloudWatch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
Amazon Cognito . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
Amazon DynamoDB . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
Amazon Elastic Compute Cloud (EC2) . . . . . . . . . . . . . . . . . . . . . . 37
Amazon Elastic Container Registry (ECR) . . . . . . . . . . . . . . . . . . . 37
Amazon Elastic Container Service (ECS) . . . . . . . . . . . . . . . . . . . . 38
Amazon MQ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
Amazon Relational Database Service (RDS) . . . . . . . . . . . . . . . . . . 39
Amazon Route 53 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Amazon Simple Email Service (SES) . . . . . . . . . . . . . . . . . . . . . . . 39
Amazon Simple Queue Service (SQS) . . . . . . . . . . . . . . . . . . . . . . 40
Amazon Simple Storage Service (S3) . . . . . . . . . . . . . . . . . . . . . . . 40
Amazon Virtual Private Cloud (VPC) . . . . . . . . . . . . . . . . . . . . . . . 40
AWS Certificate Manager . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
AWS Identity and Access Management (IAM) . . . . . . . . . . . . . . . . . 41
AWS Lambda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
AWS Secrets Manager . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
AWS Systems Manager (SSM) . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Elastic Load Balancing (ELB) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43

3. Gestión de Permisos con IAM . . . . . . . . . . . . . . . . . . . . . . . . . . . 44


Usuarios, Grupos y Roles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
Usuarios Root vs. Usuarios Regulares . . . . . . . . . . . . . . . . . . . . . . 47
Definición de Políticas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
Creando Claves de Acceso AWS para Cada Usuario . . . . . . . . . . . . . . 52
Gestionando Recursos IAM de Forma Programática . . . . . . . . . . . . . 53
Mejores Prácticas para Gestionar Permisos con IAM . . . . . . . . . . . . . 54

4. La Evolución de las Implementaciones Automatizadas . . . . . . . . . . 55


Una anécdota sobre las implementaciones manuales . . . . . . . . . . . . 55
ÍNDICE GENERAL

Despliegues de autoservicio con la Consola AWS . . . . . . . . . . . . . . . 62


Despliegues automatizados con la AWS CLI . . . . . . . . . . . . . . . . . . 63
Despliegues declarativos con CloudFormation . . . . . . . . . . . . . . . . 64
Implementaciones Programables con CDK . . . . . . . . . . . . . . . . . . . 66

5. Primeros Pasos con CDK . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69


Creando Nuestra Primera Aplicación CDK . . . . . . . . . . . . . . . . . . . 70
Desplegando una Aplicación Spring Boot con un Constructo de CDK . . 77
¿Por qué no detenernos aquí? . . . . . . . . . . . . . . . . . . . . . . . . . . . 83

6. Diseñando un Proyecto de Despliegue con CDK . . . . . . . . . . . . . . . 85


La visión general . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
Cómo trabajar con CDK . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
La aplicación CDK para el repositorio Docker . . . . . . . . . . . . . . . . . 90
La App de Network CDK . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
La Aplicación de Servicio CDK . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
Experimentando con las Aplicaciones CDK . . . . . . . . . . . . . . . . . . . 115

7. Construyendo un Pipeline de Despliegue Continuo . . . . . . . . . . . . . 117


Conceptos de Acciones de GitHub . . . . . . . . . . . . . . . . . . . . . . . . 118
Inicializando un Nuevo Entorno . . . . . . . . . . . . . . . . . . . . . . . . . 119
Implementando una Red Compartida . . . . . . . . . . . . . . . . . . . . . . 122
Desplegando un Entorno de Aplicación . . . . . . . . . . . . . . . . . . . . . 123
Creando un Flujo de Trabajo para Despliegue Continuo . . . . . . . . . . . 125
Soportando Despliegues de Alta Frecuencia con Amazon SQS y AWS
Lambda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131

Addendum: Configurando HTTPS y un Dominio Personalizado con Route


53 y ELB . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
Sistema de Nombres de Dominio (DNS) . . . . . . . . . . . . . . . . . . . . . 144
HTTPS y Seguridad de la Capa de Transporte (TLS) . . . . . . . . . . . . . . 146
ÍNDICE GENERAL

Registro o Transferencia de un Dominio . . . . . . . . . . . . . . . . . . . . 149


Creando un Certificado SSL con CDK . . . . . . . . . . . . . . . . . . . . . . . 150
Creación de un Oyente HTTPS Usando la Aplicación de Red . . . . . . . . 155
Asociando un Dominio Personalizado con el ELB . . . . . . . . . . . . . . . 158

Parte II: Spring Boot & AWS . . . . . . . . . . . . . . . .162

8. La Aplicación de Ejemplo Todo . . . . . . . . . . . . . . . . . . . . . . . . . . 164


Características . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
Arquitectura de la Aplicación . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Modelo de Dominio . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
Configuración Inicial de la Aplicación . . . . . . . . . . . . . . . . . . . . . . 169

9. Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 181


Los desafíos del desarrollo local en la nube . . . . . . . . . . . . . . . . . . 181
LocalStack - Nuestra nube AWS local . . . . . . . . . . . . . . . . . . . . . . 183
Amazon RDS local y Amazon Cognito . . . . . . . . . . . . . . . . . . . . . . 186
Reuniéndolo Todo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 187

10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon


Cognito . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 189
¿Qué es OAuth 2.0? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 189
Terminología de OAuth 2.0 . . . . . . . . . . . . . . . . . . . . . . . . . . . . 191
OpenID Connect 1.0 (OIDC) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 194
Alternativas a OAuth2 & OpenID Connect . . . . . . . . . . . . . . . . . . . 195
Uso de Amazon Cognito para la gestión de usuarios . . . . . . . . . . . . . 196
Usando Amazon Cognito como un Proveedor de Identidad con Spring
Security . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 209
Activando el Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . . 233

11. Conexión a una base de datos con Amazon RDS . . . . . . . . . . . . . . . 237


ÍNDICE GENERAL

Introducción al Servicio de Base de Datos Relacional de AWS (RDS) . . . 238


Configurando los permisos de IAM . . . . . . . . . . . . . . . . . . . . . . . . 239
Creando una aplicación de base de datos CDK . . . . . . . . . . . . . . . . . 240
Estrategias para la Inicialización de la Estructura de la Base de Datos . . 251
Configurando la Base de Datos en la Aplicación Todo . . . . . . . . . . . . 254
Usando la Base de Datos para Almacenar y Recuperar Todos . . . . . . . 256
Habilitando el Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . 263

12. Compartiendo Tareas con Amazon SQS y Amazon SES . . . . . . . . . . 265


Usando Amazon SQS para cargas de trabajo asíncronas . . . . . . . . . . . 266
Enviando correos electrónicos con Amazon SES . . . . . . . . . . . . . . . 285
Activando el Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . . 297

13. Notificaciones Push con Amazon MQ . . . . . . . . . . . . . . . . . . . . . 302


¿Qué son las Notificaciones Push de todos modos? . . . . . . . . . . . . . . 302
Notificaciones Push para Actualizaciones en Vivo . . . . . . . . . . . . . . 305
Servicios AWS para Implementar Notificaciones Push . . . . . . . . . . . 306
Configuración de un corredor de mensajes con CDK . . . . . . . . . . . . . 311
Implementando Notificaciones Push en la Aplicación Todo . . . . . . . . 320
Activando el Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . . 336

14. Rastreando las Acciones del Usuario con Amazon DynamoDB . . . . . 338
Caso de Uso: Rastreo de Acciones del Usuario . . . . . . . . . . . . . . . . . 340
Amazon RDS vs. Amazon DynamoDB . . . . . . . . . . . . . . . . . . . . . . 341
Implementación del Rastreo de Usuarios en la Aplicación Todo . . . . . 350
Habilitando el Desarrollo Local . . . . . . . . . . . . . . . . . . . . . . . . . . 364

Parte III: Preparación para la Producción


con AWS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .366
15. Registro Estructurado con Amazon CloudWatch . . . . . . . . . . . . . . 368
ÍNDICE GENERAL

Registro con AWS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 369


Terminología de Registro de CloudWatch . . . . . . . . . . . . . . . . . . . 370
Estado Actual: Registro de Texto No Estructurado . . . . . . . . . . . . . . 371
Registro y Consulta de Datos Estructurados . . . . . . . . . . . . . . . . . . 378

16. Métricas con Amazon CloudWatch . . . . . . . . . . . . . . . . . . . . . . . 390


Introducción al Monitoreo de Métricas con Amazon CloudWatch . . . . 391
Enviando Métricas desde Servicios AWS . . . . . . . . . . . . . . . . . . . . 394
Enviando métricas desde nuestra aplicación Spring Boot . . . . . . . . . 401
Monitoreo de Métricas con Amazon CloudWatch . . . . . . . . . . . . . . . 410

17. Alertando con Amazon CloudWatch . . . . . . . . . . . . . . . . . . . . . . 422


Introducción a la alerta con Amazon CloudWatch . . . . . . . . . . . . . . 423
Creando Alarmas con AWS CDK . . . . . . . . . . . . . . . . . . . . . . . . . . 426
Trabajando y Viviendo con Alarmas e Incidentes . . . . . . . . . . . . . . . 440

18. Monitoreo Sintético con Amazon CloudWatch . . . . . . . . . . . . . . . 445


Introducción a CloudWatch Synthetics . . . . . . . . . . . . . . . . . . . . . 446
Grabación de un Script Canario para la Aplicación Todo . . . . . . . . . . 448
Manteniéndolo Sencillo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 452
Automatizando el Despliegue del Script Canario con CDK . . . . . . . . . 453
Alerta sobre la Falla del Canary . . . . . . . . . . . . . . . . . . . . . . . . . . 457

Reflexiones Finales . . . . . . . . . . . . . . . . . . . . . . . . .459

Domina la Nube . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 460

Recursos Adicionales . . . . . . . . . . . . . . . . . . . . . . .462

Apéndice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .465
Usuario Técnico de GitHub Actions IAM . . . . . . . . . . . . . . . . . . . . 465
ÍNDICE GENERAL

Guía de Despliegue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 466

Registro de cambios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 473


Introducción
¿Has estado revisando descripciones de trabajos para desarrolladores última-
mente? No te preocupes, todos lo estamos haciendo. Es probable que las des-
cripciones del trabajo te pidan que tengas una amplia experiencia en “desarrollo
cloud”, además de ser un experto en al menos un stack tecnológico.

Pero, ¿qué es “desarrollo cloud”, de todos modos?

El término “cloud” ha sido confuso desde su inicio hace una década. Algunas
personas suben sus fotos al cloud. Algunas organizaciones usan “servicios
cloud” para administrar a sus empleados, clientes y proveedores. ¿Eso significa
que cuando estoy pidiendo pizza en línea también estoy usando “el cloud”?

Creemos que la idea principal de “el cloud” es el concepto de autoservicio.


Donde antes tenías que llenar formularios y firmar contratos, ahora simplemen-
te puedes registrarte para obtener un servicio en línea y usarlo de inmediato
(siempre y cuando tu tarjeta de crédito esté financiada). De la vasta gama de
servicios “en el cloud”, puedes elegir los que necesitas y agregarlos a tu caja de
herramientas.

Por lo tanto, si nos preguntas: sí, pedir pizza en línea es pedir pizza “en el
cloud”.

El término “desarrollo cloud” lleva el concepto de autoservicio al dominio del


desarrollo de software. En lugar de solicitar nuevos almacenamientos o recursos
informáticos mediante la presentación de tickets para el departamento de TI,
los creamos nosotros mismos con nuestro proveedor cloud. En lugar de escribir
notas de lanzamiento para decirle al equipo de operaciones cómo desplegar el
Introducción 2

software que hemos desarrollado, nosotros mismos lo desplegamos, usando los


servicios de un proveedor cloud. En lugar de pedir al equipo de operaciones los
registros de nuestro software mal comportado en producción, los revisamos
nosotros mismos a pedido.

El concepto de autoservicio ha interrumpido considerablemente los procesos


convencionales de desarrollo de software. Los equipos de software ahora pue-
den usar una gama de servicios en la nube no solo para construir software,
sino también para llevarlo a producción y operarlo. Hecho correctamente, esto
mejora el tiempo de lanzamiento al mercado y el aprendizaje a partir de los
comentarios de los usuarios a través de lanzamientos iterativos, uno de los
pilares del desarrollo ágil de software.

Los equipos de software ahora tienen un gran poder sobre su proceso de desa-
rrollo de software, y la igualmente gran responsabilidad de operar software en
producción. La colección de prácticas y filosofías que surgieron de este nuevo
paradigma se conoce comúnmente como “DevOps”.

Los principales actores en tecnología entienden que el desarrollo cloud permite


DevOps y equipos autoorganizados, y lo han utilizado con gran eficacia. Los
equipos autoorganizados hacen que un negocio sea mucho más fácil de escalar
porque dichos equipos tienen todos los recursos (en la nube) que necesitan
al alcance de la mano. Esto crea una cultura de responsabilidad porque cada
equipo es dueño de sus servicios en producción. Con esta cultura viene un
sentimiento de “estar en control” para el equipo de software, lo cual puede
impactar significativamente la motivación.

Este libro toma un stack tecnológico particular (Spring Boot) y un proveedor de


nube específico (AWS) y sigue un proceso completo de desarrollo de software
para presentar herramientas y métodos que respaldan una cultura DevOps.

En la Parte I, aprenderemos todo lo que necesitamos saber sobre cómo desplegar


Introducción 3

una aplicación Spring Boot en AWS. ¡En el primer capítulo, ya desplegaremos un


contenedor Docker y accederemos a él a través del navegador! Luego, aprendere-
mos cómo utilizar AWS CloudFormation y el AWS Cloud Development Kit (CDK)
para automatizar despliegues y finalmente construir un pipeline de despliegue
continuo completo con GitHub Actions.

En la Parte II, aprenderemos sobre varios servicios de AWS que podemos usar
para tareas comunes. Construiremos un registro de usuarios y un inicio de
sesión sin implementarlo nosotros mismos aprovechando el servicio Amazon
Cognito. Luego, conectaremos nuestra aplicación Spring Boot con una base
de datos relacional y una base de datos NoSQL. Además, enviaremos correos
electrónicos y nos suscribiremos a sistemas de mensajería, todo “autoservicio”
y completamente administrado por AWS.

Finalmente, en la Parte III, profundizaremos en aspectos importantes para


ejecutar una aplicación en producción. Exploraremos cómo usar Amazon Cloud-
Watch para ver registros y métricas. Al monitorear activamente nuestra aplica-
ción y crear alertas, aumentaremos la probabilidad de detectar fallas temprano.
El libro se cierra con un capítulo sobre cómo configurar HTTPS y un dominio
personalizado para nuestra aplicación.

Con esto, esperamos que te diviertas tanto leyendo este libro como nosotros
escribiéndolo.

¿Por qué Spring Boot & AWS?

Podríamos haber elegido cualquier combinación de stack tecnológico y provee-


dor de nube para este libro. Elegimos la combinación que más nos intrigaba
porque creemos que tiene mucho potencial (y, por supuesto, porque tenemos
un poco de experiencia con ella).
Introducción 4

Spring Boot es el marco líder para la construcción de aplicaciones en el ecosis-


tema JVM. Facilita la construcción de software listo para producción. Entre los
tres, hemos construido innumerables aplicaciones con Spring Boot y nos hemos
familiarizado profundamente con él.

AWS es la plataforma en la nube líder. Podemos utilizar una amplia variedad de


servicios en la nube de AWS para ayudarnos a diseñar, construir e implementar
una aplicación de software. Grandes jugadores como Netflix y Atlassian han
apostado todo a AWS, ejecutando todas sus aplicaciones SaaS en la infraestruc-
tura de AWS. Los días de desplegar tu software en un servidor situado en el
sótano de tu empresa han quedado atrás (o pronto quedarán atrás, si tu empresa
aún no está allí).

Como puedes imaginar, la combinación de Spring Boot y AWS es bastante pode-


rosa. Al tiempo de escribir, no existe un recurso integral sobre la integración de
aplicaciones Spring Boot con AWS, por eso escribimos este libro para llenar ese
vacío.

Después de leer este libro, sabrás cómo desplegar una aplicación Spring Boot en
AWS y cómo utilizar muchos servicios de AWS para facilitar tu vida.

¿Quién debería leer este libro?

Este libro está dirigido a desarrolladores que construyen software en la JVM


con Spring Boot. Deberías tener cierta experiencia con Java y Spring Boot.
Explicaremos las características de Spring Boot que estamos utilizando, pero
no en mucho detalle.

En cuanto a AWS, por otro lado, este libro no requiere conocimientos previos.
Comenzaremos desde cero. Si nunca has oído hablar de los servicios de AWS que
estamos utilizando en este libro, no te preocupes. Profundizaremos lo suficiente
Introducción 5

para ponerte al día.

Cuando desarrollamos una aplicación “en la nube”, como haremos en este libro,
estamos pasando automáticamente de un simple “desarrollo” hacia DevOps.
Seguimos desarrollando software (el “dev” en DevOps), pero también nos
importa su operación en la nube (el “ops” en DevOps).

A muchos desarrolladores no les gustan las cuestiones operativas. Eso suele


ser porque han sido entrenados durante años en prácticas convencionales de
desarrollo de software, pensando que hay un equipo de operaciones para asumir
la carga de ejecutar su aplicación en producción.

A nosotros tampoco nos gustaba tratar con cuestiones operativas. Sin embargo,
la sensación de “estar en control” que viene con el desarrollo en la nube,
despertó nuestra curiosidad por los asuntos operativos.

Así que, si eres un desarrollador a quien no le gusta - o incluso le disgusta - las


operaciones, este libro también es para ti. Esperamos que este libro genere en
ti cierto gusto por las operaciones en la nube.

Dicho esto, este libro es definitivamente para ingenieros de software y no para


administradores de sistemas o Ingenieros de Confiabilidad de Sitios (SREs). Se-
guro se decepcionarían al notar que no entramos en detalles técnicos profundos
de la infraestructura de AWS.

Prerrequisitos para los ejemplos prácticos

Este es un libro práctico con muchos ejemplos de código y cosas para probar
por ti mismo. Puedes leer el libro de principio a fin sin ejecutar los ejemplos
de código por ti mismo, pero obtendrás el mayor beneficio de aprendizaje si
experimentas con el código y lo despliegas en AWS por ti mismo. Para esto,
Introducción 6

necesitarás instalar cierto software.

Para construir y ejecutar la aplicación de muestra en tu máquina, necesitas JDK


17 (o superior). Si aún no tienes uno instalado, puedes obtenerlo en el sitio web
de Adoptium (anteriormente AdoptOpenJDK).

Además, necesitas tener Docker y Docker Compose en funcionamiento. Lo


necesitaremos para iniciar algunos contenedores de Docker cuando estemos
probando y ejecutando la aplicación, y cuando estemos jugando con el desplie-
gue en AWS. Puedes obtener Docker en el sitio web de Docker.

En este libro, cuando hablamos de la línea de comandos, asumimos una línea


de comandos Unix. Si estás trabajando en una máquina Windows, asegúrate de
tener instalado un emulador Bash como GitBash. Mejor aún, instala el Subsis-
tema de Windows para Linux (WSL) (que en nuestra opinión debería llamarse
“Subsistema Linux para Windows”).

¿Qué esperar de este libro?

Este libro no es una guía para ninguna de las certificaciones de AWS. Si quieres
prepararte para una certificación de AWS, seguramente sería mejor buscar un
recurso específico para esa certificación.

No obstante, aprenderás mucho sobre diferentes servicios de AWS en este libro.


Si vienes de Spring Boot y quieres expandirte a AWS, este libro será un mejor
inicio en el tema que un libro que te prepara para una certificación.

Este libro ofrece conocimiento práctico sobre cómo llevar una aplicación Spring
Boot a la nube y operarla allí. Construiremos un pipeline de despliegue continuo,
accederemos a los servicios de AWS más comunes desde una aplicación Spring
Boot, y aprenderemos cómo monitorear y mantener la aplicación una vez que
Introducción 7

esté en vivo.

Te encontrarás con ejemplos de código prácticos. Podrás experimentar con el


código por ti mismo - todos los ejemplos de código son parte de un repositorio
de GitHub con una aplicación Spring Boot en funcionamiento. Además, habrá
discusiones sobre por qué estamos haciendo las cosas de la manera en que las
hacemos en este libro y cuándo deberíamos elegir un camino diferente.

Poniéndonos en contacto

Agradecemos cualquier comentario sobre este libro. Como es auto publicado,


podemos actualizarlo en cualquier momento. Puedes ayudarnos a mejorarlo
para todos haciéndonos saber cuando encuentres un error o algo que no esté
claro.

Puedes publicar cualquier problema en el rastreador de problemas públicos


en GitHub. Puedes contactarnos a todos enviando un correo electrónico a in-
fo@stratospheric.dev. Alternativamente, puedes contactarnos individualmen-
te siguiendo los enlaces en la sección sobre los autores a continuación.

Recursos

Proporcionamos algunos recursos adicionales que complementan el contenido


de este libro.

No dudes en unirte a nuestra comunidad de Slack para discutir el libro, hacer


preguntas y trabajar juntos en el libro.

Navega por nuestros repositorios de GitHub, que contienen la mayoría del


código utilizado en este libro. Clona esos repositorios y haz que todo funcione
Introducción 8

localmente mientras lees el libro.

Suscríbete al boletín para recibir notificaciones cada vez que actualizamos el


libro o tenemos noticias para compartir sobre la combinación de tecnología
Spring Boot y AWS.

Inscríbete en el curso en línea complementario de Stratospheric para una


experiencia de aprendizaje más interactiva y dinámica. Este ebook establece la
base para el curso en línea. En el curso en línea cubrimos varios temas con mayor
detalle.

Acerca de los Autores

Tom Hombergs

Tom es un ingeniero de software experimentado con una pasión por construir


sistemas de la manera más simple posible. Escribe regularmente en su blog
sobre Java, Spring y AWS y es el autor de Get Your Hands Dirty on Clean Architecture,
dando consejos prácticos sobre la implementación de una arquitectura hexago-
nal.

Descubre más sobre Tom en reflectoring.io y su perfil de Twitter.

Björn Wilmsmann

Björn Wilmsmann es un consultor de TI independiente que ayuda a las empresas


a transformar su negocio en un negocio digital. Diseña y desarrolla soluciones
empresariales y aplicaciones empresariales para sus clientes. Björn proporciona
capacitación práctica en tecnologías como Angular y Spring Boot.

Descubre más sobre Björn en bjoernkw.com y su perfil de Twitter.


Introducción 9

Philip Riecks

Bajo el lema “Pruebas de Aplicaciones Java Hechas Simples”, Philip proporciona


recetas, consejos y trucos para acelerar tu éxito en las pruebas y hacer que las
pruebas sean alegres (o al menos menos dolorosas). Además de escribir en su
blog, es instructor de varios cursos en línea relacionados con Java y está activo
en YouTube.

Descubre más sobre Philip en rieckpil.de y su perfil de Twitter.


Parte I: Desplegando con AWS

En la primera parte de este libro, veremos cómo trasladar una aplicación a la


nube.

Comenzaremos de inmediato desplegando una aplicación de “Hola Mundo” en


la nube con AWS CloudFormation. ¡Sigue los pasos para desplegar la aplicación
tú mismo y experimentar esa primera sensación de éxito!

Después de esta sección práctica, abordaremos algunos conceptos básicos de


AWS. Presentaremos los servicios de AWS que se utilizarán a lo largo del libro y
explicaremos cómo AWS gestiona IAM (Identity and Access Management).

Creemos firmemente que la automatización es clave para el desarrollo de soft-


ware exitoso. Por lo tanto, el resto de esta parte está dedicado a explorar lo
que AWS CDK (Cloud Development Kit) tiene para ofrecer para automatizar al
máximo el proceso de despliegue.

Después de esta parte, habremos construido un pipeline de despliegue total-


mente automatizado con CDK y GitHub Actions.

Por ahora, no nos importa mucho qué aplicación estamos desplegando. Cual-
quier aplicación web en una imagen Docker valdría. Para propósitos de de-
mostración, utilizaremos una versión “Hola Mundo” de la aplicación Todo que
vamos a construir en la Parte II.
1. Familiarizándonos con AWS
Antes de empezar a construir cualquier característica para nuestra aplicación
Todo, queremos sentirnos cómodos con AWS. Comenzaremos a desarrollar las
funciones en la Parte II del libro.

Lo primero que haremos será desplegar una versión “Hola Mundo” de la apli-
cación Todo para obtener una rápida dosis de dopamina que nos mantendrá en
marcha.

No esperes que este capítulo profundice mucho en cada tema. Es superficial


intencionalmente para que tengas una idea de AWS incluso si no sabes nada
sobre ello. Profundizaremos en los temas en los capítulos posteriores de este
libro.

Si ya sabes cómo desplegar una imagen Docker en AWS Fargate con CloudFor-
mation, es posible que quieras saltarte este capítulo.

Preparándonos

Si nunca has desplegado una aplicación en la nube antes, te espera una sorpresa.
Vamos a desplegar una primera versión de nuestra aplicación Todo en AWS con
solo un par de comandos CLI (aunque requiere algo de preparación para que
estos comandos CLI funcionen).

Vamos a utilizar Docker para hacer que nuestra aplicación se ejecute en un


contenedor, AWS CloudFormation para describir los componentes de infra-
1. Familiarizándonos con AWS 12

estructura que necesitamos, y AWS CLI para desplegar esa infraestructura y


nuestra aplicación.

El objetivo de este capítulo no es convertirse en un experto en todo lo relacio-


nado con AWS, sino aprender un poco sobre AWS CLI y CloudFormation porque
los próximos capítulos se apoyarán en ellos. Y no hay mejor manera de aprender
que ensuciándose las manos.

Comenzaremos desde cero y configuraremos nuestra cuenta AWS primero.

Configurando una cuenta AWS

Para hacer cualquier cosa con AWS, necesitas una cuenta con ellos. Si aún no
tienes una cuenta, adelante y créala ahora.

Si ya tienes una cuenta en la que se ejecutan aplicaciones serias, es posible que


quieras crear una cuenta extra solo para asegurarte de que no estás jugando con
tu negocio serio mientras te diviertes con este libro.

Instalando AWS CLI

Para hacer magia con AWS desde nuestra línea de comandos, necesitamos
instalar AWS CLI.

AWS CLI es una potencia de una interfaz de línea de comandos que proporciona
comandos para muchos y muy diferentes servicios de AWS (224 en el momento
de escribir esto). En este capítulo, la vamos a usar para desplegar la aplicación
y luego para obtener información sobre la aplicación desplegada.

La instalación de AWS CLI varía entre los sistemas operativos, así que por
favor sigue las instrucciones oficiales para tu sistema operativo para instalar
la versión 2 de AWS CLI en tu máquina.
1. Familiarizándonos con AWS 13

Una vez que esté instalado, ejecuta aws configure. Se te pedirá que proporcio-
nes 4 parámetros:

~ aws configure
AWS Access Key ID [****************Kweu]:
AWS Secret Access Key [****************CmqH]:
Default region name [ap-southeast-2]:
Default output format [yaml]:

Puede conseguir el “AWS Access Key ID” y “AWS Secret Access Key” después
de haber iniciado sesión en su cuenta de AWS cuando haga clic en el nombre de
su cuenta y luego en “My Security Credentials”. Allí, abra la pestaña “Access
keys” y haga clic en “Create New Access Key”. Copie los valores en el prompt
de la AWS CLI.

Ahora, el AWS CLI está autorizado para hacer llamadas a las APIs de AWS en su
nombre.

A continuación, el comando aws configure le pedirá un “Default region na-


me”.

Los servicios de AWS se distribuyen entre “regions” y “availability zones”.


Cada región geográfica está bastante aislada de las otras regiones por razones
de residencia de datos y baja latencia. Cada región tiene 2 o más zonas de
disponibilidad para hacer que los servicios sean resilientes ante interrupciones.

Cada vez que se interactúa con un servicio de AWS, será con la instancia del
servicio en una región específica. Por lo tanto, elija la región más cercana a su
ubicación de la lista de puntos finales de servicio proporcionados por AWS e
introduzca el código de la región en el prompt aws configure (por ejemplo, “us-
east-1”).

Finalmente, el comando aws configure le pedirá el “Default output format”.


Esta configuración define la forma en que la AWS CLI presentará cualquier salida
a usted.
1. Familiarizándonos con AWS 14

Puede elegir entre dos males: JSON o YAML. No se le juzgará por su elección.

Se ha terminado de configurar la AWS CLI ahora. Ejecute el siguiente comando


para probarlo:

aws ec2 describe-regions

Este comando lista todas las regiones de AWS en las que podemos utilizar
instancias de EC2. EC2 significa “Elastic Cloud Compute”, que es el servicio
de AWS que proporciona máquinas virtuales en las que podemos desplegar
nuestras aplicaciones. Si el comando imprime una lista de regiones, todo está
en orden.

Inspeccionando la aplicación Todo de “Hello World”

Vamos a darle un ojo rápido a la aplicación Todo que vamos a desplegar en AWS.

Encontrarás el código fuente de la aplicación en el repositorio de GitHub de


Stratospheric. No dudes en clonarlo o de inspeccionarlo en GitHub.

En este momento, la aplicación no es más que una aplicación de “Hello World”


sin estado de Spring Boot. Construiremos características reales en esta aplica-
ción más adelante en la Parte II del libro.

La aplicación tiene un único controlador llamado IndexController que no


muestra nada más que el mensaje “¡Bienvenido a la aplicación Todo!”. No dudes
en iniciar la aplicación a través de este comando:

./gradlew bootrun

Luego, navega a http://localhost:8080 para ver el mensaje.

Para desplegar la aplicación en AWS, necesitamos publicarla como una imagen


Docker después.
1. Familiarizándonos con AWS 15

Publicando la Aplicación “Hello World” en Docker Hub

Si sabes cómo empaquetar una aplicación Spring Boot en una imagen Docker,
puedes saltarte esta sección. Ya hemos publicado la aplicación en Docker Hub,
así que puedes usar esa imagen Docker en los próximos pasos.

Si estás interesado en los pasos para crear y publicar una imagen Docker básica,
sigue leyendo.

Primero, necesitamos el Dockerfile. El repositorio ya contiene un Dockerfile


con este contenido:

FROM eclipse-temurin:17-jre

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-jar", "/app.jar"]

Este archivo instruye a Docker para crear una imagen basada en una imagen
básica eclipse-temurin, que agrupa OpenJDK 17 con una distribución de Linux.

El proyecto Eclipse Adoptium es el sucesor de AdoptOpenJDK y los entor-


nos de ejecución temurin son entornos de ejecución de Java de alta calidad
suministrados por la fundación Eclipse. La imagen no tiene relación
alguna con el IDE de Eclipse. Para más información sobre la transición
de AdoptOpenJDK a la Fundación Eclipse, consulte su anuncio oficial.

A partir de la versión 2.3.0, Spring Boot admite formas más sofisticadas de crear
imágenes Docker, incluyendo Buildpacks nativos de la nube. No entraremos en
detalles sobre esto, pero si estás interesado, esta entrada de blog ofrece una
introducción sobre lo que puedes hacer al respecto.
1. Familiarizándonos con AWS 16

Creamos el argumento JAR_FILE y le indicamos a Docker que copie el archivo


especificado por ese argumento en el archivo app.jar dentro del contenedor.

Luego, Docker iniciará la aplicación ejecutando java -jar /app.jar.

Antes de poder construir una imagen Docker, necesitamos construir la aplica-


ción usando las herramientas y procesos adecuados.

./gradlew build

Esto creará el archivo /build/libs/todo-application-0.0.1-SNAPSHOT.jar,


que será capturado por el argumento JAR_FILE en el archivo Docker.

Para crear una imagen Docker ahora podemos ejecutar este comando:

docker build -t stratospheric/todo-app-v1:latest .

Docker ahora está configurado para construir una imagen en el namespace


stratospheric, con el nombre todo-app-v1 y etiquetarla como latest. Si vas
a realizar este proceso, asegúrate de usar tu nombre de usuario de Docker Hub
como el namespace, ya que no podrás publicar una imagen de Docker en el
namespace stratospheric.

Al ejecutar docker image ls, deberías ver la imagen de Docker en la lista:

~ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
stratospheric/todo-app-v1 latest 5d3ef7cda994 3 days ago 647MB

Para desplegar esta imagen de Docker en AWS, necesitamos hacerla disponible


para AWS de alguna manera. Una forma de hacerlo es publicarla en Docker Hub,
el cual es el repositorio oficial para las imágenes de Docker (más adelante en este
libro, usaremos el servicio ECR de Amazon para desplegar imágenes de Docker).
Para hacer ello, llamamos a docker login y docker push:
1. Familiarizándonos con AWS 17

docker login
docker push stratospheric/todo-app-v1:latest

El comando de login te pedirá tus credenciales, por lo que necesitas tener una
cuenta en hub.docker.com. El comando push sube la imagen a Docker Hub para
que cualquiera pueda hacer un pull de ella desde allí con este comando:

docker pull stratospheric/todo-app-v1:latest

¡Genial! La aplicación está empaquetada en una imagen de Docker y la imagen


está publicada. Es hora de hablar sobre su despliegue en AWS.

Comenzando con los Recursos de AWS

Como mencionamos anteriormente, estaremos usando AWS CloudFormation


para desplegar alguna infraestructura y finalmente nuestra imagen de Docker
en la nube.

En resumidas cuentas, AWS CloudFormation toma un archivo YAML o JSON


como entrada y provisiona todos los recursos listados en ese archivo en la nube.
De esta manera, podemos poner en funcionamiento toda una red con balancea-
dores de carga, clústeres de aplicaciones, colas, bases de datos y cualquier otra
cosa que podamos necesitar.

Prácticamente todos los servicios de AWS proporcionan algunos recursos que


podemos provisionar con AWS CloudFormation. Casi todo lo que puedes hacer
a través de la interfaz web de AWS (llamada Consola de AWS), también puedes
hacerlo con AWS CloudFormation. Los documentos proporcionan una lista de
los recursos de AWS CloudFormation disponibles.

La ventaja de esto es clara: Con AWS CloudFormation, podemos automatizar lo


que de otro modo tendríamos que hacer manualmente.
1. Familiarizándonos con AWS 18

Echemos un vistazo a lo que vamos a desplegar en este capítulo:

Estamos desplegando un cluster ECS dentro de una subred pública en una nube privada virtual.

Para desplegar nuestra aplicación Todo, estamos comenzando con solo algunos
recursos para no sentirnos abrumados. Estamos desplegando los siguientes
recursos:

Una Nube Privada Virtual (VPC) es la base para muchos otros recursos que
desplegamos. Crea una red virtual que es accesible solo para nosotros y nuestros
recursos.

Una VPC contiene subredes públicas y privadas. Una subred pública es accesible
desde internet, una subred privada no lo es. En nuestro caso, desplegamos solo
una subred pública. Para despliegues de producción, normalmente desplega-
ríamos al menos dos subredes, cada una en una zona de disponibilidad (AZ)
diferente para una mayor disponibilidad.

Para hacer pública una subred, necesitamos un Internet Gateway. Un Internet


Gateway permite el tráfico saliente de los recursos en una subred pública a
1. Familiarizándonos con AWS 19

internet. También realiza Network Address Translation (NAT) para dirigir el


tráfico entrante de internet a los recursos en una subred pública.

No estar conectado a un Internet Gateway hace que una subred sea privada.

En nuestra subred pública, desplegamos un cluster ECS. ECS (Elastic Container


Service) es un servicio de AWS que automatiza gran parte del trabajo para
desplegar imágenes Docker.

Dentro de un cluster ECS, podemos definir uno o más servicios diferentes que
queremos ejecutar. Para cada servicio, podemos definir un task. Un task está
respaldado por una imagen Docker. Podemos decidir cuántas instancias de
cada task queremos ejecutar y ECS se encarga de mantener vivas siempre esa
cantidad de instancias.

Si el health check de una de nuestras instancias de aplicación (es decir, ins-


tancias de task) falla, ECS automáticamente matará esa instancia y reiniciará
una nueva. Si queremos desplegar una nueva versión de la imagen Docker, le
damos a ECS la URL de la nueva imagen Docker y automáticamente realizará
un despliegue en marcha, manteniendo al menos una instancia viva en todo
momento hasta que todas las instancias antiguas hayan sido reemplazadas por
otras nuevas.

¡Manos a la obra y echemos un vistazo a los archivos que describen esta infraes-
tructura!

Inspeccionando las Plantillas de CloudFormation

Puedes encontrar las plantillas de CloudFormation en la carpeta cloudformation


en GitHub.

En esa carpeta, tenemos dos archivos YAML - network.yml y service.yml - así


1. Familiarizándonos con AWS 20

como dos scripts de shell - create.sh y delete.sh.

Los archivos YAML son las plantillas de CloudFormation que describen los recur-
sos que queremos desplegar. Los scripts de shell envuelven algunas llamadas al
CLI de AWS para crear (es decir, desplegar) y eliminar (es decir, destruir) los
recursos descritos en esos archivos. network.yml describe la infraestructura de
red básica que necesitamos, y service.yml describe la aplicación que queremos
ejecutar en esa red.

Antes de mirar los archivos de CloudFormation, necesitamos discutir el concep-


to de “stacks”.

Un stack es la unidad de trabajo de CloudFormation. No podemos crear recursos


individuales con CloudFormation a menos que estén envueltos en un stack.

Un archivo YAML (o archivo JSON, si prefieres perseguir corchetes que cerrar


espacios) siempre describe los recursos de un stack. Usando el CLI de AWS,
podemos interactuar con este stack creándola, eliminándola o modificándola.

CloudFormation resolverá automáticamente las dependencias entre los recur-


sos definidos en un stack. Si definimos una subred y una VPC, por ejemplo,
CloudFormation creará la VPC antes que la subred, porque una subred siempre
se refiere a una VPC específica. Al eliminar un stack, eliminará automáticamen-
te la subred antes de eliminar la VPC.

La Pila de Red

Con los conceptos básicos de CloudFormation en mente, echemos un vistazo a


las primeras líneas de la pila de red definida en network.yml:
1. Familiarizándonos con AWS 21

AWSTemplateFormatVersion: '2010-09-09'
Description: A basic network stack that creates a VPC with a single public subnet
and some ECS resources that we need to start a Docker container
within this subnet.
Resources:
...

Un archivo de pila siempre se refiere a una versión de la sintaxis de CloudForma-


tion. La última versión es de 2010. Es difícil creer que no ha cambiado durante
más de 10 años, pero la sintaxis es bastante simple, como veremos en breve, por
lo que tiene sentido que sea estable.

A continuación, se encuentra una descripción de la pila y luego una gran sección


con la clave Resources que describe los recursos que queremos desplegar en
esta pila.

En la pila de red, queremos desplegar los recursos básicos que necesitamos para
desplegar nuestra aplicación Todo. Eso significa que queremos desplegar un VPC
con una subred pública y una puerta de enlace a internet para hacer esa subred
accesible desde el exterior. Además, queremos un clúster de ECS donde luego
podremos cargar nuestra imagen Docker.

El primer recurso que definimos dentro del bloque Resources es el VPC:

VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: '10.0.0.0/16'

Podemos elegir la clave VPC como mejor nos parezca. Podemos referenciar el
recurso por este nombre más tarde en la plantilla.

Un recurso siempre tiene un Type. Hay una variedad de diferentes tipos de


recursos disponibles ya que casi todos los servicios de AWS nos permiten crear
1. Familiarizándonos con AWS 22

recursos a través de CloudFormation. En nuestro caso, queremos desplegar un


VPC - una nube privada virtual en la que colocamos todos los demás recursos.

Luego, un recurso puede necesitar algunas Properties para funcionar. La


mayoría de los recursos necesitan propiedades. Para saber qué propiedades
están disponibles, consulte la documentación de referencia del recurso con el
que desea trabajar. La forma más fácil de llegar allí es buscando en Google
“cloudformation <nombre del recurso>”. La documentación no siempre es clara
acerca de qué propiedades son necesarias y cuáles son opcionales, por lo que
puede requerir algo de ensayo y error al trabajar con un nuevo recurso.

En el caso de nuestro VPC, solo definimos la propiedad CidrBlock que define el


rango de direcciones IP disponibles para cualquier recurso dentro del VPC que
necesite una dirección IP. El valor 10.0.0.0/16 significa que estamos creando
una red con un rango de direcciones IP desde 10.0.0.0 hasta 10.0.255.255 (los
16 bits iniciales 10.0 están fijos, el resto es libre de usar).

Podríamos desplegar la pila de CloudFormation con solo este recurso, pero ne-
cesitamos más infraestructura para desplegar nuestra aplicación. Aquí hay una
lista de todos los recursos que desplegamos con una breve descripción para cada
uno. Puedes buscarlos en el archivo network.yml para ver su configuración:

• PublicSubnet: Una subred pública en una de las zonas de disponibilidad


de la región en la que estamos desplegando. Hacemos pública esta subred
al establecer MapPublicIpOnLaunch en true y adjuntándola a una puerta de
enlace a Internet.
• InternetGateway: Una puerta de enlace a Internet para permitir el tráfico
entrante desde Internet a los recursos en nuestra subred pública y el tráfico
saliente desde la subred a Internet.
• GatewayAttachment: Este recurso de tipo VpcGatewayAttachment une
nuestra subred a la puerta de enlace a Internet, haciéndola efectivamente
1. Familiarizándonos con AWS 23

pública.
• PublicRouteTable: Una RouteTable para definir rutas entre la puerta de
enlace a Internet y la subred pública.
• PublicSubnetRouteTableAssociation: Algunos códigos estándar para
vincular la tabla de rutas con nuestra subred pública.
• PublicRoute: La ruta real que le dice a AWS que queremos permitir el
tráfico desde nuestra puerta de enlace a Internet a cualquier dirección IP
dentro de nuestra subred pública.
• ECSCluster: Un contenedor para ejecutar tareas de ECS. Desplegaremos
una tarea de ECS con nuestra imagen Docker más adelante en la pila de
servicios (service.yml).
• ECSSecurityGroup: Un grupo de seguridad que podemos usar más tarde
para permitir el tráfico a las tareas de ECS (es decir, a nuestro contenedor
Docker). Nos referiremos a este grupo de seguridad más tarde en la pila de
servicios (service.yml)
• ECSSecurityGroupIngressFromAnywhere: Una regla de grupo de seguridad
que permite el tráfico desde cualquier lugar a cualquier recurso adjunto a
nuestro ECSSecurityGroup.
• ECSRole: Un rol que otorga algunos permisos al principal ecs-service.
Estamos otorgando al servicio ECS algunos permisos para modificar ele-
mentos de red por nosotros.
• ECSTaskExecutionRole: Un rol que otorga algunos permisos al principal
ecs-tasks. Este rol dará a nuestras tareas de ECS permisos para escribir
eventos de registro, por ejemplo.

Son bastantes recursos los que necesitamos conocer y configurar. Crear planti-
llas de CloudFormation rápidamente se convierte en una maratón de ensayo y
error hasta que lo configuras justo para tu caso de uso. Más adelante en el libro,
echaremos un vistazo al AWS Cloud Development Kit (CDK) que nos quita algo
1. Familiarizándonos con AWS 24

de ese trabajo de encima.

En caso de que te hayas preguntado acerca de la sintaxis especial utilizada en


algunos lugares del archivo YAML, repasémosla rápidamente:

• Fn::Select / !Select: Nos permite seleccionar un elemento de una lista de


elementos. Lo usamos para seleccionar la primera zona de disponibilidad de
la región en la que estamos trabajando.
• Fn::GetAZs / !GetAZs: Nos da una lista de todas las zonas de disponibilidad
en una región.
• Fn::Ref / !Ref: Nos permite referenciar a otro recurso por el nombre que
le hemos dado.
• Fn::Join / !Join: Une una lista de cadenas a una sola cadena, con un
delimitador dado entre cada una.

• Fn::GetAtt / !GetAtt: Resuelve un atributo de un recurso que hemos


definido.

Todas las funciones tienen una forma larga (Fn::...) y una forma corta (!...)
que se comportan igual pero se ven un poco diferentes en YAML. En resumen,
podemos usar la forma corta para expresiones de una sola línea y la forma larga
para expresiones más largas que podríamos querer dividir en varias líneas.

Finalmente, al final de network.yml, vemos una sección de Outputs:


1. Familiarizándonos con AWS 25

Outputs:
ClusterName:
Description: The name of the ECS cluster
Value: !Ref 'ECSCluster'
Export:
Name: !Join [ ':', [ !Ref 'AWS::StackName', 'ClusterName' ] ]
... (more outputs)

Cada resultado describe un parámetro que queremos exportar de la pila para ser
utilizado en otras pilas.

Por ejemplo, exportamos el nombre del ECS Cluster bajo el nombre <NETWORK_-
STACK_NAME>:ClusterName. En otras pilas, como nuestra pila de servicio, ahora
solo necesitamos conocer el nombre de la pila de red para acceder a todos sus
parámetros de salida.

Echemos un vistazo a la pila de servicio ahora para ver cómo desplegamos


nuestra aplicación.

La Pila de Servicio

La pila de servicio se define en service.yml. La llamamos “pila de servicio”


porque describe una tarea ECS y un servicio ECS que inicia contenedores Docker
y realiza ciertas operaciones para hacerlos disponibles en Internet.

A diferencia de la pila de red, la pila de servicio comienza con una sección


Parámetros:
1. Familiarizándonos con AWS 26

AWSTemplateFormatVersion: '2010-09-09'
Description: Deploys a Docker container within a previously created VPC.
Requires a running network stack.
Parameters:
NetworkStackName:
Type: String
Description: The name of the networking stack that
these resources are put into.
ServiceName:
Type: String
Description: A human-readable name for the service.
ImageUrl:
Type: String
Description: The url of a docker image that will handle incoming traffic.
ContainerPort:
Type: Number
Default: 80
Description: The port number the application inside the docker container
is binding to.
ContainerCpu:
Type: Number
Default: 256
Description: How much CPU to give the container. 1024 is 1 CPU.
ContainerMemory:
Type: Number
Default: 512
Description: How much memory in megabytes to give the container.
DesiredCount:
Type: Number
Default: 1
Description: How many copies of the service task to run.
...

Dentro de la sección Parameters, podemos definir parámetros de entrada para


un stack. Enviamos el nombre de un stack de red existente, por ejemplo, para
que podamos referirnos a sus parámetros de salida. También, proporcionamos
una URL que apunta a la imagen Docker que queremos implementar y cualquier
otra información que quisiéramos cambiar de un despliegue a otro.

El stack de servicio despliega tres recursos:


1. Familiarizándonos con AWS 27

• LogGroup: Un contenedor para los registros de nuestra aplicación.


• TaskDefinition: La definición para una tarea ECS. La tarea descargará una
o más imágenes Docker de las URLs y las ejecutará.
• Service: Un servicio ECS que proporciona cierta lógica en torno a una
definición de tarea, como cuántas instancias deben ejecutarse en paralelo y
si se les debe asignar direcciones IP públicas.

En varias instancias, observarás referencias a las salidas del stack de red como
esta:

Fn::ImportValue:
!Join [':', [!Ref 'NetworkStackName', 'ClusterName']]

Fn:ImportValue importa un valor de salida exportado por otro stack. Como


hemos incluido el nombre del stack de red en el nombre de sus salidas, necesi-
tamos unir el nombre del stack de la red con el nombre del parámetro de salida
para obtener el valor correcto.

Así que, hemos visto más de 200 líneas de configuración YAML que describen
la infraestructura que queremos desplegar. Más tarde, veremos cómo usar CDK
para lograr esto en Java en lugar de en YAML, lo cual lo hace más reutilizable y
más fácil de manejar en general.

Inspeccionando los Scripts de Despliegue

¡Vamos a desplegar nuestra aplicación en la nube! Necesitaremos los scripts


create.sh y delete.sh de la carpeta cloudformation en el repositorio de
GitHub.

Adelante, ejecute el script create.sh ahora. Mientras espera a que el script


termine (puede tardar un par de minutos), echaremos un vistazo al script en
1. Familiarizándonos con AWS 28

sí.

El script comienza con la llamada a aws cloudformation create-stack para


crear el stack de red:

aws cloudformation create-stack \


--stack-name stratospheric-ecs-basic-network \
--template-body file://network.yml \
--capabilities CAPABILITY_IAM

aws cloudformation wait stack-create-complete \


--stack-name stratospheric-ecs-basic-network

Estamos pasando el nombre para el stack, la ruta hacia nuestra plantilla de stack
network.yml, y la capacidad CAPABILITY_IAM para autorizar al stack a realizar
cambios en los roles de IAM (Identidad y Gestión de Acceso).

Dado que el comando create-stack se ejecuta de manera asíncrona, llamamos


a aws cloudformation wait stack-create-complete después para esperar
hasta que el stack esté en funcionamiento.

A continuación, estamos haciendo lo mismo para el stack de servicio:

aws cloudformation create-stack \


--stack-name stratospheric-ecs-basic-service \
--template-body file://service.yml \
--parameters \
ParameterKey=NetworkStackName,ParameterValue=stratospheric-ecs-basic-network \
ParameterKey=ServiceName,ParameterValue=todo-app-v1 \
ParameterKey=ImageUrl,ParameterValue=docker.io/stratospheric/todo-app-v1:latest \
ParameterKey=ContainerPort,ParameterValue=8080

aws cloudformation wait stack-create-complete \


--stack-name stratospheric-ecs-basic-service

Con --parameters, estamos introduciendo todos los parámetros que desea-


mos que sean diferentes de los valores predeterminados. Específicamente, es-
tamos introduciendo docker.io/stratospheric/todo-app-v1:latest en el
1. Familiarizándonos con AWS 29

parámetro ImageUrl para indicarle a AWS que descargue nuestra imagen de


Docker y la ejecute.

Después de que ambos stacks de tecnología estén funcionando, estamos em-


pleando algunos trucos de la línea de comandos de AWS para extraer la dirección
IP pública de la aplicación que está funcionando:

CLUSTER_NAME=$(
aws cloudformation describe-stacks \
--stack-name stratospheric-ecs-basic-network \
--output text \
--query 'Stacks[0].Outputs[?OutputKey==`ClusterName`].OutputValue | [0]'
)
echo "ECS Cluster: " $CLUSTER_NAME

TASK_ARN=$(
aws ecs list-tasks \
--cluster $CLUSTER_NAME \
--output text --query 'taskArns[0]'
)
echo "ECS Task: " $TASK_ARN

ENI_ID=$(
aws ecs describe-tasks \
--cluster $CLUSTER_NAME \
--tasks $TASK_ARN \
--output text \
--query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value'
)
echo "Network Interface: " $ENI_ID

PUBLIC_IP=$(
aws ec2 describe-network-interfaces \
--network-interface-ids $ENI_ID \
--output text \
--query 'NetworkInterfaces[0].Association.PublicIp'
)
echo "Public IP: " $PUBLIC_IP

echo "You can access your service at http://$PUBLIC_IP:8080"


1. Familiarizándonos con AWS 30

Estamos usando diferentes comandos de AWS para obtener la información que


queremos. Primero, mostramos la pila de red y extraemos el nombre del clúster
de ECS. Con el nombre del clúster, obtenemos el ARN (Amazon Resource Name)
de la tarea de ECS. Con el ARN de la tarea, obtenemos la ID de la interfaz de red
de esa tarea. Y con la ID de la interfaz de red, finalmente obtenemos la dirección
IP pública de la aplicación para saber a dónde ir.

Todos los comandos utilizan la CLI de AWS para mostrar los resultados como
text y extraemos cierta información de ese texto con el parámetro --query.

La salida del script debería parecerse a esto:

StackId: arn:aws:cloudformation:.../stratospheric-ecs-basic-network/...
StackId: arn:aws:cloudformation:.../stratospheric-ecs-basic-service/...
ECS Cluster: stratospheric-ecs-basic-network-ECSCluster-qqX6Swdw54PP
ECS Task: arn:aws:ecs:.../stratospheric-ecs-basic-network-...
Network Interface: eni-02c096ce1faa5ecb9
Public IP: 13.55.30.162
You can access your service at http://13.55.30.162:8080

Continúa y copia la URL final en tu navegador y deberías ver el texto “Bienvenido


a la aplicación Todo” en tu pantalla.

¡Hurra! Acabamos de desplegar una aplicación y toda la infraestructura que ne-


cesita en la nube con un solo comando de CLI! Vamos a aprovechar eso más tarde
para crear un pipeline de despliegue continuo completamente automatizado.

Pero primero, inspeccionemos la infraestructura y la aplicación que hemos


desplegado.

Inspeccionando la Consola AWS

La consola AWS es el panel de control para todas las cosas AWS. Con nuestro
navegador, podemos ver el estado de todos los recursos que estamos utilizando,
1. Familiarizándonos con AWS 31

interactuar con ellos y aprovisionar nuevos recursos.

Podríamos haber hecho manualmente en la consola AWS todo lo que hemos


codificado en las plantillas de CloudFormation anteriores. Pero la configuración
manual de la infraestructura es propensa a errores y no es repetible, así que no
vamos a mirar cómo hacer eso.

Sin embargo, la consola AWS es un buen lugar para ver los recursos que hemos
desplegado, para comprobar su estado, y para iniciar la depuración si lo necesi-
tamos.

Continúa e inicia sesión en la consola AWS y hagamos un rápido recorrido!

Después de iniciar sesión, escribe “CloudFormation” en el cuadro “Buscar


Servicios” y selecciona el servicio CloudFormation.

Debería ver una lista de sus pilas de CloudFormation con un estado para cada
una. La lista debería contener al menos las pilas stratospheric-ecs-basic-
service y stratospheric-ecs-basic-network en estado CREATE_COMPLETE.
Haga clic en la pila de red.

En la vista detallada de una pila, obtenemos mucha información sobre la pila.


Haga clic en la pestaña “Eventos” primero.

Aquí, vemos una lista de eventos para esta pila. Cada evento es un cambio de
estado de uno de los recursos de la pila. Podemos ver el historial de eventos:
Al principio, una serie de recursos estaban en estado CREATE_IN_PROGRESS y
pasaron al estado CREATE_COMPLETE unos segundos después. Luego, cuando
los recursos de los que dependen están listos, otros recursos comenzaron su
vida de la misma manera. Y así continua. CloudFormation se encarga de las
dependencias entre recursos y los crea y elimina en la secuencia correcta.

La pestaña “Eventos” es el lugar al que ir cuando la creación de una pila falla por
alguna razón. Mostrará qué recurso falló y (usualmente) mostrará un mensaje
1. Familiarizándonos con AWS 32

de error que nos ayuda a depurar el problema.

Pasemos a la pestaña “Recursos”. Nos muestra una lista de los recursos de la


pila de red. La lista muestra todos los recursos que hemos incluido en la plantilla
de CloudFormation network.yml:

Para algunos recursos, obtenemos un enlace al recurso en la columna “Physical


ID”. Haga clic en el ID del recurso ECSCluster para echar un vistazo a nuestra
aplicación.

El enlace nos ha llevado a la consola del servicio ECS. También podemos llegar
aquí abriendo el menú desplegable “Servicios” en la parte superior de la página
y escribiendo “ECS” en el cuadro de búsqueda.

La vista detallada de nuestro cluster ECS muestra que tenemos 1 servicio y 1 tarea
ejecutándose en este cluster. Si hacemos clic en la pestaña “Tareas”, veremos
una lista de tareas en ejecución, que debería contener una sola entrada. Haga
clic en el enlace en la columna “Tarea” para obtener una vista detallada de la
tarea.

La vista detallada muestra mucha información que no nos interesa, pero tam-
bién muestra la dirección IP pública de la tarea. Esta es la dirección IP que
extrajimos mediante comandos de AWS CLI anteriormente. Puede copiarla en
su navegador, añadir el puerto 8080, y debería ver el mensaje de bienvenida de
nuevo.

Debajo de la información general hay una sección llamada “Contenedores”, que


muestra el contenedor que hemos desplegado con esta tarea. Haga clic en la
pequeña flecha a la izquierda para expandirla. En la sección “Configuración de
Log”, haga clic en el enlace “Ver logs en CloudWatch”.

CloudWatch es el servicio de Amazon para monitorear aplicaciones. En nuestra


pila de servicio, agregamos un recurso “Grupo de Logs” y usamos el nombre de
1. Familiarizándonos con AWS 33

ese grupo de logs en la configuración de logging de la definición del contenedor.


Esta es la razón por la que ahora podemos ver los logs de esa aplicación en
CloudWatch.

Después de la pestaña “Eventos” en la UI de CloudFormation, los logs son el


segundo lugar a mirar cuando (no si) algo va mal.

Esto concluye nuestro primer experimento con AWS. Siéntase libre de explorar
un poco más la consola AWS para familiarizarse con cómo funciona todo. Entra-
remos en más detalle sobre diferentes servicios en el resto de este libro.

Cuando haya terminado, no olvide ejecutar delete.sh para eliminar de nuevo


las pilas, de lo contrario, empezarán a generar costos en algún momento.
También puede eliminar las pilas a través de la UI de CloudFormation.
2. Una visión general de los servicios de
AWS
A lo largo de este libro, utilizaremos una variedad de servicios de AWS. Depen-
diendo de cómo se cuente, en el momento de la escritura, AWS ofrece entre 150 y
300 servicios diferentes para informática, almacenamiento de archivos, redes y
acceso y gestión de bases de datos, y una serie de otros casos de uso. Ese número
parece estar aumentando cada día.

Los servicios que elegimos incluir en este libro y la aplicación de muestra que
vamos a desarrollar solo pueden representar una pequeña sección de lo que AWS
tiene para ofrecer. Para algunas áreas, a veces existen servicios competidores o
aparentemente superpuestos. A veces, estos solo podrían diferir en términos de
requisitos específicos o incluso simplemente matices.

Por lo tanto, nuestra selección de servicios tiene que ser una elección opinada.
Elegimos aquellos servicios de AWS que cumplen con los requisitos de nuestra
aplicación de muestra. En caso de que hubiera una elección entre dos servicios
competidores, elegimos el más común para proporcionar más valor para el
lector.

Alguien más podría llegar a una selección diferente de servicios, y eso es perfec-
tamente aceptable. Cuando sea apropiado, estaremos discutiendo alternativas
a los servicios presentados.

Creemos que los servicios de AWS que vamos a cubrir en este libro son adecua-
dos para abordar una amplia gama de casos de uso comunes de aplicaciones web.
2. Una visión general de los servicios de AWS 35

Esto, con suerte, le permitirá poner el contenido de este libro a buen uso en el
contexto más amplio del desarrollo de aplicaciones web.

Echemos un vistazo muy rápido a cada uno de los servicios de AWS de los
que vamos a hablar en este libro para dar un poco de contexto a los próximos
capítulos.

AWS CloudFormation

Con CloudFormation, podemos describir todos los recursos de infraestructura


que necesitamos en un archivo JSON o YAML y CloudFormation aprovisionará
esos recursos para nosotros. CloudFormation es la base para automatizar el
despliegue de aplicaciones en AWS.

Ya hemos utilizado CloudFormation en el capítulo Entrando en calor con AWS para


desplegar una aplicación “Hello World”.

Además de para familiarizarnos con AWS, no utilizaremos CloudFormation


directamente más en este libro. En su lugar, utilizaremos CDK (Cloud Develop-
ment Kit) para describir e desplegar nuestra infraestructura. Como CDK se basa
en los recursos de CloudFormation, aprenderemos un poco sobre CloudForma-
tion de todos modos.

AWS Cloud Development Kit (CDK)

CDK se basa en CloudFormation y nos permite describir los recursos de Cloud-


Formation que queremos desplegar en un lenguaje de programación como Java
o TypeScript. De esta manera, tenemos una verdadera solución de “infraestruc-
tura como código” y ya no tenemos que manejar archivos de CloudFormation
en YAML o JSON.
2. Una visión general de los servicios de AWS 36

Introduciremos CDK en el capítulo Primeros pasos con CDK, aprenderemos más


sobre él en el capítulo Diseñando un proyecto de despliegue con CDK, y agregaremos
más y más recursos a nuestro proyecto CDK durante el resto de este libro. El
objetivo es desplegar nuestra aplicación de muestra y la infraestructura que
necesita con solo unos pocos comandos.

Amazon CloudWatch

Amazon CloudWatch es el servicio principal de observabilidad de Amazon. Con


un servidor de registros, servidor de métricas, paneles y alarmas, ofrece una
amplia gama de características de observabilidad.

Aprenderemos mucho sobre CloudWatch en la Parte III del libro, específica-


mente en los capítulos Registro estructurado con Amazon CloudWatch, Métricas
con Amazon CloudWatch, Alertas con Amazon CloudWatch, y Monitoreo sintético con
Amazon CloudWatch.

Amazon Cognito

Amazon Cognito proporciona capacidades de gestión de usuarios. Podemos


construir características de autenticación y autorización de usuarios contra él.
Entre otras características, proporciona interfaces para crear inicios de sesión
sociales e inicios de sesión personalizados a través de OAuth.

Examinaremos Cognito con más detalle en el capítulo Construyendo el registro de


usuarios e inicio de sesión con Cognito donde estaremos agregando características
de registro e inicio de sesión de usuarios a nuestra aplicación de muestra.
2. Una visión general de los servicios de AWS 37

Amazon DynamoDB

DynamoDB es la solución NoSQL de Amazon que promete “rendimiento a


cualquier escala”. Se puede utilizar como almacenamiento de clave-valor o
como almacenamiento de documentos para miles de millones de conjuntos de
datos si es necesario.

En el capítulo Rastreando acciones de usuario con DynamoDB, utilizaremos Dyna-


moDB para mantener documentos relativamente no estructurados, de forma
libre, además de nuestro modelo de datos relacional.

Amazon Elastic Compute Cloud (EC2)

Uno de los servicios de AWS más antiguos y originalmente disponibles, EC2


proporciona máquinas virtuales que podemos utilizar para cualquier tarea de
cálculo que podamos tener. El término “elástico” proviene del hecho de que
podemos obtener tantas instancias de cálculo como queramos y que podemos
hacerlas (casi) tan grandes o pequeñas como queramos que sean.

No vamos a utilizar EC2 directamente en este libro, pero dado que EC2 es el
servicio subyacente para muchos otros servicios de AWS, será mencionado de
vez en cuando. En su lugar, vamos a usar el servicio ECS de un nivel superior
para manejar las instancias de EC2 por nosotros (ver la siguiente sección).

Amazon Elastic Container Registry (ECR)

ECR es el registro de contenedores Docker gestionado por Amazon. Ofrece


funcionalidad para almacenar y administrar imágenes de Docker y se integra
2. Una visión general de los servicios de AWS 38

con ECS.

En el capítulo Diseñando un Proyecto de Despliegue con CDK, aprenderemos cómo


crear un repositorio de ECR y cómo publicar imágenes de Docker de nuestra
aplicación de muestra en este repositorio. Luego, configuraremos ECS para que
tome una de las imágenes publicadas y la despliegue en las instancias de EC2
por nosotros.

Amazon Elastic Container Service (ECS)

ECS es la versión de Amazon de un servicio de orquestación de contenedores


Docker. A grandes rasgos, toma imágenes de Docker como entrada y las desplie-
ga en instancias de EC2 por nosotros. Después, gestiona esta flota de instancias
de EC2 escalando hacia adentro y hacia afuera, eliminando instancias que no
funcionan correctamente, y arrancando nuevas.

Vamos a empaquetar nuestra aplicación en una imagen de Docker y luego


permitiremos que ECS maneje el despliegue de esta imagen y el estado de los
contenedores Docker resultantes. Ya tuvimos un primer vistazo a ECS en el
capítulo Acercándonos a AWS. Profundizaremos más en el capítulo Diseñando un
Proyecto de Despliegue con CDK.

Amazon MQ

Amazon MQ es un servicio que gestiona los brokers de mensajes Apache Acti-


veMQ y RabbitMQ por nosotros.

Vamos a utilizar Amazon MQ en el capítulo Notificaciones Push con Amazon


MQ pero también discutiremos algunos servicios alternativos de AWS como
Amazon SNS, AWS IoT, o una función AWS Lambda.
2. Una visión general de los servicios de AWS 39

Vamos a utilizar un broker de mensajes ActiveMQ como un relevo para las


conexiones WebSocket, ya que esto nos permitirá soportar la mensajería de
publicación-suscripción en entornos con balanceo de carga.

Amazon Relational Database Service (RDS)

RDS es el servicio gestionado de base de datos de Amazon. Se encarga de mane-


jar, ejecutar, y actualizar nuestras instancias de base de datos por nosotros.

Vamos a introducir RDS con una base de datos PostgreSQL para almacenar y
recuperar datos de nuestra aplicación de muestra en el capítulo Conectando a
una Base de Datos con RDS.

Amazon Route 53

Route 53 es un servicio de Sistema de Nombres de Dominio (DNS) en la nube.

Vamos a utilizar Route 53 para redirigir a los usuarios finales a nuestra aplicación
a través de un nombre de dominio fácil de recordar en el capítulo Configurando
HTTPS y un Dominio Personalizado con Route 53 y ELB.

Amazon Simple Email Service (SES)

SES es lo que su nombre sugiere: un servicio sencillo de correo electrónico.


Podemos usarlo para, bueno, enviar correos electrónicos.

Vamos a introducir SES en conjunto con SQS (ver abajo) en el capítulo Compar-
tiendo Tareas con SQS y SES para enviar correos electrónicos cuando un usuario
quiera compartir una tarea con otro usuario.
2. Una visión general de los servicios de AWS 40

Amazon Simple Queue Service (SQS)

SQS es el servicio de colas básico de Amazon. Proporciona una API para enviar y
recibir mensajes a gran escala y ofrece muchas opciones para manejar mensajes
de manera robusta. Es la primera opción si queremos desacoplar aspectos de
nuestra aplicación de manera asíncrona.

Vamos a utilizar SQS para encolar despliegues en el capítulo Construyendo un Pi-


peline de Despliegue Continuo y para encolar notificaciones por correo electrónico
en el capítulo Compartiendo Tareas con SQS y SES.

Amazon Simple Storage Service (S3)

S3 es otro de los servicios más utilizados de Amazon. S3 proporciona almacena-


miento de archivos y objetos a gran escala.

No vamos a utilizar S3 directamente en nuestra aplicación de muestra, pero lo


mencionaremos en el capítulo Monitoreo Sintético con Amazon CloudWatch, donde
se utiliza para subir capturas de pantalla.

Amazon Virtual Private Cloud (VPC)

Un VPC es una red virtual que puede contener otros recursos de AWS. Con un
VPC, podemos establecer los límites de la red entre el internet público y nuestra
propia infraestructura. Incluso podemos establecer límites dentro de nuestra
infraestructura.

Utilizamos el VPC para aislar nuestros recursos en su propia red privada de


IP. Dentro de esa red, los servidores pueden comunicarse entre sí y acceder
2. Una visión general de los servicios de AWS 41

a recursos, mientras que la aplicación solo estará disponible públicamente a


través de un único punto de entrada. Esto no solo mitiga posibles problemas
de seguridad sino que también simplifica enormemente el acceso a recursos.

Ya aprendimos un poco sobre VPC en el capítulo Acercándonos a AWS y profundi-


zaremos más en el capítulo Diseñando un Proyecto de Despliegue con CDK.

AWS Certificate Manager

El Certificate Manager nos permite gestionar los certificados públicos y privados


de Capa de Conexión Segura/Capa de Seguridad de Transporte (SSL/TLS) para su
uso con servicios y recursos de AWS.

En el capítulo Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB,


prepararemos y desplegaremos un certificado SSL para el nombre de dominio
que utilizaremos para nuestra aplicación a través del AWS Certificate Manager.

AWS Identity and Access Management (IAM)

IAM proporciona mecanismos para administrar usuarios y grupos de usuarios


y permisos para esos usuarios y grupos. Está entrelazado con todos los demás
servicios de AWS. Podemos usar IAM para conceder y denegar acceso a los
recursos que un servicio proporciona.

Presentaremos IAM en el capítulo Gestionando Permisos con IAM y lo desarrollare-


mos en otros capítulos a lo largo del libro cada vez que estemos creando recursos
que requieran autorización.
2. Una visión general de los servicios de AWS 42

AWS Lambda

Las Lambdas son la solución de Amazon para la computación sin servidor. En


lugar de desplegar una aplicación completa que ocupa un servidor (virtual) en
EC2, podemos desplegar una única función que se ejecuta bajo demanda. En
lugar de pagar un servidor por hora, pagamos por invocación de la función
Lambda.

Usaremos AWS Lambda (y Amazon SQS) para organizar en cola las implemen-
taciones en el capítulo Construyendo un Pipeline de Despliegue Continuo.

AWS Secrets Manager

AWS Secrets Manager es un servicio enfocado en el almacenamiento seguro y


recuperación de secretos. También proporciona características para mejorar la
seguridad rotando los secretos.

Durante el despliegue de las pilas de CloudFormation, haremos uso del Secrets


Manager para crear y recuperar secretos de conexión a la base de datos en el
capítulo Conectando a una Base de Datos con RDS. De esta manera, la contraseña
de la base de datos nunca saldrá de los servidores de AWS.

AWS Systems Manager (SSM)

AWS Systems Manager es un servicio que ayuda a operar un gran número de


aplicaciones en la nube de AWS. Además de algunos otros aspectos, proporciona
características de gestión de configuración, características de cumplimiento, e
inventario.
2. Una visión general de los servicios de AWS 43

Presentaremos la característica “almacén de parámetros” de SSM en el capí-


tulo Diseñando un Proyecto de Despliegue con CDK. El almacén de parámetros
nos permite almacenar y recuperar parámetros de configuración. Usaremos
esto para almacenar algunos parámetros con cada pila de CloudFormation que
despleguemos. Cuando despleguemos otra pila que dependa de la primera,
cargaremos los parámetros nuevamente desde el almacén, para no tener que
pasar manualmente estos parámetros.

Elastic Load Balancing (ELB)

Elastic Load Balancing es un servicio de balanceo de carga que nos permite


enrutar y distribuir el tráfico según un conjunto de reglas.

En lugar de exponer directamente nuestros servicios de aplicación a nuestros


usuarios, en Diseñando un Proyecto de Despliegue con CDK haremos uso de un
Application Load Balancer (ALB) proporcionado a través de ELB para gestionar
la carga de trabajo de nuestra aplicación.

En el capítulo Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB,


también utilizaremos una regla personalizada para redirigir de HTTP a HTTPS.
3. Gestión de Permisos con IAM
Al desplegar aplicaciones en un servicio en la nube como AWS, los conceptos
de seguridad confiables son clave. Después de todo, no solo queremos proteger
los datos de nuestros usuarios, sino también asegurarnos de que la seguridad
dentro de nuestra organización no se vea comprometida.

Con AWS Identity and Access Management (IAM), podemos abordar estas preo-
cupaciones para nuestras aplicaciones en la nube y en el contexto más amplio
de la seguridad y la gestión de accesos dentro de una organización.

Utilizar los servicios de AWS requiere tanto autenticación como los permisos
apropiados (o: privilegios) para acceder a un recurso en particular.

En este capítulo, echaremos un vistazo a los conceptos básicos de gestión


de accesos y cómo se implementan con IAM. Daremos una visión general de
la terminología, herramientas y técnicas de IAM. También esbozaremos las
mejores prácticas para gestionar permisos con AWS IAM. Finalmente, nos
sumergiremos nuevamente en nuestra aplicación de ejemplo y veremos cómo
funciona IAM en el contexto de desplegar y ejecutar una aplicación web.

A medida que continuamos desarrollando nuestra aplicación de ejemplo Todo a


lo largo de este libro, ocasionalmente necesitaremos agregar nuevos permisos
para acceder a recursos o servicios de AWS específicos. Este capítulo te equipará
con el conocimiento para hacerlo.

Si ya estás familiarizado con AWS IAM y tu cuenta de AWS ya está configurada


de acuerdo con las pautas establecidas en la documentación de AWS puedes
saltarte hasta la sección Definición de Políticas.
3. Gestión de Permisos con IAM 45

Usuarios, Grupos y Roles

Las listas de control de acceso (ACL) son un enfoque probado y verdadero para
otorgar y gestionar el acceso a los recursos. Las ACL esencialmente responden la
pregunta “¿Quién tiene permiso para acceder a un recurso específico - y en qué
medida - en un momento específico?”. Esta respuesta puede darse de muchas
maneras, siendo la más simple un solo usuario con permisos específicos para
acceder a un recurso específico.

Sin embargo, basar el control de acceso únicamente en usuarios específicos no


es particularmente escalable porque tarde o temprano tal enfoque está destina-
do a crear mucha duplicación. Si, por ejemplo, otorgamos permisos de lectura
a un archivo por usuario pero más tarde decidimos que queremos retirar esos
permisos nuevamente, tenemos que hacerlo para cada usuario individualmente.
No solo esto implica trabajo adicional, sino que también es un proceso propenso
a errores porque puede que se hayan omitido uno o dos usuarios y por lo tanto
aún tengan el permiso obsoleto.

Para aliviar este problema, podemos conceder privilegios en función del grupo
al que pertenece un usuario o del rol que asume en un momento dado. Mientras
que los grupos representan un conjunto de usuarios, los roles combinan un con-
junto de permisos que son necesarios para cumplir una determinada función.

En seguridad informática, una entidad abstracta que puede ser autenticada a


menudo se conoce como un principal.

Este diagrama ofrece una visión general de cómo las principales entidades
dentro de IAM se relacionan entre sí:
3. Gestión de Permisos con IAM 46

Los permisos son el concepto central de IAM. Se pueden otorgar a usuarios y aplicaciones de
varias formas.

En el contexto de AWS e IAM, un principal puede ser una persona o una


aplicación identificada ya sea por credenciales de usuario o por un rol asociado.
Los usuarios son individuos con una identidad y credenciales (generalmente
nombre de usuario y contraseña), mientras que los roles no tienen credenciales
pero normalmente son asumidos temporalmente por aplicaciones o usuarios
ya autenticados. Por lo tanto, los roles nos permiten asignar permisos sin
almacenar las credenciales de usuario de AWS con una aplicación. Los roles tam-
bién permiten a los usuarios de AWS ya autenticados cambiar entre diferentes
conjuntos de permisos en función de su tarea o contexto actual (piensa en un
administrador de sistemas que trabaja para varios departamentos dentro de una
organización, por ejemplo).

Permitir o restringir el acceso a un recurso es un proceso de dos partes que


3. Gestión de Permisos con IAM 47

consiste en autenticación y autorización subsiguiente. Primero, un principal


necesita identificarse a través de la autenticación. La forma más común de
hacerlo es proporcionando tanto el nombre de usuario como la contraseña.
Sin embargo, ten en cuenta que con IAM tanto los usuarios como los roles
pueden ser principals, por lo que una combinación válida de nombre de usua-
rio/contraseña es solo un método de autenticación. Una vez autenticado con
éxito, un principal puede acceder a los recursos a los que está autorizado. Para
cada solicitud, AWS comprobará si al principal se le permite realizar la acción
solicitada en el recurso solicitado, según lo dado por el Nombre de Recurso de
Amazon (ARN) del recurso.

En la siguiente sección, veremos cómo podemos poner estos conceptos en


práctica con IAM.

Usuarios Root vs. Usuarios Regulares

Como nuevo usuario de AWS, el primer encuentro con IAM ocurre inmediata-
mente después del registro.

Al registrarte para tu cuenta de AWS también estás creando tu primer principal


de IAM. Este usuario root tendrá privilegios que abarcan todo para cada recurso
bajo tu cuenta de AWS. Por esta razón, nunca debemos usar este usuario root
para el trabajo normal o para acceder a los recursos de AWS. Más bien, su única
responsabilidad es crear nuestro primer usuario y grupo de administradores en
la Consola IAM.

La Consola IAM nos permite administrar usuarios, grupos y políticas de IAM (vea
la sección Definición de Políticas) a través de una aplicación web. Más adelante,
también veremos cómo administrar recursos de manera programática.

La documentación de IAM guiará a través de los pasos iniciales para crear un


3. Gestión de Permisos con IAM 48

usuario administrativo y un grupo administrativo.

También recomendamos crear usuarios no administrativos y un grupo no admi-


nistrativo correspondiente para las personas con las que colabora. Siguiendo
el principio de mínimo privilegio, a estos usuarios individuales solo se les
debe otorgar el privilegio mínimo que cada uno de ellos requiere para hacer su
trabajo.

A diferencia del usuario raíz de la cuenta, los usuarios individuales deberán


proporcionar el ID de cuenta de AWS además de su nombre de usuario y contra-
seña (y posiblemente una contraseña de un solo uso MFA) si está activado para
esa entidad en particular). Para ahorrar a sus usuarios el esfuerzo de tener que
escribir este ID de cuenta cada vez que inician sesión, puedes proporcionarles
este enlace de atajo:

https://account-ID-or-alias.signin.aws.amazon.com/console

Reemplazar account-ID-or-alias en esta URL con tu ID de cuenta llenará auto-


máticamente el campo de entrada correspondiente en el formulario de inicio de
sesión del usuario.

Ahora que hemos configurado tanto los usuarios administrativos como los
normales y sus respectivos grupos, podemos continuar otorgándoles privilegios
para acceder a los recursos de AWS.

Definición de Políticas

Una vez que hemos creado nuestro(s) usuario(s) IAM para nuestro trabajo
diario con AWS, podemos proceder a otorgarles privilegios para los recursos de
AWS. Siguiendo el principio de mínimo privilegio, deberíamos preferir agregar
permisos según sea necesario en lugar de otorgar permisos generales.
3. Gestión de Permisos con IAM 49

Podemos otorgar privilegios adjuntando las llamadas políticas a un usuario, rol


o grupo. Podemos revocar privilegios desanexando las políticas nuevamente.
Para muchos escenarios de acceso comunes, AWS proporciona políticas precon-
figuradas administradas por el propio AWS. Algunas políticas de ejemplo son:

• AmazonEC2FullAccess: Todos los permisos requeridos para crear y admi-


nistrar recursos EC2.
• AmazonSQSReadOnlyAccess: Permisos de solo lectura para recursos SQS.
• SystemAdministrator: Todos los permisos requeridos para tareas comunes
de operaciones.

Si, por ejemplo, queremos que nuestros desarrolladores tengan acceso sin
restricciones a EC2, podemos otorgarles la política AmazonEC2FullAccess. Sin
embargo, vale la pena mencionar que IAM tiene un límite de 10 políticas por
usuario, rol o grupo.

Dependiendo de nuestros casos de uso, podría ser una buena idea crear una
jerarquía de grupos más detallada que tenga en cuenta diferentes roles y espe-
cializaciones. Por ejemplo, algunos de nuestros desarrolladores podrían trabajar
predominantemente en la infraestructura de la base de datos. Esto a su vez
podría justificar la creación de otro grupo “Desarrolladores de Bases de Datos”,
al que se podrían otorgar privilegios AmazonRDSFullAccess (RDS = Servicio de
Base de Datos Relacional). Los desarrolladores que trabajan principalmente en
los interfaces de usuario web, por otro lado, podrían ser asignados a un grupo
de “Desarrolladores de interfaz de usuario” que tiene permisos AmazonCognito-
PowerUser y AmazonS3FullAccess.

Esto nuevamente contribuye a una estrategia de “mínimo privilegio” al otorgar


solo privilegios específicos solo a los grupos que los necesitan.

Además de usar políticas administradas predefinidas, también podemos crear


3. Gestión de Permisos con IAM 50

políticas personalizadas adaptadas a nuestras necesidades. Para esto, la Consola


IAM nos proporciona tanto un editor visual como un editor JSON para componer
nuestras propias políticas. Dado que las políticas de IAM son solo recursos de
AWS ordinarios, pueden representarse como estructuras de datos JSON.

Este ejemplo muestra la representación JSON de la política administrada Ama-


zonEC2FullAccess:

{
"Version": "2012-10-17",
"Statement": [
{
"Action": "ec2:*",
"Effect": "Allow",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "elasticloadbalancing:*",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "cloudwatch:*",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "autoscaling:*",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:CreateServiceLinkedRole",
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:AWSServiceName": [
"autoscaling.amazonaws.com",
"ec2scheduled.amazonaws.com",
"elasticloadbalancing.amazonaws.com",
3. Gestión de Permisos con IAM 51

"spot.amazonaws.com",
"spotfleet.amazonaws.com",
"transitgateway.amazonaws.com"
]
}
}
}
]
}

Las políticas consisten en una colección de Declaraciones, cada una de las


cuales tiene propiedades Action, Effect y Resource, además de una propiedad
opcional Condition.

La propiedad Effect puede tener un valor de Allow o Deny. Por defecto, se niega
el acceso a los recursos. Al establecer Effect a Allow, estamos otorgando acceso
a los recurso(s) especificados por la propiedad Resource de la declaración.
Asignar a Effect un valor de Deny anula cualquier Allow previo.

La propiedad Resource puede asumir un ARN completo o un ARN parcial con ca-
racteres comodín para coincidir con varios recursos (“*” para cualquier número
de caracteres o “?” para cualquier carácter individual).

En la política AmazonEC2FullAccess anterior, Resource está establecido como *


en todas las declaraciones, otorgando acceso a todos los recursos. Si queremos
restringir los recursos para los que una declaración es válida, podríamos en su
lugar usar arn:aws:sqs:us-east-2:<ACCOUNT_ID>:mySqsQueue para otorgar
acceso únicamente a una cola SQS específica, por ejemplo. Solo necesitamos
conocer los ARNs de los recursos a los que queremos proporcionar acceso.

La propiedad Action nos permite especificar la(s) acción(es) exacta(s) que se


permitirá(n) en un recurso. Una acción consta de un namespace perteneciente
a un servicio de AWS y un nombre de acción bajo ese namespace.

La primera declaración en la política AmazonEC2FullAccess anterior, por ejemplo,


3. Gestión de Permisos con IAM 52

otorga permiso a las acciones ec2:*, es decir, todas las acciones en el namespace
“ec2”, mientras que la última declaración otorga permiso solo a la acción
específica iam:CreateServiceLinkedRole.

Una declaración también puede especificar una lista de acciones:

"Action": [
"sqs:SendMessage",
"sqs:ReceiveMessage",
"ec2:StartInstances",
"iam:ChangePassword",
"s3:GetObject"
]

Finalmente, la propiedad opcional Condition nos permite aplicar más restric-


ciones sobre cuándo se aplica una política.

En la política AmazonEC2FullAccess mencionada arriba, solo se permitirá la ac-


ción iam:CreateServiceLinkedRole desde uno de los servicios de AWS listados
bajo el atributo StringEquals de la Condition.

Una vez que hemos creado una política, podemos adjuntarla a usuarios, grupos
y roles al igual que con las políticas predefinidas.

Creando Claves de Acceso AWS para Cada Usuario

Para acceder a los recursos de manera programática a través de la CLI o la API


de AWS, los usuarios deben crear claves de acceso. Estas claves permiten la
autenticación sin proporcionar un nombre de usuario y una contraseña. Esto es
una buena práctica de seguridad porque nos permite gestionar, revocar y rotar
las credenciales de la API independientemente de la contraseña de un usuario.
Más importante aún, podemos almacenar estas claves de acceso (y sus secretos)
3. Gestión de Permisos con IAM 53

en un archivo de configuración local y no preocuparnos por las contraseñas


reales que se encuentren en algún lugar en texto plano.

El número predeterminado de claves de acceso por usuario es dos, lo que permite


a los usuarios rotar regularmente sus claves de acceso agregando una nueva y
posteriormente deshabilitando la antigua. De nuevo, hacerlo regularmente se
considera una buena práctica de seguridad.

Cada usuario puede generar claves de acceso individualmente. Necesitaremos


el “ID de Clave de Acceso AWS” resultante y la “Clave de Acceso Secreta AWS”
para usar la AWS CLI, por ejemplo (ver sección Instalando la AWS CLI).

Gestionando Recursos IAM de Forma Programática

Vale la pena destacar que no solo podemos acceder a IAM a través de la consola
web, sino también de forma programática.

Aquí es donde la CLI y la API de AWS entran en juego en IAM. Cada acción que
puede ser desencadenada manualmente por un usuario de IAM también puede
ser desencadenada a través de la CLI o la API.

También podemos usar CDK o, alternativamente, CloudFormation para crear


recursos IAM durante el despliegue.

Un caso de uso interesante para esto es crear roles con todos los permisos que
nuestra aplicación necesita para funcionar, y nuestra aplicación asume estos
roles durante la ejecución. A diferencia de los usuarios, los roles vienen con sus
propias credenciales temporales, que son gestionadas completamente por IAM.

Dado que las credenciales nunca abandonan los servidores de AWS, no podemos
accidentalmente subir las credenciales al control de versiones, lo que constitui-
ría una importante brecha de seguridad.
3. Gestión de Permisos con IAM 54

Además, somos capaces de revocar fácilmente los permisos para una aplicación
en particular, o incluso solo un subconjunto de esos permisos.

Abordaremos en detalle la gestión de permisos con CDK y CloudFormation en el


capítulo Diseñando un Proyecto de Despliegue con CDK.

Mejores Prácticas para Gestionar Permisos con IAM

Amazon tiene su propia guía integral sobre mejores prácticas de seguridad en


IAM, algunas de las cuales ya hemos hablado en este capítulo. Sugerimos revisar
esta guía para familiarizarse con las medidas de seguridad para proteger su
organización, sus usuarios y sus datos.
4. La Evolución de las Implementaciones
Automatizadas
En este libro, no solo queremos explorar los servicios de AWS que podemos usar
en nuestra aplicación Todo, sino también crear un pipeline de implementación
continua que construye nuestra aplicación y la implementa en un entorno
similar a la producción en AWS con cada commit que hacemos al repositorio
de GitHub.

De hecho, ¡construiremos este pipeline antes de hablar siquiera de agregar


alguna característica a la aplicación! En un proyecto de software, queremos
implementar en producción lo más pronto y frecuentemente posible. Solo si
implementamos nuestra aplicación de manera rápida y automática aprendere-
mos sobre las características que hemos construido. Esto nos permite pivotar en
la dirección que proporciona el mayor valor. Este es un principio fundamental
tanto de DevOps como de Agile.

Este capítulo discute la evolución de las opciones de implementación para pre-


parar el escenario para las implementaciones de infraestructura como código
con AWS CDK que veremos en los siguientes capítulos.

Una anécdota sobre las implementaciones manuales

Permíteme (Tom) contarte una historia de mis primeros días como ingeniero
de software. De los tiempos oscuros de la implementación de software. Depen-
diendo de cuándo te uniste a la industria del software, es posible que hayas
4. La Evolución de las Implementaciones Automatizadas 56

experimentado o no historias similares tú mismo. Siéntete libre de saltarte


esta sección si no necesitas ninguna motivación para construir un pipeline de
implementación continua.

En aquel entonces, cuando todavía pensaba que solo puedes llegar a ser un buen
ingeniero de software a través del sufrimiento, heredamos una base de código
de 10 años de otra empresa de software. El cliente ya no estaba contento con
esa empresa de software y nos otorgó a nosotros el contrato para mantener y
extender el producto. Ahora teníamos 350,000 líneas de código sobre las que no
sabíamos nada. Y no teníamos pruebas unitarias ni scripts de construcción. La
empresa anterior sí tenía algunas pruebas unitarias y scripts de construcción,
pero no nos los dieron porque alguien olvidó mencionarlos en el contrato de
transición. Nos sentíamos como aventureros en el peor sentido de la palabra.

Hubo un período de transición de un par de meses cuando la antigua empresa de


software debía terminar y lanzar algunas características finales. Mientras tanto,
comenzamos a desarrollar nuevas características en paralelo. Así que pusimos
una copia de la base de código en nuestro sistema de control de versiones y
comenzamos a explorar el código. La otra empresa todavía estaba trabajando
en su propia copia del código en su propio sistema de control de versiones (que
podría haber sido o no una unidad de red…).

Mi primer contacto con la implementación de esa bestia de producto de software


fue en el contexto del último lanzamiento de esa otra empresa de software.
Solían lanzar cuatro veces al año, en un fin de semana, para no interrumpir el
servicio a los usuarios. Su último lanzamiento antes de que nos hiciéramos cargo
implicó la actualización de la versión del servidor JBoss en el que se ejecutaba el
software.

No estuve personalmente involucrado, pero escuché las historias sobre esa


implementación en particular. Me imagino que el lanzamiento fue algo así
4. La Evolución de las Implementaciones Automatizadas 57

(la siguiente cuenta puede estar teñida con la experiencia de algunos otros
proyectos de software en los que estuve involucrado):

• Sábado, 08:00: Todos los involucrados con el lanzamiento se reportan


durante una teleconferencia: los gerentes de proyecto, probadores, desarro-
lladores, administradores de sistemas, y por supuesto los gerentes tanto del
cliente como de la empresa de software que llaman desde el resort de golf
donde están pasando el fin de semana. Los gerentes abren la teleconferen-
cia para decirle a todos lo importante que es lanzar la nueva versión este fin
de semana.
• Sábado, 08:05: En la teleconferencia, los gerentes de proyecto repasan la
hoja de cálculo de 50 elementos que describe los pasos que han compilado
para el fin de semana de lanzamiento. Durante la teleconferencia, descu-
bren que los administradores de sistemas no han sido informados sobre los
pasos relacionados con algunos cambios de configuración en la instancia
de producción de JBoss. Pero los gerentes de proyecto habían distribuido
la hoja de cálculo a todos para su revisión de antemano y dicen que es
demasiado tarde para quejarse ahora.
• Sábado, 08:45: Han terminado de discutir la hoja de cálculo (por ahora).
• Sábado, 09:00: Los administradores de sistemas han configurado correc-
tamente el balanceador de carga para drenar el tráfico de los servidores
de producción y servir la página de mantenimiento en su lugar. Los desa-
rrolladores todavía están ocupados construyendo la nueva versión en sus
máquinas locales.
• Sábado, 10:30: Hubo algunos errores de construcción inesperados pero
nuestros héroes desarrolladores los han solucionado. Han cifrado el archivo
EAR de 500MB y ahora lo están subiendo a la plataforma segura compartida
con los administradores de sistemas.
• Sábado, 12:00: Teleconferencia programada para informar sobre el progre-
4. La Evolución de las Implementaciones Automatizadas 58

so. Los administradores de sistemas no pudieron abrir el archivo que los


desarrolladores han enviado. Resulta que los desarrolladores habían usado
una vieja clave de cifrado. La clave de cifrado cambia cada tres meses y los
administradores de sistemas se negaron a usar la vieja clave para descifrar
la versión porque eso va en contra del protocolo de seguridad. Tomó una
hora pasar por el proceso de creación de una nueva clave de cifrado porque
tuvieron que llamar a la persona de seguridad responsable de eso. Los
gerentes de proyecto expresan su expectativa de que a partir de ahora, todo
va de acuerdo al plan.

• Sábado, 13:00: Los desarrolladores finalmente han recibido una versión


que pueden descifrar. Han seguido al pie de la letra las instrucciones de
lanzamiento proporcionadas por los desarrolladores pero cuando inician el
servidor, obtienen crípticos errores NoSuchMethodError por todas partes.
Los desarrolladores piden archivos de registro.
• Sábado, 13:30: Después de que los desarrolladores han encontrado la clave
de descifrado correcta para los archivos de registro enviados por los admins
de sistemas, ahora pueden ver los registros.
• Sábado, 14:00: Teleconferencia programada para reportar avances. Los
desarrolladores aún están analizando los errores NoSuchMethodError. Los
gerentes de proyecto ven que están 20 pasos detrás en la hoja de cálculo y
piden los archivos de registro para que puedan ayudar a identificar la causa
raíz.
• Sábado, 15:00: Los desarrolladores han pasado una hora investigando una
posible causa raíz sugerida por uno de los gerentes de proyecto (sabían que
era una distracción, pero los gerentes insistieron). Uno de los desarrolla-
dores ve que un admin de sistemas había enviado un correo electrónico
hace una hora preguntándose por qué el archivo de lanzamiento todavía
tenía el número de versión antiguo en el nombre del archivo. Resulta que
4. La Evolución de las Implementaciones Automatizadas 59

el desarrollador que construyó la versión se había olvidado de actualizar su


espacio de trabajo local desde el control de versiones.
• Sábado, 16:00: Los desarrolladores han construido, encriptado y subido con
éxito la nueva versión.
• Sábado, 16:15: Los admins de sistemas informan sobre errores diferentes
ahora y han subido los registros (sin haber sido preguntados, esta vez).
• Sábado, 16:30: Los desarrolladores preguntan a los admins de sistemas si
realmente han seguido todos los pasos en las notas de la versión porque
funciona en sus máquinas. Los admins de sistemas dicen que son muy
capaces de leer y seguir las notas de lanzamiento.
• Sábado, 17:00: Última teleconferencia programada. El lanzamiento debería
haber terminado ya. El desarrollador líder sugiere posponer la puesta en
marcha para mañana porque la gente se está desenfocando. Los gerentes
de proyecto rechazan porque han prometido a los gerentes superiores el
lanzamiento para hoy.
• Sábado, 21:00: Los desarrolladores y admins de sistemas han tenido una
teleconferencia de pie durante dos horas para comparar los archivos de
configuración uno por uno entre sus máquinas locales y la máquina de pro-
ducción para identificar cualquier diferencia. Hay muchas diferencias, por
supuesto, pero no han encontrado una que suene como si fuera responsable
de los errores que están viendo. Han comido una pizza en el intermedio y los
pensamientos se están volviendo incoherentes. Nadie está particularmente
motivado porque no les están pagando las horas extras.
• Sábado, 22:00: Otra teleconferencia para reportar avances. El gerente de
proyecto cede y pospone para mañana, a las 08:00. Todos se van a casa con
una sensación de inquietud en el estómago.

• Domingo, 08:00: Teleconferencia para comenzar el día. Uno de los desa-


rrolladores tuvo una idea en la ducha esta mañana. Propone probar con una
4. La Evolución de las Implementaciones Automatizadas 60

versión más reciente de JBoss. Nadie realmente cree que funcionará pero no
hay otra idea, así que lo intentan.
• Domingo, 09:00: Los desarrolladores informan que en sus máquinas loca-
les, todo funciona bien con la última versión de JBoss. Actualizan las notas
de lanzamiento y las envían a los admins de sistemas.
• Domingo, 10:00: Los admins de sistemas informan que el software se inicia
sin errores (o más bien, sin ningún error al que no estén acostumbrados).
Los probadores inician su trabajo y revisan su propia hoja de cálculo con
casos de prueba para ver si la nueva versión en producción funciona.
• Domingo, 12:00: Teleconferencia programada para reportar avances. Los
probadores han encontrado un bug que bloquea la implementación en
producción. Los desarrolladores comienzan a trabajar en una solución.
• Domingo, 14:00: Los desarrolladores han corregido el bug y subido una
nueva versión. Los admins de sistemas pueden descifrarlo al primer intento
y actualizar la instancia de producción.
• Domingo, 15:00: Los probadores informan que el bug ha sido solucionado
pero se ha introducido otro bug. Aún así, el nuevo bug no es tan grave como
el anterior, por lo que están de acuerdo en lanzar la versión actual (además,
no quieren perderse la fiesta de cumpleaños a la que están invitados, pero
no mencionan eso).
• Domingo, 16:00: Los admins de sistemas han trabajado a través de los
pasos finales en la hoja de cálculo. Nadie excepto ellos entiende de qué se
tratan esos pasos, pero a nadie excepto a ellos realmente les importa. Final-
mente, el equilibrador de carga se reconfigura para apuntar a la aplicación
en lugar de a la página de mantenimiento y todos se van a casa contentos.

• Lunes, 08:00: Los primeros usuarios informan que no pueden trabajar con
el software …
4. La Evolución de las Implementaciones Automatizadas 61

En general, los lanzamientos de software eran así en los primeros días de mi ca-
rrera. ¡Y ten en cuenta que lo anterior se consideraba un lanzamiento exitoso! Un
lanzamiento siempre era algo de lo que tener miedo. Incluso cuando no estaba
planificado que ayudara durante un fin de semana de lanzamiento, me sentía
nervioso al irme a casa el viernes por la noche, sabiendo que probablemente
algo saldrá mal y podrían llamarme para ayudar.

Las implementaciones manuales no eran agradables. Por lo general, solo tenía-


mos un par de lanzamientos al año. Usualmente, diferentes personas estaban
involucradas cada vez, por lo que el aprendizaje que extrapolábamos de un
lanzamiento a otro era mínima.

Además, el largo tiempo entre lanzamientos los hacía más grandes, aumen-
tando la posibilidad de fallo. Las funcionalidades se amontonaban porque no
podían esperar a la próxima versión. La presión para tener éxito era mayor, ya
sea la presión de la gerencia o la presión que nos imponíamos. Y los humanos
no toman buenas decisiones bajo presión.

Muchos pasos manuales podrían fallar y las comunicaciones entre diferentes


departamentos podrían ser malinterpretadas y terminar en acusaciones.

Debido a estos problemas, nunca quiero experimentar implementaciones ma-


nuales nuevamente. Desplegar automáticamente la última versión con cada
commit al control de versiones resuelve la mayoría de estos problemas. Las
implementaciones automatizadas aportan su propia complejidad y conjunto de
problemas, pero eso es exactamente lo que discutiremos a lo largo de este y los
próximos capítulos de este libro.
4. La Evolución de las Implementaciones Automatizadas 62

Despliegues de autoservicio con la Consola AWS

Bienvenidos al mundo de la nube, donde tenemos opciones ilimitadas para


cómo desplegar nuestra aplicación. Pasando de la implementación manual
descrita anteriormente, el siguiente nivel de sofisticación que AWS ofrece es
la Consola AWS.

La Consola AWS es nuestra principal herramienta para observar e interactuar


con las aplicaciones e infraestructuras desplegadas. Si has realizado el ejercicio
en el capítulo Familiarizándonos con AWS, ya habrás visto la Consola AWS.

Es una interfaz de usuario basada en la web que nos da acceso de autoservicio


a todos los servicios de AWS a nuestra disposición. Por ejemplo, podríamos
usar la Consola AWS para crear un VPC con subredes manualmente y luego
desplegar un contenedor Docker en estas subredes para hacerlas accesibles
desde internet.

O bien, podríamos acceder a la interfaz de usuario web del servicio CloudForma-


tion y cargar los archivos YAML que creamos en el capítulo Familiarizándonos con
AWS a través de la interfaz de usuario web para lograr el mismo resultado.

Además, podríamos usar la interfaz de usuario web del servicio Elastic Beanstalk
para simplemente cargar un archivo WAR y dejar que AWS se encargue de
provisionar la infraestructura que necesita. Hay más opciones para desplegar
una aplicación a través de la Consola AWS y parece que se agregan más cada
semana.

La Consola AWS es una puerta de entrada a nuestra nube. Podemos usarla para
desplegar recursos y monitorizar los recursos que ya hemos desplegado.

Si estamos usando la Consola AWS para desplegar nuestra aplicación, ¡este


sigue siendo un proceso manual! Sin embargo, si utilizamos uno de los servicios
4. La Evolución de las Implementaciones Automatizadas 63

de orden superior como CloudFormation o Beanstalk a través de la consola


web, esto reduce la cantidad de pasos manuales que tenemos que seguir y, por
lo tanto, aumenta la posibilidad de éxito en comparación con el escenario de
implementación manual de la sección anterior.

El autoservicio con la Consola AWS es agradable, y nos da una visión general


muy necesaria de nuestra nube pero eso no es suficiente. Queremos automatizar
nuestros despliegues hasta el último paso. Así que, subamos a otro nivel de
sofisticación.

Despliegues automatizados con la AWS CLI

Para automatizar los despliegues, necesitamos ejecutar todos los comandos


desde la línea de comandos. Luego podemos combinar estos comandos en uno
o varios scripts de shell y ejecutarlos en un servidor de compilación remoto con
cada commit enviado.

En el escenario de despliegue manual que describí anteriormente, los adminis-


tradores de sistemas probablemente ya utilizaron algunos scripts de shell que
prepararon para facilitar sus vidas. Por ejemplo, un script que descifra el archivo
que obtuvieron de los desarrolladores y copia automáticamente ese archivo en
el lugar correcto del servidor de producción podría ser útil. Sin embargo, esos
scripts todavía tendrían que llamarse manualmente entre otros pasos manuales
- y propensos a errores.

Aquí es donde entra en juego AWS CLI. Prácticamente todo lo que podemos
hacer a través de la interfaz web de la Consola AWS, también podemos lograrlo a
través de AWS CLI desde la línea de comandos. AWS CLI es una utilidad de línea
de comandos que envuelve las API de todos los servicios de AWS en una única
herramienta de línea de comandos.
4. La Evolución de las Implementaciones Automatizadas 64

En el capítulo Familiarizándonos con AWS, ya hemos visto AWS CLI en acción.


Usamos los comandos aws cloudformation para desplegar los recursos des-
critos en un archivo de CloudFormation y los comandos aws ec2 para obtener
información sobre nuestras instancias EC2 después del despliegue.

De nuevo, AWS CLI nos permite usar APIs de bajo nivel como las del servicio EC2
para aprovisionar nuestros servidores. O, podemos usar APIs de orden superior
como CloudFormation para reducir la cantidad de pasos.

No importa qué API elijamos, con AWS CLI ahora tenemos el poder de hacer todo
lo que necesitamos desde la línea de comandos. ¡Podemos construir una línea
de despliegue totalmente automatizada!

Aún así, no estamos satisfechos. No queremos perder tiempo escribiendo


scripts de shell que son difíciles de probar y aún más difíciles de depurar.

Despliegues declarativos con CloudFormation

Si usamos CloudFormation con AWS CLI, ya hemos dado otro paso hacia imple-
mentaciones automatizadas y repetibles.

Como hemos visto en el capítulo Familiarizándonos con AWS, CloudFormation


nos permite declarar todos los recursos que necesitamos en archivos de plantilla
YAML o JSON. Esta es una forma declarativa de implementación, en compa-
ración con el estilo imperativo de usar AWS CLI para crear los recursos que
necesitamos directamente a través de las APIs de otros Servicios de AWS.

Este estilo declarativo ofrece un sinfín de ventajas sobre una colección de scripts
de shell imperativos.

Declaramos “pilas” de recursos - cada pila en su propio archivo de plantilla -


y podemos crear, actualizar o eliminar una pila con un solo comando de CLI.
4. La Evolución de las Implementaciones Automatizadas 65

También podemos anidar pilas, si es necesario, para crear, actualizar o eliminar


varias pilas anidadas con un solo comando.

Podemos usar el mismo archivo de plantilla con los mismos o diferentes pará-
metros para generar una copia del entorno de producción para pruebas. Dado
que ambos entornos declaran los mismos recursos, se comportarán de manera
muy similar y nos facilitarán la vida cuando intentemos reproducir errores.

Ya no tenemos que preocuparnos (tanto) por el manejo de errores durante


las implementaciones. CloudFormation envuelve todas sus acciones en una
transacción. Si un recurso falla al ser creado, actualizado o eliminado, Cloud-
Formation automáticamente revertirá todos los cambios que ya se han hecho y
volverá al último estado de trabajo. Luego podemos revisar el mensaje de error
en la interfaz de usuario de CloudFormation de la Consola de AWS y depurar
desde allí. No más búsqueda a través de scripts de shell ilegibles cuando algo
sale mal.

Podemos compartir pilas en toda la organización para que muchos equipos pue-
dan usarlas para crear sus recursos. Esto apoya los esfuerzos de cumplimiento y
formación. Para crear un pipeline de implementación continua, ya no tenemos
que copiar un montón de scripts de shell, sino que copiamos una plantilla bien
definida y un puñado de comandos de CLI.

En resumen, CloudFormation hace que las implementaciones automatizadas


sean mucho más manejables que una colección de scripts de shell puede ser
(excepto quizás para las personas que hablan Bash como su primer idioma).

Aun así, aún no hemos alcanzado el fin del juego de automatización.


4. La Evolución de las Implementaciones Automatizadas 66

Implementaciones Programables con CDK

Si te consideras un programador y no un administrador de archivos YAML y


JSON, te alegrará conocer el Kit de Desarrollo en la Nube de AWS (CDK).

El CDK se basa en CloudFormation y actualmente permite describir recursos en


la nube en Java, Javascript, Typescript, Python o C#. “Sintetiza” el código que
creamos en uno de estos lenguajes en plantillas ordinarias de CloudFormation.
Luego podemos implementarlas con AWS CLI o el CDK CLI más especializado,
que es un poco más fácil de trabajar para nosotros, los humanos. Una “App” de
CDK puede contener una o más pilas de CloudFormation con las que podemos
interactuar por separado a través de comandos de CLI.

Cuando decimos “programable” en el título de esta sección, nos referimos a


utilizar un lenguaje de programación en lugar de crear archivos JSON o YAML.
Sin embargo, se desaconseja poner demasiada lógica de programación en una
aplicación de CDK. Con cada rama condicional en una aplicación de CDK, el
número de resultados posibles aumenta exponencialmente. Y, dado que el
objetivo de las implementaciones automatizadas es crear entornos reproduci-
bles y predecibles para nuestras aplicaciones de software, queremos la menor
variación posible.

Por lo tanto, al final, una aplicación de CDK sigue siendo en su mayoría un enfo-
que declarativo para la infraestructura como código. Es solo que declaramos los
recursos que necesitamos en un lenguaje de programación de nuestra elección
en lugar de en YAML o JSON. Entonces, además de usar un idioma diferente,
¿cuáles son los beneficios sobre las plantillas ordinarias de CloudFormation?

El principal beneficio es que podemos utilizar el poder del lenguaje de programa-


ción y su ecosistema para crear y compartir “componentes” reutilizables de uno
o más recursos de CloudFormation. Por ejemplo, podemos crear un componente
4. La Evolución de las Implementaciones Automatizadas 67

que contenga toda la infraestructura necesaria para ejecutar una aplicación de


Spring Boot en la nube, compartir esto como un módulo de Maven y usar el
componente en diferentes aplicaciones de CDK que implementan cada una una
aplicación diferente de Spring Boot.

Si bien también podríamos compartir pilas con CloudFormation, tendríamos


que resolver el problema de distribuir, versionar y reutilizar los archivos JSON
o YAML. Con CDK, podemos apoyarnos en cualquier mecanismo de distribución
que ofrezca el ecosistema del lenguaje (Maven, en el caso de Java). Además, con
CloudFormation, estamos limitados a compartir pilas completas. Con CDK, la
granularidad de los componentes puede ser tan gruesa o fina como queramos.
Un componente de CDK puede ser un solo recurso de CloudFormation precon-
figurado o una pila que contenga la infraestructura para todo un ecosistema de
microservicios. Podemos combinar cualquier número de dichos componentes
en una o varias pilas, justo como nos parezca conveniente.

El CDK en sí ofrece componentes de diferente granularidad:

• Constructos de nivel 1: estos constructos son equivalentes directos de


recursos de CloudFormation. Son los constructos más granulares. Los nom-
bres de los constructos de nivel 1 están precedidos por Cfn para “CloudFor-
mation” y se traducen 1:1 en su equivalente de CloudFormation. Podemos
referirnos a la documentación de CloudFormation para configurarlos. Un
ejemplo es el constructo CfnSecurityGroup que crea un grupo de seguridad
para restringir el acceso a ciertos recursos.
• Constructos de nivel 2: estos son grupos preconfigurados de uno o más
recursos de CloudFormation. A menudo combinan múltiples recursos y
reducen considerablemente la cantidad de código que tenemos que escri-
bir. Además, a menudo proporcionan una interfaz de programación más
conveniente para su configuración. Por ejemplo, el constructo de nivel
4. La Evolución de las Implementaciones Automatizadas 68

2 SecurityGroup, ofrece métodos para crear reglas de acceso entrante y


saliente sin tener que crear los constructos CfnSecurityGroupIngress o
CfnSecurityGroupEgress, lo que tendríamos que hacer si usáramos el
constructo de nivel 1 CfnSecurityGroup. Hablaremos más sobre los grupos
de seguridad más tarde.
• Constructos de nivel 3: estos constructos son los constructos de más alto
nivel. También se les llama “patterns” porque suelen representar ciertos
patrones de arquitectura como “desplegar una aplicación detrás de un
balanceador de carga”. El objetivo del equipo de CDK es crear una biblioteca
de tales constructos listos para usar en escenarios comunes. Sin embargo,
la mayoría de las veces los crearemos nosotros mismos adaptados a nuestra
arquitectura. Como con cualquier abstracción, el inconveniente de estos
constructos poderosos es que ocultan gran parte de lo que están haciendo.
Como una muñeca rusa, un constructo de nivel 3 contiene otros constructos,
que pueden contener aún otros constructos, y podríamos terminar desple-
gando recursos que no necesitamos.

Con CDK, hemos llegado al final de la evolución de la automatización de des-


pliegue. Podemos codificar toda nuestra infraestructura en código y desplegarla
con el clic de un botón. Vamos a trabajar directamente con CDK en el próximo
capítulo.
5. Primeros Pasos con CDK
En el capítulo Entrando en Calor con AWS, ya hemos jugado un poco con AWS
CloudFormation. Hemos desplegado un stack de red que proporciona la infra-
estructura de red que necesitamos, y un stack de servicio que despliega una
imagen Docker con nuestra aplicación Spring Boot en esa red.

En este capítulo, haremos lo mismo con el Kit de Desarrollo en la Nube (CDK)


en lugar de CloudFormation. Sin embargo, en lugar de describir nuestros stacks
en YAML, utilizaremos Java. Además, reemplazaremos la CLI de AWS con la CLI
de CDK, que nos permite desplegar y retirar nuestros stacks fácilmente.

Bajo el capó, CDK “sintetizará” un archivo CloudFormation a partir de nuestro


código Java y pasará ese archivo a la API de CloudFormation para desplegar
nuestra infraestructura. Esto significa que con CDK, describimos los mismos
recursos como lo haríamos en un archivo YAML de CloudFormation. Pero,
teniendo el poder de un lenguaje de programación real en nuestras manos (en
nuestro caso, Java), podemos construir abstracciones sobre los recursos de bajo
nivel de CloudFormation1 . Estas abstracciones se llaman “constructos” en la
jerga de CDK.

¡Vamos a crear nuestra primera aplicación CDK! Sigue los pasos de este capítulo
para crear una aplicación CDK que despliega nuestra aplicación “Hello World”
en la nube.
1 Lo más importante es que no tenemos que preocuparnos por la indentación.
5. Primeros Pasos con CDK 70

Creando Nuestra Primera Aplicación CDK

La unidad de trabajo en CDK se llama “app”. Piensa en una app como un


proyecto que importamos en nuestro IDE. En términos de Java, esto es un
proyecto Maven por defecto.

En esa app, podemos definir uno o más stacks. Y cada stack define un conjunto
de recursos que deben ser desplegados como parte de ese stack. Nota que un
stack de CDK es el mismo concepto que un stack de CloudFormation.

Una vez que tenemos una app en su lugar, la CLI de CDK nos permite desplegar o
retirar todas las stacks al mismo tiempo, o podemos elegir interactuar solo con
un stack específico.

Antes de que podamos comenzar, tenemos que cumplir con algunos requisitos
previos.

Instalando Node

Aunque estamos usando el CDK de Java, la CLI de CDK está construida con
Node.js. Por lo tanto, necesitamos instalarlo en nuestra máquina.

Si aún no tienes Node.js en funcionamiento, puedes descargarlo desde el sitio


web de Node o usar el gestor de paquetes de tu elección para instalarlo. Hemos
probado todos los pasos de este libro con Node.js 16, que es la última versión
LTS en el momento de la escritura, pero probablemente funcionará con otras
versiones también.

Puedes verificar tu versión de Node.js llamando a node -v.


5. Primeros Pasos con CDK 71

Instalando la CLI de CDK

A continuación, queremos instalar la CLI de CDK.

Con Node.js instalado, esto es tan fácil como llamar a npm install -g aws-
cdk. Esto hará que el comando CLI de CDK cdk esté disponible globalmente en
tu sistema.

Al igual que con Node.js, puedes verificar la versión de tu instalación CLI de CDK
llamando a cdk --version.

A partir de la versión 1.4 de este libro, utilizaremos AWS CDK v2. Asegú-
rate de usar una versión de cdk >= 2.0.0 para todos los ejemplos que se
presenten. Para migrar tu infraestructura existente de AWS CDK v1, sigue
la guía oficial de migración.

Creando la App de CDK

¡Ahora estamos listos para crear nuestra primera aplicación CDK!

Al igual que muchas CLIs de desarrollo modernas, la CLI de CDK proporciona la


funcionalidad de arrancar un nuevo proyecto desde cero.

Vamos a crear una nueva carpeta para nuestra app, cambiar a ella, y ejecutar
este comando:

cdk init app --language=java

Después de que CDK ha creado nuestra aplicación, nos encontramos con este
mensaje:
5. Primeros Pasos con CDK 72

# Welcome to your CDK Java project!

This is a blank project for Java development with CDK.

The `cdk.json` file tells the CDK Toolkit how to execute your app.

It is a [Maven](https://maven.apache.org/) based project, so you can open this


project with any Maven compatible Java IDE to build and run tests.

## Useful commands

* `mvn package` compile and run tests


* `cdk ls` list all stacks in the app
* `cdk synth` emits the synthesized CloudFormation template
* `cdk deploy` deploy this stack to your default AWS account/region
* `cdk diff` compare deployed stack with current state
* `cdk docs` open CDK documentation

Enjoy!

Además de algunos comandos útiles, hay información importante en este men-


saje:

• el proyecto depende de Maven para compilar y empaquetar el código, y


• hay un archivo llamado cdk.json que le dice al CDK cómo ejecutar nuestra
aplicación.

Utilizaremos esa información en la próxima sección.

Haciendo la Aplicación CDK Portátil con el Envoltorio de Maven

Antes de inspeccionar la aplicación generada en más detalle, vamos a resolver


un problema con la configuración de Maven generada automáticamente.

El mensaje anterior indica que necesitamos ejecutar mvn package para compilar
y ejecutar las pruebas. Eso significa que Maven necesita estar instalado en
5. Primeros Pasos con CDK 73

nuestra máquina. Pensando un poco más, esto también significa que Maven
necesita estar instalado en el servidor de compilación una vez que decidamos
configurar un pipeline de despliegue continuo.

Aunque no es un problema irresoluble instalar Maven en una máquina local o


remota, tendremos una solución más autónoma si la compilación se encarga de
“instalar” Maven por sí misma.

La solución a esto es el Maven Wrapper. Es un script que descarga Maven si


es necesario. Para instalarlo copiamos la carpeta .mvn y los archivos mvnw y
mvnw.cmd del proyecto de ejemplo en la carpeta principal de nuestra nueva
aplicación CDK.

En lugar de llamar a mvn package, ahora podemos llamar a ./mvnw package


para el mismo efecto, incluso si Maven no está instalado en nuestra máquina.

Pero aún no hemos terminado del todo. ¿Recuerdas el mensaje que decía que el
archivo cdk.json le dice al CDK cómo ejecutar nuestra aplicación? Veamos ese
archivo:

{
"app": "mvn -e -q compile exec:java",
"watch": {
},
"context": {
}
}

La primera línea de esta estructura JSON indica al CDK cómo compilar y luego
ejecutar nuestra aplicación CDK. Se ha configurado para llamar a mvn por defecto.
Por lo tanto, reemplacemos eso por ./mvnw y estamos listos.

Ahora, cada vez que llamemos a un comando como cdk deploy, el CDK llamará
al Wrapper de Maven en lugar de directamente a Maven para ejecutar nuestra
aplicación CDK.
5. Primeros Pasos con CDK 74

Examinando el Código Fuente Generado

Con todo lo anterior configurado, examinemos el código que el CDK ha generado


para nosotros. En la carpeta src/main/java/com/myorg, encontraremos los
archivos CdkApp y CdkStack:

public class CdkApp {


public static void main(final String[] args) {
App app = new App();

new CdkStack(app, "CdkStack");

app.synth();
}
}

public class CdkStack extends Stack {


public CdkStack(final Construct scope, final String id) {
this(scope, id, null);
}

public CdkStack(final Construct scope, final String id, final StackProps props) {
super(scope, id, props);

// The code that defines your stack goes here


}
}

¡Eso es todo el código que necesitamos para una aplicación CDK funcional!

CdkApp es la clase base de la aplicación. Es una clase Java estándar con un


método main() estándar para hacerla ejecutable. El método main() crea una
instancia de App y una instancia de CdkStack y finalmente llama a app.synth()
para indicar a la aplicación CDK que cree archivos de CloudFormation con todos
los recursos de CloudFormation que contiene. Estos archivos de CloudForma-
tion se escribirán en la carpeta llamada cdk.out.
5. Primeros Pasos con CDK 75

Cuando ejecutamos comandos CDK como cdk deploy, CDK ejecutará el método
main de CdkApp para generar los archivos de CloudFormation. El comando
deploy sabe dónde buscar estos archivos y luego los envía a la API de Cloud-
Formation para su despliegue.

La clase CdkStack representa un stack de CloudFormation. Como se mencionó


antes, una aplicación CDK contiene uno o más stacks. Este stack es donde agre-
garíamos los recursos que queremos desplegar. Agregaremos nuestros propios
recursos más tarde en este capítulo. Por ahora, lo dejaremos vacío.

Haciendo Bootstrap al entorno AWS para un despliegue CDK

Antes de poder desplegar recursos de AWS con stacks CDK personalizadas,


puede que necesitemos hacer bootstrap a nuestro entorno AWS.

El CDK requiere un entorno AWS con bootstrap en caso de que despleguemos


Assets como parte de nuestro stack CDK o generemos plantillas de AWS Cloud-
Formation que superen los 50 kilobytes.

Si no hacemos bootstrap a nuestro entorno AWS y, por ejemplo, intentamos


desplegar una plantilla de AWS CloudFormation de 100 kilobytes, el despliegue
fallará.

Para evitar tales escenarios y completar todos los posibles pasos de configura-
ción al menos una vez, vamos a hacer bootstrap a nuestro entorno AWS.

Al hacer bootstrap a nuestro entorno AWS, el AWS CDK crea un bucket S3, roles
IAM y un ECR para propósitos internos. Estos recursos ya pueden resultar en
costos mínimos en nuestra factura de AWS. Sin embargo, son insignificantes y
deberían estar cubiertos por la capa gratuita de AWS de todos modos.

Cada entorno (es decir, una combinación de una cuenta de AWS y una región)
necesita hacer bootstrap por separado. Este es un esfuerzo de única vez, a menos
5. Primeros Pasos con CDK 76

que migremos a una nueva versión principal del CDK, en cuyo caso tendríamos
que “re-hacer bootstrap” al entorno.

Para hacer bootstrap a nuestro entorno AWS, necesitamos ejecutar el siguiente


comando una vez:

cdk bootstrap aws://ACCOUNT-NUMBER/REGION

Un ejemplo de bootstrapping se ve así:

cdk bootstrap aws://123456789/ap-southeast-2


� Bootstrapping environment aws://123456789/ap-southeast-2...
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
Using default execution policy of 'arn:aws:iam::aws:policy/AdministratorAccess'. Pas\
s '--cloudformation-execution-policies' to customize.
CDKToolkit: creating CloudFormation changeset...

� Environment aws://123456789/ap-southeast-2 bootstrapped.

Después de ejecutar este comando, podemos navegar al servicio CloudForma-


tion en la consola de AWS en el navegador y deberíamos ver un stack llamado
CDKToolkit.

Eso es todo lo que necesitamos para el arranque de nuestro entorno AWS.

Si queremos eliminar los recursos creados por este proceso de arranque, pode-
mos borrar manualmente el stack dentro de la consola de AWS.

Deploy de la App CDK Generada

Intentemos hacer el deploy de la app CDK generada.

Esto es tan fácil como ejecutar el comando cdk deploy en la carpeta de la app.
Tomará un par de segundos y seremos recompensados con un mensaje de éxito
como este:
5. Primeros Pasos con CDK 77

TestStack: deploying...
TestStack: creating CloudFormation changeset...
[========================================================] (2/2)

TestStack

Stack ARN:
arn:aws:cloudformation:ap-southeast-2:...

Esto significa que CDK ha desplegado con éxito la pila vacía. Si iniciamos sesión
en la consola web de AWS y navegamos hasta el servicio CloudFormation,
deberíamos ver una pila llamada “TestStack” desplegada en dicho servicio:

La pila predeterminada de CDK en la consola web de CloudFormation.

La pila contiene un único recurso llamado CDKMetadata, que el CDK necesita


para trabajar con esa pila.

Antes de continuar, destruyamos la pila nuevamente con el comando cdk des-


troy.

Desplegando una Aplicación Spring Boot con un


Constructo de CDK

Ahora que conocemos el funcionamiento básico del CDK, despleguemos una


aplicación real. El objetivo es desplegar la misma red y el clúster de ECS que
5. Primeros Pasos con CDK 78

hemos desplegado en el capítulo Acercándonos a AWS. Vamos a desplegar la mis-


ma imagen Docker en ese clúster, es decir, la que contiene nuestra aplicación
“Hola Mundo” (puedes encontrar el código fuente en GitHub).

Como mencionamos, los recursos que incluimos en una pila de CDK se llaman
constructos. Para mostrar la potencia del CDK, y para mantenerlo simple por
ahora, hemos preparado un constructo con el nombre SpringBootApplica-
tionStack que incluye todos los recursos que necesitamos. Todo lo que nece-
sitamos hacer es incluir este constructo en nuestra pila de CDK.

Agregando la Biblioteca de Constructos de Stratospheric

Para tener acceso al constructo SpringBootApplicationStack, necesitamos in-


cluir la biblioteca cdk-constructs en nuestro proyecto. Creamos esta biblioteca
para proporcionar constructos que vamos a utilizar a lo largo del libro.

Agreguemos el siguiente fragmento al archivo pom.xml en el proyecto CDK:

<dependency>
<groupId>dev.stratospheric</groupId>
<artifactId>cdk-constructs</artifactId>
<version>0.1.0</version>
</dependency>

Desde la versión 0.1.0 en adelante, la biblioteca cdk-constructs solo


soporta AWS CDK v2.

Puede revisar el código fuente de esta biblioteca de construcciones en GitHub y


buscar la última versión de la misma en Maven Central.
5. Primeros Pasos con CDK 79

Utilizando el SpringBootApplicationStack

Como se puede deducir del nombre del constructo, SpringBootApplicationS-


tack es una pila. Se extiende de la clase Stack de la API de CDK. Esto implica
que se puede utilizar para reemplazar la clase generada CdkStack.

Por consiguiente, se modifica la clase generada CdkApp para incluir una Spring-
BootApplicationStack en lugar de un CdkStack vacío:

public class CdkApp {

public static void main(final String[] args) {


App app = new App();

String accountId = (String) app


.getNode()
.tryGetContext("accountId");
requireNonNull(accountId, "context variable 'accountId' must not be null");

String region = (String) app


.getNode()
.tryGetContext("region");
requireNonNull(region, "context variable 'region' must not be null");

new SpringBootApplicationStack(
app,
"SpringBootApplication",
makeEnv(accountId, region),
"docker.io/stratospheric/todo-app-v1:latest");

app.synth();
}

static Environment makeEnv(String account, String region) {


return Environment.builder()
.account(account)
.region(region)
.build();
}
}
5. Primeros Pasos con CDK 80

El primer cambio notable es que ahora aceptamos dos parámetros. Con


app.getNode().tryGetContext(), estamos leyendo las así llamadas
“variables de contexto” desde la línea de comandos.

Podemos pasar estos parámetros a la línea de comandos cdk utilizando el


parámetro -c, así por ejemplo:

cdk deploy -c accountId=123456789 -c region=ap-southeast-2

¿Por qué ahora pasamos el ID de la cuenta y la región de AWS a la aplicación?


La razón es para tener mayor flexibilidad. Si no se proporcionan, la CLI de CDK
siempre tomará la cuenta y la región que hemos preconfigurado con la CLI de
AWS. No tendríamos forma de desplegar recursos en otras cuentas y regiones.
Aún no necesitamos esta flexibilidad, pero SpringBootApplicationStack utili-
za constructos más sofisticados que necesitan estos parámetros como entrada.

A continuación, creamos una instancia de SpringBootApplicationStack. Pa-


samos la instancia de la aplicación para que CDK sepa que este SpringBoo-
tApplicationStack es parte de la aplicación y debe incluirse en los archivos
sintetizados de CloudFormation.

El segundo parámetro es un identificador arbitrario (pero único) para el cons-


tructo dentro de la aplicación.

El tercer parámetro combina los parámetros accountId y region para crear un


objeto Environment. Environment es una clase de CDK que estamos reutilizan-
do aquí.

El parámetro final es la URL de la imagen Docker que queremos desplegar.


Utilizaremos la misma imagen que hemos usado antes. También podríamos
decidir hacer que la URL sea una variable de contexto para ser pasada desde
afuera y hacer que la aplicación CDK sea más flexible.
5. Primeros Pasos con CDK 81

Es posible que te preguntes por qué no estamos haciendo nada con la instancia
SpringBootApplicationStack. Cuando creamos un constructo, siempre pasa-
mos un constructo padre o la aplicación padre al constructor. El constructo luego
se registrará con la aplicación para que la aplicación sepa qué constructos incluir
en la pila de CloudFormation sintetizada al llamar a app.synth().

Desplegando la Aplicación CDK

¡Probemos nuestra nueva aplicación CDK! Ejecutemos este comando:

cdk deploy -c accountId=<ACCOUNT_ID> -c region=<REGION>

Reemplaza ACCOUNT_ID y REGION con tu número de cuenta de AWS y región,


respectivamente.

El CDK te mostrará una lista de “Cambios en las Declaraciones IAM” y “Cambios


en el Grupo de Seguridad” para que puedas confirmar. Esta es una medida de
seguridad para evitar cambios no deseados en la configuración de seguridad.
Después de confirmar, la consola debería mostrar el avance de la implemen-
tación de esta manera:

Do you wish to deploy these changes (y/n)? y


SpringBootApplication: deploying...
SpringBootApplication: creating CloudFormation changeset...
[========·················································] (7/46)

7:29:22 am | CREATE_IN_PROGRESS | AWS::CloudFormation::Stack | SpringBootAppli...


7:29:28 am | CREATE_IN_PROGRESS | AWS::EC2::InternetGateway | network/vpc/IGW
7:29:28 am | CREATE_IN_PROGRESS | AWS::EC2::VPC | network/vpc
7:29:29 am | CREATE_IN_PROGRESS | AWS::IAM::Role | Service/ecsTaskRole
7:29:29 am | CREATE_IN_PROGRESS | AWS::IAM::Role | Service/ecsTaskE...

Dado que el SpringBootApplicationStack contiene muchos recursos bajo la


superficie, tomará uno o dos minutos completar el despliegue.

Cuando termine, deberíamos ver un resultado como este en la consola:


5. Primeros Pasos con CDK 82

Outputs:
SpringBootApplication.loadbalancerDnsName = prod-loadbalancer-810384126.ap-southeast\
-2.elb.amazonaws.com

Stack ARN:
arn:aws:cloudformation:ap-southeast-2:494365134671:stack/SpringBootApplication/0b6b4\
410-3be9-11eb-b5d5-0a689720a8fe

Esto significa que el stack SpringBootApplication ha sido desplegado exito-


samente. Los stacks de CloudFormation soportan el concepto de “parámetros
de output” y CDK imprime cualquier parámetro de output después de un des-
pliegue exitoso. El SpringBootApplication está construido para exponer el
nombre DNS de su balanceador de carga como un parámetro de output, por eso
vemos ese nombre DNS en la consola.

Si copiamos esta URL en nuestro navegador, deberíamos ver nuestra aplicación


“hello world”.

Al inspeccionar de nuevo la consola web de CloudFormation, deberíamos ver un


stack con una variedad de recursos
5. Primeros Pasos con CDK 83

El stack de Spring Boot CDK en la consola web de CloudFormation.

Cuando termine de inspeccionar el stack, no olvide eliminarlo para evitar costos


innecesarios:

cdk destroy -c accountId=<ACCOUNT_ID> -c region=<REGION>

¿Por qué no detenernos aquí?

Hemos desplegado con éxito una aplicación de Spring Boot con unas 20 líneas
de código Java con la ayuda de AWS CDK. Anteriormente, en el capítulo Fami-
liarizándonos con AWS, lo mismo nos ha llevado un par de cientos de líneas de
configuración YAML. ¡Es todo un logro!

Entonces, ¿por qué no detenernos aquí? ¿Por qué hay otro capítulo en profun-
didad sobre CDK? Nuestro SpringBootApplicationStack nos da todo lo que
necesitamos para desplegar una aplicación Spring Boot, ¿no es así?
5. Primeros Pasos con CDK 84

La razón principal es que nuestro constructo SpringBootApplicationStack no


es muy flexible. Lo único que podemos controlar es la URL de la imagen Docker.
Como cualquier abstracción, el SpringBootApplicationStack oculta muchos
detalles de nosotros.

¿Qué pasa si necesitamos conectar nuestra aplicación Spring Boot a una base
de datos o colas SQS? ¿Qué pasa si la ruta a la verificación de salud de nuestra
aplicación es diferente de la predeterminada? ¿Qué pasa si nuestra aplicación
necesita más potencia de CPU que las 256 unidades predeterminadas? ¿Qué pasa
si preferimos usar HTTPS en lugar de HTTP?

Además, imagina un entorno con más de una aplicación. Tendríamos una red pa-
ra el entorno de preproducción y otra para el entorno de producción. Querríamos
desplegar múltiples aplicaciones en cada red. Esto no funciona actualmente,
porque cada SpringBootApplicationStack intentaría crear su propio VPC (lo
cual fallaría para la segunda aplicación porque intentaría usar los mismos
nombres de recursos).

Esto significa que nuestro proyecto CDK necesita ser flexible. Debe permitirnos
desplegar recursos adicionales según sea necesario y proporcionar numerosas
opciones para configurar la infraestructura y nuestra aplicación. Queremos
tener un control detallado y preciso.

Para obtener este control, necesitamos crear nuestras propias pilas y construc-
tos. Y esto es exactamente lo que vamos a hacer en el próximo capítulo.
6. Diseñando un Proyecto de Despliegue
con CDK
En el capítulo anterior, ya hemos desplegado una aplicación Spring Boot en
AWS con el CDK. Utilizamos un componente preconfigurado de “caja negra”
llamado SpringBootApplicationStack, introdujimos unos pocos parámetros,
y lo envolvimos en una aplicación CDK para desplegarlo con la interfaz de línea
de comandos de CDK.

En este capítulo, queremos profundizar un nivel y responder las siguientes


preguntas:

• ¿Cómo podemos crear componentes CDK reutilizables?


• ¿Cómo integramos dichos componentes reutilizables en nuestras aplicacio-
nes CDK?
• ¿Cómo podemos diseñar un proyecto CDK fácil de mantener?

Más adelante, en el capítulo Construyendo un Pipeline de Despliegue Continuo,


crearemos un pipeline de despliegue automatizado con GitHub Actions.

¡Empecemos!

La visión general

El objetivo básico de este capítulo sigue siendo el mismo que en los capítulos
Calentando con AWS y Primeros Pasos con CDK: Queremos desplegar una simple
6. Diseñando un Proyecto de Despliegue con CDK 86

aplicación “Hola Mundo” de Spring Boot (en una imagen Docker) en una subred
pública de nuestra propia red virtual privada (VPC). Esta vez, sin embargo,
queremos hacerlo con componentes CDK reutilizables y estamos añadiendo
algunos requisitos más:

Queremos desplegar nuestra aplicación en un entorno de ensayo y un entorno de producción.

La imagen de arriba muestra lo que queremos lograr. Cada caja es un recurso de


CloudFormation (o un conjunto de recursos de CloudFormation) que queremos
desplegar. Esta es una vista de alto nivel. Por lo tanto, en realidad hay más recur-
sos involucrados, pero no nos preocupemos por eso aún. Cada color corresponde
a una pila de CloudFormation diferente. Vamos a revisar cada una de las pilas
una por una.
6. Diseñando un Proyecto de Despliegue con CDK 87

La pila de Repositorio Docker crea, como podrías haber adivinado, un re-


positorio Docker para las imágenes Docker de nuestra aplicación. El servicio
subyacente de AWS que estamos utilizando aquí es ECR - Elastic Container
Registry. Más tarde podemos usar este repositorio Docker para publicar nuevas
versiones de nuestra aplicación.

La pila de Red es prácticamente la misma que la que ya hemos visto en el


capítulo Calentando con AWS pero con algunas modificaciones. Despliega una
VPC (Red Privada Virtual) que incluye una subred pública y una subred aislada
(privada). La subred pública ahora contiene un Application Load Balancer (ALB)
que redirige el tráfico entrante a un Cluster de ECS (Elastic Container Service) -
el entorno de ejecución de nuestra aplicación. La subred aislada no es accesible
desde el exterior y está diseñada para proteger recursos internos como nuestra
base de datos.

La pila de Servicio contiene un servicio ECS y una tarea ECS. Es importante


recordar que una tarea de ECS es esencialmente una imagen Docker con algunas
configuraciones adicionales, y un servicio ECS envuelve una o varias de estas
tareas. En nuestro caso, tendremos exactamente una tarea porque solo tenemos
una aplicación. En un entorno con múltiples aplicaciones, como en un entorno
de microservicios, podríamos querer desplegar muchas tareas ECS en el mismo
servicio ECS - una para cada aplicación. ECS (en su versión “Fargate”) se en-
carga de poner en marcha instancias de cálculo EC2 para alojar la(s) imagen(es)
Docker configurada(s). Incluso se encarga del escalado automático si queremos
que lo haga.

ECS extraerá la imagen Docker que queremos desplegar como tarea directamen-
te de nuestro repositorio Docker.

Cabe destacar que desplegaremos la pila de Red y la pila de Servicio dos ve-
ces: una para un entorno de ensayo y otra para un entorno de producción.
6. Diseñando un Proyecto de Despliegue con CDK 88

Aquí es donde aprovechamos la infraestructura como código: reutilizaremos


las mismas pilas de CloudFormation para crear múltiples entornos. Usaremos
el entorno de ensayo para realizar pruebas antes de desplegar cambios en el
entorno de producción.

La pila de Repositorio Docker, por otro lado, la desplegaremos solo una vez.
Servirá imágenes Docker tanto al entorno de ensayo como al de producción.
Una vez que hayamos probado una imagen Docker de nuestra aplicación en el
entorno de ensayo, queremos desplegar exactamente la misma imagen Docker
en producción, así que no necesitamos un repositorio Docker separado para cada
entorno. Si tuviéramos más de una aplicación, sin embargo, probablemente
querríamos crear un repositorio Docker para cada aplicación para mantener
las imágenes Docker separadas de manera limpia. En ese caso, reutilizaríamos
nuestra pila de Repositorio Docker y la desplegaríamos una vez para cada
aplicación.

Veamos cómo podemos construir cada una de esas tres pilas con CDK de una ma-
nera eficiente y sostenible. Pasaremos por cada una de las pilas y discutiremos
cómo podemos implementarlas con componentes CDK reutilizables.

Cada pila vive en su propia aplicación CDK. Mientras discutimos cada pila,
señalaremos los conceptos que aplicamos al desarrollar los componentes y
aplicaciones CDK. Estos conceptos nos ayudaron a gestionar la complejidad de
CDK, y esperamos que también te ayuden en tus esfuerzos.

Dicho esto, por favor no consideres estas estrategias como una solución mágica
- diferentes circunstancias requerirán diferentes estrategias. Discutiremos cada
una de ellas en su propia sección para que no se pierdan en un mar de texto.
6. Diseñando un Proyecto de Despliegue con CDK 89

Cómo trabajar con CDK

Antes de empezar a trabajar con CDK, sin embargo, algunas palabras sobre cómo
trabajar con CDK.

La construcción manual de pilas con CDK puede requerir mucho tiempo, espe-
cialmente cuando aún no estás familiarizado con los recursos de CloudForma-
tion que quieres usar. Ajustar los parámetros de configuración de esos recursos
y luego probarlos es un gran esfuerzo porque tienes que desplegar la pila cada
vez para probarla.

Además, CDK y CloudFormation te lanzarán mensajes de error cada vez que


puedan. Especialmente con la versión Java, te encontrarás con errores extraños
de vez en cuando. Estos errores son difíciles de depurar porque el código Java
utiliza un motor JavaScript (JSii) para generar los archivos de CloudFormation.
Sus trazas de pila a menudo provienen de algún lugar profundo en ese motor de
JavaScript, con poca o ninguna información sobre lo que salió mal.

Otra fuente común de confusión es la distinción entre errores de “tiempo de


síntesis” (errores que ocurren durante la creación de los archivos de CloudFor-
mation) y errores de “tiempo de despliegue” (errores que ocurren mientras CDK
está llamando a la API de CloudFormation para desplegar una pila). Si un recurso
en una pila hace referencia a un atributo de otro recurso, este atributo será solo
un marcador de posición durante el tiempo de síntesis y se evaluará al valor real
durante el tiempo de despliegue. A veces, puede ser sorprendente que un valor
no esté disponible en el tiempo de síntesis.

CDK fue originalmente escrito en TypeScript y luego portado a otros idiomas


(por ejemplo, C#, Python y, por supuesto, Java). Esto significa que el Java CDK
aún no se siente como un ciudadano de primera clase dentro del ecosistema
de CDK. No hay tantas bibliotecas de construcción alrededor y tiene algunos
6. Diseñando un Proyecto de Despliegue con CDK 90

problemas de crecimiento que la variante original de TypeScript no tiene.

Habiendo enumerado todas esas molestias del Java CDK, no todo es malo. La
comunidad en GitHub es muy activa y ha habido una solución o solución alter-
nativa para cualquier problema que hemos encontrado hasta ahora. La inversión
de tiempo seguramente valdrá la pena una vez que hayas construido constructos
que muchos equipos de tu empresa puedan usar para desplegar rápidamente sus
aplicaciones a AWS.

Ahora, finalmente, vamos a empezar a trabajar en la construcción de aplicacio-


nes CDK!

La aplicación CDK para el repositorio Docker

Comenzaremos con la pila más simple - la pila del repositorio Docker. Esta pila
solo desplegará un único recurso de CloudFormation, es decir, un repositorio de
ECR.

Puedes encontrar el código para la DockerRepositoryApp en GitHub. Aquí está


en su totalidad:

public class DockerRepositoryApp {

public static void main(final String[] args) {


App app = new App();

String accountId = (String) app


.getNode()
.tryGetContext("accountId");
requireNonEmpty(accountId, "accountId");

String region = (String) app


.getNode()
.tryGetContext("region");
requireNonEmpty(region, "region");
6. Diseñando un Proyecto de Despliegue con CDK 91

String applicationName = (String) app


.getNode()
.tryGetContext("applicationName");
requireNonEmpty(applicationName, "applicationName");

Environment awsEnvironment = makeEnv(accountId, region);

Stack dockerRepositoryStack = new Stack(


app,
"DockerRepositoryStack",
StackProps.builder()
.stackName(applicationName + "-DockerRepository")
.env(awsEnvironment)
.build());

DockerRepository dockerRepository = new DockerRepository(


dockerRepositoryStack,
"DockerRepository",
awsEnvironment,
new DockerRepositoryInputParameters(applicationName, accountId));

app.synth();
}

static Environment makeEnv(String accountId, String region) {


return Environment.builder()
.account(accountId)
.region(region)
.build();
}

Lo desglosaremos paso a paso en las próximas secciones. Podría ser una buena
idea abrir el código en tu navegador para tenerlo a mano mientras sigues
leyendo.
6. Diseñando un Proyecto de Despliegue con CDK 92

Parametrización de ID de la cuenta y región

El primer concepto que estamos aplicando es siempre pasar un ID de la cuenta y


región.

Como se discutió en Primeros Pasos con CDK, podemos pasar parámetros a una
aplicación CDK con el parámetro de línea de comandos -c o agregándolos a
la sección de contexto en el archivo cdk.json. En la aplicación, leemos los
parámetros accountId y region de esta manera:

String accountId = (String) app


.getNode()
.tryGetContext("accountId");

String region = (String) app


.getNode()
.tryGetContext("region");

Estamos usando estos parámetros para crear un objeto Environment:

static Environment makeEnv(String accountId, String region) {


return Environment.builder()
.account(accountId)
.region(region)
.build();
}

Entonces, pasamos este objeto Environment a la stack que creamos a través del
método env() en el constructor.

No es necesario definir de manera explícita el entorno de nuestra stack CDK. Si


no definimos un entorno, la stack se desplegará en la cuenta y región que hemos
configurado en nuestra AWS CLI local. ¿Recuerdas haber ejecutado el comando
aws configure? Lo que hayas ingresado allí como cuenta y región, será lo que
se utilice.
6. Diseñando un Proyecto de Despliegue con CDK 93

Depender de la cuenta y región por defecto según nuestra configuración local no


es lo ideal. Queremos tener la capacidad de desplegar una stack desde cualquier
máquina (incluyendo los servidores CI) a cualquier cuenta y cualquier región,
por eso siempre los parametrizamos.

Verificación de la Coherencia de los Parámetros de Entrada

No debería ser una sorpresa que recomendamos fuertemente validar todos los
parámetros de entrada. Hay pocas cosas más frustrantes que desplegar una
stack y que CloudFormation se queje 5 minutos después sobre la falta de algo
en el despliegue.

En nuestro código, añadimos una simple verificación requireNonEmpty() a


todos los parámetros:

String accountId = (String) app.getNode().tryGetContext("accountId");


requireNonEmpty(accountId, "accountId");

El método requireNonEmpty() lanza una excepción con un mensaje útil si el


parámetro es nulo o una cadena vacía.

Eso es suficiente para captar una amplia gama de errores desde el principio.
Para la mayoría de los parámetros, esta simple validación será suficiente. No
queremos hacer validaciones pesadas como verificar si una cuenta o una región
realmente existe, porque CloudFormation no tarda en lanzar errores en estos
casos.

Una Pila por Aplicación

Otro concepto que estamos promoviendo es el de una única pila por aplicación
de CDK.
6. Diseñando un Proyecto de Despliegue con CDK 94

Técnicamente, CDK nos permite agregar tantas pilas como queramos a una
aplicación de CDK. Cuando interactuamos con la aplicación de CDK, podríamos
elegir qué pilas desplegar o destruir proporcionando un filtro adecuado:

cdk deploy Stack1


cdk deploy Stack2
cdk deploy Stack*
cdk deploy *

Suponiendo que la aplicación CDK contiene muchos stacks, los dos primeros
comandos desplegarían exactamente un stack. El tercer comando desplegaría
todos los stacks con el prefijo “Stack”, y el último comando desplegaría todos
los stacks.

Sin embargo, existe un gran inconveniente con este enfoque. CDK creará los
archivos de CloudFormation para todos los stacks, incluso si solo queremos
desplegar un único stack. Esto significa que tenemos que proporcionar los pa-
rámetros de entrada para todos los stacks, incluso si solo deseamos interactuar
con un único stack.

Es muy probable que diferentes stacks requieran diferentes parámetros de


entrada, por lo que tendríamos que proporcionar parámetros para un stack que
no es de nuestro interés en este momento.

Este problema puede mitigarse colocando todos los parámetros de entrada en


la sección context de un archivo cdk.json compartido, de manera que no
tengamos que pasarlos al comando cdk usando el parámetro -c. Pero esto
todavía implica que los stacks de CDK están acoplados a través de este archivo
cdk.json.

Podría tener sentido agrupar ciertos stacks fuertemente acoplados en la misma


aplicación CDK, pero en general, deseamos que nuestros stacks estén ligera-
mente acoplados, si es que están acoplados. Por lo tanto, optamos por envolver
cada stack en su propia aplicación CDK para desacoplar los stacks.
6. Diseñando un Proyecto de Despliegue con CDK 95

En el caso de nuestra DockerRepositoryApp, estamos creando solo un stack:

Stack dockerRepositoryStack = new Stack(


app,
"DockerRepositoryStack",
StackProps.builder()
.stackName(applicationName + "-DockerRepository")
.env(awsEnvironment)
.build());

Un parámetro de entrada para la aplicación es el applicationName, es decir,


el nombre de la aplicación para la cual queremos crear un repositorio Docker.
Estamos utilizando el applicationName para prefijar el nombre del stack, para
que podamos identificar el stack rápidamente en CloudFormation.

El Componente DockerRepository

Ahora, veamos el componente DockerRepository. Este componente es el cora-


zón de la DockerRepositoryApp:

DockerRepository dockerRepository = new DockerRepository(


dockerRepositoryStack,
"DockerRepository",
awsEnvironment,
new DockerRepositoryInputParameters(applicationName, accountId));

DockerRepository es otro de los constructores de nuestra biblioteca de cons-


tructores.

Estamos pasando el dockerRepositoryStack previamente creado como el ar-


gumento de ámbito, para que el constructor se agregue a ese stack.

El constructor DockerRepository espera un objeto de tipo DockerRepositor-


yInputParameters como parámetro, que agrupa todos los parámetros de en-
trada que el constructor necesita en un solo objeto. Usamos este enfoque para
6. Diseñando un Proyecto de Despliegue con CDK 96

todos los constructores en nuestra biblioteca porque no queremos manejar


listas largas de argumentos y queremos hacer muy explícito qué parámetros
deben ir en un constructor específico.

Echemos un vistazo al código del constructor en sí:

public class DockerRepository extends Construct {

private final IRepository ecrRepository;

public DockerRepository(
final Construct scope,
final String id,
final Environment awsEnvironment,
final DockerRepositoryInputParameters dockerRepositoryInputParameters) {
super(scope, id);

this.ecrRepository = Repository.Builder.create(this, "ecrRepository")


.repositoryName(dockerRepositoryInputParameters.dockerRepositoryName)
.lifecycleRules(singletonList(LifecycleRule.builder()
.rulePriority(1)
.maxImageCount(dockerRepositoryInputParameters.maxImageCount)
.build()))
.build();

// grant pull and push to all users of the account


ecrRepository.grantPullPush(
new AccountPrincipal(dockerRepositoryInputParameters.accountId));
}

public IRepository getEcrRepository() {


return ecrRepository;
}
}

DockerRepository extiende Construct, lo que lo hace un constructo customi-


zado. La principal responsabilidad de este constructo es crear un repositorio
ECR con Repository.Builder.create() y pasar algunos de los parámetros que
previamente recopilamos en DockerRepositoryInputParameters.
6. Diseñando un Proyecto de Despliegue con CDK 97

Repository es un constructo de nivel 2, lo que significa que no expone di-


rectamente los atributos subyacentes de CloudFormation, sino que ofrece una
abstracción sobre ellos para mayor comodidad. Una de estas comodidades es
el método grantPullPush(), que usamos para conceder a todos los usuarios de
nuestra cuenta AWS acceso para realizar operaciones de push y pull de imágenes
Docker al y desde el repositorio, respectivamente.

En esencia, nuestro constructo customizado DockerRepository es solo un


envoltorio glorificado alrededor del constructo Repository de CDK con la res-
ponsabilidad adicional de gestionar los permisos. Es un poco excesivo para
el propósito, pero es un buen candidato para introducir la estructura de los
constructos en nuestra biblioteca de cdk-constructs.

Envolver Comandos de CDK con NPM

Con la aplicación CDK anterior, ahora podemos desplegar un repositorio Docker


con este comando:

cdk deploy \
-c accountId=... \
-c region=... \
-c applicationName=...

Eso funcionará mientras tengamos una única aplicación CDK, pero como po-
drías suponer ahora, vamos a construir varias aplicaciones CDK - una para cada
stack. En cuanto haya más de una aplicación en el classpath, CDK se quejará
porque no sabe a cuál de esas aplicaciones iniciar.

Para solucionar este problema, utilizamos el parámetro --app:


6. Diseñando un Proyecto de Despliegue con CDK 98

cdk deploy \
--app "./mvnw -e -q compile exec:java \
-Dexec.mainClass=dev.stratospheric.todoapp.cdk.DockerRepositoryApp" \
-c accountId=... \
-c region=... \
-c applicationName=...

Con el parámetro --app, podemos definir el ejecutable que CDK debería lla-
mar para ejecutar la aplicación de CDK. Por defecto, CDK llama a mvn -e -q
compile exec:java para correr una aplicación (este valor predeterminado está
configurado en cdk.json, como se discute en Primeros pasos con CDK).

Al tener más de una aplicación de CDK en el classpath, necesitamos indicarle


a Maven qué aplicación ejecutar, así que añadimos la propiedad del sistema
exec.mainclass y la dirigimos a nuestra DockerRepositoryApp.

Ahora hemos resuelto el problema de tener más de una aplicación de CDK pero
no queremos teclear todo eso en la línea de comandos cada vez que queramos
probar un despliegue, ¿verdad?

Para hacerlo un poco más cómodo al ejecutar un comando con muchos argu-
mentos, trasladamos los parámetros de configuración no sensibles al archivo
cdk.json:

{
"context": {
"accountId": "221875718260",
"region": "eu-central-1",
"applicationName": "todo-app"
}
}

Además, integraremos la llamada CDK en un paquete NPM. Para esto, crearemos


un archivo package.json que contiene un script para cada comando que
queremos ejecutar:
6. Diseñando un Proyecto de Despliegue con CDK 99

{
"name": "stratospheric-cdk",
"version": "0.1.0",
"private": true,
"scripts": {
"repository:deploy": "cdk deploy --app ...",
"repository:destroy": "cdk destroy --app ..."
},
"devDependencies": {
"aws-cdk": "2.5.0"
}
}

Una vez que hayamos ejecutado npm install para instalar la dependencia CDK
(y sus dependencias transitivas, en ese sentido), podemos implementar nuestro
stack de repositorio Docker con un simple npm run repository:deploy.
A lo largo de este libro, añadiremos scripts para todas nuestras aplicaciones CDK
en este archivo package.json.
El único parámetro que tenemos que proporcionar al comando cdk es el pará-
metro --app, ya que este varía para cada aplicación CDK.

En caso de necesidad, podemos sobrescribir un parámetro en la línea de coman-


dos con:

npm run repository:deploy -- -c applicationName=...

Los argumentos después del -- anularán cualquier argumento definido en el


script package.json o en el archivo cdk.json. Podemos utilizar este mecanis-
mo para pasar secretos y contraseñas a nuestras aplicaciones y evitar compro-
meter cualquier información sensible en nuestro repositorio de GitHub.

Con este archivo package.json, ahora tenemos una ubicación central donde
podemos buscar los comandos que tenemos a nuestra disposición para imple-
mentar o eliminar pilas de CloudFormation. Además, no tenemos que escribir
6. Diseñando un Proyecto de Despliegue con CDK 100

mucho para ejecutar uno de los comandos. Más tarde añadiremos más coman-
dos a este archivo y enriqueceremos nuestro archivo cdk.json con nuevos
parámetros de configuración. Puedes echar un vistazo al archivo completo con
las tres pilas en GitHub.

La App de Network CDK

La siguiente pila que vamos a examinar es la Network stack. La aplicación CDK


que contiene ese paso es la NetworkApp. Puedes encontrar su código en GitHub:

public class NetworkApp {

public static void main(final String[] args) {


App app = new App();

String environmentName = (String) app


.getNode()
.tryGetContext("environmentName");
requireNonEmpty(environmentName, "environmentName");

String accountId = (String) app


.getNode()
.tryGetContext("accountId");
requireNonEmpty(accountId, "accountId");

String region = (String) app


.getNode()
.tryGetContext("region");
requireNonEmpty(region, "region");

Environment awsEnvironment = makeEnv(accountId, region);

Stack networkStack = new Stack(


app,
"NetworkStack",
StackProps.builder()
.stackName(environmentName + "-Network")
.env(awsEnvironment)
6. Diseñando un Proyecto de Despliegue con CDK 101

.build());

Network network = new Network(


networkStack,
"Network",
awsEnvironment,
environmentName,
new Network.NetworkInputParameters());

app.synth();
}

static Environment makeEnv(String account, String region) {


return Environment.builder()
.account(account)
.region(region)
.build();
}

Está construido en el mismo patrón que el DockerRepositoryApp. Primero,


tenemos algunos parámetros de entrada, luego creamos una pila, y finalmente,
agregamos un constructo Network a esa pila.

Exploremos esta aplicación con un poco más de detalle.

Gestionando Diferentes Entornos

La primera diferencia con el DockerRepositoryApp es que ahora esperamos un


environmentName como un parámetro de entrada.

Recuerda que uno de nuestros requisitos es la capacidad de desplegar nuestra


aplicación en diferentes entornos como staging o producción. Introdujimos el
parámetro environmentName precisamente para ese propósito.

El nombre del entorno puede ser una cadena de texto cualquiera. Lo usamos en
el método stackName() para prefijar el nombre de la pila. Más tarde, veremos
6. Diseñando un Proyecto de Despliegue con CDK 102

que lo usamos dentro del constructo Network también para prefijar los nombres
de algunos otros recursos. Esto separa la pila y los otros recursos de los desple-
gados en otro entorno.

Una vez que hemos desplegado la aplicación con, por ejemplo, el nombre del
entorno “staging”, podemos desplegarla de nuevo con el nombre del entorno
“prod” y se desplegará una nueva pila. Si usamos el mismo nombre de entorno,
CDK reconocerá que una pila con el mismo nombre ya ha sido desplegada y
actualizará esa pila en lugar de tratar de crear una nueva.

Con este sencillo parámetro, ahora tenemos el poder de desplegar múltiples


redes que están completamente aisladas entre sí.

El Constructo Network

Echemos un vistazo al constructo Network. Este es otro constructo de nuestra


biblioteca de constructos, y puede encontrar el código completo en GitHub. Aquí
está un fragmento:

public class Network extends Construct {

// fields omitted

public Network(
final Construct scope,
final String id,
final Environment environment,
final String environmentName,
final NetworkInputParameters networkInputParameters) {

super(scope, id);

this.environmentName = environmentName;

this.vpc = createVpc(environmentName);
6. Diseñando un Proyecto de Despliegue con CDK 103

this.ecsCluster = Cluster.Builder.create(this, "cluster")


.vpc(this.vpc)
.clusterName(prefixWithEnvironmentName("ecsCluster"))
.build();

createLoadBalancer(vpc, networkInputParameters.getSslCertificateArn());

createOutputParameters();
}

// other methods omitted

Está estructurado de forma muy similar a la pila de red que creamos en el capí-
tulo Familiarizándonos con AWS. Creamos un VPC y un clúster de ECS para alojar
nuestra aplicación más tarde. Además, ahora estamos creando un balanceador
de carga y lo conectamos al clúster de ECS. Este balanceador de carga distribuirá
las solicitudes entre varios nodos de nuestra aplicación.

El balanceador de carga toma el VPC y un certificado SSL opcional como entrada.


Por ahora, hemos omitido un certificado SSL, por lo que el balanceador de
carga solo permitirá llamadas HTTP planas. Si quieres experimentar con un
certificado SSL, crea uno en el AWS Certificate Manager, copia su ARN, y pásalo a
los NetworkInputParameters, como lo hemos hecho en NetworkApp en GitHub.

Existen alrededor de 100 líneas de código ocultas en los métodos createVpc() y


createLoadBalancer() que crean construcciones de nivel 2 y conexiones entre
ellas. Pero eso es mucho mejor que un par de cientos de líneas de código en
YAML, ¿no crees?

No entraremos en los detalles de este código, sin embargo, porque es mejor con-
sultar en los documentos de CDK y CloudFormation para entender qué recursos
usar y cómo usarlos. Si te interesa, no dudes en explorar el código del constructo
Network en GitHub y abrir los documentos de CDK en una segunda ventana del
6. Diseñando un Proyecto de Despliegue con CDK 104

navegador para leer sobre cada uno de los recursos. Si los documentos de CDK
no profundizan lo suficiente siempre puedes buscar el recurso respectivo en los
documentos de CloudFormation.

Compartiendo Parámetros de Salida a través de SSM

Sin embargo, vamos a investigar el método createOutputParameters() llama-


do en la última línea del constructor: ¿Qué hace ese método?

Nuestra NetworkApp crea una red en la que podemos colocar nuestra aplicación
más tarde. Otros stacks - como el stack de Servicio, que vamos a ver a continua-
ción - necesitarán conocer algunos parámetros de esa red para poder conectarse
a ella. El stack de Servicio necesitará saber en qué VPC ubicar sus recursos, a qué
balanceador de carga conectar, y en qué clúster de ECS desplegar el contenedor
Docker, por ejemplo.

La pregunta es: ¿Cómo obtiene el stack de Servicio estos parámetros? Podría-


mos, por supuesto, buscar estos parámetros a mano después de desplegar el
stack de Network, y luego pasarlos manualmente como parámetros de entrada
cuando desplegamos el stack de Servicio. Eso requeriría intervención manual,
sin embargo, lo cual estamos tratando de evitar.

Podríamos automatizarlo usando la CLI de AWS para obtener esos parámetros


después de que el stack de Network esté desplegado, como hicimos en el
capítulo Familiarizándonos con AWS, pero eso requeriría scripts de shell largos
y propensos a errores.

Optamos por una solución más elegante que es más fácil de mantener y más
flexible: Cuando desplegamos el stack de Network, almacenamos cualquier
parámetro que otros stacks necesiten en la tienda de parámetros SSM.

Y eso es lo que el método createOutputParameters() está haciendo. Para cada


6. Diseñando un Proyecto de Despliegue con CDK 105

parámetro que queremos exponer, crea un constructo StringParameter con el


valor del parámetro:

private void createOutputParameters(){


StringParameter vpcId=StringParameter.Builder.create(this,"vpcId")
.parameterName(createParameterName(environmentName,PARAMETER_VPC_ID))
.stringValue(this.vpc.getVpcId())
.build();

// more parameters
}

Un detalle importante es que el método createParameterName() antepone al


nombre del parámetro el nombre del entorno para hacerlo único, incluso cuando
la pila se despliega en varios entornos al mismo tiempo:

private static String createParameterName(


String environmentName,
String parameterName) {
return environmentName + "-Network-" + parameterName;
}

Un nombre de parámetro de muestra podría ser staging-Network-vpcId. El


nombre deja en claro que este parámetro contiene el ID de la VPC que imple-
mentamos con el stack de Network en staging.

Con este patrón de nombres, podemos leer los parámetros que necesitamos al
construir otros stacks sobre el stack de Network.

Para que sea más práctico recuperar los parámetros nuevamente, hemos añadi-
do métodos estáticos al constructor Network que recuperan un solo parámetro
del almacén de parámetros:
6. Diseñando un Proyecto de Despliegue con CDK 106

private static String getVpcIdFromParameterStore(


Construct scope,
String environmentName) {

return StringParameter.fromStringParameterName(
scope,
PARAMETER_VPC_ID,
createParameterName(environmentName, PARAMETER_VPC_ID))
.getStringValue();
}

Este método utiliza la misma construcción StringParameter para leer el pa-


rámetro de la tienda de parámetros nuevamente. Para asegurarnos de que
estamos obteniendo el parámetro para el entorno correcto, estamos pasando
el nombre del entorno al método.

Finalmente, proporcionamos el método público getOutputParametersFromPa-


rameterStore() que recoge todos los parámetros de salida de la construcción
Network y los combina en un objeto de tipo NetworkOutputParameters:

public static NetworkOutputParameters getOutputParametersFromParameterStore(


Construct scope,
String environmentName) {

return new NetworkOutputParameters(


getVpcIdFromParameterStore(scope, environmentName),
// ... other parameters
);
}

Luego podemos invocar este método desde otras aplicaciones CDK para obtener
todos los parámetros con una sola línea de código.

Pasamos el stack o construct desde el que estamos llamando al método como


el parámetro scope. La otra aplicación CDK solo tiene que proporcionar el
parámetro environmentName y obtendrá todos los parámetros que necesita del
construct Network para este entorno.
6. Diseñando un Proyecto de Despliegue con CDK 107

¡Los parámetros nunca abandonan nuestras aplicaciones CDK, lo que significa


que no tenemos que pasarlos en scripts o parámetros de línea de comandos!

Quizás recuerdes la sección Outputs en la plantilla CloudFormation en el capí-


tulo Familiarizándonos con AWS y te preguntes por qué no estamos utilizando la
característica de parámetros de salida de CloudFormation. Con el construct de
nivel 1 CfnOutput, CDK realmente admite salidas de CloudFormation.

Sin embargo, estas salidas están fuertemente acopladas al stack que las crea,
mientras que queremos crear parámetros de salida para constructs que luego
pueden componerse en un stack. Además, la tienda SSM sirve como una visión
general de todos los parámetros que existen en diferentes entornos, lo que
facilita mucho la depuración de errores de configuración.

Otra razón para usar parámetros SSM es que tenemos más control sobre ellos.
Podemos nombrarlos como queramos y podemos acceder a ellos fácilmente
utilizando el patrón descrito anteriormente. Eso permite un modelo de progra-
mación conveniente.

Dicho esto, los parámetros SSM tienen la desventaja de incurrir en costos adicio-
nales de AWS con cada llamada API a la tienda de parámetros SSM. En nuestra
aplicación de ejemplo, esto es insignificante pero en una gran infraestructura
puede sumar una cantidad considerable.

En conclusión, podríamos haber utilizado salidas de CloudFormation en lugar


de parámetros SSM - como siempre, es un juego de equilibrios.

La Aplicación de Servicio CDK

Echemos un vistazo a la última aplicación CDK para este capítulo: ServiceApp.


Aquí se encuentra la mayoría del código. De nuevo, puedes encontrar el código
6. Diseñando un Proyecto de Despliegue con CDK 108

completo en GitHub:

public class ServiceApp {

public static void main(final String[] args) {


App app = new App();

String environmentName = (String) app


.getNode()
.tryGetContext("environmentName");
requireNonEmpty(environmentName, "environmentName");

String applicationName = (String) app


.getNode()
.tryGetContext("applicationName");
requireNonEmpty(applicationName, "applicationName");

String accountId = (String) app


.getNode()
.tryGetContext("accountId");
requireNonEmpty(accountId, "accountId");

String springProfile = (String) app


.getNode()
.tryGetContext("springProfile");
requireNonEmpty(springProfile, "springProfile");

String dockerImageUrl = (String) app


.getNode()
.tryGetContext("dockerImageUrl");
requireNonEmpty(dockerImageUrl, "dockerImageUrl");

String region = (String) app


.getNode()
.tryGetContext("region");
requireNonEmpty(region, region);

Environment awsEnvironment = makeEnv(accountId, region);

ApplicationEnvironment applicationEnvironment = new ApplicationEnvironment(


applicationName,
environmentName
);
6. Diseñando un Proyecto de Despliegue con CDK 109

Stack serviceStack = new Stack(


app,
"ServiceStack",
StackProps.builder()
.stackName(applicationEnvironment.prefix("Service"))
.env(awsEnvironment)
.build());

DockerImageSource dockerImageSource =
new DockerImageSource(dockerRepositoryName, dockerImageTag);

NetworkOutputParameters networkOutputParameters =
Network.getOutputParametersFromParameterStore(
serviceStack,
applicationEnvironment.getEnvironmentName());

ServiceInputParameters serviceInputParameters =
new ServiceInputParameters(
dockerImageSource,
environmentVariables(springProfile))
.withHealthCheckIntervalSeconds(30);

Service service = new Service(


serviceStack,
"Service",
awsEnvironment,
applicationEnvironment,
serviceInputParameters,
networkOutputParameters);

app.synth();
}
}

De nuevo, su estructura es muy similar a la de las aplicaciones CDK que hemos


discutido antes. Extraemos varios parámetros de entrada, creamos una pila, y
luego añadimos un constructo de nuestra biblioteca de constructos a la pila -
esta vez el constructo Service.

Sin embargo, aquí están ocurriendo algunas cosas nuevas. Vamos a explorarlas.
6. Diseñando un Proyecto de Despliegue con CDK 110

Gestionando Diferentes Entornos

En la pila de la Red, ya utilizamos un parámetro environmentName para poder


crear múltiples pilas para diferentes entornos desde la misma aplicación CDK.

En el ServiceApp, vamos un paso más allá e introducimos el parámetro appli-


cationName.

A partir de estos dos parámetros, creamos un objeto de tipo ApplicationEnvi-


ronment:

ApplicationEnvironment applicationEnvironment = new ApplicationEnvironment(


applicationName,
environmentName
);

Usamos este objeto ApplicationEnvironment para anteponer al nombre del


conjunto que estamos creando. El componente Service también lo usa inter-
namente para añadir al inicio los nombres de los recursos que crea.

Mientras que para el conjunto de red era suficiente con añadir al inicio el
environmentName a los conjuntos y recursos, ahora necesitamos que el prefijo
contenga también el applicationName. Después de todo, podríamos querer
usar el conjunto de Service para implementar varias aplicaciones en la misma
red.

Por lo tanto, dado el environmentName “staging” y el applicationName “to-


doapp”, todos los recursos llevarán el prefijo staging-todoapp- para contem-
plar la implementación de múltiples conjuntos de Service, cada uno con una
aplicación diferente.
6. Diseñando un Proyecto de Despliegue con CDK 111

Acceso a los parámetros de salida desde SSM

También estamos utilizando applicationEnvironment para acceder a los pará-


metros de salida de un constructo de red implementado previamente:

NetworkOutputParameters networkOutputParameters =
Network.getOutputParametersFromParameterStore(
serviceStack,
applicationEnvironment.getEnvironmentName());

El método estático Network.getOutputParametersFromParameterStore()


que discutimos anteriormente carga todos los parámetros del constructor
Network que se desplegó con el environmentName dado. Si no se encuentran
parámetros con el prefijo respectivo, CloudFormation se quejará durante el
despliegue y detendrá el despliegue del stack del Servicio.

Luego pasamos estos parámetros al constructor Service para que pueda usarlos
para vincular los recursos que despliega a la infraestructura de red existente.

Más adelante en este libro, haremos más uso de este mecanismo cuando este-
mos creando más stacks que expongan parámetros que la aplicación necesita,
como una URL de base de datos o parámetros de contraseña.

Descargando una Imagen Docker

El constructor Service expone la clase DockerImageSource, que nos permite


especificar el origen de la imagen Docker que queremos desplegar:

DockerImageSource dockerImageSource =
new DockerImageSource(dockerImageUrl);

El ServiceApp no debería ser responsable de definir de dónde obtener una


imagen Docker, por lo que delegamos esa responsabilidad al solicitante, quien
6. Diseñando un Proyecto de Despliegue con CDK 112

debe proporcionar el parámetro de entrada dockerImageUrl. A continuación,


introducimos la URL en el DockerImageSource y finalmente pasamos el Docke-
rImageSource a la estructura Service.

El DockerImageSource también tiene un constructor que requiere un dockerRe-


positoryName y un dockerImageTag. El dockerRepositoryName es el nombre
de un repositorio ECR. Esto nos facilita el apuntar al repositorio Docker que
hemos desplegado previamente utilizando nuestro stack DockerRepository.
Vamos a hacer uso de este constructor cuando construyamos un pipeline de
despliegue continuo más adelante.

Gestión de Variables de Entorno

Una aplicación Spring Boot (o cualquier otra aplicación), normalmente está


parametrizada en función del entorno en el que se despliega. Los parámetros
pueden diferir dependiendo del entorno. Spring Boot permite esto a través de
los perfiles de configuración. Spring Boot carga las propiedades de configura-
ción de diferentes archivos YAML o de propiedades, según sea el valor de la
variable de entorno SPRING_PROFILES_ACTIVE.

Por ejemplo, si la variable de entorno SPRING_PROFILES_ACTIVE tiene el valor


staging, Spring Boot primero cargará todos los parámetros de configuración
del archivo común application.yml. Luego, agregará todos los parámetros
de configuración del archivo application-staging.yml, sobrescribiendo cual-
quier parámetro que ya haya sido cargado desde el archivo común.

La estructura Service nos permite introducir un mapa con variables de entorno.


En nuestro caso, agregamos la variable SPRING_PROFILES_ACTIVE con el valor
que toma la variable springProfile, que es un parámetro de entrada para el
ServiceApp.
6. Diseñando un Proyecto de Despliegue con CDK 113

static Map<String, String> environmentVariables(String springProfile) {


Map<String, String> vars = new HashMap<>();
vars.put("SPRING_PROFILES_ACTIVE", springProfile);
return vars;
}

Añadiremos más variables de entorno en los próximos capítulos a medida que


nuestra infraestructura crezca.

El Service Construct

Finalmente, echemos un vistazo rápido al construct Service. El código de este


construct es de un par de centenares de líneas, lo que lo hace demasiado largo
para discutir en detalle aquí. Sin embargo, hablemos de algunos de sus aspectos
más destacados.

El objetivo del construct Service es crear un servicio ECS dentro del clúster ECS
que proporciona el construct Network. Para ello, crea muchos recursos en su
constructor (vea el código completo en GitHub):

public Service(
final Construct scope,
final String id,
final Environment awsEnvironment,
final ApplicationEnvironment applicationEnvironment,
final ServiceInputParameters serviceInputParameters,
final Network.NetworkOutputParameters networkOutputParameters){
super(scope,id);

CfnTargetGroup targetGroup=...
CfnListenerRule httpListenerRule=...
LogGroup logGroup=...

...
}
6. Diseñando un Proyecto de Despliegue con CDK 114

Realiza bastante más que la pila de servicios del capítulo Entrando en calor con
AWS:

• Crea una CfnTaskDefinition para definir una tarea de ECS que aloja la
imagen de Docker proporcionada.
• Añade un CfnService al clúster de ECS previamente desplegado en el
construct Network y añade las tareas a él.
• Crea un CfnTargetGroup para el balanceador de carga desplegado en el
construct Network y lo vincula al servicio de ECS.
• Crea un CfnSecurityGroup para los contenedores de ECS y lo configura para
que el balanceador de carga pueda enrutar el tráfico a los contenedores de
Docker.
• Crea un LogGroup para que la aplicación pueda enviar registros a Cloud-
Watch.

Es posible que notes que estamos utilizando principalmente constructs de nivel


1 aquí, es decir, constructs con el prefijo Cfn. Estos constructs son equivalentes
directos a los recursos de CloudFormation y no proporcionan abstracción sobre
ellos. ¿Por qué no utilizamos constructs de nivel superior que nos habrían
ahorrado algo de código?

La razón es que los constructs de nivel superior existentes realizaban cosas


que no queríamos que hicieran. Añadían recursos que no necesitábamos y
por los que no queríamos pagar. Por lo tanto, decidimos crear nuestro propio
construct Service de nivel superior a partir de exactamente esos recursos de
CloudFormation de bajo nivel que necesitamos.

Esto resalta una posible desventaja de los constructs de alto nivel: Diferentes
proyectos de software necesitan diferentes infraestructuras, y los constructs
de alto nivel no siempre son lo suficientemente flexibles para satisfacer esas
6. Diseñando un Proyecto de Despliegue con CDK 115

diferentes necesidades. La biblioteca de constructs que creamos para este libro,


por ejemplo, probablemente no satisfará todas las necesidades de tu próximo
proyecto de AWS.

Podríamos, por supuesto, crear una biblioteca de constructs que sea altamente
parametrizada y flexible para muchos requisitos diferentes. Esto podría hacer
que los constructs sean complejos y propensos a errores, sin embargo. Otra op-
ción es invertir esfuerzo para crear tu propia biblioteca de constructs adecuada
para tu proyecto (o organización).

Todo se reduce a compromisos.

Accediendo al Servicio

Una vez desplegado, el servicio es accesible a través del balanceador de carga.

Puedes abrir la URL del servicio desde el Servicio de Contenedores Elásticos


en la Consola de AWS si navegas a la pestaña Networking bajo “Clusters >
TU_CLÚSTER > Services > TU_SERVICIO”.

En “DNS names”, encontrarás la URL del balanceador de carga. Haz clic en


“open address” para abrir el servicio en tu navegador:

Accediendo al servicio desplegado

Experimentando con las Aplicaciones CDK

Si deseas experimentar con las aplicaciones CDK que hemos discutido anterior-
mente, siéntete libre de clonar el repositorio de GitHub y navega a la carpeta
cdk. Luego:
6. Diseñando un Proyecto de Despliegue con CDK 116

• ejecuta npm install para instalar las dependencias


• observa el package.json
• cambia los parámetros dentro del cdk.json (lo más importante, establece
el ID de tu cuenta de AWS)
• ejecuta npm run repository:deploy para desplegar un repositorio de Doc-
ker
• ejecuta npm run network:deploy para desplegar una red
• ejecuta npm run service:deploy para desplegar la aplicación “Hello
World” Todo

Luego, echa un vistazo en la Consola de AWS para ver los recursos que crearon
esos comandos.

No olvides eliminar las pilas después, ya sea eliminándolas en la consola de


CloudFormation, o llamando a los scripts npm run *:destroy, ya que de lo
contrario, incurrirás en costes adicionales.

Como parte del apéndice, encontrarás una guía de despliegue detallada


para desplegar la aplicación Todo completa que estamos a punto de crear
en los próximos capítulos.
7. Construyendo un Pipeline de
Despliegue Continuo
La frecuencia de despliegue es una de las cuatro métricas DORA2 que miden
el rendimiento de la entrega de software. El rendimiento de la entrega de
software está altamente correlacionado con el rendimiento organizacional, por
lo que queremos desplegar frecuentemente. Esto significa que no queremos
desencadenar un despliegue manualmente, sino implementar un pipeline de
despliegue continuo que pase cada cambio a producción lo más rápido posible.

Para desplegar rápidamente sin intervención manual (que es propensa a erro-


res) automatizamos el proceso de despliegue. Cada cambio en nuestro código
debería desencadenar un despliegue. En este capítulo, utilizaremos el proyecto
CDK que hemos construido en el capítulo anterior para construir un Pipeline de
Despliegue Continuo Automatizada.

Aunque estamos utilizando Acciones de GitHub como la herramienta para cons-


truir este pipeline, los conceptos se aplican a cualquier otra herramienta CI/CD
también. Como escondemos la mayoría del trabajo detrás de los comandos npm
en nuestro proyecto CDK, el pipeline será solo una ligera capa de configuración
sobre las Acciones de GitHub y debería ser fácilmente transferible a otras
herramientas CI/CD.

Es posible que note que con CodeDeploy y CodePipeline, AWS proporciona


sus propias herramientas para desplegar aplicaciones en su nube. Decidimos
2 Investigación y Evaluaciones de DevOps. Ver también “Accelerate” de Nicole Forsgren, Jez Humble y Gene

Kim.
7. Construyendo un Pipeline de Despliegue Continuo 118

usar Acciones de GitHub en su lugar porque nos permite crear un pipeline a


partir de un archivo de configuración en el repositorio de código, mientras que
CodeDeploy y CodePipeline toman un enfoque más propietario. Configurar un
pipeline a partir de un archivo de configuración es el estándar de facto en estos
días. Por lo tanto, este enfoque es más fácil de transferir a otras herramientas
como Jenkins, CircleCI o Bitbucket Pipelines, si es necesario.

En este capítulo, primero hablaremos sobre las Acciones de GitHub antes de


construir flujos de trabajo auto-servidos que podemos usar para activar un
nuevo entorno de aplicación, si es necesario. Luego, construiremos un flujo
de trabajo de despliegue continuo. Finalmente, discutiremos cómo soportar el
despliegue continuo con una base de código que recibe muchos commits.

Comencemos hablando sobre la terminología de Acciones de GitHub.

Conceptos de Acciones de GitHub

Hay cuatro conceptos principales de Acciones de GitHub que estaremos utili-


zando: flujos de trabajo, trabajos, pasos y ejecuciones de flujo de trabajo. Los
términos han sido escogidos bastante bien por el equipo de GitHub porque son
en gran medida autoexplicativos.

Un paso es la unidad más pequeña dentro de un pipeline CI/CD construida con


Acciones de GitHub. Idealmente, ejecuta un solo comando como el checkout
del código fuente, la compilación del código, o la ejecución de las pruebas.
Deberíamos aspirar a que cada paso sea lo más simple posible para mantenerlo
mantenible. Componemos múltiples pasos en un trabajo.

Un trabajo agrupa múltiples pasos en una unidad lógica y los coloca en una
secuencia. Mientras los pasos dentro de un trabajo se ejecutan en secuencia,
varios trabajos dentro de un flujo de trabajo se ejecutan en paralelo por defecto.
7. Construyendo un Pipeline de Despliegue Continuo 119

Si un trabajo depende de los resultados de otro trabajo, podemos marcarlo


como dependiente de ese otro trabajo y las Acciones de GitHub las ejecutarán
en secuencia. Mientras todos los pasos dentro de un trabajo se ejecutan en el
mismo contenedor y sistema de archivos, un trabajo siempre comienza fresco,
y tendremos que encargarnos de transportar cualquier artefacto de construcción
de un trabajo a otro, si es necesario.

Un flujo de trabajo, a su vez, agrupa múltiples trabajos en una unidad lógica.


Mientras que los pasos y los trabajos son conceptos internos, un flujo de trabajo
puede ser desencadenado por eventos externos como un push en un repositorio
o un webhook de alguna herramienta. Un flujo de trabajo puede contener
muchos trabajos que se ejecutan en secuencia o en paralelo o una mezcla de
ambos, como veamos conveniente. Las Acciones de GitHub mostrarán una bo-
nita visualización de los trabajos dentro de un flujo de trabajo y cómo dependen
unos de otros cuando un flujo de trabajo está en ejecución.

Una ejecución de flujo de trabajo, finalmente, es una instancia de un flujo


de trabajo que ha corrido o está corriendo actualmente. Podemos revisar las
ejecuciones anteriores en la interfaz de usuario de GitHub y ver si alguno de
los pasos o trabajos falló y ver los registros de cada trabajo.

Eso es suficiente terminología, por ahora, ¡veamos algunos flujos de trabajo


reales!

Inicializando un Nuevo Entorno

Vamos a crear un flujo de trabajo que inicialice y prepare nuestro entorno AWS.
Puede revisar el código de todos los flujos de trabajo que estamos discutiendo
en GitHub:
7. Construyendo un Pipeline de Despliegue Continuo 120

name: 01 - Manually Bootstrap the CDK Environment

on:
workflow_dispatch

env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}

jobs:
deploy:
runs-on: ubuntu-20.04
name: Bootstrap CDK
if: github.ref == 'refs/heads/main'
steps:

- name: Checkout code


uses: actions/checkout@v2

- name: Set up JDK 17


uses: actions/setup-java@v2
with:
distribution: 'temurin'
java-version: 17
cache: 'maven'

- name: NPM install


working-directory: cdk
run: npm install

- name: Deploy CDK bootstrap stack


working-directory: cdk
run: npm run bootstrap

- name: Deploy Docker registry


working-directory: cdk
run: npm run repository:deploy

Este flujo de trabajo tiene una única responsabilidad: Preparar nuestra cuenta de
AWS para despliegues de infraestructura y aplicaciones con el CDK. Esto incluye
7. Construyendo un Pipeline de Despliegue Continuo 121

iniciar el entorno de AWS y crear un registro de contenedores Docker con ECR.

Para llegar al punto en el que podemos ejecutar comandos CDK, nuestro trabajo
requiere un par de pasos preparatorios: obtener el código, instalar Java, y
ejecutar npm install.

Estamos pasando las credenciales AWS como variables de entorno desde el


contexto de secrets de GitHub. Estas credenciales AWS pertenecen a un usuario
de IAM de tipo técnico con privilegios similares a los de un administrador. Este
usuario necesita permisos suficientes para crear y destruir infraestructura en
nuestra cuenta de AWS. Hemos creado este usuario con antelación en la consola
de gestión de AWS y copiado la ID de la clave de acceso y el secreto.

Visita el apéndice para obtener una lista de los permisos requeridos.

El contexto de secrets de GitHub Actions hará disponibles todos los secretos


que hemos configurado en los ajustes de nuestro repositorio de GitHub. Los
secretos son “solo de escritura” y no pueden ser vistos de nuevo después de
almacenarlos. Tampoco se registrarán en los logs de GitHub Actions. Incluso si
uno de nuestros trabajos los imprimiera en la consola, serían enmascarados con
caracteres de asterisco en los logs.

Vale la pena mencionar que hemos configurado para que este flujo de trabajo
se active mediante un evento de workflow_dispatch, lo que significa que tiene
que ser activado manualmente. GitHub Actions mostrará un botón en la interfaz
de usuario que nos permite activar este flujo de trabajo.

Este flujo de trabajo necesita ser ejecutado solo una vez para un entorno de AWS.
Automatizando estos pasos con GitHub Actions, ahora tenemos una capacidad
de autoservicio para todos los desarrolladores del equipo para iniciar nuevos
entornos.
7. Construyendo un Pipeline de Despliegue Continuo 122

Implementando una Red Compartida

Antes de que podamos desplegar nuestra aplicación, necesitamos implementar


la infraestructura. Podríamos hacerlo manualmente, por supuesto, porque es
una tarea única. Pero, ¿no sería genial si pudiéramos hacer de esto una tarea
de autoservicio para los desarrolladores que quieren crear su propio entorno de
prueba?

El primer elemento de un entorno para nuestra aplicación es la pila de red, que


puede ser compartida entre múltiples aplicaciones (o instancias de la misma
aplicación). Por lo tanto, vamos a crear un flujo de trabajo que implemente una
red:

name: 02 - Manually create a shared environment

on:
workflow_dispatch:
inputs:
environmentName:
description: 'The name of the environment to create.'
required: true

# ... environment variables

jobs:
deploy-network-stack:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
name: Deploy the network stack
steps:
# ... preparatory steps

- name: Deploy network stack


working-directory: cdk
run: |
npm run network:deploy -- \
-c environmentName=${{ github.event.inputs.environmentName }}
7. Construyendo un Pipeline de Despliegue Continuo 123

El paso principal llama al comando network:deploy del proyecto CDK que


hemos construido en el capítulo Diseñando un Proyecto CDK.

Al igual que con el flujo de trabajo de bootstrap anterior, estamos utilizando el


evento workflow_dispatch para iniciar manualmente este flujo de trabajo. Co-
mo definimos un parámetro de entrada adicional environmentName, la interfaz
de usuario también nos mostrará un campo de entrada para definir el nombre
del entorno para el que queremos crear una red.

Cualquier desarrollador en el equipo puede activar este flujo de trabajo para


crear o actualizar su propia infraestructura de red. Solo necesitan navegar al
flujo de trabajo en su navegador y hacer clic en un botón para desplegar su propia
red, si la necesitan. También podemos usar este flujo de trabajo para actualizar
una red existente, por ejemplo, si hemos cambiado algo en el stack de red en
nuestro proyecto CDK. Solo tenemos que pasar el correcto environmentName y
GitHub Actions. CDK y CloudFormation harán el trabajo por nosotros.

Desplegando un Entorno de Aplicación

Nuestra aplicación Todo necesita más infraestructura que solo una red, aunque.
En capítulos posteriores, integraremos una base de datos, sistemas de men-
sajería e infraestructura de autenticación también. Otras aplicaciones pueden
requerir su propia instancia de base de datos u otra infraestructura con estado.

Para crear esta infraestructura específica de la aplicación, construimos un flujo


de trabajo que es muy similar al flujo de trabajo de red compartida en la sección
anterior:
7. Construyendo un Pipeline de Despliegue Continuo 124

name: 03 - Manually create the Todo-App environment

on:
workflow_dispatch:
inputs:
environmentName:
description: 'The name of the environment to deploy the resources to.'
required: true

jobs:
deploy-messaging-stack:
...
deploy-database-stack:
...
deploy-cognito-stack:
...

Al igual que antes, este flujo de trabajo se activa manualmente y toma un


environmentName como un parámetro de entrada.

Este flujo de trabajo tiene un par de trabajos. Cada trabajo contiene pasos
muy similares a los pasos del flujo de trabajo en la sección anterior, pero
cada uno con un comando diferente. Un trabajo puede llamar a npm run de-
ploy:database para desplegar la base de datos, y otro puede llamar a npm
run deploy:messaging para iniciar una pila con una instancia de ActiveMQ.
Crearemos las aplicaciones CDK detrás de estos comandos en capítulos poste-
riores de este libro. Dado que estas pilas son independientes entre sí, podemos
desplegarlas en paralelo. Por lo tanto, colocamos cada pila en su propio trabajo.

Con los dos flujos de trabajo que hemos construido hasta ahora, un desarro-
llador puede crear su propio entorno para desplegar su propia instancia de la
aplicación Todo. Como los flujos de trabajo se activan manualmente, el entorno
no se actualizará automáticamente cuando cambiemos algo en las aplicaciones
CDK correspondientes. Sin embargo, podemos modificar fácilmente los flujos
de trabajo para que se activen por un cambio en el código CDK para volver
7. Construyendo un Pipeline de Despliegue Continuo 125

a desplegar automáticamente la infraestructura en los entornos de prueba y


producción, por ejemplo. Hemos elegido no hacer eso, sin embargo, porque la
infraestructura tiende a no cambiar muy a menudo - y si cambia, queremos que
un humano esté involucrado para evaluar el impacto.

Con la infraestructura en marcha, finalmente podemos crear un pipeline de


despliegue continuo que despliega la aplicación con cada cambio de código.

Creando un Flujo de Trabajo para Despliegue Continuo

Digamos que hemos ejecutado todos los flujos de trabajo que hemos discutido
hasta ahora y hemos creado un entorno completo. Ahora es el momento de
desplegar nuestra aplicación Todo en este entorno.

Ahora vamos a construir un flujo de trabajo de “Despliegue”, que se verá así en


papel:

El flujo de trabajo de “Despliegue” compila, publica y despliega la aplicación.

El flujo de trabajo contiene:

• un paso de “Compilación” que compila el código y ejecuta las pruebas,


7. Construyendo un Pipeline de Despliegue Continuo 126

• un paso de “Publicar” que crea una imagen de Docker y la publica en un


repositorio ECR, y
• un paso de “Despliegue” que llama al proyecto CDK para desplegar esa
imagen de Docker en la infraestructura que hemos creado anteriormente.

Aquí está el esqueleto de nuestro flujo de trabajo de despliegue continuo en


GitHub Actions:

name: 04 - Publish Todo-App

on:
push:
paths:
- 'application/**'
- 'cdk/**/*Service*'
- 'cdk/pom.xml'
workflow_dispatch:
jobs:
build-and-deploy:
...

No se activa por un evento workflow_dispatch como los flujos de trabajo


anteriores, sino por un push al repositorio de código. Sin embargo, no queremos
iniciar una implementación si un archivo README cambia (o cualquier otro ar-
chivo en el repositorio que no sea parte de la aplicación). Cada implementación
bloquea la cadena de implementación durante un par de minutos y no queremos
bloquearlo innecesariamente. Por lo tanto, configuramos el flujo de trabajo para
que solo se active cuando algo cambie dentro de la carpeta application y/o en
algunos archivos seleccionados de nuestra carpeta cdk.

El flujo de trabajo solo tiene una tarea llamada build-and-deploy, que im-
plementa los tres pasos del diagrama anterior: “Build”, “Publish” y “Deploy”.
Podríamos haber construido una tarea separada para cada uno de los pasos, pero
7. Construyendo un Pipeline de Despliegue Continuo 127

dado que queremos compartir la salida de un paso con los siguientes, esto habría
complicado un poco más el flujo de trabajo.

Veamos cada uno de los tres pasos.

El paso “Build”

El paso “Build” es bastante sencillo:

- name: Build application


working-directory: application
run: ./gradlew build --stacktrace

Ejecutamos la tarea de construcción de Gradle dentro de la carpeta application.


Gradle se encarga de ejecutar las pruebas y empaquetar nuestra aplicación
Spring Boot en un archivo JAR.

El paso de “Publicación”

Una vez que la aplicación ha sido empaquetada, el siguiente paso en el flujo de


trabajo es crear y publicar una imagen de Docker:

- name: Create Docker image tag


id: dockerImageTag
run: echo "::set-output name=tag::$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA}"

- name: Publish Docker image to ECR registry


if: github.ref == 'refs/heads/main'
env:
DOCKER_IMAGE_TAG: ${{ steps.dockerImageTag.outputs.tag }}
working-directory: application
run: |
docker build -t todo-app .
docker tag todo-app our.ecr.amazonaws.com/todo-app:${DOCKER_IMAGE_TAG}
docker tag todo-app our.ecr.amazonaws.com/todo-app:latest
aws ecr get-login-password --region ${AWS_REGION} \
7. Construyendo un Pipeline de Despliegue Continuo 128

| docker login --username AWS --password-stdin our.ecr.amazonaws.com


docker push our.ecr.amazonaws.com/todo-app:${DOCKER_IMAGE_TAG}
docker push our.ecr.amazonaws.com/todo-app:latest

Estos son en realidad dos pasos: uno para la creación de una etiqueta para la
imagen Docker y otro para la creación y publicación de la imagen Docker.

El paso de “Crear etiqueta de imagen Docker” simplemente construye una


cadena a partir de la fecha y hora actuales y el hash SHA del commit de Git.
Podemos usar esa cadena para etiquetar de forma única nuestra imagen Docker.

El paso de “Publicar imagen Docker en el registro ECR” luego ejecuta una


variedad de comandos diferentes que:

• crean una imagen Docker a partir del Dockerfile en la carpeta applica-


tion,
• etiquetan esa imagen con la etiqueta de imagen Docker creada en el paso
anterior,
• también etiquetan esa imagen con la etiqueta latest,
• obtienen las credenciales de acceso para nuestro repositorio ECR,
• utilizan estas credenciales para permitir a Docker acceder a ese repositorio
ECR, y finalmente,
• publican la imagen y ambas etiquetas al repositorio.

Si este paso se ha finalizado con éxito, ahora tenemos una imagen Docker
actualizada en nuestro repositorio ECR, lista para ser desplegada.

Las variables de entorno AWS requeridas se configuran una vez en la parte


superior del flujo de trabajo y, por lo tanto, están implícitamente disponibles
para cada ejecución de trabajo:
7. Construyendo un Pipeline de Despliegue Continuo 129

env:
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}

Hasta ahora, esto es un flujo de trabajo de entrega continua. Con cada cambio al
código, entregamos una nueva versión de la aplicación a un repositorio Docker.

Para tener un flujo de trabajo de despliegue continuo, aún falta la parte de


“desplegar”.

El Paso de “Desplegar”

Gracias a nuestro proyecto CDK, el paso de “Desplegar” también es bastante


sencillo:

- name: Deploy service stack


if: github.ref == 'refs/heads/main'
working-directory: cdk
run: |
npm run service:deploy -- \
-c environmentName=staging \
-c applicationName=todo-app \
-c dockerImageTag=${GITHUB_RUN_NUMBER}

Simplemente llama al comando npm run service:deploy que hemos creado


en el capítulo Diseñando un Proyecto CDK para actualizar el stack del servicio con
la última imagen de Docker.

Revisando el Pipeline de Despliegue Continuo

Con los pasos de “Construir”, “Publicar” y “Desplegar” en su lugar, ahora tene-


mos un pipeline de despliegue continuo que funciona. Cada vez que realizamos
7. Construyendo un Pipeline de Despliegue Continuo 130

un commit en la rama main de nuestro repositorio de GitHub, se activará el


pipeline y se desplegará una nueva versión de la aplicación en la nube. Los
cambios en otras ramas que no sean main solo ejecutarán la parte de “Construir”
del pipeline porque no queremos publicar nuevas imágenes de Docker para las
compilaciones de ramas.

El paso de “Desplegar”, en particular, llevará algún tiempo en finalizar en cada


ejecución de la acción. Cuando actualizamos el stack del servicio, el clúster
de ECS comenzará automáticamente nuevos nodos con la nueva versión de la
aplicación y luego eliminará los nodos con la versión antigua. Dependiendo de
la configuración, esto puede tardar hasta 15 minutos.

Sin embargo, ¿qué sucede si realizamos dos o más commits al repositorio de


código en un marco de tiempo de 15 minutos? Ambos commits iniciarán la
acción de GitHub Actions y se ejecutarán exitosamente los pasos de “Construir”
y “Publicar”. Pero la ejecución de la acción que fue iniciada en segundo lugar
fallará en el paso de “Desplegar” porque CDK/CloudFormation detectará que
otro despliegue está actualmente en progreso. La segunda ejecución de la acción
tendrá que ser iniciada manualmente nuevamente una vez que el despliegue
haya terminado.

Por lo tanto, aunque esta solución probablemente sea totalmente adecuada para
muchos proyectos, no es adecuada para proyectos con una alta frecuencia de
commits. Muchas ejecuciones de las acciones fallarán y no hay garantía de que
un commit se desplegará de manera oportuna.

En la siguiente sección, discutiremos una solución a este problema.


7. Construyendo un Pipeline de Despliegue Continuo 131

Soportando Despliegues de Alta Frecuencia con Amazon


SQS y AWS Lambda

Intentemos y extendamos el pipeline de despliegue continuo que hemos cons-


truido hasta ahora para solucionar el problema con los commits frecuentes.
Mientras se está realizando un despliegue, no queremos iniciar un segundo
despliegue porque de todos modos fallaría. En cambio, queremos esperar hasta
que el despliegue actual esté terminado, y luego iniciar un nuevo despliegue
para desplegar la última versión de la aplicación que se publicó en el registro
de Docker mientras tanto.

GitHub Actions proporciona una funcionalidad de “Concurrencia” para este


caso de uso. Esto nos permite asignar un “grupo de concurrencia” a cada trabajo.
Todos los trabajos dentro del mismo grupo de concurrencia se pondrán en cola
si otro trabajo con el mismo grupo de concurrencia ya está en ejecución. Los
trabajos actualmente en cola se cancelarán si se pone en cola un nuevo trabajo.
Esta funcionalidad hace exactamente lo que queremos.

Sin embargo, no todas las herramientas de CI proporcionan una funcionalidad


de concurrencia como esta. De todos modos, queremos mostrar cómo podemos
integrar SQS, funciones Lambda y APIs de terceros para resolver un problema.
Entonces, vamos a crear nuestra propia solución de encolado que se puede usar
con cualquier herramienta de CI.

Esto es lo que vamos a construir:


7. Construyendo un Pipeline de Despliegue Continuo 132

Desplegando solo la última versión de una imagen de Docker con la ayuda de SQS y Lambda.

En lugar de un solo flujo de trabajo de GitHub, ahora tenemos dos: un flujo de


trabajo de “Publicar” y un flujo de trabajo de “Desplegar”.

El flujo de trabajo de “Publicar” es muy similar al anterior, excepto por el último


paso. En lugar de llamar a nuestra aplicación CDK para desplegar el stack del
servicio, enviamos un evento a una cola de Amazon SQS que está dedicada a
recibir solicitudes de despliegue.

Cada vez que llega una solicitud de despliegue a la cola, será procesada por
una función de AWS Lambda que vamos a desarrollar. Esta función Lambda
luego desencadenará un despliegue solo si actualmente no hay un despliegue
en ejecución, ordenando efectivamente los despliegues uno tras otro, incluso si
hubo muchos commits en un corto período.

La Lambda activará el flujo de trabajo de “Desplegar” a través de la API de


GitHub, que finalmente utiliza nuestra aplicación CDK para desplegar el stack
de servicio.
7. Construyendo un Pipeline de Despliegue Continuo 133

En las siguientes secciones, veremos todos esos nuevos elementos.

Construyendo una Función Lambda Secuenciadora

Comencemos construyendo la función Lambda. Elegimos TypeScript como el


lenguaje de programación y Node.js como el entorno de ejecución de Lambda.
Podríamos haber elegido cualquier otro lenguaje soportado por AWS Lambda
también, pero queremos mostrar la flexibilidad que tenemos con las Lambdas.

Nuestra función de manejo de AWS Lambda procesa todos los mensajes SQS
entrantes de esta manera:

export const handler = async (e: SqsEvent): Promise<any> => {


const queueUrl = process.env.QUEUE_URL as string;
const region = process.env.REGION as string;
const githubToken = process.env.GITHUB_TOKEN as string;
const event = new SqsEventWrapper(e);
const latestDeploymentEvent: DeploymentEvent = event.getLatestDeploymentEvent();
const github = new GitHub(githubToken);
const queue = new DeploymentQueue(queueUrl, region);

console.log(`Received event: ${JSON.stringify(latestDeploymentEvent)}`);

// If there are more events in the queue: finish and wait for the next event.
if (await queue.hasWaitingEvents()) {
console.log(
"Skipping this event because there are more events waiting in the queue!"
);
return;
}

// If the GitHub workflow is currently running: retry this event later


if (await github.isWorkflowCurrentlyRunning(latestDeploymentEvent)) {
console.log(
"GitHub workflow is currently running - retrying at a later time!"
);
throw "retrying later!";
}
7. Construyendo un Pipeline de Despliegue Continuo 134

// Triggering the GitHub workflow.


await github.triggerWorkflow(latestDeploymentEvent);
};

No vamos a profundizar en todos los detalles de las funciones que estamos


utilizando, pero puedes explorar el código en su totalidad en GitHub.

Una función Lambda siempre debe declarar exactamente una función controla-
dora que se ejecuta cuando la Lambda es activada. En nuestro caso, la Lambda
se ejecutará cada vez que exista un nuevo evento de implementación en nuestra
cola de implementación. En Typescript/Javascript, esta función controladora
es simplemente una función que exportamos en nuestro archivo principal
(index.ts).

La función controladora toma como entrada un SqsEvent, que es simplemente


una estructura JSON para la cual hemos declarado un tipo nosotros mismos. Este
tipo describe la estructura de un evento entrante de SQS. Lo más importante es
que un evento de SQS tiene el campo Records, que contiene uno o más eventos,
cada uno con un body que contiene la información real de un evento de SQS.
Esperamos que el contenido de un evento tenga esta forma:

{
"commitSha": "674e5044b3b269ccb8f4530193cc144f2d6a5ae6",
"ref": "main",
"owner": "stratospheric-dev",
"repo": "stratospheric",
"workflowId": "05-update-todo-app-in-staging.yml",
"dockerImageTag": "20210320051451-674e5044b3b269ccb8f4530193cc144f2d6a5ae6"
}

Cada evento contiene el nombre de la imagen Docker que necesita ser desplega-
da.

Al comienzo de nuestra función controladora, leemos algunos parámetros de las


variables de entorno como la URL de la cola SQS y un token de API para acceder
7. Construyendo un Pipeline de Despliegue Continuo 135

a la API de GitHub. Veremos más adelante cómo configurarlos.

El código principal de la función controladora consta de solo tres pasos:

Primero, verificamos si la cola SQS tiene más eventos esperando en línea. Si hay
al menos un otro evento esperando, simplemente pasamos por alto el evento
actual. La idea es que en este caso, no queremos desplegar la imagen Docker
de este evento ya que ya hay una imagen Docker más reciente esperando ser
desplegada en la cola. En lugar de eso, desplegamos esa imagen Docker más
reciente. Un detalle importante para que esto funcione es que la cola SQS debe
estar configurada como una cola FIFO (primero en entrar, primero en salir).
Esto significa que el orden de los eventos que llegan a la función Lambda está
garantizado para ser el orden en el que los eventos fueron recibidos por la cola
SQS. Para obtener más detalles sobre cómo funciona SQS, echa un vistazo al
capítulo Compartiendo Todos con SQS y SES.

Si no hay otro evento esperando en línea, verificamos si hay un despliegue actual


en marcha. Estamos llamando a la API de GitHub para ver si el flujo de trabajo
“Deploy” está actualmente en marcha. Usamos los campos ref, owner, repo,
y workflowId del evento y la variable de entorno GITHUB_TOKEN para construir
una solicitud a GitHub. Si el flujo de trabajo está en marcha, lanzamos un error.
Si la invocación de Lambda sale con un error, le indica a la cola SQS que el evento
no se ha procesado con éxito, y se volverá a intentar después de un tiempo.

Finalmente, si no hay otro evento esperando en línea y el flujo de trabajo


“Deploy” no está en marcha, ponemos en marcha el flujo de trabajo “Deploy” a
través de la API de GitHub.
7. Construyendo un Pipeline de Despliegue Continuo 136

Provisionando la Cola y Lambda con CDK

Ahora que tenemos el código de Lambda listo, necesitamos desplegar Lambda


y la cola SQS. Podríamos hacerlo manualmente, por supuesto, pero dado que
ya tenemos un proyecto CDK en marcha, podemos agregar otro stack a él para
hacerlo todo automático y repetible.

Por lo tanto, estamos agregando un DeploymentSequencerApp a nuestro pro-


yecto CDK que crea un DeploymentSequencerStack. Aquí está todo el código
para el stack:

public DeploymentSequencerStack(
final Construct scope,
final String id,
final Environment awsEnvironment,
final String applicationName,
final String githubToken) {

super(scope, id, StackProps.builder()


.stackName(applicationName + "-Deployments")
.env(awsEnvironment).build());

this.deploymentsQueue = Queue.Builder.create(this, "deploymentsQueue")


.queueName(applicationName + "-deploymentsQueue.fifo")
.fifo(true)
.build();

SqsEventSource eventSource = SqsEventSource.Builder.create(deploymentsQueue)


.build();

this.deploymentsLambda = LambdaFunction.Builder.create(new Function(


this,
"deploymentSequencerFunction",
FunctionProps.builder()
.code(Code.fromAsset("./deployment-sequencer-lambda/dist/lambda.zip"))
.runtime(Runtime.NODEJS_12_X)
.handler("index.handler")
.reservedConcurrentExecutions(1)
.events(singletonList(eventSource))
7. Construyendo un Pipeline de Despliegue Continuo 137

.environment(Map.of(
"GITHUB_TOKEN", githubToken,
"QUEUE_URL", deploymentsQueue.getQueueUrl(),
"REGION", awsEnvironment.getRegion()
)).build()
)).build();

Creamos una cola SQS con fifo configurado en true. Ten en cuenta que el
nombre de una cola FIFO siempre necesita tener el sufijo “.fifo”, de lo contrario,
habrá un error durante el despliegue.

Luego, creamos una LambdaFunction con el código de nuestro proyecto Lambda.


El código de la Lambda se empaqueta dentro del archivo lambda.zip en la
carpeta deployment-sequencer-lambda/dist. Tenemos que asegurarnos de
construir un nuevo archivo .zip cada vez antes de que estemos desplegando
la DeploymentSequencerStack.

Para el entorno de ejecución, elegimos Node 12 y como controlador proporcio-


namos index.handler porque la función controlador se llama handler y está
ubicada en el archivo index.ts (o más bien index.js después de que se ha
transpilado de TypeScript a JavaScript).

Es importante destacar que establecemos reservedConcurrentExecutions en


1. Hacemos esto porque no queremos múltiples instancias de la Lambda proce-
sando eventos SQS al mismo tiempo. Queremos tener una sola instancia que
controle la secuencia de los eventos de despliegue. Si tuviéramos múltiples
instancias procesando eventos de despliegue de manera concurrente, la lógica
en el código Lambda no funcionaría.

A continuación, añadimos un SqsEventSource a la Lambda. Esto conecta la cola


SQS con la Lambda. Cada evento en la cola ahora activará la función Lambda.

Finalmente, agregamos algunas variables de entorno que necesita el código


7. Construyendo un Pipeline de Despliegue Continuo 138

Lambda para funcionar. Ya hemos visto cómo la función Lambda lee estas
variables de entorno en el código Lambda anterior.

Esos son todos los recursos que necesitamos para apoyar nuestra idea de secuen-
ciar los despliegues.

Para facilitar el despliegue de la nueva pila, añadimos nuevos scripts al packa-


ge.json de nuestro proyecto CDK:

{
...
"scripts": {
...
"deployment-sequencer:deploy": "cdk deploy \"*\" --app ...",
"deployment-sequencer:destroy": "cdk destroy \"*\" --app ..."
}
}

Entonces, podemos simplemente ejecutar npm run deployment-


sequencer:deploy y npm run deployment-sequencer:destroy desde la
línea de comandos o desde un flujo de trabajo de CI para implementar la cola
de despliegue y Lambda.

Usted puede encontrar el código completo de DeploymentSequencerApp en


GitHub.

Dividiendo el Flujo de Trabajo de ‘Publicación’

El elemento que ahora nos falta es entender cómo los eventos de despliegue
llegan a la cola de SQS de despliegue. Para esto, debemos modificar nuestro
existente flujo de trabajo de ‘Desplegar’.

Anteriormente, contábamos con un flujo de trabajo que incluía los pasos de


‘Construir’, ‘Publicar’ y ‘Desplegar’. Vamos a trasladar el paso de ‘Desplegar’
7. Construyendo un Pipeline de Despliegue Continuo 139

a su propio flujo de trabajo para que pueda ser activado por nuestro Lambda. Así
es como se ve el nuevo flujo de trabajo de ‘Desplegar’:

name: 05 - Update the Todo-App in staging

on:
workflow_dispatch:
inputs:
docker-image-tag:
description: "The Docker image to deploy"
required: true

env:
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}

jobs:
deploy-app:
runs-on: ubuntu-20.04
name: Deploy Todo App
steps:

- name: Checkout code


uses: actions/checkout@v2

- name: NPM install


working-directory: cdk
run: npm install

- name: Create or update service stack


if: github.ref == 'refs/heads/main'
working-directory: cdk
run: |
npm run service:deploy -- \
-c environmentName=staging \
-c applicationName=todo-app \
-c dockerImageTag=${{ github.event.inputs.docker-image-tag }}

Este flujo de trabajo se inicia ahora exclusivamente mediante workflow_dis-


7. Construyendo un Pipeline de Despliegue Continuo 140

patch, lo que significa que podemos iniciarlo manualmente o a través de la API


de GitHub. En nuestro caso, el Lambda activará el flujo de trabajo a través de la
API de GitHub. Pasará el parámetro de entrada docker-image-tag para que el
flujo de trabajo sepa qué imagen Docker desplegar.

El resto del flujo de trabajo se parece mucho a lo que teníamos antes. En la


llamada a nuestra aplicación CDK, estamos utilizando la variable especial de
GitHub Actions github.event.inputs para enviar la etiqueta de la imagen
Docker a la aplicación CDK.

Ahora, todo lo que queda por hacer es modificar el flujo de trabajo “Desplegar”
anterior y convertirlo en el nuevo flujo de trabajo “Publicar”. En lugar de
desplegar la imagen Docker, el flujo de trabajo “Publicar” enviará una solicitud
de despliegue a la cola de SQS después de que se haya publicado una imagen
Docker:

name: 04 - Publish Todo-App

on:
push:
paths:
- 'application/**'
- 'cdk/**/*Service*'
- 'cdk/pom.xml'
workflow_dispatch:
jobs:
build-and-publish:
runs-on: ubuntu-20.04
name: Build and publish Todo App
steps:

- name: Create Docker image tag


id: dockerImageTag
run: echo "::set-output name=tag::$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA}"

- name: Publish Docker image to ECR registry


if: github.ref == 'refs/heads/main'
...
7. Construyendo un Pipeline de Despliegue Continuo 141

- name: Sending deployment event to queue


if: github.ref == 'refs/heads/main'
env:
DOCKER_IMAGE_TAG: ${{ steps.dockerImageTag.outputs.tag }}
run: |
export EVENT_PAYLOAD="\
{\"commitSha\": \"$GITHUB_SHA\",\
\"ref\": \"main\", \
\"owner\": \"stratospheric-dev\", \
\"repo\": \"stratospheric\", \
\"workflowId\": \"05-update-todo-app-in-staging.yml\", \
\"dockerImageTag\": \"$DOCKER_IMAGE_TAG\"}"
aws sqs send-message \
--queue-url=https://.../todo-app-deploymentsQueue.fifo \
--message-group-id default \
--message-deduplication-id $GITHUB_SHA \
--message-body "$EVENT_PAYLOAD"

Hemos añadido el paso “Enviando el evento de despliegue a la cola”, que pri-


mero crea una variable de entorno con el nombre EVENT_PAYLOAD que contiene
una estructura JSON con la información que la Lambda necesita para procesar un
evento de despliegue. Luego, utilizamos el CLI de AWS para enviar este evento
a la cola SQS de despliegue que provisionamos previamente.

¡Eso es! Cada vez que el workflow de publicación ha publicado con éxito una
imagen de Docker con una nueva versión de nuestra aplicación, ahora enviará el
nombre de esa imagen de Docker a la cola de despliegue, que la enviará a nuestra
Lambda, que a su vez decidirá si y cuándo llamar a nuestro flujo de trabajo de
despliegue con una dada etiqueta de imagen de Docker.

Revisión del Pipeline de Despliegue Continuo

Puede parecer excesivo crear una solución creada desde cero como la que hici-
mos arriba para asegurarnos de que los despliegues se ejecutan en secuencia. Y
7. Construyendo un Pipeline de Despliegue Continuo 142

tienes razón si tienes acceso a herramientas que pueden hacerlo por ti. Si no,
sin embargo, invertir un par de días de trabajo en este tipo de automatización
evitará la activación manual y reactivación de despliegues fallidos para todo el
equipo y será beneficioso a largo plazo.

Con el poder de CDK, también podemos empaquetar la solución en una biblio-


teca de constructos y ponerla a disposición de todos los equipos de la empresa,
amortizando la inversión de tiempo aún más rápido.

Por favor, tenga en cuenta que la solución descrita aquí no es infalible. La cola y
la Lambda se aseguran de que los despliegues se activan en la misma secuencia
en la que llegan a la cola. Si dos construcciones disparadas por dos commits
se están ejecutando aproximadamente al mismo tiempo, y la construcción
disparada por el commit anterior tarda más que la construcción disparada por
el commit posterior, entonces la función Lambda podría descartar la solicitud
de despliegue para la imagen de Docker que contiene el commit posterior y solo
desplegará la imagen de Docker que contiene el commit anterior. Para resolver
esto, se necesitaría algún tipo de almacenamiento de datos persistente para
almacenar la secuencia de commits, que entonces podría ser consultada cada
vez que se recibe un evento de despliegue.
Addendum: Configurando HTTPS y un
Dominio Personalizado con Route 53 y
ELB
Una vez que la aplicación esté desplegada, podemos acceder a ella a través de
HTTP y una dirección IP. ¿Recuerdas el capítulo Familiarizándose con AWS, donde
utilizamos el AWS CLI para obtener la dirección IP pública de nuestra aplicación
desplegada? Utilizando este enfoque, la URL de nuestra aplicación podría lucir
algo así:

http://13.55.30.162

Alternativamente, podemos usar la dirección del balanceador de carga que AWS


genera automáticamente para nosotros, como esta dirección:

https://staging-loadbalancer-1376672807.eu-central-1.elb.amazonaws.com/

Sin embargo, aunque esto pueda ser adecuado para fines de desarrollo o pruebas,
ningún usuario quiere escribir esta URL en su navegador. Generalmente preferi-
mos servir nuestra aplicación de producción a través de un nombre de dominio
personalizado como app.stratospheric.dev.

Un nombre de (sub)dominio memorable es mejor en términos de experiencia


del usuario, marca, marketing y optimización de motores de búsqueda (SEO).

Además, queremos mantener seguros los datos de nuestros usuarios. Eso signi-
fica que queremos usar HTTPS y la Seguridad de la Capa de Transporte (TLS)/SSL
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 144

en lugar de HTTP sin cifrar. Esto es prácticamente un estándar hoy en día y los
usuarios lo esperan.

En este capítulo, añadiremos un dominio personalizado a nuestra aplicación y


crearemos e instalaremos un certificado SSL para ese dominio.

También implementaremos HTTPS de manera que, incluso cuando la aplicación


se accede a través de HTTP sin cifrar, la solicitud se redirige a la URL HTTPS.

AWS nos permite administrar la configuración de DNS, los certificados SSL y


las reglas de redirección HTTP utilizando Amazon Route 53, AWS Certificate
Manager y Elastic Load Balancing, respectivamente.

Pero antes de sumergirnos en cómo configurar todo esto con AWS, aprendamos
un poco sobre DNS para contextualizar todo.

Sistema de Nombres de Dominio (DNS)

Originado en 1983, el Sistema de Nombres de Dominio (DNS) es uno de los


sistemas y protocolos fundamentales que hacen que Internet funcione como
estamos acostumbrados. Es el servicio que nos permite acceder a sitios web a
través de nombres de dominio memorables como stratospheric.dev en lugar
de direcciones IP.

Si bien nombrar máquinas y servicios en Internet es el uso más común de DNS,


también puede utilizarse para asignar nombres a entidades en redes privadas
basadas en TCP/IP, como intranets corporativas.

A pesar de ser una parte esencial de la infraestructura de la red, a menudo


damos por sentado el DNS. No queremos entrar en demasiado detalle aquí, pero
ofreceremos un breve repaso de cómo funciona el DNS.

El DNS es un sistema jerárquico y distribuido para asignar nombres a recursos.


Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 145

En Internet, existen trece servidores de nombres raíz, responsables del dominio


raíz de la red. Cada dominio de nivel superior (TLD), como .com o .dev, tiene
un servidor de nombres de TLD. Cada subdominio (por ejemplo, stratosphe-
ric.dev) tiene a su vez un servidor de nombres autoritario asociado con él.

Los clientes utilizan un software de resolución de DNS, generalmente propor-


cionado por el sistema operativo del cliente, para mapear nombres de dominio
a direcciones IP.

Cuando un usuario solicita un recurso bajo un subdominio específico, digamos


stratospheric.dev, ese nombre de dominio se resuelve de la siguiente mane-
ra:

1. El software de resolución de DNS del cliente consulta a un servidor de


nombres raíz para el TLD .dev.
2. El software de resolución de DNS luego emite una solicitud a ese servidor
de nombres de TLD para obtener el servidor de nombres autoritario para
stratospheric.dev.
3. Finalmente, el software de resolución de DNS le pide a este servidor de
nombres autoritario la dirección IP de la máquina que sirve recursos para el
dominio stratospheric.dev. Esa máquina puede ser un solo servidor o un
gateway/proxy inverso, que a su vez dirige las solicitudes a varias máquinas
(como es el caso con el VPC y el gateway de Internet para nuestra aplicación
de ejemplo Todo).

Con esta información recopilada, el cliente puede recuperar recursos de una


dirección de red sin que el usuario tenga que introducir esa dirección.

Existen varios tipos de entradas DNS, por ejemplo:

• Registros A: Registros de dirección para mapear dominios a direcciones IP


como en el ejemplo anterior.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 146

• Registros CNAME: Alias para mapear de un dominio a otro.


• Registros TXT: Recursos de cadena de texto arbitrarios comúnmente utili-
zados para políticas de intercambio de correo como SPF, DKIM y DMARC.

Entre otros usos, los registros TXT proporcionan un enfoque conveniente para
verificar la propiedad de un subdominio. Quien controla las entradas de DNS
para un dominio controla en última instancia los recursos servidos desde ese
dominio y, por lo tanto, puede demostrar su propiedad cambiando un registro
TXT.

Más adelante haremos uso de esto para verificar nuestra identidad al solicitar
un certificado SSL para un dominio.

HTTPS y Seguridad de la Capa de Transporte (TLS)

Aunque no es tan antiguo como el DNS, SSL (lanzado públicamente por Netscape
en 1995), su sucesor TLS, y el protocolo HTTPS que habilita son ingredientes
indispensables de Internet moderno.

TLS - y su predecesor SSL - es un protocolo criptográfico que permite una


comunicación segura a través de una red informática. Aunque es en gran medida
agnóstico en términos de los protocolos de comunicación a los que puede
aplicarse, su aplicación más conocida es en el Protocolo seguro de transferencia
de hipertexto (HTTPS). HTTPS, a su vez, es una extensión de HTTP que nos
permite cifrar los datos en tránsito.

Más específicamente, HTTPS puede describirse como HTTP sobre TLS: la comu-
nicación sigue siendo responsabilidad del HTTP que conocemos y usamos, mien-
tras que TLS habilita el cifrado y la verificación criptográfica de la identidad.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 147

A través de TLS, HTTPS proporciona seguridad mediante cifrado asimétrico y


criptografía de clave pública.

En general, como su nombre indica, estos algoritmos funcionan a través de


pares de claves públicas y privadas. En la forma más básica de cifrado de clave
pública-privada, al cifrar un mensaje para un receptor, el remitente utiliza la
clave pública del receptor en una función de trampa criptográfica. Una función
de trampa criptográfica es una función que, al igual que una trampa real, propor-
ciona una “salida fácil” para aquellos que saben que hay una trampa (y dónde se
encuentra esa trampa). El resultado de una función de trampa criptográfica (es
decir, el mensaje cifrado) es fácil de calcular, mientras que su inverso (es decir,
el mensaje descifrado) es difícil de calcular sin información adicional (la clave
secreta del receptor). Un mensaje cifrado de esa manera es difícil de descifrar sin
conocimiento de la clave privada correspondiente. Sin embargo, si se conoce esa
clave privada, el descifrado se vuelve fácil. Por lo tanto, para descifrar el mensaje
cifrado y obtener el mensaje de texto plano original, el receptor simplemente
usa una clave privada complementaria en una operación inversa.

Solo las claves públicas son intercambiadas públicamente (de ahí su nombre).
Dado que el conocimiento de la clave pública por sí solo no permite a un atacante
el acceso no autorizado, esto no compromete la seguridad.

Aunque en general se ha demostrado matemáticamente que el cifrado asimé-


trico es un medio de transporte seguro (dada una longitud de clave suficiente),
todavía queda un problema complicado: ¿Cómo verificamos que la entidad
para la que encriptamos un mensaje es realmente la entidad que creemos que
es? En otras palabras: ¿Cómo verificamos la identidad? Si no lo hiciéramos,
esto nos haría susceptibles a los ataques de hombre en el medio (MITM). Con
esta categoría de ataques, un atacante espía la comunicación fingiendo ser el
receptor legítimo de un mensaje.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 148

Si, por ejemplo, las máquinas que alojan nuestra aplicación de ejemplo Todo
fueran comprometidas, un actor malicioso podría hacerse pasar por la entidad
legítima detrás de app.stratospheric.dev e intentar desviar las credenciales
de usuario.

Aquí es donde entran en juego las autoridades de certificación (CAs) de con-


fianza. Dentro de TLS, estas CAs están autorizadas para emitir certificados fir-
mados criptográficamente que confirman la propiedad de una clave pública en
particular. Junto con la clave pública, estos certificados contienen información
asociada con esa clave, como el nombre de dominio en cuestión y los datos de
contacto de la persona a la que se ha emitido el certificado. Tales certificados
son comúnmente conocidos como certificados SSL. La propiedad puede ser
confirmada de varias maneras. Como se mencionó anteriormente, una manera
popular de demostrar la propiedad de un dominio es modificar un registro TXT
de DNS.

Si bien es posible tener certificados autofirmados, es decir, certificados que


están firmados criptográficamente por su propietario en lugar de una autoridad
de certificación independiente, esto nos dejaría abiertos a los ataques MITM.
Cualquiera puede autenticar un certificado SSL e intentar reclamar una iden-
tidad o la propiedad de un dominio. La única forma de verificar esa afirmación
es a través de una tercera parte independiente y de confianza.

Sin embargo, los certificados autofirmados tienden a usarse con bastante fre-
cuencia en configuraciones internas de empresas, por ejemplo en intranets. En
esos casos, la propia empresa se convierte en la autoridad de confianza para los
recursos internos. Para esos casos de uso, esto podría ser aceptable dependiendo
de los requisitos exactos y las medidas de seguridad adicionales para prevenir la
intrusión. Para el conjunto de Internet, eso no es una opción ya que las partes
involucradas no necesariamente se conocen o confían entre sí.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 149

Con este trasfondo, ahora hemos sentado las bases para trabajar con DNS,
HTTPS y certificados SSL/TLS. Entonces, vamos a profundizar en la configura-
ción del DNS y del SSL para el nombre de dominio de nuestra aplicación.

Registro o Transferencia de un Dominio

Dado que un nombre de dominio válido es un requisito previo para un certificado


SSL verificado, primero necesitamos crear registros del Sistema de Nombres de
Dominio (DNS) para nuestro dominio personalizado. La herramienta para hacer
esto dentro del ecosistema de AWS es Route 53.

El nombre “Route 53” es un juego de palabras con el puerto UDP 53, que es el
puerto predeterminado para el protocolo DNS.

Con Route 53, podemos registrar un nuevo dominio o transferir uno existente
desde un registrador de dominios diferente como GoDaddy, Namecheap o Hetz-
ner. La consola de Route 53 proporciona asistentes para guiarnos a través de
cualquiera de esos procesos.

Una vez que nuestro dominio personalizado deseado ha sido registrado o trans-
ferido a Route 53, Route 53 nos proporcionará una zona alojada que podremos
configurar.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 150

Configuración de una zona alojada en Route 53.

Una zona alojada es un contenedor para los registros DNS de un dominio


específico dentro de Route 53. En la captura de pantalla de arriba, podemos ver
los registros que pertenecen al dominio stratospheric.dev. Estos registros
definen el comportamiento de enrutamiento para nuestro dominio más allá
de nuestra puerta de enlace a Internet. Por ejemplo, los registros MX - o
intercambiadores de correo - especifican cómo se supone que se debe enrutar
el tráfico de correo electrónico hacia y desde nuestro dominio.

Creando un Certificado SSL con CDK

Ahora tenemos una visión general de cómo funcionan DNS y SSL. Hemos visto
cómo estas tecnologías pueden usarse para asignar un nombre de dominio a
nuestra aplicación y al mismo tiempo cifrar todo el tráfico entrante y saliente
para nuestra aplicación.

Podemos usar la zona alojada mencionada anteriormente para aplicar la confi-


Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 151

guración de enrutamiento y HTTPS a nuestra aplicación.

Estas opciones de configuración generalmente solo deben aplicarse una vez


durante el ciclo de vida de una aplicación.

No obstante, automatizar este proceso sigue siendo una buena idea porque
documenta implícitamente un proceso y hace que ese proceso sea reproducible
en diferentes entornos.

Para el próximo ejemplo, es obligatorio tener el dominio controlado por


Route53 para que funcione la validación automática de DNS. Para crear
un certificado SSL para un dominio que no forma parte de Route53, siga
la documentación oficial de AWS y valide manualmente el certificado SSL.
Tan pronto como tenga el ARN para su certificado SSL verificado, continúe
con la siguiente sección.

Entonces, echemos un vistazo a cómo automatizar - y documentar - la realiza-


ción de los ajustes necesarios para nuestro caso de uso con CDK.

Para esta tarea, creamos una nueva app de CDK llamada CertificateApp:

public class CertificateApp {

public static void main(final String[] args) {


App app = new App();

// ...

String hostedZoneDomain = (String) app


.getNode()
.tryGetContext("hostedZoneDomain");
String applicationDomain = (String) app
.getNode()
.tryGetContext("applicationDomain");

// ...
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 152

new CertificateStack(
app,
"certificate",
awsEnvironment,
applicationEnvironment,
applicationDomain,
hostedZoneDomain
);

app.synth();
}
}

Esta aplicación principalmente inicializa una instancia CertificateStack.

Toma los parámetros hostedZoneDomain y applicationDomain. Estos paráme-


tros nos permiten configurar de manera flexible la “hosted zone” y el dominio
deseado para la aplicación, evitando la necesidad de codificarlos en nuestro
código de infraestructura.

El hostedZoneDomain se refiere al nombre de la “hosted zone” dentro de


Route53. Para nuestro ejemplo, ese es stratospheric.dev. Con application-
Domain, especificamos el nombre de dominio para el cual queremos crear el
certificado SSL. Este será el dominio al que nuestros usuarios accederán a
nuestra aplicación más adelante, por ejemplo, app.stratospheric.dev.

Indicamos estos parámetros dentro de nuestro archivo cdk.json y agregamos


dos nuevos scripts en el package.json de nuestro proyecto CDK: certifica-
te:deploy y certificate:destroy.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 153

{
"scripts": {
"certificate:deploy": "cdk deploy --app ... ",
"certificate:destroy": "cdk destroy --app ..."
}
}

Para poder automatizar las acciones de Route53 y Certificate Manager, necesita-


mos incluir las dependencias relevantes en nuestro pom.xml del proyecto CDK:

<!-- Both imports can be omitted when using cdk-constructs -->


<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>${aws-cdk-lib.version}</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>${constructs.version}</version>
</dependency>

Cuando incluimos la dependencia cdk-constructs en nuestro proyecto, pode-


mos omitir las importaciones de dependencias mencionadas anteriormente, ya
que ambas estarán disponibles de forma transitiva a través de la biblioteca cdk-
constructs.

En nuestra nueva CertificateStack, utilizamos estas dependencias para crear


y verificar automáticamente el certificado SSL.

Primero, retiramos la zona alojada que creamos durante el registro o la transfe-


rencia de nuestro dominio:
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 154

IHostedZone hostedZone = HostedZone.fromLookup(


scope,
"HostedZone",
HostedZoneProviderProps.builder()
.domainName(hostedZoneDomain)
.build()
);

Recuerda que una zona hospedada es un contenedor para todos los registros
DNS que pertenecen a un dominio. Usamos el hostedZoneDomain que hemos
proporcionado como entrada a nuestro CertificateApp para localizarlo.

Luego, generamos un nuevo certificado SSL que se valida mediante DNS (un
certificado validado por DNS):

DnsValidatedCertificate websiteCertificate = DnsValidatedCertificate.Builder


.create(this, "WebsiteCertificate")
.hostedZone(hostedZone)
.region(awsEnvironment.getRegion())
.domainName(applicationDomain)
.build();

Un certificado validado por DNS (también conocido como certificado validado


por dominio) se basa en que solo el propietario de un dominio tiene control
sobre sus entradas de DNS. Utilizando este método, el emisor del certificado
nos envía una cadena única. Al demostrar que somos capaces de crear un
registro DNS TXT para nuestro dominio con este valor único, podemos probar
que tenemos control sobre los ajustes de DNS de ese dominio.

Fíjate cómo este proceso complejo y multietápico solo requiere un constructo


de alto nivel de la biblioteca de constructos del Administrador de Certificados.
Este constructo realiza todas las tareas complejas, incluyendo el intercambio de
claves criptográficas y la creación de registros DNS TXT para nosotros.

En cuanto despleguemos esta pila de CDK con npm run certificate:deploy,


obtendremos el ARN del certificado SSL impreso en la consola:
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 155

� certificate (staging-todo-app-Certificate)

Outputs:
certificate.sslCertificateArn = arn:aws:acm:eu-central-1:...:certificate/...

Este parámetro de salida será importante para los siguientes pasos, ya que
estamos a punto de crear un oyente HTTPS para nuestro balanceador de carga.

Creación de un Oyente HTTPS Usando la Aplicación de Red

Hasta ahora, hemos desplegado la NetworkApp sin ninguna información sobre


nuestro certificado SSL.

Hemos diseñado el constructo subyacente Network de tal manera que solo crea
un oyente HTTP para el ELB si no se pasa ningún certificado SSL. AWS no
proporciona certificados SSL para sus dominios ELB predeterminados. Siempre
que queramos crear un oyente HTTPS, debemos proporcionar un certificado SSL
válido.

Tan pronto como pasamos un sslCertificateArn a la NetworkApp, el construc-


to Network creará dos oyentes ELB. El oyente predeterminado para el puerto 80
(HTTP) y uno para el puerto 443 (HTTPS), utilizando el certificado SSL:
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 156

// ... creating an HTTP listener by default

if (sslCertificateArn.isPresent()) {
IListenerCertificate certificate = ListenerCertificate
.fromArn(sslCertificateArn.get());

httpsListener = loadBalancer.addListener("httpsListener",
BaseApplicationListenerProps.builder()
.port(443)
.protocol(ApplicationProtocol.HTTPS)
.certificates(Collections.singletonList(certificate))
.open(true)
.build()
);

httpsListener.addTargetGroups("https-defaultTargetGroup",
AddApplicationTargetGroupsProps.builder()
.targetGroups(Collections.singletonList(dummyTargetGroup))
.build());

Primero obtenemos el ListenerCertificate basado en el parámetro sslCer-


tificateArn, luego lo pasamos a la construcción de nuestro listener HTTPS.
Como hicimos cuando creamos el listener HTTP, añadimos un dummyTarget-
Group para el grupo objetivo predeterminado. El grupo objetivo real que apunta
a nuestro servicio ECS se añadirá tan pronto como volvamos a desplegar nuestra
ServiceApp.

Servir nuestra aplicación con esta configuración desde ambos puertos 80 y 443
podría hacer que algunos usuarios accedan a nuestra aplicación a través de HTTP.
Para evitar cualquier tráfico inseguro, por lo tanto, debemos garantizar la opción
segura y sólo permitir el acceso a través de HTTPS. Para que esto funcione,
redireccionamos automáticamente todo tráfico no cifrado a HTTPS:
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 157

ListenerAction redirectAction = ListenerAction.redirect(


RedirectOptions.builder()
.protocol("HTTPS")
.port("443")
.build()
);

ApplicationListenerRule applicationListenerRule = new ApplicationListenerRule(


this,
"HttpListenerRule",
ApplicationListenerRuleProps.builder()
.listener(httpListener)
.priority(1)
.conditions(List.of(ListenerCondition.pathPatterns(List.of("*"))))
.action(redirectAction)
.build()

Adjuntamos la ApplicationListenerRule adicional a nuestro listener HTTP


existente. Usando la prioridad 1, esta regla anulará cualquier otra regla de
escucha existente, imponiendo así la comunicación HTTPS.

Para aplicar estos cambios en nuestra NetworkApp desplegada, añadimos el ARN


del certificado SSL a la sección context de nuestro cdk.json utilizando la clave
sslCertificateArn.

Lo que queda por hacer es desencadenar una reimplemetación de nuestras


aplicaciones de red y de servicio:

npm run network:deploy


npm run service:deploy

Después de este redespliegue, nuestro balanceador de carga escucha el tráfico


en ambos puertos, el 80 y el 443. Cualquier tráfico HTTP se redirige automáti-
camente al puerto 443 para garantizar una comunicación cifrada para nuestros
usuarios.

Hasta el momento, todo el tráfico de entrada y salida de nuestra aplicación


está asegurado con TLS. Sin embargo, nuestros usuarios aún tienen que usar el
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 158

dominio ELB predeterminado y no pueden acceder a la aplicación desde nuestro


dominio personalizado app.stratospheric.dev.

Arreglaremos esto a continuación.

Asociando un Dominio Personalizado con el ELB

Hasta el momento, los usuarios no pueden acceder a nuestra aplicación al


ingresar app.stratospheric.dev en el navegador. Route53 aún no reconoce
(hasta ahora) nuestro subdominio app y nuestra intención de redirigir el tráfico
a nuestro balanceador de carga.

Lo que falta es un registro A DNS para este subdominio que señale al ELB.

Veamos cómo podemos crear este registro A utilizando el CDK.

La próxima aplicación de CDK funciona para cualquier dominio que es


administrado dentro de Route53. Cuando se utiliza otro registrador de
dominios, tienes que crear este registro manualmente.

Como nuestro objetivo es automatizar lo más posible nuestra configuración


de la infraestructura, creamos una nueva aplicación CDK llamada DomainApp.
Similar a la CertificateApp, el DomainStack depende del applicationDomain
y del nombre de la zona alojada (hostedZoneName):
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 159

public class DomainApp {

public static void main(final String[] args) {


App app = new App();

// ...

String hostedZoneDomain = (String) app


.getNode()
.tryGetContext("hostedZoneDomain");
String applicationDomain = (String) app
.getNode()
.tryGetContext("applicationDomain");

// ...

new DomainStack(
app,
"domain",
awsEnvironment,
applicationEnvironment,
hostedZoneDomain,
applicationDomain
);

app.synth();
}

Para un despliegue y limpieza convenientes, extendemos nuestro


package.json con dos nuevos comandos para este stack:
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 160

{
"scripts": {
"domain:deploy": "cdk deploy --app ... ",
"domain:destroy": "cdk destroy --app ..."
}
}

Dentro del DomainStack, primero recuperamos nuestra instancia ELB utilizan-


do los parámetros almacenados en el almacén de parámetros SSM por nuestro
constructo de Network previamente desplegado de nuevo:

Network.NetworkOutputParameters networkOutputParameters =
Network.getOutputParametersFromParameterStore(
this,
applicationEnvironment.getEnvironmentName()
);

IApplicationLoadBalancer applicationLoadBalancer = ApplicationLoadBalancer


.fromApplicationLoadBalancerAttributes(
this,
"LoadBalancer",
ApplicationLoadBalancerAttributes.builder()
.loadBalancerArn(
networkOutputParameters.getLoadBalancerArn())
.securityGroupId(
networkOutputParameters.getLoadbalancerSecurityGroupId())
.loadBalancerCanonicalHostedZoneId(
networkOutputParameters.getLoadBalancerCanonicalHostedZoneId())
.loadBalancerDnsName(
networkOutputParameters.getLoadBalancerDnsName())
.build()
);

Tenemos que usar el método fromApplicationLoadBalancerAttributes() pa-


ra obtener nuestro balanceador de carga existente. Existe otro método, fromLoo-
kup(), pero este requiere valores de cadena concretos en vez de los tokens de
sustitución devueltos por los StringParameters de CDK, así que no podemos
utilizarlo en nuestro caso.
Addendum: Configurando HTTPS y un Dominio Personalizado con Route 53 y ELB 161

Finalmente, creamos un registro A de DNS (que mapea un nombre de dominio


a una dirección IP) para el dominio de nuestra aplicación y lo dirigimos hacia el
ALB de nuestra aplicación a través del ARN de nuestro ALB:

ARecord aRecord = ARecord.Builder.create(this, "ARecord")


.recordName(applicationDomain)
.zone(hostedZone)
.target(
RecordTarget.fromAlias(new LoadBalancerTarget(applicationLoadBalancer)))
.build();

Lo que queda por hacer es ejecutar npm run domain:deploy para crear un
registro A para nuestro subdominio app dentro de Route53. Nuestra aplicación
puede que no sea accesible inmediatamente, ya que el cambio en el DNS puede
demorar algunos minutos hasta que se propague a los servidores DNS alrededor
del mundo.

Con estas dos apps de CDK, es decir CertificateApp y DomainApp, ahora hemos
configurado nuestro DNS y la configuración del certificado SSL como código. De
esta manera, la infraestructura relacionada con la configuración del dominio de
nuestra aplicación puede ser reproducida y regenerada, si fuera necesario.

A partir de ahora, la aplicación de ejemplo Todo es accesible desde nuestro


dominio personalizado utilizando HTTPS: https://app.stratospheric.dev.

Con esta infraestructura lista para producción en marcha, ahora es el momento


de implementar algunas funcionalidades!
Parte II: Spring Boot & AWS

¡Ahora que tenemos un pipeline de despliegue configurado, podemos comenzar


a implementar una aplicación encima de este!

En esta parte del libro, presentaremos nuestra aplicación de ejemplo Todo y nos
prepararemos para el desarrollo en local.

Luego, implementaremos algunas características que se integran con los servi-


cios de AWS.

Comenzaremos con la configuración del registro y el inicio de sesión de usuarios


con Cognito e integraremos eso en nuestra aplicación Spring Boot.

Luego, hablaremos sobre RDS, el servicio de Amazon para bases de datos rela-
cionales, y lo usaremos para crear una base de datos PostgreSQL para nuestra
aplicación.

Dos casos de uso importantes para cualquier aplicación son la gestión de colas
de mensajes y el envío de correos electrónicos, por lo que integraremos con
Amazon SQS y SES para implementar una función de colaboración que notifica
a un usuario cuando un Todo ha sido compartido con él.

Para mostrar un caso de uso más avanzado, implementaremos notificaciones


push en Amazon MQ y WebSocket que notifican a un usuario en tiempo real
sobre una solicitud de colaboración en la aplicación Todo, ¡sin recargar la página!
163

Finalmente, presentaremos Amazon DynamoDB como un almacenamiento


NoSQL y lo usaremos para rastrear las acciones de los usuarios en nuestra
aplicación.

En cada capítulo, ampliaremos el proyecto CDK que hemos creado en la Parte


I, de modo que al final, tendremos un pipeline de despliegue continuo que crea
toda la infraestructura de AWS que necesitamos con un simple clic.
8. La Aplicación de Ejemplo Todo
Para explorar las diversas funcionalidades de AWS y las mejores prácticas,
vamos a crear una aplicación web de ejemplo con Spring Boot.

Las aplicaciones Todo son algo así como un estándar de oro para comparar las
características de lenguajes o marcos. La lógica de negocio de tal aplicación es
simple, lo que nos permite concentrarnos en el lenguaje o marco en cuestión.

Por lo tanto, decidimos crear otra aplicación web Todo, que servirá como un
ejemplo continuo a medida que avanzamos.

La lógica de negocio real de la aplicación no importa realmente, al final. Es solo


un medio para presentar las diversas funcionalidades de AWS y cómo podemos
usarlas en el contexto de una aplicación Spring Boot del mundo real.

Te sugerimos que mires el código de la aplicación en GitHub en paralelo a la


lectura de este capítulo.

Características

Para empezar, obtengamos una visión general de las características de la apli-


cación Todo y los servicios de AWS que utilizaremos para implementarlas en un
entorno en la nube de AWS.
8. La Aplicación de Ejemplo Todo 165

Registro y Acceso

La aplicación de ejemplo es una aplicación multiusuario que permite a cada


usuario tener su propio conjunto de tareas. Como los usuarios normalmente
querrían mantener sus tareas privadas, necesitamos un medio para separar las
tareas por sus respectivos propietarios. Usaremos Spring Security en conjunto
con Amazon Cognito para lograr esto y para realizar tanto la autenticación como
la autorización de los usuarios.

Los usuarios serán identificados por sus direcciones de correo electrónico. Los
datos del usuario en sí se almacenarán en un pool de usuarios de Cognito.
Nuestra aplicación utilizará OIDC (OpenID Connect, un marco de autenticación
sobre OAuth2) para recuperar y mantener la sesión de Cognito del usuario.

CRUD: Visualización, Adición, y Eliminación de Tareas

Cualquier aplicación que maneje datos modificables por el usuario, en este caso,
las tareas, tiene que proporcionar algún tipo de funcionalidad CRUD (Crear,
Recuperar, Update, Delete).

Los usuarios pueden crear tareas y agregar notas de texto a esas tareas. Pueden
ver sus tareas en la pantalla y editarlas o borrarlas.

Para guardar los datos, utilizaremos una base de datos PostgreSQL que se ejecuta
en el Servicio de Bases de Datos Relacionales (RDS) de Amazon.

Compartiendo Tareas y Notificaciones por Correo Electrónico

A veces, los usuarios podrían no solo querer trabajar en sus tareas solos, sino
colaborar también con otros usuarios. Por lo tanto, la aplicación de ejemplo
permite a los usuarios compartir sus tareas con otros a través de notificaciones
8. La Aplicación de Ejemplo Todo 166

por correo electrónico enviadas a través del Servicio de Correo Electrónico


Simple (SES) y el Servicio de Cola Simple (SQS) de Amazon.

Notificaciones Push

Cuando se comparten tareas y se colabora con otros, queremos estar al día


y ser notificados sobre los cambios en tiempo real. Para mostrar ese tipo de
funcionalidad, la aplicación de ejemplo utiliza WebSockets y un intermediario
de mensajes Apache ActiveMQ administrado que se ejecuta en Amazon MQ para
notificar al propietario de una tarea directamente en el navegador una vez que
se ha aceptado una solicitud de colaboración para una tarea.

Arquitectura de la Aplicación

La aplicación es una simple aplicación Spring Boot que intenta seguir patrones
comunes de Spring y Spring Boot. Este libro es, ante todo, sobre cómo correr
una aplicación Spring Boot en AWS, después de todo. No queremos perdernos
en detalles técnicos o consideraciones de diseño que no son relevantes para el
tema de este libro.

Este capítulo te dará una comprensión de la aplicación que vamos a desplegar


en AWS, para que no nos enredemos en detalles a medida que avanzamos.

La clase TodoApplication sirve como el punto de inicio.

Configuración

Existe un paquete dev.stratospheric.config que contiene varias clases de


configuración. Un ejemplo es la clase WebSecurityConfig que configura Spring
Security.
8. La Aplicación de Ejemplo Todo 167

Complementando estas clases de configuración están los archivos de configu-


ración YAML predeterminados de Spring Boot en la carpeta resources, que
contienen las configuraciones básicas requeridas para correr la aplicación. Estos
archivos de configuración YAML vienen tanto para el entorno de desarrollo local
(application-dev.yml) como para un entorno AWS (application-aws.yml)
con un application.yml predeterminado para aquellos atributos de configura-
ción que son comunes a ambos entornos.

Características

Para las características en sí, la aplicación sigue una estructura de paquetes


por funcionalidad. Por lo tanto, las carpetas de funcionalidades collaboration,
person, registration, y todo contienen los componentes de código relaciona-
dos con estas funcionalidades respectivas. Estos componentes de código abar-
can controladores, interfaces de servicio (y sus implementaciones), repositorios
de Spring Data JPA, y clases de modelos de datos.

Interfaz de Usuario

Dado que Thymeleaf está muy bien integrado con Spring Boot, decidimos usarlo
para renderizar las vistas al usuario. Las plantillas HTML de Thymeleaf y los
archivos estáticos se pueden encontrar en la carpeta resources, como es la
práctica común.

Almacenamiento

La aplicación utiliza Spring Data JPA para guardar datos en una base de datos
PostgreSQL. Utilizamos Flyway para configurar y luego migrar la base de datos
de la aplicación al estado requerido por el código fuente de la aplicación actual.
8. La Aplicación de Ejemplo Todo 168

La carpeta resources contiene un subdirectorio llamado db/migration/post-


gresql con los scripts SQL de migración de Flyway para la configuración de la
base de datos de la aplicación.

Modelo de Dominio

Echemos un vistazo al modelo de dominio, que hemos mantenido bastante


simple.

El modelo de dominio está estructurado alrededor de la entidad “Tarea”, como


se muestra en este diagrama:

El modelo de dominio de la aplicación de tareas.

Una tarea tiene una prioridad y un estado, por lo que podemos manejarlas de
manera diferente dependiendo de estos atributos. Podemos agregar notas de
texto y recordatorios a una tarea, y una persona puede tener tareas.
8. La Aplicación de Ejemplo Todo 169

Configuración Inicial de la Aplicación

Echemos un vistazo a la base técnica de la arquitectura y las características de


nuestra aplicación. Primero explicaremos la configuración mínima para poner
en marcha nuestra aplicación de tareas en AWS y servir una vista Thymeleaf.

Dependencias Principales

¿Qué tienen en común todos los excelentes proyectos de Spring Boot? Todos
fueron creados con start.spring.io. Lo mismo sucedió con nuestra aplicación de
tareas. Echemos un vistazo a la configuración y las dependencias principales de
nuestro esqueleto de proyecto.

Aparte de elegir Gradle como la herramienta de construcción del proyecto y Java


17 como el lenguaje de programación, nuestro proyecto incluye los siguientes
arrancadores de Spring Boot/Cloud:

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.awspring.cloud:spring-cloud-aws-starter'

Estos arrancadores tienen los siguientes propósitos:

• Spring Boot Starter Web: El núcleo de nuestra aplicación. Incluye y au-


toconfigura Tomcat como un contenedor Servlet incrustado para nuestra
aplicación Spring MVC.
• Spring Boot Starter Thymeleaf: Este arrancador incluye todas las depen-
dencias relevantes de Thymeleaf y autoconfigura el ViewResolver para
renderizar las plantillas de Thymeleaf.
8. La Aplicación de Ejemplo Todo 170

• Spring Boot Starter Validation: Desde la versión 2.4 de Spring Boot, el


componente de validación ya no forma parte de spring-boot-start-web.
Por lo tanto, debemos incluirlo explícitamente cada vez que queremos
validar las cargas de trabajo entrantes usando Bean Validation.
• Spring Cloud Starter AWS: El principal punto de integración para integrar
nuestra aplicación Spring Boot con AWS.

El proyecto Spring Cloud AWS se encarga de alinear la versión del SDK Java de
AWS (v2 desde Spring Cloud AWS 3.0, anteriormente v1) para nosotros utilizan-
do internamente el software.amazon.awssdk:bom. Podemos sobrescribir la
versión del SDK Java de AWS, pero entonces tendríamos que garantizar nosotros
mismos su compatibilidad:

dependencyManagement {
imports {
// explicitly define the AWS Java SDK v2 version
mavenBom "software.amazon.awssdk:bom:2.20.4"
}
}

Aunque solo tenemos una dependencia de Spring Cloud (hasta ahora), estamos
utilizando el BOM de Spring Cloud para alinear las versiones de las dependen-
cias.
8. La Aplicación de Ejemplo Todo 171

Un BOM (Bill of Materials, en inglés) es un concepto de Maven para


alinear las versiones de las dependencias para una gestión eficiente de las
mismas. Es un tipo especial de POM que define las versiones de las depen-
dencias en un punto centralizado. El objetivo principal es evitar incompa-
tibilidades que pueden ocurrir cuando incluimos diferentes versiones de
dependencias relacionadas. En caso de que estemos utilizando un BOM,
podemos incluir una dependencia en nuestro proyecto sin especificar
su versión explícitamente. Muchos proyectos/bibliotecas/marcos de Java
que publican múltiples artefactos utilizan este concepto (por ejemplo,
Spring Cloud, AWS Java SDK, JUnit 5 y Testcontainers).

El BOM de Spring Cloud AWS asegura que todas las dependencias (próximas) que
forman parte del paraguas de Spring Cloud AWS compartan la misma versión:

ext {
set('awsSpringVersion', '3.0.0-RC1')
}

dependencyManagement {
imports {
mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:${awsSpringVersion}"
}
}

El 17 de abril de 2020, el equipo de Spring Cloud anunció que Spring Cloud


AWS ya no formará parte del paraguas de Spring Cloud y la correspon-
diente cadena de lanzamientos. Spring Cloud AWS encontró un nuevo
hogar en awspring y ahora es un proyecto comunitario impulsado por
los principales contribuyentes Maciej Walkowiak, Eddú Meléndez y Matej
Nedic.

Aparte del Spring Boot Starter y Spring Cloud AWS, estamos incluyendo las
siguientes dependencias relacionadas con la interfaz de usuario:
8. La Aplicación de Ejemplo Todo 172

implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:2.5.3'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:bootstrap:4.6.1'
implementation 'org.webjars:font-awesome:5.15.3'

La dependencia thymeleaf-layout-dialect permite crear diseños reutiliza-


bles para nuestras vistas de Thymeleaf. Echaremos un vistazo a nuestro diseño
básico en una de las próximas secciones.

Como nuestro interfaz de usuario necesita un estilo adecuado, estamos utili-


zando los llamados WebJars que agrupan las bibliotecas web (por ejemplo, CSS
o JavaScript de Bootstrap) en archivos JAR. Luego podemos servir estos recursos
estáticos como parte de nuestra aplicación Spring Boot.

Con cada nueva característica de nuestra aplicación Todo, añadiremos más


dependencias a esta configuración. Como nuestras dependencias centrales ya
están en su lugar, podemos continuar configurando la configuración de AWS
relevante para nuestra aplicación.

Configuración Específica de AWS

Primero, necesitamos configurar el acceso a nuestra cuenta de AWS. El SDK de


AWS para Java ya ofrece varias soluciones para esto, como el uso de variables de
entorno, un archivo de propiedades, o cargarlas desde el Servicio de Metadatos
de Instancia de Amazon EC2. Técnicamente hablando, son implementaciones
de la interfaz AwsCredentialsProvider que forman parte de la dependencia
aws-java-sdk-core.

Con Spring Cloud AWS, también podemos configurar nuestras credenciales de


AWS de la “manera Spring Boot”. Es decir, podemos almacenar las credenciales
dentro de nuestro application.yml definiendo estas propiedades:

• spring.cloud.aws.credentials.secret-key, y
8. La Aplicación de Ejemplo Todo 173

• spring.cloud.aws.credentials.access-key.

Sin embargo, el Elastic Container Service (ECS) proporciona una variable de en-
torno (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) para todos nuestros con-
tenedores en ejecución. Esta ruta apunta a la ubicación de nuestras credenciales
para el rol IAM adjunto a nuestra tarea de ECS. El SDK de AWS viene con un
proveedor (ContainerCredentialsProvider) que puede recuperar las creden-
ciales para esta configuración.

Dado que esto implica la menor sobrecarga de configuración, favoreceremos


este enfoque. No tenemos ningún esfuerzo de configuración adicional ya que
Spring Cloud AWS configura un DefaultCredentialsProvider. Al inicio de la
aplicación, el SDK de Java de AWS recorrerá esta cadena de proveedores en busca
de credenciales de AWS.

Esta cadena de proveedores detectará el entorno de ejecución de ECS subyacente


y configurará el acceso utilizando la interfaz AwsCredentials. En caso de que la
cadena de proveedores no pueda encontrar las credenciales de AWS en ningún
lugar de búsqueda, nuestra aplicación no se iniciará.

Lo que queda es configurar la región de AWS a la que desplegamos nuestra


aplicación. Spring Cloud AWS puede detectar esto automáticamente basándose
en nuestro entorno utilizando DefaultAwsRegionProviderChain. Esta cadena
de proveedores intenta resolver la región de AWS desde varios lugares:

• una propiedad del sistema llamada aws.region


• una variable de entorno llamada AWS_REGION
• un archivo de configuración dentro de ∼/.aws
• el Servicio de Metadatos de EC2

Como estamos usando ECS en Fargate (lo que significa que no especificamos
nuestras propias instancias de EC2, porque Fargate las especifica por nosotros),
8. La Aplicación de Ejemplo Todo 174

la recuperación de metadatos funciona de manera un poco diferente. Lamenta-


blemente, esto no es soportado por Spring Cloud AWS.

Aparte de la cadena de proveedores mencionada anteriormente, Spring Cloud


AWS también ofrece una opción de configuración de región estática como
parte de nuestro archivo application.yml. Para nuestro caso de uso, eso es
suficiente ya que desplegamos nuestra aplicación a una región dedicada:

spring:
cloud:
aws:
region:
static: eu-central-1

Para una implementación más flexible y una configuración independiente de la


región, también podemos configurar la variable de entorno AWS_REGION como
parte de nuestro constructo Service CDK.

Nuestra Primera Vista Thymeleaf

Nuestra aplicación inicial ya tiene un punto de acceso público que muestra una
vista Thymeleaf:
8. La Aplicación de Ejemplo Todo 175

@Controller
public class IndexController {

@GetMapping
public String getIndex() {
return "index";
}

Esta @Controller de Spring MVC resuelve la vista index ubicada dentro de


src/main/resources/templates:

<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='start', headline='Welcome to the Todo Application!'">
<head>
<title>Start</title>
</head>
<section class="section" layout:fragment="page-content">
<div class="container">
<p>There's not much to see here (yet).</p>
</div>
</section>
</html>

No hay mucho que explicar para esta vista index ya que solo estamos rende-
rizando un mensaje estático. Sin embargo, vale la pena mencionar el uso del
namespace de layout. Como queremos reutilizar la misma estructura de página
para todas nuestras vistas, vamos a definir un layout general.

El th:with define variables a las que podemos referirnos en nuestros fragmen-


tos Thymeleaf o layout.

Nuestro layout, ubicado dentro de src/main/resources/templates/layout,


define la estructura básica de cada página próxima:
8. La Aplicación de Ejemplo Todo 176

<!DOCTYPE html>
<html lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org">

<!--/*@thymesVar id="headline" type="java.lang.String"*/-->

<head>
<meta charset="UTF-8">
<title layout:title-pattern="$CONTENT_TITLE | $LAYOUT_TITLE">
Todo Application
</title>

<meta content="IE=edge" http-equiv="X-UA-Compatible"/>


<meta content="width=device-width, initial-scale=1" name="viewport"/>

<link rel="icon" th:href="@{/favicon.svg}">

<link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">


<link rel="stylesheet" th:href="@{/webjars/font-awesome/css/all.css}">
<link rel="stylesheet" th:href="@{/styles.css}">

<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/popper.js/umd/popper.min.js}"></script>
<script th:src="@{/webjars/bootstrap/js/bootstrap.min.js}"></script>
</head>
<body>
<div>
<div th:replace="fragments/header :: header"></div>

<div th:replace="fragments/toast :: toast"></div>

<div class="container has-text-centered">


<h1 th:text="${headline}" class="title text-center"></h1>
<div th:replace="fragments/messages :: messages"></div>
<div layout:fragment="page-content"></div>
</div>

<div th:replace="fragments/modals :: confirmDeletionModal"></div>


<div th:replace="fragments/footer :: footer"></div>
</div>
</body>
8. La Aplicación de Ejemplo Todo 177

</html>

Incluimos recursos de CSS y JavaScript que se requieren para cada vista y es-
tructuramos la página con un conjunto de fragmentos. Estos fragmentos tienen
varios propósitos: mostrar la barra de navegación superior, el pie de página,
renderizar mensajes informativos y un modal de confirmación.

Estos fragmentos son parte de src/main/resources/templates/fragments.

Además, hacemos referencia a la variable ${headline} que cada vista define


para renderizar un título para la página.

Con layout:fragment, entonces especificamos dónde se debe mostrar el con-


tenido real de la vista.

Para una gran referencia de Spring Boot & Thymeleaf con ejemplos prác-
ticos (paginación, internacionalización, pruebas, seguridad, etc.) y una
explicación en profundidad, echa un vistazo al libro de Wim Deblauwe
Taming Thymeleaf.

Docker Image

Como desplegamos nuestra aplicación Todo en un entorno de contenedores


en AWS, tenemos que crear una Docker Image para nuestra aplicación. El
Dockerfile del capítulo Familiarizándonos con AWS es nuestra base:
8. La Aplicación de Ejemplo Todo 178

FROM eclipse-temurin:17-jre

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-jar", "/app.jar"]

El proyecto AdoptOpenJDK se trasladó a la Fundación Eclipse. Bajo el para-


guas de Eclipse Adoptium, ahora están publicando entornos de ejecución
de Java llamados Temurin. A parte del nombre, esta imagen Docker no
tiene ninguna relación con el IDE de Eclipse.

Resumen del Repositorio GitHub

Administramos todos los ejemplos de código fuente para este libro como parte
del repositorio GitHub de Stratospheric. El repositorio contiene:

• las aplicaciones CDK para desplegar la infraestructura de AWS (en la carpeta


cdk)
• el pipeline de despliegue continuo (en la carpeta .github/workflows)
• el código de la aplicación Todo en su estado final (en la carpeta applica-
tion)
• el código del estado de la aplicación Todo para cada capítulo (en la carpeta
chapters) y su configuración de CloudFormation y CDK, respectivamente

También contiene algunas plantillas de CloudFormation para la infraestructura


de AWS que podríamos usar como alternativa a las aplicaciones CDK (en la
carpeta cloudformation).

Continúa y revisa el código. Asegúrate de tener todo lo necesario instalado según


el capítulo de Prerrequisitos.
8. La Aplicación de Ejemplo Todo 179

Construyendo la Aplicación

Para construir y probar la aplicación, cambia al directorio application en tu


línea de comandos y ejecuta este comando Gradle predeterminado:

./gradlew build

Esto descargará automáticamente la versión adecuada de Gradle y luego ejecu-


tará Gradle para construir y probar la aplicación.

Si ves BUILD SUCCESSFUL después de un tiempo, ¡todo está en orden!

Ejecutando la Aplicación en Modo de Desarrollo

La aplicación se conecta a una base de datos PostgreSQL y a varios servicios de


AWS. Para iniciar la aplicación en una máquina local, de alguna manera también
necesitamos acceso a esos servicios localmente.

Eso no es una tarea sencilla, y hay varias soluciones para esto. En el próximo
capítulo Desarrollo Local examinaremos con más detalle cómo funciona el desa-
rrollo local. Por ahora, es suficiente saber que utilizamos Docker Compose.

En la carpeta application, ejecuta docker-compose up para poner en marcha


cada componente de infraestructura que necesitamos.

Luego, para iniciar nuestra aplicación localmente, ejecuta este comando de


Gradle, también desde la carpeta application:

./gradlew bootRun

Este comando utiliza el plugin de Spring Boot para Gradle y arranca la aplicación
Spring Boot.

Puedes revisar la configuración de Gradle en el archivo build.gradle.


8. La Aplicación de Ejemplo Todo 180

Configuración de la compilación y Despliegue Continuo

La carpeta .github/workflows contiene archivos para los flujos de trabajo de


GitHub Actions que creamos en el capítulo Construyendo un Pipeline de Despliegue
Continuo que despliegan la aplicación a AWS con cada modificación del código.
La aplicación solo se desplegará a AWS si hay una nueva push de Git a la rama
main. Al hacer push en una rama de características, simplemente se ejecutará
la compilación de la aplicación (incluyendo pruebas).
9. Desarrollo Local
En este capítulo, discutiremos cómo podemos desarrollar e iniciar la aplicación
Todo de manera local. Tener una configuración de desarrollo local conveniente
permite que cada desarrollador reciba retroalimentación inmediata para los
cambios de código y que todos puedan familiarizarse con la aplicación al usarla.

Como el tema principal de este libro es AWS, podemos esperar que nuestra
aplicación integre múltiples servicios de AWS. Cada vez que iniciamos la aplica-
ción en nuestras máquinas locales, debemos decidir cómo interactuar con estos
servicios de AWS.

¿Vamos a simular los servicios para el desarrollo local? ¿Estamos llamando


a los servicios reales? ¿Podemos ejecutarlos localmente de alguna manera?
Probablemente no, porque el ecosistema completo de AWS es demasiado grande
para caber en nuestras máquinas locales.

Nuestro objetivo principal es hacer que el desarrollo local sea lo más conve-
niente posible. Tener una extensa lista de pasos de configuración con múltiples
scripts para ejecutar no solo es perjudicial para la experiencia del desarrollador,
sino que también requiere mantener la lista actualizada. Todos conocemos la
frustración de trabajar con un nuevo proyecto, seguir las instrucciones en el
README, y luego darnos cuenta de que están desactualizadas.

Los desafíos del desarrollo local en la nube

Analicemos diferentes opciones para hacer que el desarrollo local contra los
servicios de AWS sea lo más conveniente posible.
9. Desarrollo Local 182

Primero, podríamos conectarnos a los servicios reales de AWS. Esto requiere


que toda nuestra configuración de AWS esté funcionando. Con esta opción,
estaríamos lo más cerca posible de la producción, y nuestra aplicación no
necesitaría ninguna modificación.

Sin embargo, hay algunas desventajas importantes para este enfoque. Si solo te-
nemos un entorno de AWS, los desarrolladores podrían interferir con el entorno
de producción, y los usuarios reales podrían ver datos de prueba divertidos (o no
tan divertidos). Podemos solucionar esto proporcionando un entorno de prueba
separado o, idealmente, un entorno bajo demanda para cada desarrollador. Esto
incurre en costos adicionales por el uso de los servicios de AWS. También sería
inconveniente para los desarrolladores porque necesitarían una cuenta de AWS
y tendrían que gastar algo de dinero de AWS solo para iniciar la aplicación
localmente.

Otro enfoque sería deshabilitar partes de nuestra aplicación que requieren


comunicación con AWS (por ejemplo, con interruptores de características). Eso,
a su vez, significaría que no podríamos suscribirnos a una cola, leer de una tabla
DynamoDB, o acceder a otros recursos de AWS cuando ejecutamos la aplicación
con el perfil de desarrollo. Si bien esto es técnicamente posible y no requiere
ninguna configuración adicional, nuestra aplicación local sería diferente del
entorno de producción con este enfoque. Las nuevas funciones que involucran
servicios de AWS no podrían probarse localmente y requerirían hacer commits
sin probar.

Además, terminaríamos con una complejidad adicional para nuestra aplicación


ya que la mayoría de nuestras funciones tendrían dos variantes: una para el en-
torno de producción en AWS y otra para el desarrollo local. Tampoco podríamos
apreciar toda la funcionalidad de la aplicación Todo localmente porque no todas
las funciones estarían disponibles. No queremos esto.
9. Desarrollo Local 183

Veamos si podemos combinar las dos opciones que acabamos de discutir.

De alguna manera necesitamos un simulacro de AWS que permita iniciar y utili-


zar la mayoría de las funciones de la aplicación Todo. Esto no debería implicar un
costo significativo y debería caber en nuestras máquinas. El enfoque idealmente
necesita poca o ninguna configuración manual y debe estar disponible en todos
los sistemas operativos. También podemos vivir con el hecho de que un pequeño
subconjunto de nuestras funciones funcione de manera ligeramente diferente.
El envío de correos electrónicos es un buen ejemplo de esto. Aunque podríamos
configurar un servidor local SMTP, probablemente podamos prescindir de la en-
trega de correos electrónicos para el desarrollo local. Afortunadamente, existe
una solución para esto: LocalStack.

LocalStack - Nuestra nube AWS local

LocalStack es …

“Una nube de AWS completamente funcional.”

Este proyecto fue anteriormente propiedad de Atlassian, pero ahora es un


proyecto independiente y de código abierto. El objetivo principal de LocalStack
es proporcionar un marco de prueba y simulación fácil de usar para desarrollar
aplicaciones en la nube.

Con LocalStack, podemos activar los servicios centrales de AWS en nuestra


máquina local para probar nuestra aplicación sin ninguna interacción con la
nube real de AWS.

Hay varias formas de instalar y comenzar LocalStack, pero vamos a utilizar su


imagen oficial de Docker.
9. Desarrollo Local 184

El siguiente comando inicia LocalStack y expone el servicio AWS S3 en el puerto


4566

docker run -p 4566:4566 -e SERVICES=s3 localstack/localstack:0.14.4

Para ahora hacer uso de este AWS S3 local mientras trabajamos con el AWS CLI,
tenemos que pasar localhost:4566 como el --endpoint-url:

aws s3api create-bucket \


--bucket local-s3-bucket \
--endpoint-url http://localhost:4566

aws s3api put-object \


--bucket local-s3-bucket \
--key any_file.pdf \
--body any_file.pdf \
--endpoint-url http://localhost:4566

Con las dos instrucciones de AWS CLI mencionadas anteriormente, primero


creamos un bucket S3 y luego subimos un fichero a este bucket S3 local. No
necesitamos configurar ninguna credenciales específicas de AWS, ya que Lo-
calStack no utiliza el método de autenticación IAM. Cada AWS_ACCESS_KEY y
AWS_SECRET_KEY serán aceptados (o más bien: ignorados) por LocalStack.

Esto implica que tenemos que anular nuestros clientes de Java AWS SDK para
conectarse a LocalStack en lugar de AWS cuando iniciamos la aplicación local-
mente. Con Spring Cloud AWS, podemos anular convenientemente las URLs de
los puntos finales y las regiones para todos los clientes de AWS SDK:
9. Desarrollo Local 185

spring:
cloud:
aws:
endpoint: http://localhost:4566
region:
static: eu-central-1

Cuando se trata de configurar las credenciales de AWS para el entorno local,


introducimos manualmente las credenciales de AWS. De lo contrario, la apli-
cación no iniciará ya que el DefaultCredentialsProvider no podrá encontrar
ninguna credencial de AWS, dado que no estamos simulando un entorno de
ejecución local ECS:

spring:
cloud:
aws:
# above configuration
credentials:
secret-key: foo
access-key: bar

De forma predeterminada, LocalStack evita cualquier autenticación del cliente


AWS. Por lo tanto, se acepta cualquier combinación de clave secreta y clave de
acceso.

Lo que queda es crear nuestros recursos (por ejemplo, un bucket S3 o cola SQS)
una vez que LocalStack está en funcionamiento. Esto debe suceder antes de
iniciar la aplicación Todo. El contenedor Docker de LocalStack ejecuta cualquier
script de shell que está en la carpeta /docker-entrypoint-initaws.d cuando
todos los servicios están disponibles.

Podemos hacer uso de esta funcionalidad y crear un script para inicializar


nuestra infraestructura:
9. Desarrollo Local 186

#!/bin/sh

awslocal sqs create-queue --queue-name stratospheric-todo-sharing

# ... more infrastructure to setup

El binario awslocal es una envoltura alrededor de la verdadera CLI de AWS que


es parte del contenedor de LocalStack. Esta pequeña envoltura ya está configu-
rada para apuntar a los servicios de AWS simulados dentro del contenedor.

También haremos un uso intensivo de LocalStack cuando se trate de probar


nuestra aplicación. Hablaremos más sobre esto en un próximo capítulo.

Amazon RDS local y Amazon Cognito

LocalStack funciona muy bien para la mayoría de los servicios de AWS y sus
características básicas. Sin embargo, hay servicios de AWS que no son parte
de la edición comunitaria de LocalStack. Nuestra aplicación, por ejemplo, usará
PostgreSQL de RDS para la persistencia y Amazon Cognito como proveedor de
identidad, ambos solo son compatibles con la versión pro de LocalStack. Como
no podemos esperar que todos los lectores gasten dinero en la versión pro de
LocalStack, optaremos por una alternativa (aunque aún deberías respaldar este
proyecto si lo estás utilizando).

Reemplazar RDS es sencillo. Podemos usar una imagen oficial de Docker de


PostgreSQL ya que no vamos a utilizar muchas características específicas de
RDS.

Para Amazon Cognito, vamos a elegir un proveedor de identidad diferente. Ya


que vamos a utilizar OIDC (OpenID Connect, una capa de identidad sobre el
protocolo OAuth 2.0 - más en el próximo capítulo Construyendo el Registro de
Usuarios y el Inicio de Sesión con Cognito), estamos haciendo uso de un protocolo
9. Desarrollo Local 187

estandarizado. Lo bueno de los estándares es que podemos reemplazar un


proveedor existente con otra solución compatible.

Por lo tanto, utilizaremos Keycloak, una solución de gestión de identidad y


acceso de código abierto, para el desarrollo a nivel local. Preconfiguraremos la
instancia de Keycloak con usuarios, clientes y otros ajustes. De esta manera, no
se requiere ninguna configuración manual.

Reuniéndolo Todo

Como no queremos iniciar cada contenedor Docker uno por uno con docker run,
utilizaremos docker-compose. En los próximos capítulos, ampliaremos nuestro
archivo docker-compose.yml con contenedores adicionales o configuraciones.

Cuando estés desarrollando, solo necesitas ejecutar docker-compose up antes


de iniciar la aplicación con ./gradlew bootRun.

Finalmente, tendremos dos perfiles de aplicación:

• aws: para ejecutar la aplicación en AWS en modo de producción.


• dev: para el desarrollo a nivel local, conectándose a contenedores Docker
locales y sobrescribiendo los clientes de AWS para apuntar a localhost.

Con Spring Boot, podemos separar las dos configuraciones con dos archivos:
application-aws.yml y application-dev.yml. Además de esto, tendremos
un application.yml que configura los valores por defecto para ambos perfiles.

Para las bibliotecas de JavaScript y CSS que necesita nuestra interfaz, estamos
utilizando Webjars. Esto significa que no hay necesidad de descargar ningún
recurso externo desde un CDN ya que todo está empaquetado y servido por
nuestra aplicación.
9. Desarrollo Local 188

Esta configuración permite trabajar con la aplicación Todo en “modo avión”.


No se requiere conexión a internet después de una descarga inicial de todas las
dependencias e imágenes Docker.

Con este enfoque, reconocemos que nuestra configuración local no está re-
flejando totalmente la verdadera configuración de AWS. Todavía puede haber
diferencias, especialmente en cuanto a la carga de trabajo y el rendimiento. Sin
embargo, esta configuración es “suficientemente buena” y fácil de configurar y
mantener.
10. Construyendo Registro de Usuarios
e Inicio de Sesión con Amazon Cognito
Con este capítulo, estamos añadiendo las primeras características a nuestra
aplicación Todo: registro de usuarios e inicio de sesión. La gestión de grupos de
usuarios es una tarea común en todas las aplicaciones empresariales. Siempre
que tratamos con estas cuestiones de seguridad, generalmente es mejor usar
una solución prefabricada en lugar de reinventar la rueda.

Para quitarnos esta tarea delicada de las manos, hemos elegido un servicio de
AWS que podemos integrar sin problemas con nuestra aplicación Spring Boot.

Antes de entrar directamente en la configuración de nuestra aplicación Todo,


primero discutiremos la terminología importante y las especificaciones forma-
les (OAuth 2.0 & OpenID Connect 1.0) que hacen que todo esto funcione.

¿Qué es OAuth 2.0?

OAuth 2.0 (Open Authorization) es …

“… el protocolo estándar de la industria para la autorización. OAuth 2.0


se centra en la simplicidad del desarrollador del cliente al proporcionar
flujos de autorización específicos para aplicaciones web, aplicaciones
de ordenador, teléfonos móviles y dispositivos para el hogar.”
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 190

Mientras que autenticación es el proceso de verificar la identidad de al-


guien (por ejemplo, validando credenciales), autorización es determinar
qué nivel de acceso tiene un usuario para recursos o operaciones especí-
ficas (por ejemplo, solo los usuarios administradores están autorizados a
crear nuevos usuarios).

Este estándar existe actualmente en dos versiones: OAuth 1.0 y OAuth 2.0.
Ambas abordan la misma idea, pero la versión 2 simplifica la integración y
ofrece más flexibilidad. Una comparación en profundidad de ambas versiones
está disponible en el blog OAuth 2.0 Simplificado.

Como el enfoque de este libro está en Spring Boot y AWS, sólo cubriremos las
partes de OAuth que son necesarias para entender la integración entre nuestra
aplicación y Amazon Cognito. Hay excelentes recursos disponibles en línea para
profundizar más en el tema. Marco Behler, por ejemplo, ha elaborado una guía
exhaustiva que se centra en OAuth 2.0 y Spring Security.

La mayoría de nosotros probablemente hemos visto OAuth 2.0 en acción en


algún momento al usar aplicaciones de Software como Servicio (SaaS) modernas.
A menudo, se utiliza detrás de las escenas cuando otorgamos acceso a los
recursos o operaciones de una aplicación de terceros.

Un buen ejemplo es la plataforma de CI/CD Travis CI, que podemos usar para
construir y desplegar código de nuestros proyectos en GitHub. Después de
registrarnos en una cuenta de Travis CI, se nos pide que otorguemos a Tra-
vis acceso a nuestros repositorios de GitHub para que Travis pueda construir
nuestros proyectos en cada push. Para que esto funcione, seremos redirigidos a
GitHub, donde luego tendremos que introducir nuestras credenciales de GitHub.
Después, tenemos que confirmar que estaremos otorgando a Travis CI acceso
a un conjunto de operaciones/información que Travis CI puede ahora realizar
en nuestro nombre. Una vez concedido, veremos nuestros proyectos de GitHub
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 191

aparecer en la interfaz web de Travis.

Para entender cómo funciona el escenario que acabamos de describir sin dar a
Travis acceso a nuestras credenciales de GitHub (lo que sería un fallo de seguri-
dad significativo), primero tenemos que definir y explicar algunos términos de
OAuth 2.0.

Terminología de OAuth 2.0

El protocolo OAuth 2.0 define cuatro roles:

• Titular del Recurso: Normalmente un usuario final (nosotros) que posee re-
cursos en una aplicación de terceros (por ejemplo, repositorios en GitHub).
• Servidor de Recursos: La mayoría de las veces, un servidor (API de GitHub)
que expone recursos protegidos.
• Aplicación Cliente: Una aplicación (por ejemplo, Travis CI) que quiere
acceder a los recursos protegidos en nombre del Titular del Recurso.
• Servidor de Autenticación: Un servidor que autentica al Titular del Recurso
(usuario final) y emite tokens de acceso después de obtener la autorización.

Cómo una Aplicación Cliente obtiene un token de acceso válido para solicitar
recursos protegidos de un Titular del Recurso depende del caso de uso y de la
arquitectura de la aplicación. OAuth2.0 define diferentes flujos de trabajo (los
llamados “tipos de concesión”) sobre cómo una aplicación puede acceder a
recursos protegidos. Los tipos de concesión más comúnmente usados son los
siguientes:

• Código de Autorización: Más comúnmente usado para aplicaciones web y


móviles. Requiere el lanzamiento de un navegador para comenzar el flujo.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 192

• Credenciales de Cliente: Adecuado para la comunicación servidor-a-


servidor sin interacción del usuario.
• Código de Dispositivo: Usado por dispositivos con limitaciones de entrada
(por ejemplo, televisores inteligentes) para solicitar un token de acceso.
• Token de Refresco: El cliente puede intercambiar frecuentemente el token
de refresco por un nuevo token de acceso sin requerir que el usuario sea
redirigido cada vez que el token de acceso expira.

En nuestro ejemplo con Travis, hemos usado la autorización del tipo Authori-
zation Code para permitir el acceso a nuestros repositorios de GitHub. Travis
necesita un token de acceso válido para acceder a las APIs de GitHub y recabar
información sobre nuestros repositorios en GitHub. Usando la autorización
Authorization Code, el procedimiento funciona así en un alto nivel:

1. La aplicación (el Client, Travis en este caso) nos redirige (al Resource Owner)
al Authorization Server (la página de inicio de sesión de GitHub).
2. El Resource Owner (nosotros) introduce las credenciales para el Authorization
Server y aprueba la solicitud para acceder a los datos en su nombre.
3. El usuario es redirigido a la aplicación (Travis) con un código de autorización
en la cadena de consulta.
4. La aplicación (Travis) intercambia este código de autorización por un token
de acceso para obtener datos desde el Resource Server (la API de GitHub).

El siguiente diagrama visualiza este flujo:


10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 193

OAuth 2.0 Authorization Code Grant Flow

Con la ayuda de los llamados “scopes”, un cliente define las operaciones y datos
a los que necesita tener acceso. Aunque hay diferentes operaciones y recursos
disponibles en GitHub (como “leer perfil”, “ver repositorios públicos”, “admi-
nistrar una organización”, etc.), Travis no requiere todos estos. Definiendo un
subconjunto de scopes permite a Travis realizar solo aquellas operaciones que
necesita.

Aunque OAuth 2.0 se centra en la autorización, ¿podríamos utilizar de alguna


forma estos conceptos para habilitar la autenticación? Aquí es donde entra en
juego OpenID Connect 1.0.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 194

OpenID Connect 1.0 (OIDC)

Mientras que OAuth 2.0 se enfoca en la autorización (otorgar a Travis CI acceso a


tus repositorios de GitHub), existe un protocolo adicional que se apoya en OAuth
2.0 para solucionar la autenticación: OpenID Connect 1.0 (abreviado como OIDC).

En resumen, OIDC es un …

“… capa de identidad simple sobre el protocolo OAuth 2.0. Permite


a los clientes verificar la identidad del usuario final basándose en
la autenticación realizada por un servidor de autorización, así como
obtener información básica del perfil del usuario final de una forma
interoperable y similar a REST.”

Con OIDC, la entidad del usuario final se convierte en el recurso protegido en


lugar de nuestros repositorios de GitHub o contactos de Gmail, por ejemplo. Este
mecanismo de autenticación funciona con varios tipos de autorización de OAuth
2.0, siendo Authorization Code el que se utiliza más comúnmente.

El proceso es similar al que ya describimos para el ejemplo de Travis CI y GitHub.


La principal diferencia es que la aplicación cliente (nuestra aplicación) ahora
se llama “Relying Party” (RP). Además, el parámetro openid es obligatorio, y
el usuario final se convierte en el recurso protegido al que nuestra aplicación
solicita acceso.

Siempre que veas “Iniciar sesión con GitHub” o “Iniciar sesión con Google” en
la página de inicio de sesión de un sitio web o una aplicación, esa aplicación o
sitio web está haciendo uso de este protocolo OpenID Connect.

Para los siguientes capítulos, basta con entender que OIDC es una capa de
identidad sobre OAuth 2.0 que permite el inicio de sesión de los usuarios con
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 195

una cuenta de un tercero (por ejemplo, Google, GitHub, Twitter). Esto nos libera
de tener que implementar nosotros mismos la gestión de usuarios. Puedes leer
la especificación oficial para obtener más información en mayor detalle.

AWS proporciona el servicio “Cognito”, que implementa las especificaciones


de OAuth 2.0 y OpenID Connect y que vamos a integrar en nuestra aplicación de
ejemplo.

Alternativas a OAuth2 & OpenID Connect

Como alternativa a OAuth2 y OIDC, nuestra aplicación también podría imple-


mentar su propia gestión de usuarios. Spring Security ofrece un conjunto de
mecanismos de autenticación listos para usar: autenticación básica, autentica-
ción basada en formularios, etc. Además, el equipo de Spring ha introducido
recientemente el proyecto Authorization Server.

Con todas estas soluciones, tendríamos que almacenar información sensible de


nuestros usuarios (como contraseñas) y ofrecer funciones como la recuperación
de contraseñas o correos electrónicos de confirmación. Esto es interesante de
implementar (al menos una vez), pero el esfuerzo adicional de mantenimiento
y las preocupaciones de seguridad quizá no lo justifiquen.

Dado que la seguridad es un tema crucial, preferimos que los expertos nos
resuelvan este tema. Si se descuida al inicio de un proyecto, añadir funciones
relacionadas con la seguridad a última hora, justo antes de ponerlo en marcha,
no es beneficioso. Tener resuelto el tema de la seguridad desde el comienzo del
proyecto proporciona tranquilidad y mejora nuestro tiempo de lanzamiento al
mercado, ya que podemos concentrarnos en nuestras funcionalidades.

Por otro lado, ahora tenemos una dependencia adicional que es crucial para
que nuestra aplicación funcione: Dependemos del uptime del proveedor de
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 196

identidad. Por lo tanto, nuestra aplicación sería inutilizable (al menos sus áreas
protegidas) siempre que haya una interrupción del servicio.

Uso de Amazon Cognito para la gestión de usuarios

En las próximas secciones, aprenderemos más sobre Cognito, su vocabulario y


cómo configurarlo e integrarlo con nuestra aplicación “Todo”.

Introducción a Amazon Cognito

Amazon Cognito es un servicio administrado que proporciona autenticación, au-


torización y gestión de usuarios para aplicaciones web y móviles. Admite OAuth
2.0, OpenID Connect 1.0 y varios otros protocolos. Utilizando este servicio,
delegaremos la gestión de usuarios a AWS. Esto nos proporciona las siguientes
características:

• gestión de usuarios segura y sencilla


• modelo de pago por usuario activo
• inicios de sesión sociales a través de Facebook, Google, etc.
• autenticación de múltiples factores opcional
• SDKs para varios lenguajes de programación (Java, JavaScript, Python, etc.)
• página de inicio de sesión web personalizable

Soluciones similares listas para usar que podríamos usar en lugar de Amazon
Cognito son Okta, Keycloak o Auth0.

Echemos un vistazo a la terminología de Cognito.


10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 197

Terminología de Amazon Cognito

Un User Pool actúa como un directorio de usuarios donde podemos almacenar


y gestionar información del usuario. Cada User Pool viene con funcionalidad de
inicio de sesión. Esto incluye una interfaz web para iniciar sesión que podemos
personalizar y configurar, por ejemplo con inicios de sesión sociales adicionales
(Google, GitHub, etc.) o autenticación de múltiples factores.

Crearemos un único User Pool para nuestra aplicación y almacenaremos allí a


todos nuestros usuarios. No es necesario tener un User Pool por separado por
aplicación ya que podríamos querer iniciar sesión con las mismas credenciales
en varias aplicaciones.

Como parte del User Pool, también podemos configurar nuestra política de
contraseñas, definir atributos de usuario requeridos y opcionales, habilitar
mecanismos de recuperación de contraseña y personalizar las notificaciones por
correo electrónico.

Un User Pool App Client está asociado con un User Pool y tiene permiso para
realizar operaciones de API no autenticadas como registrando o registrar usua-
rios. Por lo tanto, cada App Client requiere un ID de cliente y una clave secreta
opcional.

Como parte de la creación del App Client, configuramos las URL de callback/lo-
gout y definimos qué flujos y alcances de OAuth 2.0 puede utilizar este cliente.
Registraremos nuestra aplicación “Todo” como un User Pool App Client para
habilitar el inicio de sesión de usuario a través de OIDC y el flujo de OAuth 2.0
Authorization Code Grant.

Con un Identity Pool, podemos mapear un usuario de un Identity Provider a un rol


IAM. Esto nos permite dar a los usuarios acceso a recursos de AWS en función
de sus permisos IAM. Dado que los usuarios de nuestra aplicación “Todo” no
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 198

necesitan roles IAM para usar la aplicación, un Identity Pool no es relevante para
nosotros.

Para una comparación en profundidad de Identity Pools y User Pools echa un vis-
tazo a la publicación de blog de Jake Bennett Understanding Amazon Cognito User
and Identity Pools for Serverless Apps. No solo este artículo explica su diferencia,
sino que también muestra cómo podríamos usar ambos conceptos juntos.

Como todos los demás recursos de AWS que proporcionamos para nuestra
aplicación, preferimos la infraestructura como código sobre los esfuerzos ma-
nuales repetitivos en la Consola de AWS. Veamos cómo podemos crear nuestros
recursos de Cognito con CDK.

La aplicación Amazon Cognito CDK

Siguiendo nuestra convención del capítulo Designing a Deployment Project with


CDK, crearemos una nueva aplicación CDK para nuestra instancia de Cognito:

public class CognitoApp {


public static void main(final String[] args) {
App app = new App();

// omitted standard configuration values like the AWS region and sanity checks

String applicationUrl = (String) app


.getNode()
.tryGetContext("applicationUrl");

String loginPageDomainPrefix = (String) app


.getNode()
.tryGetContext("loginPageDomainPrefix");

Environment awsEnvironment = makeEnv(accountId, region);

ApplicationEnvironment applicationEnvironment = new ApplicationEnvironment(


applicationName,
environmentName
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 199

);

new CognitoStack(
app,
"cognito",
awsEnvironment,
applicationEnvironment,
new CognitoStack.CognitoInputParameters(
applicationName,
applicationUrl,
loginPageDomainPrefix));

app.synth();
}
}

Puede encontrar el código fuente completo para el CognitoApp en GitHub.

Aparte de nuestros parámetros de entrada estándar (environmentName, appli-


cationName, accountId, region), esta aplicación CDK depende de los siguien-
tes parámetros adicionales:

• applicationUrl: Como parte de la configuración del UserPoolClient,


necesitamos definir las URLs de callback y logout válidas. Este parámetro
nos permite pasar la URL base final de nuestra aplicación. Un ejemplo de
URL sería https://app.stratospheric.dev.
• loginPageDomainPrefix: Cada grupo de usuarios ofrece una interfaz
de usuario web personalizable para el inicio de sesión de los usuarios.
Podemos proporcionar un dominio personalizado o pasar un prefijo
para usar un dominio de Amazon Cognito que se verá algo así:
https://<prefix>.auth.<region>.amazoncognito.com.

Para la definición de stack que viene, incluimos el módulo Cognito de AWS CDK
en nuestro proyecto CDK. La biblioteca construct de AWS CDK ya proporciona
constructos estables de nivel 1 y nivel 2 para User Pools:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 200

<!-- Both imports can be omitted when using cdk-constructs -->


<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>${aws-cdk-lib.version}</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>${constructs.version}</version>
</dependency>

Nuestro CognitoStack de AWS CDK define los tres recursos: un UserPool, un


UserPoolClient, y un UserPoolDomain. Comencemos con la configuración de
la UserPool.

Crear el UserPool

El UserPool actúa como el directorio de usuarios y es el primer recurso que


creamos como parte de nuestro CognitoStack (mira el código en GitHub):

public class CognitoStack extends Stack {

// fields omitted

public CognitoStack(
final Construct scope,
final String id,
final Environment awsEnvironment,
final ApplicationEnvironment applicationEnvironment,
final CognitoInputParameters inputParameters) {

super(scope, id, StackProps.builder()


.stackName(applicationEnvironment.prefix("Cognito"))
.env(awsEnvironment).build());

this.applicationEnvironment = applicationEnvironment;

this.userPool = UserPool.Builder.create(this, "userPool")


10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 201

.userPoolName(inputParameters.applicationName + "-user-pool")
.selfSignUpEnabled(false)
.standardAttributes(StandardAttributes.builder()
.email(StandardAttribute
.builder()
.required(true)
.mutable(false)
.build())
.build())
.signInAliases(SignInAliases
.builder()
.username(true)
.email(true)
.build())
.signInCaseSensitive(true)
.autoVerify(AutoVerifiedAttrs
.builder()
.email(true)
.build())
.mfa(Mfa.OFF)
.accountRecovery(AccountRecovery.EMAIL_ONLY)
.passwordPolicy(PasswordPolicy.builder()
.requireLowercase(true)
.requireDigits(true)
.requireSymbols(true)
.requireUppercase(true)
.minLength(12)
.tempPasswordValidity(Duration.days(7))
.build())
.build();

// further Cognito-related resources

}
}

A diferencia del enfoque de crear nuestros propios Constructs de CDK que se-
guimos en el capítulo Diseñando un Proyecto de Despliegue con CDK, aquí estamos
utilizando un enfoque ligeramente diferente: Estamos creando un Stack de CDK
que utiliza constructs oficiales de Cognito de CDK de nivel 2 en lugar de nuestros
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 202

propios bloques de construcción. Esto nos da más flexibilidad ya que podemos


ajustar la configuración altamente personalizable de Cognito.

La mayoría de los atributos de este recurso son autoexplicativos. Desactivamos


selfSignUp para que sólo los usuarios administradores puedan agregar nuevos
usuarios a nuestro grupo de usuarios. De lo contrario, los usuarios serían capa-
ces de registrarse ellos mismos.

Nuestra aplicación Todo actuará como administrador y creará nuestros usuarios.


Para que esto funcione, tenemos que extender el rol IAM de nuestra tarea ECS y
permitir que nuestra aplicación realice todas las operaciones relacionadas con
el proveedor de identidad.

El constructo Service de nuestra biblioteca cdk-constructs ahora acepta


una lista de objetos PolicyStatement, que son necesarios para configurar el
acceso a los recursos internos de AWS para nuestra aplicación. Agregaremos el
siguiente objeto a esta lista (ver ServiceApp en GitHub) para permitir todas las
operaciones para cognito-idp:

.withTaskRolePolicyStatements(List.of(
PolicyStatement.Builder.create()
.sid("AllowCreatingUsers")
.effect(Effect.ALLOW)
.resources(
List.of(String.format("arn:aws:cognito-idp:%s:%s:userpool/%s", region,
accountId, cognitoOutputParameters.getUserPoolId()))
)
.actions(List.of("cognito-idp:AdminCreateUser"))
.build()
))

Cuando se trabaja con roles IAM para Cognito, asegúrese de usar


cognito-idp como prefijo al apuntar a las acciones relevantes de un User
Pool. El prefijo cognito-identity se refiere a Identity Pools.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 203

Para minimizar la cantidad de información del usuario, sólo requerimos un


nombre de usuario y una dirección de correo electrónico durante el proceso
de registro. El nombre de usuario se incluye por defecto. Por lo tanto, no
necesitamos agregarlo al configurar los standardAttributes.

Los usuarios pueden utilizar tanto su correo electrónico como su nombre de


usuario para iniciar sesión (signInAliases). Ambos son “case-sensitive”.
Aprobamos automáticamente el correo electrónico del usuario al agregar email
a los atributos autoVerify.

La autenticación multifactorial (MFA) está desactivada ya que colocamos mfa


en OFF. Los usuarios sólo pueden usar su correo electrónico para recuperar sus
cuentas en caso de que olviden sus contraseñas (accountRecovery).

Como último paso, ajustamos la passwordPolicy a un sólido estándar de segu-


ridad al solicitar a los usuarios que introduzcan una contraseña con al menos
doce caracteres mixtos (símbolos, dígitos, mayúsculas, minúsculas).

Por defecto, este constructo de nivel 2 configura Amazon Cognito como el


servicio de entrega de correo electrónico (para enviar la contraseña o un correo
electrónico de recuperación, por ejemplo) en lugar de Amazon SES. Sin embargo,
con esta configuración, debemos tener en cuenta que Amazon Cognito tiene un
límite diario de correo electrónico. Es recomendable usar Amazon SES a largo
plazo (como se sugiere en las mejores prácticas).

Creación del UserPoolClient y UserPoolDomain

A continuación, añadimos un UserPoolClient para nuestra aplicación Todo.


Si tuviéramos otros clientes que necesitan acceso a Cognito (por ejemplo, una
aplicación móvil), tendríamos que duplicar la configuración a continuación y
ajustar los valores:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 204

this.userPoolClient = UserPoolClient.Builder.create(this, "userPoolClient")


.userPoolClientName(inputParameters.applicationName + "-client")
.generateSecret(true)
.userPool(this.userPool)
.oAuth(OAuthSettings
.builder()
.callbackUrls(asList(
String.format(
"%s/login/oauth2/code/cognito",
inputParameters.applicationUrl),
"http://localhost:8080/login/oauth2/code/cognito"
))
.logoutUrls(asList(
inputParameters.applicationUrl,
"http://localhost:8080"))
.flows(OAuthFlows
.builder()
.authorizationCodeGrant(true)
.build())
.scopes(asList(
OAuthScope.EMAIL,
OAuthScope.OPENID,
OAuthScope.PROFILE))
.build())
.supportedIdentityProviders(singletonList(
UserPoolClientIdentityProvider.COGNITO))
.build();

Definimos el nombre del cliente (userPoolClientName) para evitar nombres de


clientes aleatorios. Por razones de seguridad, AWS genera el secreto para este
cliente para nosotros (generateSecret). Posteriormente, podemos usar el SDK
de Cognito para recuperar este valor autogenerado.

Al establecer userPool al objeto Java que se devolvió al crear el UserPool,


conectamos este cliente a nuestra Piscina de Usuarios.

A continuación, para las URL de callback y logout, estamos agregando


dos URL. Una URL es la URL de producción final, mientras que la otra
se utiliza para el desarrollo local y la solución de problemas. La ruta
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 205

/login/oauth2/code/cognito es la URI de callback estándar para Spring


Security.

Con los atributos restantes, permitimos que este cliente de la piscina de usua-
rios utilice el flujo de Concesión de Código de Autorización OAuth 2.0 con un
conjunto predefinido de alcances de OAuth 2.0.

El método supportedIdentityProviders() espera una lista de proveedores


de identidad que nuestros usuarios pueden usar para iniciar sesión con este
cliente. Aparte de COGNITO, podríamos agregar FACEBOOK, AMAZON, GOOGLE, o un
proveedor de identidad personalizado aquí.

El último recurso de este montón es el UserPoolDomain:

this.userPoolDomain = UserPoolDomain.Builder.create(this, "userPoolDomain")


.userPool(this.userPool)
.cognitoDomain(CognitoDomainOptions
.builder()
.domainPrefix(inputParameters.loginPageDomainPrefix)
.build())
.build();

Como no estamos utilizando un dominio personalizado para la página de in-


greso, estamos configurando el prefijo para el dominio de Amazon Cognito. Si
desplegamos este stack en la región eu-central-1 y utilizamos “stratospheric”
como el LoginPageDomainPrefix, obtendremos la siguiente URL de ingreso:
https://stratospheric.auth.eu-central-1-amazoncognito.com.

Podríamos también reutilizar el parámetro de entrada applicationName como


el prefijo del dominio, pero tendríamos que asegurarnos de que este prefijo
es único para la región de AWS. El parámetro adicional nos proporciona la
flexibilidad de escoger otro prefijo si el nombre de la aplicación ya está ocupado.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 206

Parámetros de Salida de Amazon Cognito

Para configurar Spring Security para nuestra aplicación Todo más adelante, ne-
cesitamos exponer los atributos dinámicos de nuestra configuración de Cognito:

StringParameter.Builder.create(this, "userPoolId")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USER_POOL_ID))
.stringValue(this.userPool.getUserPoolId())
.build();

StringParameter.Builder.create(this, "userPoolClientId")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USER_POOL_CLIENT_ID))
.stringValue(this.userPoolClient.getUserPoolClientId())
.build();

StringParameter.Builder.create(this, "logoutUrl")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USER_POOL_LOGOUT_URL))
.stringValue(this.logoutUrl)
.build();

StringParameter.Builder.create(this, "providerUrl")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USER_POOL_PROVIDER_URL))
.stringValue(this.userPool.getUserPoolProviderUrl())
.build();

AWS genera valores aleatorios tanto para userPoolId como para userPool-
ClientId. Necesitamos hacer accesibles ambos ID más adelante para recuperar
el secreto del cliente OAuth 2.

El logoutUrl es necesario para cerrar completamente la sesión del usuario final.


Más sobre esto al final de este capítulo. Esta URL contiene nuestro loginDomain-
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 207

Prefix y la región de AWS, que lucirá algo así:

https://stratospheric.auth.eu-central-1.amazoncognito.com/logout

Necesitamos exponer el providerUrl para configurar Spring Security para des-


cubrir los puntos finales relevantes de OAuth 2.0. Esta URL contiene la región
de AWS y nuestro identificador de pool de usuarios y se verá así:

https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_pD8flsXa

Para recuperar este secreto generado automáticamente, invocamos el método


getUserPoolClientSecret() en nuestro UserPoolClient. Después, conver-
timos en una cadena el valor secreto que devuelve este método, que después
almacenamos en AWS Parameter Store:

this.userPoolClientSecret = this.userPoolClient.getUserPoolClientSecret().unsafeUnwr\
ap();

StringParameter userPoolClientSecret =
StringParameter.Builder.create(this, "userPoolClientSecret")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USER_POOL_CLIENT_SECRET))
.stringValue(this.userPoolClientSecret)
.build();

En resumen, necesitamos los siguientes valores resultantes para integrar co-


rrectamente nuestra aplicación con Cognito:

• User Pool provider URL


• User Pool logout URL
• User Pool client name
• User Pool client ID
• User Pool client secret
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 208

Como nuestra aplicación Spring Boot espera estos valores al inicio de la aplica-
ción, tenemos que desplegar o actualizar la pila de Cognito antes de iniciar un
nuevo despliegue de ECS de nuestra aplicación Todo.

Finalmente, ampliamos nuestro package.json con dos scripts para crear, ac-
tualizar, o destruir la aplicación Cognito CDK de forma cómoda:

npm run cognito:deploy


npm run cognito:destroy

Una nota sobre los Parámetros Seguros

Es posible que notes que almacenamos el parámetro userPoolClientSecret


como un StringParameter simple en el Almacén de Parámetros de AWS, lo que
significa que no está encriptado como un secreto. Más adelante, en el capítulo
Conectando a una Base de Datos con RDS, veremos cómo almacenar y recuperar un
secreto encriptado en el Gestor de Secretos de AWS.

Por sí sola, esta encriptación no tiene mucho valor, ya que pasaremos todos los
secretos como variables de entorno en texto plano al contenedor Docker con
nuestra aplicación Spring Boot. Eso significa que estarán disponibles en texto
plano durante la ejecución del programa. Además, todas las variables de entorno
se muestran en texto plano en la consola web de ECS.

Otra opción para manejar parámetros secretos es almacenarlos como secretos


en el Gestor de Secretos de AWS e inyectar solo el nombre del parámetro como
una variable de entorno en nuestra aplicación. La aplicación puede entonces
usar el nombre del parámetro para cargar el secreto de la tienda de secretos
cuando lo necesite. De esta manera, el parámetro no estaría accesible como
una variable de entorno durante toda la ejecución de la aplicación. Solo sería
accesible para los administradores con permisos para el Gestor de Secretos.

Spring AWS integra el Gestor de Secretos de AWS para hacer disponibles los
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 209

secretos como propiedades de la aplicación. Preferimos la simplicidad antes


que la seguridad en este caso y usamos una variable de entorno simple para el
secreto del cliente del pool de usuarios de Cognito.

Como alternativa, también podríamos almacenar valores de configuración sen-


sibles como SecureStrings dentro del Almacén de Parámetros de AWS y usar
Spring Cloud AWS para obtener los valores al inicio de la aplicación.

Usando Amazon Cognito como un Proveedor de Identidad


con Spring Security

Ahora, veamos cómo podemos integrar el Pool de Usuarios de Cognito y el


Cliente con nuestra aplicación Spring Boot.

Spring Security ya soporta tanto OAuth 2.0 como OpenID Connect 1.0 de serie.
Dado que estamos basándonos en un protocolo estándar de la industria, hay
un procedimiento común que ocurre detrás de escena que Spring Security
implementa para nosotros.

Los componentes de seguridad relevantes son parte de los siguientes dos Spring
Boot starters:

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

Estos arrancadores ya hacen mucho trabajo de configuración por nosotros.


Lo que queda es configurar la información sobre el Proveedor de 1.0 OpenID
Connect - que en nuestro caso es Cognito.

Spring Security necesita conocer un conjunto de URI del proveedor externo


- la authorization-uri, token-uri, user-info-uri, etc. - para realizar el
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 210

flujo de Authorization Code Grant de OAuth 2.0. Aunque podríamos especificar


todos estos atributos por nuestra cuenta, hay una solución aún más sencilla
disponible.

La especificación de OpenID Connect requiere que cada proveedor compatible


exponga un endpoint de descubrimiento para recuperar los valores de configu-
ración. Este endpoint debe estar disponible en la siguiente ruta especificada:
FULL_URI_OF_THE_PROVDER/.well-known/openid-configuration.

Al invocar este endpoint (a través de una solicitud HTTP GET) para nuestro pool
de usuarios de Cognito obtenemos el siguiente resultado:

curl -v https://cognito-idp.eu-central-1.amazonaws.com/eu-
central-1_pXOUNokLO/.well-known/openid-configuration

{
"authorization_endpoint": "https://dev101...amazoncognito.com/oauth2/authorize",
"id_token_signing_alg_values_supported": [
"RS256"
],
"issuer": "https://...amazonaws.com/eu-central-1_pXOUNokLO",
"jwks_uri": "https:/...amazonaws.com/eu-central-1_pXOUNokLO/.well-known/jwks.json",
"response_types_supported": [
"code",
"token"
],
"scopes_supported": [
"openid",
"email",
"phone",
"profile"
],
"subject_types_supported": [
"public"
],
"token_endpoint": "https://...amazoncognito.com/oauth2/token",
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 211

],
"userinfo_endpoint": "https://...amazoncognito.com/oauth2/userInfo"
}

Al especificar el punto de acceso de Descubrimiento de OpenID de nuestro


proveedor de identidad, Spring Security recupera automáticamente toda la
información relevante al inicio de la aplicación.

Para resumir todo, la configuración relevante de Spring Security en nuestro


archivo application-aws.yml se verá así:

spring:
security:
oauth2:
client:
registration:
cognito:
clientId: ${COGNITO_CLIENT_ID}
clientSecret: ${COGNITO_CLIENT_SECRET}
scope: openid, profile, email
clientName: stratospheric-client
provider:
cognito:
issuerUri: ${COGNITO_PROVIDER_URL}

Los marcadores de posición ${} serán sustituidos con variables de entorno que
determinamos al desplegar nuestra aplicación Todo con ECS (usando SpEL - el
potente lenguaje de expresión de Spring).

Registro de usuarios con Amazon Cognito

Un User Pool vacío es bueno para nuestra factura de AWS, pero no para la
reputación de nuestra aplicación. Es hora de llenarlo con usuarios reales de
nuestra aplicación Todo.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 212

Al configurar el User Pool de Cognito, especificamos que solo los usuarios admi-
nistradores pueden agregar nuevos usuarios al pool.

Para la comunicación con Cognito, estamos utilizando el SDK de Java. Para


alinear todas las versiones del SDK de Java de AWS, Spring Cloud AWS ya
especifica el BOM del SDK de Java de AWS. Así, solo necesitamos agregar la
siguiente dependencia a nuestro proyecto sin tener que especificar una versión:

dependencies {
implementation 'software.amazon.awssdk:cognitoidentityprovider'
}

A continuación, definimos un Spring Bean de tipo CognitoIdentityProvider-


Client ya que este cliente no es configurado automáticamente por Spring Cloud
AWS:

@Configuration
public class AwsConfig {

@Bean
@ConditionalOnProperty(prefix = "custom",
name = "use-cognito-as-identity-provider", havingValue = "true")
public CognitoIdentityProviderClient cognitoIdentityProviderClient(
AwsRegionProvider regionProvider,
AwsCredentialsProvider awsCredentialsProvider) {
return CognitoIdentityProviderClient.builder()
.credentialsProvider(awsCredentialsProvider)
.region(regionProvider.getRegion())
.build();
}
}

Como se describe en el capítulo Desarrollo Local, no queremos conectarnos


al verdadero servicio Cognito cuando trabajamos localmente, por lo que este
Spring bean no se activará si nuestra propiedad personalizada use-cognito-as-
identity-provider está configurada en false.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 213

Nuestros usuarios podrán registrar sus cuentas como parte de la aplicación


“Todo”. Para evitar que los bots generen cuentas automáticamente, agregamos
una capa de protección y requerimos un código de invitación en cada registro.
Con ese fin, vamos a crear una vista pública de Thymeleaf que contenga la
información relevante para los nuevos registros.

El modelo para nuestra vista contiene la siguiente información:

public class Registration {

@NotBlank
private String username;

@Email
private String email;

@ValidInvitationCode
private String invitationCode;

// getters & setters

Estamos utilizando Bean Validation para validar los datos entrantes al enviar el
formulario. Un endpoint básico del controlador Spring MVC expone esta vista e
instancia un objeto Registration vacío para que el usuario lo llene:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 214

@Controller
@RequestMapping("/register")
public class RegistrationController {

private final RegistrationService registrationService;

public RegistrationController(RegistrationService registrationService) {


this.registrationService = registrationService;
}

@GetMapping
public String getRegisterView(Model model) {
model.addAttribute("registration", new Registration());
return "register";
}
}

La sección relevante de Thymeleaf para el formulario HTML asocia los atributos


del modelo a los campos de entrada y muestra un mensaje de error cada vez que
la validación no es exitosa:

<form th:action="@{/register}" th:object="${registration}" method="post">


<div class="form-group">
<label for="username">Username</label>
<input
type="text"
th:field="*{username}"
class="form-control"
id="username"
required>
</div>
<div class="form-group">
<label for="email">Email address</label>
<input
type="email"
th:field="*{email}"
class="form-control"
id="email"
required
aria-describedby="emailHelp">
<small id="emailHelp" class="form-text text-muted">
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 215

We'll never share your email with anyone else.


</small>
</div>
<div class="form-group">
<label for="invitationCode">Invitation code</label>
<input
th:field="*{invitationCode}"
th:classappend="${#fields.hasErrors('invitationCode')}? 'is-invalid'"
type="text"
class="form-control"
id="invitationCode"
aria-describedby="invitationCodeHelp"
required>
<div th:if="${#fields.hasErrors('invitationCode')}"
th:text="${#strings.listJoin(#fields.errors('invitationCode'), ', ')}"
class="invalid-feedback">
</div>
<small id="invitationCodeHelp" class="form-text text-muted">
Enter the invitation code you received to register.
</small>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>

No entraremos en muchos detalles sobre Thymeleaf ni explicaremos


cada aspecto de nuestras opiniones ya que el tema principal de este
libro es AWS y Spring Boot. Si estás buscando un excelente recurso para
desarrollar aplicaciones Spring Boot con Thymeleaf, te recomendamos
encarecidamente el libro Taming Thymeleaf de Wim Deblauwe.

Nuestros usuarios verán la siguiente página de registro:


10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 216

Página de registro Stratospheric.

Para validar el código de invitación enviado, estamos definiendo un Cons-


traintValidator personalizado que comprueba el código contra un conjunto
de códigos de invitación válidos (ver InvitationCodeValidator en GitHub).
Podemos configurar estos códigos con una variable de entorno o almacenarla
en el AWS SSM Parameter Store.

Siempre que el usuario pulsa el botón de enviar, el navegador realiza un HTTP


POST contra /register e incluye la información de registro como contenido.

Para poder procesar esta solicitud, tenemos que agregar un nuevo mapeo de
punto final a nuestro RegistrationController:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 217

@PostMapping
public String registerUser(
@Valid Registration registration,
BindingResult bindingResult,
Model model, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
model.addAttribute("registration", registration);
return "register";
}

try {
registrationService.registerUser(registration);

redirectAttributes.addFlashAttribute("message",
"You successfully registered for the Todo App. " +
"Please check your email inbox for further instructions."
);
redirectAttributes.addFlashAttribute("messageType", "success");

return "redirect:/";
} catch (CognitoIdentityProviderException exception) {

model.addAttribute("registration", registration);
model.addAttribute("message", exception.getMessage());
model.addAttribute("messageType", "danger");

return "register";
}
}

Primero, nos aseguramos de que nuestro modelo cumpla con nuestras normas
de validación (por ejemplo, no se permite un correo electrónico o nombre de
usuario vacíos). Aunque también contamos con una validación básica del lado
del cliente, nunca sabemos cómo los clientes llegan a nuestro punto de acceso
y, por lo tanto, siempre debemos validar el payload.

Si hay errores de validación, la llamada a bindingResult.hasErrors() devuel-


ve verdadero, y mostramos la página de registro al usuario de nuevo, proporcio-
nando sugerencias adicionales. Por ejemplo, esto puede suceder si un usuario
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 218

introduce un código de validación incorrecto.

Página de registro de Stratospheric con error de vinculación.

Delegamos el registro real al RegistrationService y le pasamos los valores


enviados. Nuestro RegistrationService es una interfaz, ya que tendremos dos
variantes de la misma dependiendo del entorno en el que se ejecute nuestra
aplicación:

public interface RegistrationService {


void registerUser(Registration registration);
}

Para el entorno de producción en AWS, crearemos un nuevo usuario dentro de


nuestra Cognito User Pool:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 219

@Service
@ConditionalOnProperty(prefix = "custom",
name = "use-cognito-as-identity-provider", havingValue = "true")
public class CognitoRegistrationService implements RegistrationService {

private final CognitoIdentityProviderClient cognitoIdentityProviderClient;


private final String userPooldId;

public CognitoRegistrationService(
CognitoIdentityProviderClient cognitoIdentityProviderClient,
@Value("${COGNITO_USER_POOL_ID}") String userPoolId) {
this.cognitoIdentityProviderClient = cognitoIdentityProviderClient;
this.userPooldId = userPoolId;
}

@Override
public void registerUser(Registration registration) {
AdminCreateUserRequest registrationRequest = new AdminCreateUserRequest()
.withUserPoolId(userPooldId)
.withUsername(registration.getUsername())
.withUserAttributes(
new AttributeType()
.withName("email")
.withValue(registration.getEmail()),
new AttributeType()
.withName("email_verified")
.withValue("true")
)
.withDesiredDeliveryMediums(DeliveryMediumType.EMAIL)
.withForceAliasCreation(Boolean.FALSE);

cognitoIdentityProviderClient.adminCreateUser(registrationRequest);
}
}

El SDK de Java de Amazon Cognito proporciona las clases relevantes para


realizar un AdminCreateUserRequest. No transmitimos mucha información
junto con el registro, ya que solo requerimos un nombre de usuario y un correo
electrónico del usuario.

Hasta ahora, el usuario no ha seleccionado ninguna contraseña. Eso es porque


10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 220

configuramos nuestra User Pool de Cognito para enviar una contraseña temporal
al buzón del usuario tras el registro.

Iniciar sesión con Amazon Cognito

Actualmente, todos los endpoints de nuestra aplicación están protegidos por la


auto-configuración de Spring Security. Nuestra aplicación Todo debería tener
tanto un área protegida como una pública. Solo los usuarios que han iniciado
sesión deben poder crear, editar, eliminar y compartir tareas.

Sin embargo, la vista previa para el proceso de registro debe estar disponible
sin ninguna autenticación. Además, la página de inicio de nuestra aplicación y
el endpoint /health tienen requisitos similares. Lo mismo ocurre con cualquier
recurso estático (JavaScript, CSS, etc.) que nuestra aplicación Spring Boot sirve
al navegador.

Cualquier otra parte de la aplicación debe estar protegida y solo ser accesible
para los usuarios que iniciaron sesión a través de OIDC y sus credenciales de
Cognito.

Veamos cómo podríamos configurar nuestra aplicación para satisfacer estos


requisitos.

Spring Security nos permite definir nuestras propias reglas de seguridad pro-
porcionando un bean SecurityFilterChain. Podemos inyectar la instancia
de HttpSecurity de la aplicación para especificar nuestras restricciones de
seguridad utilizando un constructor en cadena:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 221

@Configuration
public class WebSecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity)
throws Exception {
httpSecurity
.csrf()
.and()
.oauth2Login()
.and()
.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.permitAll()
.requestMatchers("/", "/health", "/register").permitAll()
.anyRequest().authenticated();

return httpSecurity.build();
}
}

Con Spring Security 5.7.0-M2, el equipo de Spring ha declarado la ob-


solescencia de la clase WebSecurityConfigurerAdapter. Anteriormente,
extendíamos esta clase para especificar nuestra configuración de Spring
Security. Ahora, el equipo de desarrollo de Spring nos anima a avanzar
hacia una configuración de seguridad basada en componentes. Encuentra
más información acerca de esta obsolescencia aquí.

Con la configuración anterior, primero habilitamos la protección CSRF (para


prevenir un ataque CSRF). Debido a la excelente integración de Thymeleaf,
Spring MVC y Spring Security, todos nuestros formularios HTML ya están pro-
tegidos por defecto e incluyen un token CSRF oculto.

Después, configuramos el soporte de autenticación para OIDC. Con


oauth2Login, Spring Security se refiere a la autenticación OAuth 2.0 y/o
OpenID Connect 1.0. Como seguimos las convenciones por defecto y ya que
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 222

hemos especificado las propiedades de configuración pertinentes dentro de


application-aws.yml, no hay nada más que configurar con respecto a OIDC.

La parte restante de la configuración de seguridad permite cualquier solicitud a


nuestros recursos estáticos y puntos de acceso públicos. Todo lo demás requiere
autenticación.

Como parte de nuestro encabezado de aplicación, ahora podemos incluir un


botón para permitir a los usuarios iniciar sesión. Solo mostraremos este botón
cuando un visitante no autenticado accede a nuestra aplicación.

Controlamos esto con el método isAnonymous() que es parte de la dependencia


thymeleaf-extras-springsecurity5:

<li class="nav-item" sec:authorize="isAnonymous()">


<a class="btn btn-primary" th:href="@{/oauth2/authorization/cognito}">
Login
</a>
</li>

El lugar de inicio de sesión contiene el nombre de nuestro cliente/proveedor


OAuth 2.0: cognito. Spring Security se encarga de redirigir al usuario a nuestro
diálogo de inicio de sesión del User Pool de Cognito.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 223

Formulario de inicio de sesión alojado de Amazon Cognito.

Una vez que el usuario ingresa las credenciales correctas, será redirigido de
nuevo a nuestra aplicación, y Spring Security creará una sesión válida para
este usuario. Técnicamente hablando, Spring Security crea un Principal en la
forma de un OidcUser. Entonces podemos inyectar ese Principal en nuestros
controladores de Spring MVC:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 224

@GetMapping
public String getIndex(Model model, @AuthenticationPrincipal OidcUser user) {
// extract attributes from the user object

return "index";
}

O podemos obtenerlo del SecurityContextHolder:

OidcUser user = (OidcUser) SecurityContextHolder


.getContext()
.getAuthentication()
.getPrincipal();

Incluso podemos comprobar el estado de autenticación en nuestras


vistas de Thymeleaf. Si vinculamos el espacio de nombres XML
http://www.thymeleaf.org/extras/spring-security al prefijo sec,
podemos usarlo para renderizar condicionalmente partes de una vista
basándonos en expresiones de seguridad:

<!-- rendered for unauthenticated users -->


<div sec:authorize="isAnonymous()">
<p>Seems like you are not logged-in yet. Please login first to see your Todos.</p>
<a class="btn btn-primary" th:href="@{/oauth2/authorization/cognito}">
Login
</a>
</div>

<!-- rendered for authenticated users -->


<div sec:authorize="isAuthenticated()">
<p>Welcome to the protected area!</p>
<p>Your email: [[${email}]]</p>
<p>Your claims:</p>
<ul>
<li
th:each="claim : ${claims}"
th:text="${claim.key} + ': ' + ${claim.value}">
</li>
</ul>
</div>
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 225

Además, también podemos acceder al objeto de autenticación de Spring Security


haciendo referencia al objeto de utilidad de expresión #authentication.

<li class="nav-item" sec:authorize="isAuthenticated()">


Howdy, [[${#authentication.principal.attributes.name}]]!
</li>

Limitaciones al escalar horizontalmente

Si bien este procedimiento funciona sin problemas cuando se inicia la aplicación


localmente o con una sola instancia, nos encontramos con problemas cuando
escalamos a múltiples instancias. Dado que nuestra infraestructura en AWS se
asegura de ejecutar al menos dos instancias de nuestra aplicación Todo, estamos
afectados por este problema.

Tener una aplicación “stateless” permite escalar y distribuir el tráfico sin nin-
gún problema. Desafortunadamente, dos detalles técnicos nos impiden tener
una aplicación Todo completamente “stateless”. Echemos un vistazo al prime-
ro.

Una solicitud de muestra de OAuth 2.0 Authorization Code Grant se ve así:

https://authorization-server.com/oauth/authorize
?client_id=a17c21ed
&response_type=code
&state=5ca75bd30
&redirect_uri=https%3A%2F%2Fexample-app.com%2Fauth
&scope=openid

La especificación define el parámetro state como un campo sugerido. Este pará-


metro se utiliza para transferir el estado y también para proteger contra ataques
CSRF. Spring Security genera una cadena aleatoria para este valor y verifica que
el mismo valor sea retornado después de que el usuario se autentique.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 226

Esta validación falla siempre que un callback se redirige a una instancia de


Spring Boot que no inició esta llamada y, por lo tanto, no tiene conocimiento de
este estado. Cuando hay varios nodos ejecutando nuestra aplicación en paralelo,
es alta la probabilidad de que esta validación falle.

Una solución es configurar sesiones persistentes (conocidas como sesiones


pegajosas) como parte del Balanceador Elástico de Carga de AWS. Esto garantiza
que los usuarios siempre sean dirigidos a la misma instancia a la que fueron
asignados en su primera solicitud.

Podemos configurar sesiones pegajosas para el ApplicationTargetGroup como


parte de los ServiceInputParameters de nuestra estructura Service en CDK:

new Service(
serviceStack,
"Service",
awsEnvironment,
applicationEnvironment,
new Service.ServiceInputParameters(...)
.withStickySessionsEnabled(true)
);

La segunda cuestión es la creación de sesiones HTTP en memoria. Sin más


configuración, la sesión solo es conocida por la instancia de Spring Boot que la
creó. Si una solicitud llega a una instancia diferente, Spring Security rechazará
la solicitud. Con nuestra configuración de sesión persistente, esto no sucederá,
ya que nuestros clientes siempre son dirigidos a la misma instancia.

Con este enfoque, perdemos el beneficio de un fail-over desapercibido. Siempre


que se termina una de nuestras instancias, el usuario es redirigido a la otra
instancia y tiene que iniciar sesión de nuevo. Quizás no sea la mejor experiencia
de usuario, pero tampoco es un motivo de gran preocupación.

Como alternativa a la configuración de sesiones persistentes para nuestro equi-


librador de carga, Spring proporciona un sub-proyecto llamado Spring Session.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 227

Con este sub-proyecto, podemos habilitar sesiones agrupadas para nuestra


aplicación Spring utilizando un almacenamiento persistente como Hazelcast,
MongoDB, o un RDBMS para almacenar la información de la sesión.

El almacenamiento persistente actúa como un único punto de verdad para toda


la información de la sesión y es resistente a los re-despliegues y fallos de
la aplicación. Cualquier instancia de Spring Boot reconocerá un JSESSIONID
entrante incluso aunque la sesión fue creada en una instancia diferente. Como
Spring Security almacena el parámetro state para la Autorización de Código de
Grant OAuth 2.0 como un atributo de sesión, el callback también será aceptado
en cualquier instancia.

Tanto las sesiones persistentes como Spring Session soportan la escalabilidad


de nuestra configuración de autenticación y autorización. Para reducir la com-
plejidad adicional de la configuración, vamos a seguir adelante con las sesiones
persistentes.

Proceso de Deslogueo

Cuando un usuario autenticado entra en nuestra aplicación, mostramos un


botón de deslogueo como parte de nuestro encabezado principal:

<li class="nav-item" sec:authorize="isAuthenticated()">


<form th:action="@{/logout}" method="post">
<input class="btn btn-danger" type="submit" value="Logout">
</form>
</li>

La ruta /logout es la ubicación predeterminada de Spring Security para manejar


el proceso de cierre de sesión.

Como nuestra aplicación de Spring Boot crea y almacena una sesión para cada
uno de nuestros usuarios, el primer paso es invalidar esta sesión una vez que el
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 228

usuario decide cerrar la sesión. Esto hará que el usuario actual sea desconocido
para nuestra aplicación. Posteriormente, todas las partes isAnonymous() de
nuestras vistas Thymeleaf se evaluarán como true.

Sin embargo, el usuario aún tiene una sesión activa en el proveedor de identidad.
Con cada intento subsiguiente de volver a iniciar sesión en nuestra aplicación,
los usuarios no tienen que proporcionar sus credenciales para Cognito (a menos
que la sesión expire) y se inician sesión automáticamente.

OIDC tiene un procedimiento estandarizado para también cerrar la sesión del


usuario en el proveedor de identidad: OpenID Connect RP (Relying Party)-
Initiated Logout 1.0. Desafortunadamente, esta especificación es actualmente
un borrador y, por lo tanto, no es un requisito estricto para la compatibilidad.

En resumidas cuentas, el servidor de autorización debe exponer un end_ses-


sion_endpoint que acepta una lista predefinida de parámetros para cerrar la
sesión del usuario y redirigirlos de nuevo a la aplicación.

Spring Security ya es capaz de seguir este procedimiento estándar para cerrar


completamente la sesión del usuario. Activamos esto al especificar el Oidc-
ClientInitiatedLogoutSuccessHandler como parte del logoutSuccessHand-
ler de nuestro WebSecurityConfig :
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 229

@Configuration
public class WebSecurityConfig {

private final ClientRegistrationRepository clientRegistrationRepository;

public WebSecurityConfig(
ClientRegistrationRepository clientRegistrationRepository) {
this.clientRegistrationRepository = clientRegistrationRepository;
}

private OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {


OidcClientInitiatedLogoutSuccessHandler successHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
successHandler.setPostLogoutRedirectUri("{baseUrl}");
return successHandler;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity)
throws Exception {
httpSecurity
.csrf()
.and()
.oauth2Login()
.and()
.authorizeRequests()
.requestMatchers(PathRequest
.toStaticResources()
.atCommonLocations())
.permitAll()
.requestMatchers("/", "/health", "/register")
.permitAll()
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessHandler(oidcLogoutSuccessHandler());

return httpSecurity.build();
}
}

Esto funciona bien para los proveedores de identidad que ya soportan este
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 230

borrador. La mala noticia es que Cognito no sigue esta especificación (aún). Sin
embargo, Cognito expone un endpoint que podemos usar para cerrar la sesión
de un usuario final. Para que esto funcione, tenemos que implementar nuestro
propio LogoutSuccessHandler.

Como el endpoint de cierre de sesión de Cognito espera una simple solicitud


HTTP GET con dos parámetros de consulta, podemos usar el SimpleUrlLo-
goutSuccessHandler de Spring Security.

Lo que queda es sobreescribir el método determineTargetUrl() y crear la URL


correspondiente:

public class CognitoOidcLogoutSuccessHandler


extends SimpleUrlLogoutSuccessHandler {

private final String logoutUrl;


private final String clientId;

public CognitoOidcLogoutSuccessHandler(
String logoutUrl,
String clientId) {
this.logoutUrl = logoutUrl;
this.clientId = clientId;
}

@Override
protected String determineTargetUrl(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {

UriComponents baseUrl = UriComponentsBuilder


.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.build();

return UriComponentsBuilder
.fromUri(URI.create(logoutUrl))
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 231

.queryParam("client_id", clientId)
.queryParam("logout_uri", baseUrl)
.encode(StandardCharsets.UTF_8)
.build()
.toUriString();
}
}

Hacemos tanto logoutUrl (que representa el endpoint de cierre de sesión de


nuestra instancia de Cognito, por ejemplo, https://stratospheric...amazoncognito/log
y clientId (el id del cliente de nuestra aplicación Todo) configurables, ya que
estos dependen de la instancia actual de Cognito.

El parámetro logout_uri es la URL a la que Cognito redirigirá al usuario final


después del cierre de sesión. Esta tiene que ser una URL válida que fue configu-
rada como parte de las LogoutURLs del cliente de la app. Para nuestra aplicación,
redirigimos a todos los usuarios a la URL base de la aplicación Todo.

Con este LogoutSuccessHandler personalizado en su lugar, ahora podemos


definirlo como un bean cuando custom.use-cognito-as-identity-provider
está configurado en true e inyectar las credenciales relevantes y la región para
configurar el handler:

@Configuration
public class LogoutSuccessHandlerConfig {

@Bean
@ConditionalOnProperty(prefix = "custom",
name = "use-cognito-as-identity-provider", havingValue = "true")
public CognitoIdentityProviderClient cognitoIdentityProviderClient(
AwsRegionProvider regionProvider,
AwsCredentialsProvider awsCredentialsProvider) {

return CognitoIdentityProviderClient.builder()
.credentialsProvider(awsCredentialsProvider)
.region(regionProvider.getRegion())
.build();
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 232

}
}

Finalmente, tenemos que hacer que nuestra configuración de HttpSecurity


reconozca este manejador de cierre de sesión:

@Configuration
public class WebSecurityConfig {

private final LogoutSuccessHandler logoutSuccessHandler;

public WebSecurityConfig(LogoutSuccessHandler logoutSuccessHandler) {


this.logoutSuccessHandler = logoutSuccessHandler;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity)
throws Exception {
httpSecurity
// ...
.logout()
.logoutSuccessHandler(logoutSuccessHandler);

return httpSecurity.build();
}
}

El único inconveniente que surge al no implementar un LogoutSuccessHand-


ler personalizado es que después de que un usuario cierre sesión en nuestra
aplicación, la sesión en Cognito sigue siendo válida. Para cada intento de inicio
de sesión posterior, nuestros usuarios entrarán de inmediato. Eso no es un
problema en sí mismo, pero hace que el cambio de usuarios dentro del mismo
navegador (sin borrar manualmente las cookies, eso es) sea más complicado,
por ejemplo.
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 233

Activando el Desarrollo Local

Como ya se explicó en el capítulo Desarrollo Local, Cognito no es parte del tier


gratuito de LocalStack. Por lo tanto, para el desarrollo local, usamos Keycloak
como un proveedor de identidad OIDC compatible en su lugar.

Para iniciar y detener una instancia local de Keycloak usamos Docker Com-
pose. Como Keycloak es el único componente de infraestructura que nues-
tra aplicación Todo necesita en este punto del libro, nuestro archivo docker-
compose.yml es bastante directo:

version: '3.3'

services:
keycloak:
image: quay.io/keycloak/keycloak:18.0.0-legacy
ports:
- 8888:8080
environment:
- KEYCLOAK_USER=keycloak
- KEYCLOAK_PASSWORD=keycloak
- DB_VENDOR=h2
- JAVA_OPTS=\
-Dkeycloak.migration.action=import \
-Dkeycloak.migration.provider=singleFile \
-Dkeycloak.migration.file=/tmp/stratospheric-realm.json
volumes:
- ./src/test/resources/keycloak/stratospheric-realm.json:\
/tmp/stratospheric-realm.json

El archivo stratospheric-realm.json que estamos mapeando al interior del


contenedor Docker contiene la configuración para Keycloak. Para evitar cual-
quier paso de configuración a mano (definir usuarios, configurar el reino, etc.),
importamos esta configuración durante el inicio de Keycloak (consulte el archi-
vo stratospheric-realm.json en GitHub).
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 234

Esta configuración pre-carga tres usuarios y registra nuestra aplicación ‘To-


do’ como un cliente registrado. Lo único que queda por hacer es especificar
las propiedades relevantes de Spring Security como parte de nuestro archivo
application-dev.yml:

spring:
security:
oauth2:
client:
registration:
cognito:
clientId: spring-boot-application
clientSecret: 27b07baf-53ba-42c6-b11f-6384769cada3
scope: openid
provider:
cognito:
issuerUri: http://localhost:8888/auth/realms/stratospheric

Dado que siempre utilizamos la misma configuración de Keycloak, podemos


codificarlo en duro el secreto del cliente. Seguimos utilizando cognito como
la clave del proveedor para evitar una mayor complejidad en nuestra configura-
ción.

La única funcionalidad que no funciona localmente es el registro de usuarios.


Aun así, veremos un mensaje de éxito al intentar registrarse, pero internamente
no estamos haciendo nada:
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 235

@Service
@ConditionalOnProperty(prefix = "custom",
name = "use-cognito-as-identity-provider", havingValue = "false")
public class LocalRegistrationService implements RegistrationService {

@Override
public void registerUser(Registration registration) {
// no registration as we use a local Keycloak instance
// with a pre-defined set of users
}
}

Aún podemos acceder a la aplicación Todo utilizando uno de los usuarios prede-
finidos:

• tom con contraseña stratospheric


• bjoern con contraseña stratospheric
• philip con contraseña stratospheric

Hay buenas noticias para la función de logout OIDC mencionada anteriormente:


Keycloak ya soporta este estándar en proceso. Esto significa que siempre que
iniciemos la aplicación de manera local, seremos capaces de cerrar la sesión
completamente.

Esto es posible al cambiar el LogoutSuccessHandler actual según el perfil. Si se


está ejecutando localmente, instanciamos el OidcClientInitiatedLogoutSuc-
cessHandler de Spring Security, ya que Keycloak ya soporta la especificación de
logout iniciada por el Proveedor de Recursos (RP):
10. Construyendo Registro de Usuarios e Inicio de Sesión con Amazon Cognito 236

@Configuration
public class LogoutSuccessHandlerConfig {

@Bean
@ConditionalOnProperty(prefix = "custom", name = "use-cognito-as-identity-provider\
", havingValue = "false")
public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository \
clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler successHandler = new OidcClientInitiated\
LogoutSuccessHandler(clientRegistrationRepository);
successHandler.setPostLogoutRedirectUri("{baseUrl}");
return successHandler;
}
}
11. Conexión a una base de datos con
Amazon RDS
En cuanto queremos dar a nuestros usuarios un medio de mantener sus datos
a través de las sesiones, necesitamos algún tipo de almacenamiento de datos.
Nuestra aplicación de muestra Todo no es la excepción, ya que permitirá a los
usuarios crear, editar y compartir tareas.

Las aplicaciones web suelen utilizar sistemas de gestión de bases de datos relaciona-
les (SGBDR) para el almacenamiento de datos. Debido al lenguaje generalmente
utilizado para interactuar con ellos, estos sistemas de bases de datos también
se conocen más comúnmente como bases de datos SQL (Structured Query
Language).

Las bases de datos relacionales almacenan y manejan datos en forma de filas


en tablas y las relaciones entre dichas tablas. Este enfoque, ideado por E. F.
Codd en 1970, ha sido el método estándar para manejar datos persistentes en
aplicaciones web durante mucho tiempo.

Fue con la aparición de las aplicaciones Web 2.0 y el enfoque NoSQL promovido
en ese contexto que alternativas como almacenes de documentos o bases de
datos de objetos volvieron a ser ampliamente consideradas. Hoy en día, estas
bases de datos no relacionales a menudo se utilizan en conjunto con bases de
datos relacionales. En el capítulo Rastreo de acciones de usuario con DynamoDB
hablaremos más sobre las bases de datos NoSQL en el contexto de AWS.

Sin embargo, las bases de datos relacionales no van a desaparecer, ya que siguen
siendo útiles para operaciones comunes de CRUD (Create, Read, Update, Delete)
11. Conexión a una base de datos con Amazon RDS 238

o para datos de series temporales, por ejemplo. Por lo tanto, utilizaremos una
base de datos de este tipo para nuestra aplicación de muestra Todo.

En la aplicación de muestra Todo, usaremos una base de datos para almacenar


tareas, así como para administrar usuarios y compartir tareas. Para ello, hemos
optado por un SGBDR PostgreSQL administrado por Amazon Relational Databa-
se Service (RDS).

Aunque la configuración de la base de datos implica especificaciones de AWS,


su uso en nuestra aplicación basada en Spring funciona tal como se esperaría de
una base de datos ordinaria, no en la nube, no AWS.

Pero antes de profundizar en los detalles de cómo usar la base de datos desde
nuestra aplicación Spring Boot, veamos qué ofrece Amazon RDS y cómo pode-
mos automatizar la provisión de una base de datos para nuestra aplicación.

Introducción al Servicio de Base de Datos Relacional de


AWS (RDS)

Amazon Relational Database Service (RDS) es el servicio de AWS para ejecutar y


gestionar bases de datos relacionales.

Aparte de PostgreSQL, MySQL, MariaDB, Oracle Database y Microsoft SQL Ser-


ver, RDS también soporta la tecnología de base de datos propia de Amazon,
Aurora. Aurora es un SGBDR compatible con MySQL y PostgreSQL diseñado
específicamente con los requisitos de las aplicaciones en la nube altamente
escalables en mente.

RDS nos permite crear y administrar bases de datos relacionales en AWS utili-
zando sus herramientas y técnicas habituales como AWS CLI, IAM y CDK.

Además de usar las herramientas anteriores para integrar la base de datos en


11. Conexión a una base de datos con Amazon RDS 239

nuestro entorno AWS, podemos manejar la base de datos a través de la Consola


de Administración de Amazon RDS.

Como es común con los servicios de AWS, RDS proporciona una infraestructura
escalable que se adapta a las necesidades de nuestra aplicación y nuestros
usuarios. El entorno subyacente está completamente gestionado por AWS, lo
que nos libera de la carga del mantenimiento.

Además, RDS soporta SSL para encriptar datos en tránsito y con el Servicio de
Gestión de Claves de AWS (KMS) podemos encriptar nuestros datos en reposo
también. RDS se integra con AWS Config para proporcionarnos un medio de
garantizar el cumplimiento registrando y auditando cambios en las configura-
ciones de la base de datos.

Configurando los permisos de IAM

A estas alturas, asumimos que los conceptos básicos de IAM ya han sido configu-
rados como se describe en el capítulo Manejando Permisos con IAM. Para acceder
a los recursos de RDS durante el desarrollo, simplemente necesitamos adjuntar
la política AmazonRDSFullAccess gestionada por AWS a aquellos grupos de IAM
en los que se encuentran nuestros desarrolladores de aplicaciones.

Este acceso es necesario para desplegar una nueva base de datos o hacer cambios
a una instancia de base de datos existente a través de la Consola de AWS o a
través de la aplicación CDK que vamos a construir para ese propósito. Una vez
que la base de datos está desplegada y lista para usar, es mejor eliminar el acceso
completo de nuevo, al menos para entornos similares a la producción.

La aplicación también necesita acceso a la base de datos, pero incluiremos los


permisos necesarios en la aplicación CDK, de modo que esté completamente
desacoplado de los permisos de IAM del desarrollador.
11. Conexión a una base de datos con Amazon RDS 240

Creando una aplicación de base de datos CDK

Como hicimos antes para los recursos de Cognito, ahora crearemos una aplica-
ción CDK que nos permita desplegar una base de datos con un simple comando
de CLI.

El siguiente diagrama proporciona una visión general de la infraestructura


adicional que vamos a crear para nuestra base de datos:

Desplegando una instancia de PostgreSQL en nuestra infraestructura AWS.

Es decir, además de las stacks de red y servicio que ya tenemos, vamos a


crear una nueva stack de CDK para la base de datos. Esta stack desplegará una
instancia de base de datos PostgreSQL en las subredes privadas proporcionadas
por nuestra stack de red. La aplicación que se ejecuta en nuestra stack de servicio
luego se conectará a la base de datos.

El Construct CDK PostgresDatabase

Para crear el entorno necesario para nuestra base de datos, utilizaremos el


construct PostgresDatabase de nuestra biblioteca de constructs. Puedes ver su
11. Conexión a una base de datos con Amazon RDS 241

código en GitHub.

La stack de red que creamos anteriormente se encarga de crear todos los


recursos básicos que necesitamos para ejecutar nuestra aplicación Spring Boot
y nuestra base de datos. Cuando se despliega, escribe algunos parámetros en
el almacén de parámetros de SSM, incluyendo información sobre las subredes
aisladas que creó para la base de datos. El construct PostgresDatabase recupera
estos parámetros del almacén de parámetros utilizando el método auxiliar
Network.getOutputParametersFromParameterStore():

public class PostgresDatabase extends Construct {

// ...

public PostgresDatabase(
final Construct scope,
final String id,
final Environment awsEnvironment,
final ApplicationEnvironment applicationEnvironment,
final DatabaseInputParameters databaseInputParameters) {

// ...

Network.NetworkOutputParameters networkOutputParameters =
Network.getOutputParametersFromParameterStore(
this,
applicationEnvironment.getEnvironmentName());

// ...

}
}

En caso de que estos parámetros no estén presentes en la tienda de parámetros


(es decir, cuando el stack de red no se ha desplegado antes), el construct
PostgresDatabase fallará al desplegar.

El construct PostgresDatabase también toma un objeto de tipo DatabaseIn-


11. Conexión a una base de datos con Amazon RDS 242

putParameters como un parámetro, que contiene algunos parámetros de con-


figuración que necesita para establecer la base de datos.

Repasemos el código del PostgresDatabase para ver qué está haciendo con
todos estos parámetros.

Grupo de Seguridad de la Base de Datos

Primero, el construct crea un grupo de seguridad de la base de datos en el que más


tarde pondremos la base de datos. Además, añade un grupo de subred de la base
de datos, que combina un conjunto de subredes en un grupo para ser utilizado
por la instancia de base de datos:

CfnSecurityGroup databaseSecurityGroup = CfnSecurityGroup.Builder.create(


this,
"databaseSecurityGroup")
.vpcId(networkOutputParameters.getVpcId())
.groupDescription("Security Group for the database instance")
.groupName(applicationEnvironment.prefix("dbSecurityGroup"))
.build();

CfnDBSubnetGroup subnetGroup = CfnDBSubnetGroup.Builder.create(


this,
"dbSubnetGroup")
.dbSubnetGroupDescription("Subnet group for the RDS instance")
.dbSubnetGroupName(applicationEnvironment.prefix("dbSubnetGroup"))
.subnetIds(networkOutputParameters.getIsolatedSubnets())
.build();

Más tarde pasaremos tanto el nombre del grupo de subred como el ID del grupo
de seguridad al constructo CfnDBInstance que crea nuestra instancia de base
de datos.

Secreto para la Autenticación de la Base de Datos

A continuación, crearemos un Secret llamado databaseSecret, que se utilizará


como contraseña para la base de datos:
11. Conexión a una base de datos con Amazon RDS 243

ISecret databaseSecret = Secret.Builder.create(this, "databaseSecret")


.secretName(applicationEnvironment.prefix("DatabaseSecret"))
.description("Credentials to the RDS instance")
.generateSecretString(SecretStringGenerator.builder()
.secretStringTemplate(String.format(
"{\"username\": \"%s\"}",
username))
.generateStringKey("password")
.passwordLength(32)
.excludeCharacters("@/\\\" ")
.build())
.build();

El argumento del método secretStringTemplate() especifica una estructura


JSON con el nombre de usuario. El argumento del método generateString-
Key() define que la contraseña generada se añada a esta estructura JSON en el
campo de la contraseña. La cadena JSON resultante tendrá este aspecto:

{
"username": "<value of DBUserName parameter>",
"password": "<generated password>"
}

Al utilizar el método excludeCharacters(), estamos excluyendo algunos ca-


racteres de la creación de la contraseña porque no están permitidos en las
instancias de PostgreSQL RDS. Obtendríamos un mensaje de error que dice
“Solo se pueden usar caracteres ASCII imprimibles excepto ‘/’, ‘@’, ‘”’, ‘ ‘ si
la contraseña contuviera cualquiera de estos caracteres.

Instancia de la base de datos

El núcleo de un stack de bases de datos es, por supuesto, la instancia de la base


de datos:
11. Conexión a una base de datos con Amazon RDS 244

String username = sanitizeDbParameterName(applicationEnvironment.prefix("dbUser"));

CfnDBInstance dbInstance = CfnDBInstance.Builder.create(this, "postgresInstance")


.allocatedStorage(String.valueOf(databaseInputParameters.storageInGb))
.availabilityZone(networkOutputParameters
.getAvailabilityZones()
.get(0))
.dbInstanceClass(databaseInputParameters.instanceClass)
.dbName(sanitizeDbParameterName(applicationEnvironment.prefix("database")))
.dbSubnetGroupName(subnetGroup.getDbSubnetGroupName())
.engine("postgres")
.engineVersion(databaseInputParameters.postgresVersion)
.masterUsername(username)
.masterUserPassword(databaseSecret
.secretValueFromJson("password")
.toString())
.publiclyAccessible(false)
.vpcSecurityGroups(singletonList(databaseSecurityGroup.getAttrGroupId()))
.build();

Pasamos los previamente creados subnetGroup, databaseSecurityGroup, y


databaseSecret a la configuración de la instancia de DB.

El resto de los parámetros de configuración los establecemos de manera estática


- como el parámetro publiclyAccessible, que configuramos a false - o los
leemos desde databaseInputParameters o networkOutputParameters.

Adjuntar el Secreto

Finalmente, adjuntamos el secreto a la base de datos:


11. Conexión a una base de datos con Amazon RDS 245

CfnSecretTargetAttachment.Builder.create(this, "secretTargetAttachment")
.secretId(databaseSecret.getSecretArn())
.targetId(dbInstance.getRef())
.targetType("AWS::RDS::DBInstance")
.build();

Esto vincula el secreto con la base de datos, así podemos aprovechar la función
de rotación de secretos proporcionada por el AWS Secrets Manager.

Parámetros de Salida

Finalmente, el componente PostgresDatabase exporta algunos recursos del


stack de la base de datos, para que podamos usarlos desde otros stacks, como
nuestro stack de servicios:

StringParameter endpointAddress =
StringParameter.Builder.create(this, "endpointAddress")
.parameterName(createParameterName(
this.applicationEnvironment,
PARAMETER_ENDPOINT_ADDRESS))
.stringValue(this.dbInstance.getAttrEndpointAddress())
.build();

StringParameter endpointPort =
StringParameter.Builder.create(this, "endpointPort")
.parameterName(createParameterName(
this.applicationEnvironment,
PARAMETER_ENDPOINT_PORT))
.stringValue(this.dbInstance.getAttrEndpointPort())
.build();

StringParameter databaseName =
StringParameter.Builder.create(this, "databaseName")
.parameterName(createParameterName(
this.applicationEnvironment,
PARAMETER_DATABASE_NAME))
.stringValue(this.dbInstance.getDbName())
.build();
11. Conexión a una base de datos con Amazon RDS 246

StringParameter securityGroupId =
StringParameter.Builder.create(this, "securityGroupId")
.parameterName(createParameterName(
this.applicationEnvironment,
PARAMETER_SECURITY_GROUP_ID))
.stringValue(this.databaseSecurityGroup.getAttrGroupId())
.build();

StringParameter secret =
StringParameter.Builder.create(this, "secret")
.parameterName(createParameterName(
this.applicationEnvironment,
PARAMETER_SECRET_ARN))
.stringValue(this.databaseSecret.getSecretArn())
.build();

Necesitaremos los parámetros endpointAddress, endpointPort, databaseNa-


me, securityGroupId, y secret en el stack de servicio para conectar nuestra
aplicación Spring Boot a la base de datos.

Tenga en cuenta que los parámetros almacenados en la tienda de parámetros


no están cifrados. Eso significa que son visibles para cualquiera con acceso a la
tienda de parámetros. Si eso no es suficiente para sus propósitos de seguridad,
asegúrese de almacenar estos parámetros en el AWS Secrets Manager en su
lugar.

Sin embargo, mantenemos el nombre de usuario y la contraseña de la base


de datos en un Secret, en lugar de como valores de texto sin formato. Solo
almacenamos el ARN del Secret en la tienda de parámetros. Por lo tanto, no
se comparte ninguna información sensible a través de la tienda de parámetros.

La aplicación CDK de la base de datos

Finalmente, para poder desplegar nuestro componente PostgresDatabase, lo


envolvemos en una aplicación CDK llamada DatabaseApp (el código completo
11. Conexión a una base de datos con Amazon RDS 247

está disponible en GitHub):

public class DatabaseApp {

public static void main(final String[] args) {


App app = new App();

String environmentName = (String) app


.getNode()
.tryGetContext("environmentName");

String applicationName = (String) app


.getNode()
.tryGetContext("applicationName");

String accountId = (String) app


.getNode()
.tryGetContext("accountId");

String region = (String) app


.getNode()
.tryGetContext("region");

Environment awsEnvironment = makeEnv(accountId, region);

ApplicationEnvironment applicationEnvironment = new ApplicationEnvironment(


applicationName,
environmentName
);

Stack databaseStack = new Stack(


app,
"DatabaseStack",
StackProps.builder()
.stackName(applicationEnvironment.prefix("Database"))
.env(awsEnvironment)
.build());

new PostgresDatabase(
databaseStack,
"Database",
awsEnvironment,
applicationEnvironment,
11. Conexión a una base de datos con Amazon RDS 248

new PostgresDatabase.DatabaseInputParameters());

app.synth();
}

// ...
}

La aplicación crea un Stack y agrega una estructura PostgresDatabase a este


utilizando los DatabaseInputParameters predeterminados. Si quisiéramos ha-
cer configurables cualquiera de los parámetros en DatabaseInputParameters,
podríamos pasarlos a la aplicación y luego a los DatabaseInputParameters
desde allí.

Implementando el Stack de la Base de Datos

Para interactuar con el nuevo stack de la base de datos, añadimos los scripts
database:deploy y database:destroy al archivo package.json en nuestro
proyecto CDK.

Ahora podemos ejecutar estos scripts desde la línea de comandos para imple-
mentar un stack de la base de datos:

npm run database:deploy


npm run database:destroy

Modificando el Stack de Servicios

Lo que queda es informar a nuestra aplicación Spring Boot que utilice la nueva
base de datos. Para este propósito, modificamos el stack de servicios existente
que es responsable de implementar el contenedor Docker donde reside nuestra
aplicación. Agregamos las variables de entorno por defecto que Spring Boot
utiliza para definir la conexión a la base de datos:
11. Conexión a una base de datos con Amazon RDS 249

• SPRING_DATASOURCE_URL,
• SPRING_DATASOURCE_USERNAME, y
• SPRING_DATASOURCE_PASSWORD.

Spring Boot automáticamente lee estas variables de entorno y crea una fuente
de datos apuntando a nuestra nueva base de datos.

Para este fin, en ServiceApp primero cargamos un objeto NetworkOutputPara-


meters desde el constructor PostgresDatabase:

PostgresDatabase.DatabaseOutputParameters databaseOutputParameters =
PostgresDatabase.getOutputParametersFromParameterStore(
parametersStack,
applicationEnvironment);

El método getOutputParametersFromParameterStore() es un método auxi-


liar que hemos incorporado en la estructura de la base de datos. Este método
carga todos los parámetros de salida que discutimos anteriormente en un solo
objeto.

Después, pasamos estos parámetros al método environmentVariables(), des-


tinado a crear un mapa con todas las variables de entorno que se insertarán en
el contenedor Docker de nuestra aplicación Spring Boot:
11. Conexión a una base de datos con Amazon RDS 250

public class ServiceApp {

// ...

static Map<String, String> environmentVariables(


Construct scope,
PostgresDatabase.DatabaseOutputParameters databaseOutputParameters,
String springProfile) {

Map<String, String> vars = new HashMap<>();

String databaseSecretArn = databaseOutputParameters.getDatabaseSecretArn();


ISecret databaseSecret = Secret.fromSecretCompleteArn(
scope,
"databaseSecret",
databaseSecretArn);

vars.put("SPRING_DATASOURCE_URL",
String.format("jdbc:postgresql://%s:%s/%s",
databaseOutputParameters.getEndpointAddress(),
databaseOutputParameters.getEndpointPort(),
databaseOutputParameters.getDbName()));

vars.put("SPRING_DATASOURCE_USERNAME",
databaseSecret.secretValueFromJson("username").toString());

vars.put("SPRING_DATASOURCE_PASSWORD",
databaseSecret.secretValueFromJson("password").toString());

// ...

return vars;
}

// ...
}

Combinamos los parámetros EndpointAddress, EndpointPort, y DBName para


crear una URL JDBC válida de este formato:
11. Conexión a una base de datos con Amazon RDS 251

jdbc:postgresql://<EndpointAddress>:<EndpointPort>/<DBName>

Cargamos el nombre de usuario y la contraseña del Secret que creamos en el


stack de la base de datos. ¡Ten en cuenta que este secreto nunca sale de los
servidores de AWS! ¡No necesitamos ponerlo en un archivo de configuración en
ningún lugar!

Además, ya que el stack de servicios ahora depende de los parámetros de salida


del stack de la base de datos, necesitamos desplegar el stack de la base de datos
antes que el stack de servicios.

¡En este punto, nuestro trabajo de infraestructura de base de datos específica


para AWS está hecho! Ahora podemos cosechar las recompensas de nuestros
esfuerzos: Podemos usar nuestro recién creado stack de base de datos y la base
de datos PostgreSQL que contiene desde nuestra aplicación Spring Boot como
cualquier otra base de datos PostgreSQL.

Estrategias para la Inicialización de la Estructura de la


Base de Datos

Ahora que la base de datos ha sido desplegada y está en funcionamiento, necesi-


tamos crear un esquema de base de datos para nuestra aplicación. Existen varias
formas de abordar esta tarea. Veamos algunas de ellas.

Creando la Estructura de la Base de Datos Manualmente

La técnica más básica, agnóstica de marcos, sería simplemente ejecutar un


script de inicialización después del inicio de la aplicación, por ejemplo, utili-
zando la anotación @PostConstruct. Este enfoque nos proporciona más con-
trol sobre el proceso de inicialización, pero también tiende a ser más frágil y
11. Conexión a una base de datos con Amazon RDS 252

propenso a errores porque el código DDL creado manualmente puede quedar


obsoleto o desincronizado con el código fuente de la aplicación. Además, debido
a que tal código DDL no es administrado por el propio framework, esto puede
llevar a resultados no deseados. ¡Por ejemplo, los scripts destinados a entornos
de desarrollo podrían ejecutarse inadvertidamente en producción también!

Scripts DDL Estándar: schema.sql, data.sql

Spring Boot proporciona una forma estándar de crear esquemas de bases de


datos e importar datos. Lo hace a través de scripts SQL en la ruta de clase raíz
que siguen convenciones de nombres específicos. Un script schema.sql ubicado
bajo la ruta de clase raíz creará el esquema de base de datos requerido, mientras
que un data.sql usará INSERTs SQL para llenar ese esquema con datos reales.

Este enfoque, aunque estandarizado y más agnóstico que el anterior, presenta


desventajas similares: mientras que con esta técnica los archivos para generar
la estructura de la base de datos y poblar la base de datos con datos iniciales
son administrados por Spring Boot, mantener estos archivos en sincronía con
el código fuente de la aplicación aún puede convertirse en un problema con el
tiempo.

Generación de DDL con JPA y Hibernate

Las APIs de bases de datos como JPA y los marcos ORM (object-relational
mapping) como Hibernate pueden generar o modificar un esquema de base de
datos automáticamente al iniciar la aplicación. La ventaja de este enfoque es
que la estructura de la base de datos generada corresponderá automáticamente
a nuestras clases de modelo y código de aplicación en general. Por lo tanto,
no necesitamos mantener manualmente scripts SQL separados en este caso.
11. Conexión a una base de datos con Amazon RDS 253

Sin embargo, (re)inicializar la base de datos de esta manera puede que no


siempre produzca la estructura exacta que queremos porque estos frameworks
vienen con convenciones. Aunque el comportamiento predeterminado puede
ser personalizado, esto puede resultar en una configuración compleja y difícil de
entender. Otro gran inconveniente de esta técnica es que solo maneja una única
situación, autoritativa, para nuestra estructura de base de datos. Esto significa
que en cualquier momento solo hay una definición de nuestra estructura de base
de datos (la actual), y mantener un historial de versiones de la estructura de la
base de datos rápidamente se vuelve engorroso.

Herramientas de Migración de Base de Datos: Liquibase y Flyway

Aquí es donde entran en juego las herramientas de migración de bases de


datos como Liquibase o Flyway. Estas herramientas nos permiten mantener
diferentes versiones de nuestra estructura de base de datos que corresponden
a nuestro código fuente de la aplicación en cualquier momento dado. En cierto
modo, estas herramientas son sistemas de control de versiones para esquemas
de bases de datos.

Spring Boot se integra con ambas herramientas. Elegimos Flyway por su sim-
plicidad y por ser el enfoque predeterminado de facto para las aplicaciones
Spring Boot. En lugar de un lenguaje basado en XML para definir migraciones,
como el que utiliza Liquibase, Flyway utiliza scripts SQL simples para aplicar
migraciones. Aunque no es tan agnóstico en RDBMS, este enfoque es mucho
más simple ya que no incurre en la complejidad adicional que implica aprender
otro idioma para mantener nuestra aplicación.

Sin embargo, sujeto a tus requisitos, Liquibase aún puede ser una elección válida.
Por ejemplo, dependiendo del entorno, es posible que desees admitir múltiples
RDBMS diferentes como PostgreSQL o Oracle Database sin tener que mantener
11. Conexión a una base de datos con Amazon RDS 254

scripts de migración individuales para cada dialecto SQL que viene con esos. En
ese caso, debido a su enfoque más agnóstico, Liquibase podría tener sus méritos.

Configurando la Base de Datos en la Aplicación Todo

Habiendo elegido Flyway como nuestra herramienta para inicializar y adminis-


trar el esquema de la base de datos, ahora vamos a sumergirnos en el código de
nuestra aplicación de muestra para ver cómo podemos configurar la nueva base
de datos. Si ya estás familiarizado con la configuración de una aplicación Spring
Boot para usar una base de datos SQL, puedes omitir con seguridad esta sección.

Conectando a la Base de Datos

Para conectarse a la instancia de la base de datos PostgreSQL, nuestra aplicación


de muestra necesita algunas dependencias adicionales:

dependencies {
// ...
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
// ...
}

org.postgresql:postgresql contiene el controlador JDBC para conectarse a la


base de datos PostgreSQL, mientras que org.springframework.boot:spring-
boot-starter-data-jpa nos proporciona Spring Data JPA (y sus dependen-
cias), una biblioteca ampliamente utilizada para acceder a bases de datos desde
aplicaciones Spring Boot.

Ahora, una vez que hemos iniciado el conjunto de servicios, nuestra aplicación
Spring Boot se conectará automáticamente a la base de datos PostgreSQL uti-
11. Conexión a una base de datos con Amazon RDS 255

lizando los parámetros estándar SPRING_DATASOURCE_URL, SPRING_DATASOUR-


CE_USERNAME y SPRING_DATASOURCE_PASSWORD que establecimos antes.

Inicializando la Base de Datos

Ya que nuestra base de datos está en funcionamiento y nuestra aplicación de


muestra, llamada “Todo”, está conectada a ella, ahora podemos utilizar cual-
quiera de las estrategias para inicializar nuestra base de datos que se discutieron
anteriormente.

Dado que hemos decidido usar Flyway para este propósito, agregamos esta
dependencia al archivo build.gradle:

dependencies {
// ...
implementation 'org.flywaydb:flyway-core'
// ...
}

Ahora podemos agregar archivos de migración SQL a la carpeta predeterminada


utilizada por Flyway para PostgreSQL RDBMS. Esta carpeta se encuentra en
db/migration/postgresql en la carpeta src/main/resources de la aplicación.

Cualquier archivo .sql que coloquemos en esa carpeta que siga las conven-
ciones de nomenclatura de Flyway se ejecutará automáticamente al iniciar la
aplicación.

¡Ahora podemos comenzar a implementar la lógica de negocio que requiere


almacenamiento de datos persistente para nuestras entidades Todo! Podemos
hacer esto de la misma manera que lo haríamos para cualquier aplicación basada
en Spring y JPA, por ejemplo, con la anotación @Entity de JPA, repositorios
basados en JPA o el estereotipo @Repository de Spring.
11. Conexión a una base de datos con Amazon RDS 256

Usando la Base de Datos para Almacenar y Recuperar


Todos

Ahora que hemos preparado la infraestructura circundante y nuestra aplicación


Spring Boot está configurada para usar nuestra nueva base de datos PostgreSQL,
finalmente podemos ponerla en uso en nuestro código de aplicación.

Si ya estás familiarizado con el uso de JPA en una aplicación Spring Boot, puedes
omitir esta sección sin problema.

El modelo de dominio de nuestra aplicación está estructurado en torno a la


entidad “Todo”, como se muestra en este diagrama:

Modelo de dominio de la aplicación Todo.

Como se menciona en el capítulo La Aplicación de Ejemplo Todo, nuestra apli-


cación sigue una estructura de paquete por característica. Esto significa que
las carpetas de características collaboration, person, registration y todo
11. Conexión a una base de datos con Amazon RDS 257

contienen los artefactos de código relacionados con cada una de esas caracte-
rísticas. Estos paquetes incluyen controladores, interfaces de servicio (y sus
implementaciones), repositorios de Spring Data JPA y clases de modelo de
datos.

Echaremos un vistazo más de cerca a algunas de las clases del paquete


dev.stratospheric.todo para examinar cómo usamos la nueva base de datos
PostgreSQL creada para nuestra aplicación.

Nos centraremos en la clase Todo y cómo se utiliza. Esta clase está anotada con
la anotación @Entity del paquete jakarta.persistence (la API proporcionada
por JPA / Jakarta Persistence). Esta anotación marca la clase como una entidad
de base de datos. Por defecto, el nombre de clase no calificado (en lugar de su
nombre completamente calificado, que incluiría el nombre del paquete) se utiliza
como el nombre de la tabla de la base de datos de la entidad.

Como mencionamos anteriormente, usamos Flyway para la inicialización y


migración de la base de datos. Por lo tanto, necesitamos proporcionar un
script de migración DDL SQL que cree esta tabla de base de datos al iniciar la
aplicación. Según las convenciones de Flyway, hemos nombrado a este script
V001__INIT_DATABASE.sql y lo hemos colocado en la carpeta /src/main/re-
sources/db/migration/postgresql.

El script SQL contiene esta declaración:


11. Conexión a una base de datos con Amazon RDS 258

-- ...

create table TODO


(
ID BIGSERIAL not null primary key,
DESCRIPTION VARCHAR(255),
DUE_DATE DATE,
PRIORITY INTEGER,
STATUS VARCHAR(255),
TITLE VARCHAR(255),
OWNER_ID BIGINT,
constraint FK_TODO_OWNER
foreign key (OWNER_ID) references PERSON (ID)
);

-- ...

Esta declaración creará la tabla de base de datos necesaria con las columnas
requeridas para almacenar la información de cada “todo”. Al examinar más de
cerca la clase Todo, podemos ver que estas columnas se reflejan en los atributos
de la clase:

// ...

@Entity
public class Todo {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotBlank
@Size(max = 30)
private String title;

@Size(max = 100)
private String description;

private Priority priority;

@NotNull
11. Conexión a una base de datos con Amazon RDS 259

@Future
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate dueDate;

@Enumerated(EnumType.STRING)
private Status status;

@ManyToOne
@JoinColumn(name = "owner_id")
private Person owner;

// ...
}

Nuevamente, al igual que con el nombre de la entidad y el nombre de la tabla, los


nombres de los atributos por defecto coinciden con los nombres de las columnas.
La mayoría de estos atributos están anotados con una o más anotaciones. Estas
anotaciones nos permiten especificar aún más las reglas y restricciones que
deben aplicarse a un atributo.

Clave Primaria e Identidad de Objeto

El atributo id está anotado con @Id del paquete jakarta.persistence, marcan-


do este atributo como el identificador único de la entidad (o: clave primaria).

La anotación @GeneratedValue (también del paquete jakarta.persistence)


denota que el valor del atributo se genera automáticamente. El argumento
strategy = GenerationType.IDENTITY especifica aún más que este valor se
proporciona a través de una columna de identidad en la tabla de la base de datos.
Este comportamiento a su vez está habilitado por esta definición de columna del
script de migración SQL, específicamente las palabras clave BIGSERIAL y clave
primaria:
11. Conexión a una base de datos con Amazon RDS 260

-- ...

ID BIGSERIAL not null primary key

-- ...

Restricciones y Validación

Algunas columnas están anotadas con anotaciones del paquete


jakarta.validation.constraints, como @NotBlank, @Size, o @Future.
Dichas anotaciones nos permiten definir las reglas - o: restricciones - que nos
gustaría aplicar a cada uno de los atributos y su valor. Por ejemplo, se espera
que el título del todo contenga una cadena de texto no vacía o en blanco con un
máximo de 30 caracteres.

Almacenamiento y Recuperación de Información

Tras haber definido nuestra entidad en la base de datos, podemos utilizar-


la para almacenar y recuperar información. Con Spring Data JPA, la herra-
mienta que nos permite hacerlo es la interfaz JpaRepository del paquete
org.springframework.data.jpa.repository. Esta interfaz nos proporciona
un conjunto de métodos para gestionar y recuperar datos:
11. Conexión a una base de datos con Amazon RDS 261

public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, Que\


ryByExampleExecutor<T> {

@Override
List<T> findAll();

@Override
List<T> findAll(Sort sort);

@Override
List<T> findAllById(Iterable<ID> ids);

@Override
<S extends T> List<S> saveAll(Iterable<S> entities);

// ...
}

Para utilizar esto en una entidad de base de datos específica, como nuestra clase
Todo, debemos ampliar esta interfaz con nuestra propia interfaz TodoReposi-
tory:

public interface TodoRepository extends JpaRepository<Todo, Long> {


List<Todo> findAllByOwnerEmailOrderByIdAsc(String email);
}

Esta interfaz define un JpaRepository encargado de persistir entidades Todo


con un ID de tipo Long. Asimismo, hemos incorporado el método findAllByOw-
nerEmailOrderByIdAsc() que nos permite buscar todos los Todos cuyo owner
posee la dirección de email que se suministra como argumento en el método
email. Spring Data JPA hace uso de un mecanismo de generación de consultas
que logra deducir la consulta SQL necesaria a partir del nombre del método
(para más información, consulta Defining Query Methods en la documentación
de Spring Data JPA).

Posteriormente, TodoRepository puede ser inyectado en otras clases, como por


11. Conexión a una base de datos con Amazon RDS 262

ejemplo nuestro TodoService, por medio de la inyección de dependencias de


Spring:

// ...

@Service
public class TodoService {

private final TodoRepository todoRepository;


private final PersonRepository personRepository;

public TodoService(
TodoRepository todoRepository,
PersonRepository personRepository) {
this.todoRepository = todoRepository;
this.personRepository = personRepository;
}

public Todo saveNewTodo(Todo todo) {


// ...

return todoRepository.save(todo);
}
}

Spring inyectará automáticamente el TodoRepository en el constructor de


TodoService. Con esta dependencia, ahora podemos insertar una nueva fila en
la tabla de la base de datos llamando a todoRepository.save().

El TodoService, a su vez, se inyecta en el TodoController, nuevamente uti-


lizando la inyección del constructor, donde se utiliza para crear, recuperar,
actualizar y eliminar tareas en varios métodos del controlador. Estos métodos
del controlador se asignan a las rutas HTTP dentro de nuestra aplicación que
se pueden acceder a través de las solicitudes HTTP GET (en caso de los métodos
anotados con @GetMapping) y POST (para esos métodos anotados con @PostMap-
ping).

Finalmente, la plantilla Thymeleaf en la carpeta resources/templates de nuestra


11. Conexión a una base de datos con Amazon RDS 263

aplicación, específicamente dashboard.html, show.html, y edit.html, hacen


uso de estos métodos del controlador para permitir al usuario mostrar y editar
tareas.

Habilitando el Desarrollo Local

Todavía falta algo en nuestra configuración: En la mayoría de los casos, no


querríamos esperar hasta que toda nuestra infraestructura AWS haya sido re-
deplegada a través de nuestro flujo de trabajo de despliegue continuo después
de un cambio de código. En cambio, queremos probar cambios localmente.

Aquí es donde entra en juego una instancia de base de datos local. Podemos
usar Docker para iniciar una instancia añadiendo un servicio al archivo docker-
compose.yml ubicado en el directorio raíz de nuestra aplicación:

services:
postgres:
image: postgres:12.9
ports:
- 5432:5432
environment:
- POSTGRES_USER=stratospheric
- POSTGRES_PASSWORD=stratospheric
- POSTGRES_DB=stratospheric
# ...

Con estas líneas añadidas al archivo docker-compose.yml, podemos ejecutar


docker-compose up desde la línea de comando en nuestro directorio raíz para
iniciar una instancia de PostgreSQL junto a otros servicios ya establecidos en
docker-compose.yml.

Lo único que tenemos que hacer ahora es añadir estas líneas al archivo de
propiedades application-dev.yml que se encuentra en la carpeta src/main/-
resources de nuestra aplicación:
11. Conexión a una base de datos con Amazon RDS 264

spring:
datasource:
url: jdbc:postgresql://localhost:5432/stratospheric
username: stratospheric
password: stratospheric
# ...

Dado que nuestro archivo build.gradle ya configura Gradle para usar dev
como Perfil de Spring (utilizando -Dspring.profiles.active=dev como un
argumento JVM), estos ajustes serán detectados automáticamente cuando se
ejecute ./gradlew bootrun:

// ...
bootRun {
jvmArgs = [
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005",
"-Dspring.profiles.active=dev",
]
}
12. Compartiendo Tareas con Amazon
SQS y Amazon SES
Con este capítulo, implementaremos la próxima funcionalidad para nuestra
aplicación de tareas e integraremos dos nuevos servicios de AWS: SQS (Simple
Queue Service) y SES (Simple Email Service).

Hasta el momento, los usuarios de nuestra aplicación solo trabajan en sus tareas
por sí mismos. Dado que esto puede resultar monótono, nos gustaría fomentar
alguna colaboración entre nuestra base de usuarios.

La próxima funcionalidad permite al propietario de una tarea compartirla con


otros usuarios de nuestra aplicación. De esta manera, pueden trabajar colabora-
tivamente en la tarea en cuestión. Similar al flujo de trabajo de las aplicaciones
colaborativas típicas, el usuario con el que se ha compartido una tarea recibe
una notificación y tiene que aceptar la colaboración primero.

Técnicamente hablando, vamos a desacoplar esta función en dos operaciones.


Cada vez que un usuario decide compartir una de sus tareas, primero almace-
naremos esta solicitud en una cola de SQS. La cola actúa como un búfer, y otra
parte de nuestra aplicación luego manejará las solicitudes entrantes y enviará
correos electrónicos.

Este capítulo tiene dos partes que construyen iterativamente la función de


compartir. Comenzaremos con el componente de mensajería y aprenderemos
cómo enviar y recibir mensajes de SQS. A continuación viene la parte de envío
de correos electrónicos, donde integramos Amazon SES.
12. Compartiendo Tareas con Amazon SQS y Amazon SES 266

Comencemos y primero entendamos cómo el servicio de mensajería totalmente


administrado de AWS (SQS) puede ayudarnos a construir aplicaciones resisten-
tes y desacopladas.

Usando Amazon SQS para cargas de trabajo asíncronas

Antes de integrar Amazon SQS con nuestra aplicación Spring Boot, veamos
cómo funciona este servicio de AWS.

Introducción a Amazon SQS

Amazon SQS es un servicio de mensajería completamente administrado. Po-


demos usar este servicio para transmitir mensajes entre diferentes partes de
nuestro sistema de manera altamente escalable. Permite la comunicación de
punto a punto donde solo un receptor maneja el mensaje en un momento dado.
Amazon SQS puede ayudarnos aún más a integrar o desacoplar componentes en
una arquitectura distribuida.

Los mensajes que procesamos con SQS pueden tener una carga útil con un
tamaño máximo de 256 KB. Permite el cifrado del lado del servidor de nuestros
mensajes, y podemos controlar el acceso a él. Interactuamos con SQS a través
de una API HTTPS genérica, similar a otros servicios de AWS. Dependiendo de
la configuración, SQS puede guardar nuestro mensaje durante un “período de
retención” de hasta 14 días.

La escalabilidad, durabilidad y confiabilidad de nuestro procesamiento con SQS


dependen del tipo de cola. AWS ofrece dos tipos de SQS diferentes: FIFO y
Estándar.

Con el tipo de cola Estándar, los mensajes se entregarán con un esfuerzo por
12. Compartiendo Tareas con Amazon SQS y Amazon SES 267

mantener el orden. SQS no garantiza un orden estricto de los mensajes para este
tipo de cola, y ocasionalmente los mensajes pueden entregarse fuera de orden.
Además, los mensajes se entregan al menos una vez. Nuestro receptor podría
recibir el mismo mensaje más de una vez. A pesar de tener estos inconvenientes,
este tipo de cola ofrece una capacidad de procesamiento casi ilimitada (según la
documentación de AWS). También es la opción más económica.

Como su nombre lo indica, el tipo de cola FIFO sigue un modelo de entrega de


primero en entrar, primero en salir. Este tipo de cola garantiza la entrega de
los mensajes en el mismo orden en que se enviaron a la cola. Cada mensaje se
procesará exactamente una vez, evitando que nuestro consumidor reciba dupli-
cados. La estricta coherencia resulta en una factura de AWS un 25% más alta en
comparación con el tipo de cola Estándar. Cuando se trata de escalabilidad, las
colas FIFO manejarán hasta 3,000 mensajes por segundo. Podemos aumentar
este límite contactando al soporte de AWS.

El modelo de precios de Amazon SQS no tiene costos iniciales. Después de que


superamos el primer millón de solicitudes gratuitas por mes, nos cobran por el
número de solicitudes y la transferencia de datos salientes.

Las operaciones básicas que realizamos contra la API de SQS son SendMessa-
ge, ReceiveMessage y DeleteMessage. Cada mensaje permanecerá en la cola
hasta que un consumidor lo elimine activamente de la cola. En otras palabras,
Amazon SQS no elimina automáticamente un mensaje cuando se consume, pero
el consumidor tiene que reconocer el procesamiento exitoso del mensaje al
eliminarlo.

Los clientes de SQS tienen que realizar proactivamente la operación Receive-


Message regularmente para consumir y verificar nuevos mensajes. Amazon SQS
no enviará mensajes a un cliente en particular.

Para evitar el consumo paralelo de mensajes, Amazon SQS tiene el concepto de


12. Compartiendo Tareas con Amazon SQS y Amazon SES 268

un tiempo de visibilidad. Este tiempo de espera define el período después del


cual un mensaje estará disponible nuevamente para todos los consumidores
después de que un consumidor lo haya recibido. Si un consumidor no elimina
el mensaje dentro del período de tiempo de espera establecido (debido a un
procesamiento lento, por ejemplo), SQS reproducirá el mensaje al incluirlo en
la respuesta de una siguiente llamada a la API ReceiveMessage. Por lo tanto,
deberíamos configurar este valor al tiempo máximo que demora procesar un
solo mensaje. Podemos definir el tiempo de visibilidad tanto para toda la cola
como para cada solicitud de ReceiveMessage individualmente.

Para evitar recibir el mismo mensaje no procesable una y otra vez (por ejemplo
debido a una carga útil inválida o a un tiempo de inactividad de un sistema
remoto), Amazon SQS admite el concepto de las llamadas Colas de Letras
Muertas (DLQ por sus siglas en inglés).

Colas de Letras Muertas

Cada mensaje de Amazon SQS tiene un atributo ReceiveCount que almacena


un contador y rastrea cuántas veces ya se ha consumido el mensaje. Como
parte de la configuración de la cola, podemos definir un maxReceiveCount para
especificar cuántos intentos de procesamiento tienen nuestros mensajes antes
de que sean movidos a una cola de letras muertas.

Una cola de letras muertas es simplemente otra cola de Amazon SQS que
almacena mensajes no procesables. El tipo de esta cola tiene que coincidir con el
tipo de la cola de origen. En otras palabras, una cola FIFO solo puede tener como
objetivo una cola FIFO como una cola de letras muertas. Las DLQ son opcionales,
por lo que podemos definir colas de Amazon SQS sin especificar una DLQ.

Sin embargo, es una buena práctica general crear una DLQ para cada una de
nuestras colas de procesamiento por varias razones:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 269

• Ayuda a analizar y depurar escenarios de error.


• Aislamos mensajes problemáticos para investigaciones posteriores.
• Reducimos la carga en nuestro sistema si hay múltiples (si no miles) de
mensajes no procesables.
• Mejoramos la observabilidad, ya que podemos definir alarmas para nuestras
colas de letras muertas para detectar fallos temprano.
• No bloqueamos el procesamiento de mensajes al consumir el mismo men-
saje defectuoso una y otra vez.

La estrategia para manejar estos mensajes muertos depende mucho del caso
de uso y del escenario de error. En caso de que haya habido un contratiempo
temporal (por ejemplo, un sistema remoto del que dependemos no estaba
disponible), podemos mover los mensajes de vuelta a nuestra cola de origen
para su procesamiento.

Amazon SQS vs. AWS SNS vs. Amazon MQ

Amazon SQS no es el único servicio de mensajería en la cartera de servicios de


AWS. Mientras que Amazon SQS es un servicio de colas (de punto a punto), con
AWS SNS (Simple Notification Service), podemos informar a múltiples recepto-
res para cada mensaje. Esto se conoce como el patrón de publicar-suscribir que
internamente funciona con temas a los que se suscriben varios consumidores.

Tanto Amazon SQS como AWS SNS son altamente escalables y no requieren
ninguna configuración ya que AWS los gestiona completamente.

En contraposición a esto, con Amazon MQ podemos iniciar una instancia admi-


nistrada de Apache ActiveMQ o RabbitMQ. Ambos son intermediarios de men-
sajes que soportan múltiples APIs tradicionales (JMS) así como una variedad de
protocolos de mensajería (AMQS, MQTT, STOMP, etc.).
12. Compartiendo Tareas con Amazon SQS y Amazon SES 270

Discutiremos Amazon MQ y ActiveMQ, junto con otras capacidades de mensa-


jería y notificación de AWS, en mayor detalle como parte del próximo capítulo
Notificaciones Instantáneas con Amazon MQ.

Ya es suficiente de teoría por ahora. Vamos a meternos de lleno en la implemen-


tación de la función de compartir. Como primer paso, crearemos los recursos de
AWS requeridos con AWS CDK.

Creando la Configuración de Amazon SQS con CDK

Como recordatorio rápido, crearemos la cola de Amazon SQS para desacoplar


técnicamente la función de compartir tareas pendientes y enviar un mensaje
a SQS cada vez que un usuario decida invitar a un colaborador para su tarea
pendiente. Enviar el correo electrónico de invitación real es entonces la respon-
sabilidad del consumidor de mensajes de SQS.

Para nuestro caso de uso, la cola estándar de Amazon SQS se ajusta a nuestro
propósito. No necesitamos un orden estricto de mensajes porque realmente
no importa qué correo electrónico enviemos primero. Además, no nos importa
mucho si recibimos un mensaje dos veces, porque podríamos almacenar en la
base de datos a quién ya le enviamos un correo electrónico, para que el mismo
usuario no reciba el correo electrónico dos veces.

Crearemos una nueva aplicación CDK para las partes relacionadas con la men-
sajería de nuestra aplicación. Puedes encontrar el código de esta aplicación en
GitHub. Para esta MessagingApp no necesitamos ningún parámetro de entrada
adicional excepto los predeterminados:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 271

public class MessagingApp {

public static void main(final String[] args) {


App app = new App();

// omitted standard configuration values like the AWS region and sanity checks

Environment awsEnvironment = makeEnv(accountId, region);

ApplicationEnvironment applicationEnvironment = new ApplicationEnvironment(


applicationName,
environmentName
);

new MessagingStack(
app,
"messaging",
awsEnvironment,
applicationEnvironment);

app.synth();
}
}

Como el módulo CDK de Amazon SQS viene con constructos de nivel 2 estables,
podemos crear nuestros recursos de Amazon SQS de manera conveniente y no
tenemos que trabajar con constructos de CloudFormation de bajo nivel. Dentro
de MessagingStack, creamos tanto nuestras colas de procesamiento como las
de dead-letter de Amazon SQS:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 272

this.todoSharingDlq = Queue.Builder.create(this, "todoSharingDlq")


.queueName(applicationEnvironment.prefix( "todo-sharing-dead-letter-queue"))
.retentionPeriod(Duration.days(14))
.build();

this.todoSharingQueue = Queue.Builder.create(this, "todoSharingQueue")


.queueName(applicationEnvironment.prefix("todo-sharing-queue"))
.visibilityTimeout(Duration.seconds(30))
.retentionPeriod(Duration.days(14))
.deadLetterQueue(DeadLetterQueue.builder()
.queue(todoSharingDlq)
.maxReceiveCount(3)
.build())
.build();

Para ambas colas de procesamiento, ajustamos el retentionPeriod a la dura-


ción máxima de 14 días. Esto nos proporciona suficiente tiempo para analizar
el problema subyacente, incluso si se ignora una primera alarma, o si nuestro
equipo de desarrollo está de vacaciones durante la Navidad.

Ajustamos el visibilityTimeout a 30 segundos, lo que debería dar a nuestra


aplicación Spring Boot tiempo suficiente para guardar la solicitud de colabo-
ración en la base de datos y enviar un correo electrónico. Siempre podemos
modificar este valor tan pronto como tengamos un buen entendimiento de
cómo se comporta nuestra aplicación en producción.

Conectamos la cola principal de procesamiento con la cola de cartas muer-


tas pasando el campo todoSharingDlq al constructor DeadLetterQueue. Con
maxReceiveCount puesto a 3, Amazon SQS moverá el mensaje a la DLQ si
nuestra aplicación falla en eliminar el mensaje de la cola en el cuarto intento
de procesamiento.

A menos que invoquemos el método fifo(true) en el construct, la cola SQS


creada será estándar. En caso de que optemos por un tipo de cola FIFO, el nombre
de la cola debe terminar con el sufijo .fifo.
12. Compartiendo Tareas con Amazon SQS y Amazon SES 273

Luego, tenemos que revelar el nombre de la cola SQS con la que se conectará
nuestra aplicación:

StringParameter.Builder.create(this, "todoSharingQueueName")
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_TODO_SHARING_QUEUE_NAME))
.stringValue(this.todoSharingQueue.getQueueName())
.build();

Esto almacena el nombre de la cola en el almacén de parámetros SSM para que


luego podamos usarlo para configurar nuestra aplicación Spring Boot. Spring
Cloud AWS acepta tanto los nombres lógicos como físicos para una cola. Inter-
namente, obtendrá la URL de la cola ya que el SDK de AWS para SQS requiere
esta URL para recibir mensajes.

No vamos a conectar con la cola de mensajes no entregables (dead-letter queue)


desde nuestra aplicación. Los mensajes pueden acabar en la DLQ por varias
razones. Realmente no podemos implementar un procedimiento automático
para entender qué salió mal. Por lo tanto, la inspección manual es el único
enfoque viable aquí. Por ahora, sin embargo, es suficiente aislar los mensajes
no procesables. Más adelante, podemos crear una alarma de AWS CloudWatch
para recibir una notificación tan pronto como el primer mensaje llegue a esta
cola.

Con la definición del recurso Amazon SQS ya configurada, nuestra aplicación


ahora requiere acceso a la cola de procesamiento. De manera similar a cómo
hemos configurado el acceso a Amazon Cognito en el capítulo Construyendo el
Registro y Login de Usuarios con Cognito, añadimos una nueva PolicyStatement
como parte de nuestra construcción Service:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 274

.withTaskRolePolicyStatements(List.of(
// ... skipping other PolicyStatements,
PolicyStatement.Builder.create()
.sid("AllowSQSAccess")
.effect(Effect.ALLOW)
.resources(List.of(
String.format("arn:aws:sqs:%s:%s:%s", region, accountId,
messagingOutputParameters.getTodoSharingQueueName())
))
.actions(Arrays.asList(
"sqs:DeleteMessage",
"sqs:GetQueueUrl",
"sqs:ListDeadLetterSourceQueues",
"sqs:ListQueues",
"sqs:ListQueueTags",
"sqs:ReceiveMessage",
"sqs:SendMessage",
"sqs:ChangeMessageVisibility",
"sqs:GetQueueAttributes"))
.build()
))

Esto otorga a nuestra aplicación los permisos necesarios para enviar, recibir
y eliminar mensajes para cualquier cola de Amazon SQS, puesto que hemos
utilizado el comodín *. Ahora estamos en condiciones de empezar a integrar
Amazon SQS en nuestra aplicación Todo.

Utilizando Amazon SQS para nuestra aplicación Todo

Como primer paso, debemos añadir una nueva dependencia a nuestro proyecto:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 275

dependencies {
// omitted other dependencies
implementation 'io.awspring.cloud:spring-cloud-starter-aws-messaging'
}

La versión de esta dependencia es gestionada por Spring Cloud BOM, que ya


incluimos como parte de las dependencias básicas en el capítulo La Aplicación
de Ejemplo de Tareas Pendientes. El módulo de mensajería de Spring Cloud AWS
ofrece las siguientes características tanto para Amazon SQS como para AWS
SNS:

• Puntos finales de escucha/notificación impulsados por anotaciones.


• Integración con la API de mensajería de Spring (completa para SQS, parcial
para SNS).
• Soporte para la serialización de mensajes (que SQS solo reconoce como
cadenas de texto).
• Acceso cómodo a través de SqsTemplate (para SQS) y NotificationMessa-
gingTemplate (para SNS).

En las secciones venideras, no trataremos ninguna característica específica de


AWS SNS de Spring Cloud AWS y nos enfocaremos en Amazon SQS.

Para preparar nuestra aplicación de Spring Boot para SQS, solo queda un paso
por dar. El SqsTemplate que utilizaremos para acceder cómodamente a Amazon
SQS no viene auto-configurado por defecto. En lugar de esta abstracción y para
evitar cualquier interacción con el AWS Java SDK de bajo nivel, añadimos un
bean de tipo SqsTemplate a nuestro contexto de aplicación:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 276

@Configuration
public class MessagingTemplateConfig {

@Bean
public SqsTemplate sqsTemplate(
SqsAsyncClient sqsAsyncClient) {
return SqsTemplate.newTemplate(sqsAsyncClient);
}
}

Spring Cloud AWS configura automáticamente para nosotros el Amazon SQS


Java SDK AmazonSQSAsync.

Comencemos con el primer paso y enviemos un mensaje a SQS cada vez que un
usuario decida invitar a un colaborador a uno de sus todos.

Enviando Mensajes a Amazon SQS

Para permitir a los usuarios agregar colaboradores, mejoramos los elementos


de la acción del todo con un elemento desplegable. Dentro del desplegable,
nuestros usuarios encontrarán una lista de todos los colaboradores disponibles
para compartir sus todos. Este nuevo elemento se muestra para cada todo que
el usuario posee:

Compartiendo un todo con un colaborador.


12. Compartiendo Tareas con Amazon SQS y Amazon SES 277

Creamos este elemento como parte de la <table> que visualiza cada todo dentro
de la vista dashboard.html:

<div class="dropdown-menu" aria-labelledby="dropdownMenuLink">


<span class="dropdown-item" th:if="${collaborators.isEmpty()}">
No collaborator available
</span>
<form th:method="POST"
th:each="collaborator : ${collaborators}"
th:action="@{/todo/{todoId}/collaborations/{collaboratorId}
(todoId=${todo.id}, collaboratorId=${collaborator.id})}">
<button
th:text="${collaborator.name}"
type="submit"
name="submit"
class="dropdown-item">
</button>
</form>
</div>

El atributo ${collaborators} se completa como parte del controlador Spring


MVC para la vista del tablero y contiene una lista de todos los colaboradores
disponibles. Los usuarios podrán compartir su tarea o ítem de la lista de tareas
con cualquier usuario de nuestra aplicación, excepto con ellos mismos, y solo si
son los dueños de la tarea.

El TodoCollaborationController gestiona las solicitudes de colaboración en-


trantes y las pasa al TodoCollaborationService para iniciar el proceso de
compartir:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 278

@Controller
@RequestMapping("/todo")
public class TodoCollaborationController {

private final TodoCollaborationService todoCollaborationService;

public TodoCollaborationController(
TodoCollaborationService todoCollaborationService) {
this.todoCollaborationService = todoCollaborationService;
}

@PostMapping("/{todoId}/collaborations/{collaboratorId}")
public String shareTodoWithCollaborator(
@PathVariable("todoId") Long todoId,
@PathVariable("collaboratorId") Long collaboratorId,
RedirectAttributes redirectAttributes
) {

String collaboratorName = todoCollaborationService


.shareWithCollaborator(todoId, collaboratorId);

// add success message

return "redirect:/dashboard";
}
}

En este servicio, primero confirmamos que tanto el colaborador como la tarea


existan en nuestra base de datos. Luego, verificamos que el usuario sea el
propietario de la tarea y que no exista una petición de colaboración equivalente
en curso.

Cada nueva solicitud de colaboración se almacena en nuestra base de datos


PostgreSQL. La entidad JPA TodoCollaborationRequest contiene la informa-
ción pertinente sobre la invitación. Para una seguridad adicional, generamos
un token aleatorio para cada solicitud de colaboración, el cual es necesario para
aceptar la invitación:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 279

@Service
@Transactional
public class TodoCollaborationService {

// more fields (Spring Data JPA repositories, etc.)

private final SqsTemplate sqsTemplate;


private final String todoSharingQueueName;

public String shareWithCollaborator(Long todoId, Long collaboratorId) {

// check if todo and collaborator exists


// return if there's already an active collaboration request

TodoCollaborationRequest collaboration = new TodoCollaborationRequest();


collaboration.setToken(UUID.randomUUID().toString());
collaboration.setCollaborator(collaborator);
collaboration.setTodo(todo);

todo.getCollaborationRequests().add(collaboration);

todoCollaborationRequestRepository.save(collaboration);

sqsTemplate.send(todoSharingQueueName,
new TodoCollaborationNotification(collaboration));

return collaborator.getName();
}
}

Para transmitir la información de colaboración, poblamos un objeto TodoColla-


borationNotification. Este actúa como un objeto de transferencia de datos
(DTO) y contiene la información relevante sobre la solicitud de colaboración.

En lugar de incluir un objeto completo TodoCollaborationNotification en


el mensaje SQS, podríamos hacer que el mensaje contenga solo el ID de la
entidad TodoCollaborationRequest. En este escenario, el consumidor de un
mensaje tendría que usar este ID para cargar la solicitud de la base de datos
nuevamente. Optamos por un enfoque “plano” que incluye toda la información
12. Compartiendo Tareas con Amazon SQS y Amazon SES 280

necesaria para enviar un correo electrónico. Esto desacopla aún más la función
de envío de correos electrónicos del resto de nuestra aplicación. Sin embargo,
hay una desventaja en esta solución: la persona invitada podría ver información
desactualizada sobre la tarea, ya que el propietario puede actualizar la tarea
entre el envío de la solicitud de colaboración y la espera de la confirmación. Sin
embargo, decidimos que esto es aceptable.

Como último paso, usamos el SqsTemplate para enviar un mensaje a una cola
SQS especificada. El método send() serializará el objeto Java a una cadena JSON
antes de enviarlo a SQS.

Con esta configuración, los nuevos mensajes ahora se acumularán dentro de


nuestra cola SQS. En caso de que no consumamos los mensajes dentro de 14
días (el retentionPeriod de nuestra cola), desaparecerán. Para evitar este
escenario y usuarios descontentos, ahora necesitamos consumir los mensajes.
Conectémonos a la cola SQS como consumidor para procesar más las solicitudes
de colaboración.

Recibiendo Mensajes de Amazon SQS con Spring Cloud AWS

La responsabilidad de nuestro consumidor es informar al colaborador invitado


por correo electrónico. Para fines de demostración y para evitar la inicialización
de un nuevo servicio, nos autoconsumiremos los mensajes en la misma aplica-
ción.

¿Por qué entonces hacemos el esfuerzo extra y enviamos un mensaje a SQS - no


podríamos simplemente enviar el correo electrónico directamente dentro del
TodoCollaborationService? Por supuesto que podríamos, pero esto acoplaría
el envío de correos electrónicos con el resto de nuestra aplicación. Al usar una
cola SQS en medio, desacoplamos la lógica de invitación de la lógica de envío
de correos electrónicos y ganamos la flexibilidad para mover fácilmente estas
12. Compartiendo Tareas con Amazon SQS y Amazon SES 281

características dentro de nuestra base de código (o incluso en otra base de


código).

Además, la cola SQS entre estas dos operaciones actúa como un búfer. Esto nos
da un mecanismo de reintento implícito porque, con nuestro maxReceiveCount,
intentamos procesar el mensaje hasta cuatro veces. Como estamos construyen-
do un sistema distribuido y tenemos que integrarnos con un proveedor de la
nube, prácticamente cualquier cosa puede fallar. Por lo tanto, deberíamos estar
preparados para el fracaso. Además de esto, podemos controlar el rendimiento
del envío de correos electrónicos limitando el número de mensajes que consu-
mimos. Esto ayuda a mantenerse dentro de los límites de posibles límites de
envío de correo electrónico.

Spring Cloud AWS proporciona dos formas de consumir mensajes:

• utilizando un oyente impulsado por anotación, y


• utilizando el método receive() del SqsTemplate.

Mientras que llamar a receive() explícitamente es útil para el consumo de


mensajes a demanda, con una clase de oyente anotada, estamos interrogando
continuamente mensajes con un hilo en segundo plano y podemos procesar
mensajes casi en tiempo real. También podemos interactuar directamente con
el cliente SQS (AmazonSQS) a través del SDK de Java de AWS, pero en ese caso,
perderíamos la abstracción de Spring Messaging y tendríamos que lidiar con la
API de bajo nivel.

La clase de oyente real es un bean estándar del Spring Framework. Cualquier


método público de esta clase que use la anotación @SqsListener actúa como un
método de manejo de SQS. Esta anotación espera una lista de nombres lógicos
o físicos de colas SQS. Para nuestro caso de uso, solo escuchamos una cola, pero
también podríamos definir el mismo manejador para varias colas SQS:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 282

@Component
public class TodoSharingListener {

@SqsListener(value = "${custom.sharing-queue}")
public void listenToSharingMessages(TodoCollaborationNotification payload) {

// upcoming implementation to notify collaborator via email and AWS ES


LOG.info("Incoming todo sharing payload: {}", payload);

}
}

Como parte del argumento del método del receptor, podemos definir el objeto
Java requerido. Spring Cloud AWS, y en este caso particular su integración
con Spring Messaging, es responsable de extraer la carga útil del mensaje y
resolverla utilizando un PayloadMethodArgumentResolver. Por defecto, esto
hace uso del MappingJackson2MessageConverter para deserializar el mensaje
JSON entrante (de tipo String) a un objeto Java utilizando el ObjectMapper de
Jackson.

Aceptando mensajes de Amazon SQS con Spring Cloud AWS

Hasta ahora, consumimos el mensaje SQS entrante, extraemos su carga útil y la


convertimos a un objeto Java. Sin embargo, aún falta una responsabilidad clave
del receptor SQS: eliminar el mensaje después de procesarlo.

Recordemos que un mensaje permanecerá dentro de SQS hasta que el consumi-


dor lo elimine explícitamente. El proceso de recibir un mensaje no lo eliminará
de la cola. Por lo tanto, después de procesar (exitosamente) el mensaje, tenemos
que aceptarlo eliminando el mensaje de la cola.

El SDK Java SQS de AWS proporciona un método de bajo nivel para este propósi-
to: deleteMessage(). Este método espera el identificador de recibo del mensaje.
Este identificador puede tener hasta 1024 caracteres de longitud e identifica un
evento de consumición de mensaje en particular.
12. Compartiendo Tareas con Amazon SQS y Amazon SES 283

Spring Cloud AWS proporciona una abstracción sobre esto para que no tenga-
mos que hacer un seguimiento de todos los identificadores de recibo y gestionar
la eliminación de mensajes por nuestra cuenta. Para los receptores SQS basados
en anotaciones, podemos especificar una política de eliminación. Si estamos
utilizando el método receive() de la SqsTemplate, el mensaje se eliminará
inmediatamente.

Spring Cloud AWS define las siguientes cuatro políticas de eliminación como
parte del conjunto SqsMessageDeletionPolicy:

• ALWAYS: El resultado del procesamiento es irrelevante (éxito o excepción)


ya que el mensaje se eliminará siempre.
• NEVER: Nosotros estamos a cargo de eliminar el mensaje.
• NO_REDRIVE: Elimina el mensaje en caso de éxito y en caso de una excepción
solo si no se ha configurado una política de reintento para la cola. Esta es la
opción predeterminada.
• ON_SUCCESS: Elimina el mensaje a menos que se lance una excepción duran-
te el procesamiento.

Dado que, con nuestra cola de mensajes no entregados, hemos configurado una
política de reintento para nuestra configuración SQS, la política de eliminación
NO_REDRIVE predeterminada es suficiente. Siempre que nuestro procesamiento
de mensajes finalice sin una excepción, Spring Cloud AWS eliminará el men-
saje. Si nuestro procesamiento de mensajes lanza una excepción, el mensaje
permanecerá dentro de la cola, e intentaremos procesar nuevamente el mensaje
después de que el visibilityTimeout haya transcurrido, hasta que llegue a la
cola de mensajes no entregados después de demasiados intentos fallidos.

Sin embargo, veamos cómo sería una política de eliminación de NEVER. Podemos
configurar la deletionPolicy por receptor SQS como parte de la anotación
@SqsListener:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 284

@SqsListener(value = "${custom.sharing-queue}", deletionPolicy = NEVER)


public void listenToSharingMessages(
TodoCollaborationNotification payload,
Acknowledgment acknowledgment) {

// perform processing
if(successfulProcessing) {
acknowledgment.acknowledge();
}

Con esta política de eliminación, Spring Cloud AWS nunca borrará automáti-
camente un mensaje para nosotros. Podemos inyectar un objeto Acknowled-
gement y tener el control total sobre cuándo confirmar (es decir, borrar) el
mensaje.

Por favor, ten en cuenta que el método acknowledge() devuelve un Future de


Java, y podríamos querer esperar hasta que este se complete. La eliminación del
mensaje en sí internamente es solo otra solicitud HTTP a AWS, la cual puede
fallar en cualquier momento.

Además, podemos obtener las cabeceras de un mensaje SQS si éstas son rele-
vantes para nuestro procesamiento de mensajes:

@SqsListener(value = "${custom.sharing-queue}")
public void listenToSharingMessages(
TodoCollaborationNotification payload,
@Headers Map<String, Object> headers) {

También es posible resolver un encabezado específico utilizando la anotación


@Header("HEADER_NAME").

Con nuestro oyente de mensajes SQS ya establecido, es hora de informar al


colaborador por correo electrónico. Veamos cómo Amazon SES puede ayudarnos
12. Compartiendo Tareas con Amazon SQS y Amazon SES 285

aquí y cómo podemos integrar este servicio específico de AWS con nuestro
backend de Spring Boot en la siguiente sección.

Enviando correos electrónicos con Amazon SES

La segunda parte de este capítulo implementa la notificación de correo electró-


nico que falta para completar nuestra función de colaboración. Integraremos
Amazon Simple Email Service (SES) con nuestra aplicación de ejemplo “Todo”
para informar a un colaborador sobre una tarea compartida en “Todo”.

Introducción a Amazon SES

Amazon SES (Simple Email Service) es un servicio de envío y recepción de co-


rreos electrónicos fácil de configurar. La administración de una infraestructura
de correo electrónico en las instalaciones es un tema complejo. Con Amazon
SES, aprovechamos años de experiencia en infraestructura de correo electrónico
en Amazon.

Podemos interactuar con Amazon SES a través del SDK de AWS, la interfaz SMTP
de Amazon SES o la API de Amazon SES directamente.

Los posibles casos de uso para Amazon SES van desde correos electrónicos tran-
saccionales clásicos (como confirmaciones de registro) hasta correspondencia
de correo electrónico de marketing y boletines informativos. Se nos facturará
en función del número de correos electrónicos que enviamos y recibimos. No
hay costos iniciales por licencias o recursos de servidor.

Además, Amazon SES está disponible en varias regiones. Cada cuenta de AWS
tiene acceso al entorno de pruebas de Amazon SES en las regiones disponibles
por defecto. Aprenderemos más sobre este acceso al entorno de pruebas en la
12. Compartiendo Tareas con Amazon SQS y Amazon SES 286

siguiente sección.

Amazon SES viene con cuotas de envío de correo electrónico para prevenir el
fraude y mantener una buena reputación de la dirección IP. Estas cuotas definen
cuántos correos electrónicos podemos enviar por día y el número máximo de
correos electrónicos que podemos enviar por segundo. Podemos configurar esas
cuotas por región poniéndonos en contacto con el Centro de Soporte de AWS.

La cuota predeterminada depende del tipo de instancia de Amazon SES, que


vamos a analizar en la siguiente sección.

Creando la instancia de Amazon SES

A diferencia de otros servicios de AWS como AWS S3 o Amazon SQS, no es


necesario crear una instancia de Amazon SES ya que todas las nuevas cuentas
de AWS se ubicarán en el entorno de pruebas de Amazon SES por defecto.
No encontraremos recursos de CloudFormation o constructos de CDK para
crear la instancia de Amazon SES para una región determinada, ya que ya está
disponible.

No obstante, hay recursos específicos de Amazon SES que podemos inicializar


con infraestructura como código (ya sea CloudFormation o CDK).

Con AWS::SES::Template, por ejemplo, podemos configurar plantillas de co-


rreo electrónico reutilizables. En caso de que queramos definir reglas para
correos electrónicos entrantes (por ejemplo, para almacenarlos en un bucket
S3), podemos crear un recurso AWS::SES::ReceiptRule. No usaremos ninguno
de estos recursos para nuestra aplicación de ejemplo “Todo”, ya que solo nece-
sitamos la función más básica de Amazon SES: enviar correos electrónicos.

Hay un pequeño ajuste para nuestra configuración de infraestructura CDK exis-


tente. Como estamos a punto de enviar correos electrónicos desde nuestra
12. Compartiendo Tareas con Amazon SQS y Amazon SES 287

aplicación de Spring Boot, el rol de tarea de ECS requiere permisos suficientes


para la API de Amazon SES. Agregaremos una nueva PolicyStatement a nuestro
componente de AWS CDK ServiceApp:

.withTaskRolePolicyStatements(List.of(
// ... existings policy statements
PolicyStatement.Builder.create()
.sid("AllowSendingEmails")
.effect(Effect.ALLOW)
.resources(
List.of(String.format(
"arn:aws:ses:%s:%s:identity/stratospheric.dev", region, accountId))
)
.actions(List.of(
"ses:SendEmail",
"ses:SendRawEmail"
))
.build()
))

Cuando trabajas con Amazon SES, es crucial entender las implicaciones del
acceso a sandbox. Como su nombre indica, viene con varias restricciones:

• solo podemos enviar correos electrónicos a direcciones de correo electróni-


co verificadas,
• solo podemos enviar correos electrónicos desde dominios y direcciones de
correo electrónico verificados,
• solo podemos enviar 200 correos electrónicos dentro de 24 horas, y
• solo podemos enviar un correo electrónico por segundo.

Estas restricciones pueden ser aceptables para fines de desarrollo y pruebas


con una correspondencia de correo electrónico mínima y previsible, pero no
para producción. Antes de poder usar de manera efectiva Amazon SES, primero
debemos salir del entorno sandbox y solicitar acceso a producción.
12. Compartiendo Tareas con Amazon SQS y Amazon SES 288

Solicitando Acceso a Producción en Amazon SES

El acceso a sandbox de Amazon SES al que estamos asignados por defecto limita
el uso para producción. No solo las cuotas de envío son bajas sino que también
tenemos que verificar cada dirección de correo electrónico antes de siquiera
contactar al usuario por primera vez. Esto impide la operación sin problemas.

Es por eso que debemos solicitar acceso a producción tan pronto como nuestra
aplicación esté a punto de salir en vivo por primera vez. Solicitar acceso a
producción es un esfuerzo manual único a través de un ticket de soporte.

Para solicitar acceso a producción de Amazon SES, tenemos que presentar un


ticket de soporte de AWS. Como parte de este ticket, debemos especificar la URL
de nuestro sitio web, nuestro caso de uso, información de contacto adicional y
qué tipo de correos electrónicos estamos a punto de enviar. Para nuestra función
de colaboración, el tipo correcto es TRANSACTIONAL (para correos electrónicos
como confirmaciones de pedidos), ya que la alternativa sería PROMOTIONAL (para
correos electrónicos relacionados con marketing).

Este ticket desencadena un proceso de verificación en el lado de AWS y puede


demorar hasta 24 horas. En caso de que estemos operando en varias regiones
de AWS, tenemos que solicitar acceso a producción de Amazon SES para cada
región de manera individual.

Para obtener una guía paso a paso actualizada y visual sobre cómo solicitar
acceso a producción, echa un vistazo a la documentación de Amazon SES.
Dado que la consola de Amazon SES está actualmente en transición de v1
a v2, las capturas de pantalla podrían quedar obsoletas bastante pronto.

Una vez que se habilita el acceso a producción, podemos solicitar un aumento


(si es necesario) de nuestras cuotas de envío de Amazon SES (tasa de envío y
12. Compartiendo Tareas con Amazon SQS y Amazon SES 289

número de correos electrónicos por día). Este paso es opcional y se puede lograr
con un ticket de soporte adicional.

Con el acceso a producción de Amazon SES en su lugar, primero necesitamos


verificar nuestro dominio antes de enviar correos electrónicos desde nuestra
aplicación Spring Boot.

Verificando un Dominio

Para enviar correos electrónicos con Amazon SES, debemos verificar cada
identidad (nombre de dominio) que vamos a usar como parte de “De”,
“Fuente”, “Remitente” o “Ruta de retorno” para evitar el uso no autorizado.
Nuestro objetivo es enviar correos electrónicos de confirmación desde
noreply@stratospheric.dev para implementar nuestra función de compartir
tareas pendientes.

El proceso para verificar una identidad de dominio es un esfuerzo único dentro


de la Consola de Administración de AWS.

Para obtener una guía actualizada sobre cómo verificar identidades de


dominio, sigue los pasos tal como se describen en la documentación de
AWS.

Durante este proceso, se nos pregunta si queremos crear configuraciones de


DKIM (DomainKeys Identified Mail) para esta identidad de dominio. Al firmar
nuestro mensaje de correo electrónico con la clave privada de DKIM, podemos
demostrar que el correo electrónico proviene de nuestro dominio. Las firmas de
DKIM se almacenan en los registros DNS de nuestro dominio. Los receptores
pueden verificar dicha firma para asegurarse de que el mensaje no fue modifi-
cado durante el tránsito. Como recomendación general, deberíamos optar por
las configuraciones de DKIM para mejorar nuestras tasas de entrega de correo
electrónico.
12. Compartiendo Tareas con Amazon SQS y Amazon SES 290

Para verificar que somos los propietarios del dominio, necesitamos actualizar
la configuración DNS de nuestro dominio. Al final del proceso de creación de
la identidad de dominio, podemos descargar un archivo .csv con los cambios
requeridos en el conjunto de registros.

En caso de que estemos utilizando Route 53 como nuestro proveedor de DNS, el


propio servicio de DNS de Amazon, nuestras configuraciones DNS se actualizan
automáticamente. Lo que queda es esperar hasta que la consola de Amazon SES
informe que nuestro dominio está verificado.

Si estamos utilizando un proveedor de DNS diferente (GoDaddy, Namecheap,


Cloudflare, lo que sea), debemos actualizar la configuración de DNS de nuestro
dominio nosotros mismos. Como estamos lidiando con cambios en nuestras
configuraciones de DNS, la propagación a todos los demás servidores DNS puede
tardar hasta 48 horas. Sin embargo, la mayoría de las veces, la propagación es
cuestión de horas.

Con stratospheric.dev siendo un dominio verificado de Amazon SES, pode-


mos empezar a implementar confirmación por correo electrónico.

Usando Amazon SES para nuestra aplicación Todo

Tanto el marco Spring como Spring Cloud AWS ofrecen un excelente soporte
para enviar correos electrónicos desde nuestro backend de Java. Spring define
dos interfaces centrales para hacerlo: MailSender y JavaMailSender. Java-
MailSender hereda de MailSender y añade soporte para enviar mensajes MIME
por correo electrónico.

En general, Spring proporciona métodos de utilidad y una abstracción conve-


niente sobre la API estándar de JavaMail (hoy en día Jakarta Mail).

Aunque el soporte de correo de Spring no depende de ningún sistema de correo


12. Compartiendo Tareas con Amazon SQS y Amazon SES 291

específico, Spring Cloud AWS proporciona una implementación de ambas inter-


faces que utiliza Amazon SES como el sistema de transporte subyacente.

Para empezar, añadimos la siguiente dependencia de Spring Cloud AWS a nues-


tro archivo build.gradle:

dependencies {
implementation 'io.awspring.cloud:spring-cloud-aws-starter-ses'
}

Se nos auto-configura una instancia del cliente SDK de Amazon SES SesClient.
Podemos interactuar directamente con el cliente SDK o usar el ya menciona-
do MailSender o JavaMailSender, para los cuales Spring Cloud AWS auto-
configura una implementación, que a su vez utiliza Amazon SES internamente.

Con esta configuración lista, podemos empezar a enviar correos electrónicos a


nuestros usuarios.

Enviando Correos Electrónicos para Invitar a Colaboradores

Continuaremos con la implementación donde la dejamos en la sección anterior.


Ya tenemos el @SqsListener listo para consumir nuestros mensajes de solicitud
de colaboración de Amazon SQS. Lo que hace falta es notificar al colaborador
mediante el envío de un correo electrónico.

El MailSender de Spring es suficiente para nuestro caso de uso, ya que no


estamos enviando correos electrónicos complejos con archivos adjuntos o un
formato más complicado que requeriría el uso de JavaMailSender. Podemos
inyectar el MailSender auto-configurado en nuestro TodoSharingListener y
crear el mensaje de correo electrónico basado en los datos recibidos:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 292

@Component
public class TodoSharingListener {

private final MailSender mailSender;

public TodoSharingListener(MailSender mailSender) {


this.mailSender = mailSender;
}

@SqsListener(value = "${custom.sharing-queue}", deletionPolicy = ON_SUCCESS)


public void listenToSharingMessages(TodoCollaborationNotification payload) {

SimpleMailMessage message = new SimpleMailMessage();


message.setFrom("noreply@stratospheric.dev");
message.setTo(payload.getCollaboratorEmail());
message.setSubject("A todo was shared with you");
message.setText("Nicely formatted text including the confirmation link");

mailSender.send(message);
}
}

Como hemos confirmado la identidad del dominio para nuestro dominio stra-
tospheric.dev, podemos utilizar noreply@stratospheric.dev como el re-
mitente del correo electrónico. Con el acceso de producción de Amazon SES
habilitado, también podemos enviar correos electrónicos a cualquier destino sin
tener que confirmar cada objetivo manualmente.

El formato del cuerpo del correo electrónico se ha omitido en el ejemplo de


código anterior por razones de brevedad (pero está disponible en GitHub).
Contiene metadatos sobre el todo (título, fecha de vencimiento, descripción,
etc.) y, lo más notable, el enlace de confirmación con un token de corta duración.

Antes de continuar, recordemos las implicaciones de usar el tipo de cola SQS


predeterminada de AWS. Al trabajar con el tipo de cola predeterminado (en
lugar de FIFO), debemos tener en cuenta que los mensajes se entregan al menos
una vez. Esto significaría que nuestra aplicación podría enviar el mismo correo
12. Compartiendo Tareas con Amazon SQS y Amazon SES 293

electrónico de invitación varias veces en nuestro ejemplo concreto. Aunque la


probabilidad de esto es relativamente baja y el impacto en el negocio podría ser
aceptable (simplemente molestaríamos al colaborador…), podemos mitigar ese
riesgo.

Para evitar acciones duplicadas o datos inconsistentes cuando se recibe el


mismo mensaje dos veces, podemos diseñar el procesamiento de mensajes de
manera idempotente.

Una operación es idempotente si produce el mismo resultado para la


misma entrada incluso cuando se ejecuta varias veces. En nuestro con-
texto, para tener un procesamiento idempotente, consumir el mismo
mensaje de Amazon SQS dos veces no debería resultar en una segunda
notificación por correo electrónico. Para una explicación más detallada de
Idempotencia, eche un vistazo a la siguiente lección en video del proyecto
del Tutorial de Rest API.

Para nuestro caso de uso, podríamos llevar un registro de a quién ya hemos


notificado. Para cada mensaje entrante de SQS, comprobaríamos primero si
esta solicitud de colaboración es nueva y, en caso contrario, descartaríamos
el mensaje ya que la persona ya ha sido informada. Para nuestra aplicación
de muestra Todo, aceptaremos el hecho de que podríamos enviar la misma
invitación dos veces.

En cuanto llamamos a mailSender.send(), el correo electrónico se entrega a


Amazon SES. Ahora es responsabilidad de AWS entregar ese correo electrónico.
Siempre que enviemos correos electrónicos desde nuestra aplicación, es crucial
monitorear nuestras tasas de entrega. No es improbable que otros servidores de
correo electrónico rechacen nuestros mensajes o los marquen como spam. Eso
es algo que retomaremos en el capítulo Métricas con Amazon CloudWatch.

Una vez que la persona invitada recibe nuestro correo electrónico y hace clic en
12. Compartiendo Tareas con Amazon SQS y Amazon SES 294

el enlace de la invitación, retomamos nuestra responsabilidad. Veamos cómo


nuestro backend de Spring Boot maneja estas confirmaciones en la siguiente
sección.

Aceptación de Confirmaciones

El enlace que incluimos en el correo electrónico de invitación tiene la siguiente


estructura:

<domain>/todo/{todoId}/collaborations/{collaboratorId}/confirm?token={token}

Se dirige a un punto final de nuestro backend de Spring Boot para confirmar


la colaboración. Este punto final requiere autenticación y, por lo tanto, el
colaborador necesita estar conectado. Con esta capa adicional de seguridad,
aseguramos que solo la persona invitada pueda confirmar la colaboración.

El siguiente punto final de Spring MVC maneja la parte de confirmación de la


característica de colaboración:

@Controller
@RequestMapping("/todo")
public class TodoCollaborationController {

// ... existing endpoint to create a collaboration

@GetMapping("/{todoId}/collaborations/{collaboratorId}/confirm")
public String confirmCollaboration(
@PathVariable("todoId") Long todoId,
@PathVariable("collaboratorId") Long collaboratorId,
@RequestParam("token") String token,
@AuthenticationPrincipal OidcUser user,
RedirectAttributes redirectAttributes
) {

if(todoCollaborationService
.confirmCollaboration(user.getEmail(), todoId, collaboratorId, token)) {
12. Compartiendo Tareas con Amazon SQS y Amazon SES 295

redirectAttributes.addFlashAttribute("message",
"You've confirmed that you'd like to collaborate on this todo.");
redirectAttributes.addFlashAttribute("messageType", "success");
}else {
redirectAttributes.addFlashAttribute("message",
"Invalid collaboration request.");
redirectAttributes.addFlashAttribute("messageType", "danger");
}

return "redirect:/dashboard";
}
}

En el marco del método confirmCollaboration() de TodoCollaborationSer-


vice, confirmamos la colaboración o la rechazamos:

public class TodoCollaborationService {

// other methods and fields

public boolean confirmCollaboration(String authenticatedUserEmail,


Long todoId, Long collaboratorId, String token) {

Person collaborator = personRepository


.findByEmail(authenticatedUserEmail)
.orElseThrow(() -> new IllegalArgumentException());

if (!collaborator.getId().equals(collaboratorId)) {
return false;
}

TodoCollaborationRequest collaborationRequest = collaborationRequestRepository


.findByTodoIdAndCollaboratorId(todoId, collaboratorId);

if (collaborationRequest == null ||
!collaborationRequest.getToken().equals(token)) {
return false;
}

Todo todo = todoRepository


.findById(todoId)
.orElseThrow(() -> new IllegalArgumentException());
12. Compartiendo Tareas con Amazon SQS y Amazon SES 296

todo.addCollaborator(collaborator);

collaborationRequestRepository.delete(collaborationRequest);

return true;
}
}

Rechazamos la validación por una de las siguientes razones:

• el usuario que ha iniciado sesión intenta confirmar una solicitud de colabo-


ración para otro usuario,
• no existe ninguna solicitud de colaboración para esta tarea y/o usuario, o
• el token de confirmación no es válido.

Dependiendo del resultado del proceso de validación, devolvemos un valor


Booleano para mostrar un mensaje de éxito o error al usuario.

Estamos registrando al usuario invitado como colaborador para la tarea al llamar


todo.addCollaborator(collaborator). Hemos modelado esta relación con
@ManyToMany ya que una tarea puede tener varios colaboradores, y una persona
puede colaborar en numerosas tareas:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 297

@Entity
public class Todo {

// ... more fields

@ManyToMany
@JoinTable(name = "todo_collaboration",
joinColumns = @JoinColumn(name = "todo_id"),
inverseJoinColumns = @JoinColumn(name = "collaborator_id")
)
private List<Person> collaborators = new ArrayList<>();

public void addCollaborator(Person person) {


this.collaborators.add(person);
person.getCollaborativeTodos().add(this);
}
}

Dado que este método confirmCollaboration() se ejecuta dentro de una


transacción, Hibernate determinará las declaraciones SQL necesarias con su
mecanismo de “dirty checking”, y no tenemos que llamar explícitamente a
save() para persistir este cambio.

Como una tarea adicional de mantenimiento, estamos eliminando esta solicitud


de colaboración en particular de nuestra base de datos ya que ya no tenemos que
hacer seguimiento de ella.

Una vez que la persona invitada confirma con éxito la colaboración, informare-
mos al propietario del todo con una notificación push. Vamos a implementar
esta característica en el próximo capítulo Notificaciones Push con Amazon MQ.

Activando el Desarrollo Local

Lo que queda es actualizar nuestra configuración de desarrollo local. Estamos


integrando dos nuevos servicios de AWS a nuestra pila de tecnología existente
12. Compartiendo Tareas con Amazon SQS y Amazon SES 298

con la función de colaboración: Amazon SQS y Amazon SES. Cuando desarro-


llamos y arrancamos la aplicación localmente, necesitamos acceso a estos dos
servicios de AWS.

Como ya se describió en el capítulo Desarrollo Local, estamos utilizando Docker y


LocalStack para simular una nube AWS local. Con ese fin, estamos extendiendo
nuestro archivo existente docker-compose.yml con una nueva definición de
servicio:

version: '3.3'

services:
# ... existing Docker container definitions
localstack:
image: localstack/localstack:0.14.4
ports:
- 4566:4566
environment:
- SERVICES=sqs,ses
- DEFAULT_REGION=eu-central-1
- USE_SINGLE_REGION=true
volumes:
- ./src/test/resources/localstack/local-aws-infrastructure.sh:\
/docker-entrypoint-initaws.d/init.sh

Como parte de la definición del contenedor Docker, estamos instruyendo a


LocalStack qué servicios de AWS iniciar utilizando la variable de entorno SER-
VICES. Además, definimos la región predeterminada de AWS. No es necesario
configurar credenciales específicas para nuestros clientes del SDK de AWS ya
que LocalStack permite el uso de cualquier credenciales.

Para inicializar y configurar nuestra nube AWS local, montamos un script dentro
del contenedor Docker (local-aws-infrastructure.sh). LocalStack ejecutará
cualquier script dentro de la carpeta docker-entrypoint-initaws.d durante
la inicialización del contenedor. Como parte de nuestro script de inicialización,
12. Compartiendo Tareas con Amazon SQS y Amazon SES 299

estamos creando nuestra cola Amazon SQS y validamos varias identidades de


correo electrónico para Amazon SES:

#!/bin/sh

awslocal sqs create-queue --queue-name stratospheric-todo-sharing

awslocal ses verify-email-identity --email-address noreply@stratospheric.dev


awslocal ses verify-email-identity --email-address info@stratospheric.dev
awslocal ses verify-email-identity --email-address tom@stratospheric.dev
awslocal ses verify-email-identity --email-address bjoern@stratospheric.dev
awslocal ses verify-email-identity --email-address philip@stratospheric.dev

La herramienta CLI awslocal es un envoltorio para el binario aws que se dirige


a LocalStack en lugar de a la nube AWS real.

Iniciando con la versión v0.11.0 de LocalStack, todos los servicios de AWS


son accesibles a través de un solo servicio en el borde en el puerto 4566.
Con versiones anteriores de LocalStack, cada servicio de AWS asignaba un
puerto dedicado.

Una vez que el contenedor LocalStack está en funcionamiento, necesitamos


configurar los clientes de AWS SDK de nuestra aplicación para dirigirse a nuestra
nube AWS local. Logramos esto al sobrescribir las URL de los endpoints como
parte de nuestro application-dev.yml:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 300

spring:
cloud:
aws:
endpoint: http://localhost:4566
region:
static: eu-central-1
credentials:
secret-key: foo
access-key: bar

Con esta configuración, los clientes del SDK de AWS autoconfigurados se dirigen
a LocalStack.

Existe una excepción con el servicio Amazon SES de LocalStack. No enviará


ningún correo electrónico a la bandeja de entrada del colaborador. LocalStack
funciona como un entorno aislado y almacenará los correos electrónicos reci-
bidos en la memoria. Sin embargo, nuestra aplicación recibirá una respuesta
exitosa cada vez que usemos el MailSender de Spring.

Para que toda la colaboración funcione correctamente, podemos confirmar las


colaboraciones automáticamente a nivel local. Con una propiedad booleana
como custom.auto-confirm-collaborations, podemos controlar este com-
portamiento. Para todos los perfiles de la aplicación, excepto dev, este valor se
establece en false. Nuestro TodoSharingListener comprueba esta propiedad
al finalizar el procesamiento:
12. Compartiendo Tareas con Amazon SQS y Amazon SES 301

@Component
public class TodoSharingListener {

// ...

private final boolean autoConfirmCollaborations;

public TodoSharingListener(
@Value("${custom.auto-confirm-collaborations}")
boolean autoConfirmCollaborations) {
this.autoConfirmCollaborations = autoConfirmCollaborations;
}

@SqsListener(value = "${custom.sharing-queue}", deletionPolicy = ON_SUCCESS)


public void listenToSharingMessages(TodoCollaborationNotification payload) {

// ...

if (autoConfirmCollaborations) {
LOG.info("Auto-confirm collaboration request");
todoCollaborationService.confirmCollaboration(
payload.getCollaboratorEmail(), payload.getTodoId(),
payload.getCollaboratorId(), payload.getToken());
}
}
}

El servicio Amazon SQS de LocalStack funciona tal como se espera: podemos


publicar y consumir mensajes SQS cuando corremos nuestra aplicación local-
mente.
13. Notificaciones Push con Amazon MQ
Como se discutió en el capítulo Compartiendo Tareas con SQS y SES, ahora pode-
mos compartir nuestras tareas con otros. Eso ya es bastante útil para trabajar
en tareas de manera colaborativa. Sin embargo, recibir retroalimentación en
tiempo real cada vez que un colaborador hace cambios en una tarea, sería lo
que los gerentes de producto llaman un “delighter” - una característica que
emociona a nuestros usuarios más allá de sus expectativas.

Aquí es donde entran en juego las notificaciones push. En este capítulo, explora-
remos las notificaciones push, crearemos la infraestructura de AWS requerida
e implementaremos en nuestra aplicación Spring Boot.

¿Qué son las Notificaciones Push de todos modos?

El término notificaciones push se originó en el espacio de aplicaciones móviles en


2009. Normalmente se refiere a un servidor de backend que envía información
a la interfaz de usuario de una aplicación móvil en lugar de que la aplicación
misma inicie una solicitud - o esté solicitando - para obtener información del
servidor.

La principal ventaja de este enfoque es que, en lugar de sondear continuamente


en busca de nueva información - y por lo tanto usar muchos recursos de red y
del sistema - un cliente solo recibe notificaciones cuando hay una actualización
que es relevante para ese cliente específico.

Aunque el término se acuñó originalmente para aplicaciones móviles, el patrón


general se aplica a cualquier tipo de aplicación cliente-servidor distribuida,
13. Notificaciones Push con Amazon MQ 303

especialmente si hay varios clientes involucrados, que a su vez pueden necesitar


comunicarse entre sí.

Este patrón viene en varias sabores e implementaciones. Un patrón de diseño


común para implementar dicho comportamiento es el patrón observador, como
se esboza en este diagrama de secuencia:

Un observador se suscribe a un sujeto y es notificado por el sujeto cuando ocurre algo.

Con este patrón de diseño, múltiples observadores se suscriben a un sujeto o


observable, que a su vez notificará a cada uno de sus suscriptores una vez que
13. Notificaciones Push con Amazon MQ 304

haya una actualización disponible.

Para tener en cuenta tanto la escalabilidad como una gestión más fácil de los
suscriptores, los intermediarios de mensajes como Apache ActiveMQ generali-
zan esta idea siguiendo el patrón de publicación-suscripción. Este patrón nos
permite tener una relación n:m entre publicadores y suscriptores con múltiples
publicadores publicando mensajes a un tema o canal y múltiples suscriptores
recibiendo mensajes de ese tema:

Un intermediario de mensajes conecta a los publicadores con los suscriptores a través de temas
a los que un suscriptor puede suscribirse.

Aplicaremos este concepto general de un servidor notificando a sus clientes cada


vez que haya una actualización relevante para una tarea en nuestra aplicación.
En nuestro caso, notificaremos a los usuarios en la ventana de su navegador
cuando otro usuario haya aceptado su solicitud para colaborar en una tarea.

Para hacerlo, estaremos utilizando WebSocket y STOMP como protocolos sobre


un intermediario de mensajes ActiveMQ que se ejecuta en Amazon MQ. Si bien
implementar un patrón de publicación-suscripción es ciertamente posible con
otros protocolos como HTTP, WebSocket y STOMP se prestan particularmente
a este escenario ya que WebSocket - a diferencia de HTTP - permite una
comunicación bidireccional, full-duplex.

Aunque técnicamente no es necesario, un protocolo de comunicación bidireccio-


13. Notificaciones Push con Amazon MQ 305

nal simplifica enormemente la implementación de un patrón de publicación-


suscripción. Hay enfoques alternativos como el Sondeo HTTP, la Transmisión
HTTP o los Eventos enviados por el servidor, cada uno de los cuales tiene sus
propias ventajas y desventajas.

Aún así, WebSocket no solo cumple con nuestras necesidades para nuestro
caso de uso particular, sino que también ofrece un mejor rendimiento y menor
latencia que HTTP.

Notificaciones Push para Actualizaciones en Vivo

En el capítulo Compartiendo Tareas con SQS y SES hemos visto cómo podemos
comunicarnos y colaborar con otros usuarios con Amazon Simple Queue Service
(SQS) y Amazon Simple Email Service (SES).

Hasta ahora, sin embargo, esa comunicación ha sido solo de una vía: permitimos
a un usuario compartir sus tareas con otros usuarios pero el dueño de la tarea
aún no recibe una notificación en caso de que sus colaboradores hayan avanzado
en una tarea compartida. Para enmendar esa situación, implementaremos una
función de notificación que informará al usuario de dichas actualizaciones.

Si bien usar el intermediario de mensajes incrustado en memoria de Spring


podría parecer una solución obvia - y simple - esto no funciona aquí porque
queremos ejecutar (al menos) dos instancias de nuestra aplicación Spring Boot
para fiabilidad. En este escenario, solo los clientes conectados a una instancia
específica podrían intercambiar mensajes entre sí. Por lo tanto, para comunicar-
se entre instancias necesitamos un servicio con estado que permita compartir
mensajes entre instancias de la aplicación.

Emplearemos un servicio de AWS para ese servicio con estado. Dentro del
ecosistema de AWS, hay bastantes servicios que nos permiten implementar
13. Notificaciones Push con Amazon MQ 306

notificaciones con un patrón de publicación-suscripción. Cada uno de esos


servicios tiene como objetivo diferentes casos de uso, así que echemos un
vistazo a ellos.

Servicios AWS para Implementar Notificaciones Push

Echemos un vistazo rápido a cada una de estas alternativas potenciales. Cada


una tiene ventajas y desventajas. Decidir cuál usar es una cuestión de compen-
saciones.

Una de las compensaciones que necesitamos considerar es la pregunta de


“¿Quiero optar por una solución sin servidor o me parece bien ejecutar una
instancia de un servicio que esté siempre activa?”. La respuesta a esa pregunta
tiene un impacto importante en nuestra estructura de costos y el modelo de
pago utilizado. Algunos servicios son pago por uso mientras que otros son pago
por hora.

Amazon Pinpoint

“Amazon Pinpoint es un servicio flexible y escalable de comunicaciones de


marketing salientes y entrantes.”

Aunque eso puede no sonar útil inmediatamente, Pinpoint de hecho ofrece


notificaciones push móviles como una de sus funciones.

Nuestra aplicación es una aplicación web, y esto nos impide usar Pinpoint
para nuestros propósitos. Además, Pinpoint tiene mucho más que ofrecer en
términos de comunicaciones de marketing y segmentación de usuarios, lo cual
es irrelevante para nuestro caso de uso en el mejor de los casos y añade una
13. Notificaciones Push con Amazon MQ 307

complejidad innecesaria y características que no necesitamos en el peor de los


casos.

Por estas razones, no vamos a usar Pinpoint.

Amazon IoT (Core)

“Amazon IoT Core te permite conectar dispositivos IoT a la nube de AWS sin
la necesidad de aprovisionar o administrar servidores.”

Amazon IoT es otro de esos servicios específicos de la industria cuyo conjunto de


funciones puede parecer a primera vista que no tiene nada que ver con nuestros
requerimientos.

Aunque Amazon IoT está claramente orientado al caso de uso de permitir que
los objetos físicos se comuniquen entre sí en el Internet de las Cosas, uno de los
protocolos utilizados para hacerlo es de hecho WebSocket.

WebSocket, a su vez, se aplica a nuestro caso de uso. En teoría, no hay nada


que nos impida usar Amazon IoT aunque no tengamos objetos físicos que se
comuniquen entre sí sino más bien clientes de navegador y las personas que
utilizan estos clientes.

Sin embargo, usar infraestructura diseñada para propósitos de IoT y la gran


escala que eso implica también parece un sobre-diseño para nuestro caso de
uso, que es bastante simple.

Por lo tanto, tampoco vamos a usar Amazon IoT.

Amazon SNS

Según la descripción propia de Amazon,


13. Notificaciones Push con Amazon MQ 308

“Amazon Simple Notification Service (Amazon SNS) es un servicio de men-


sajería totalmente administrado tanto para la comunicación de aplicación a
aplicación (A2A) como de aplicación a persona (A2P). Es un servicio adminis-
trado que proporciona entrega de mensajes de los editores a los suscriptores”.

Esto suena bastante a lo que necesitamos para nuestros propósitos, ¿verdad? De


hecho, lo hace. SNS permite a los editores enviar mensajes a temas, a los que a
su vez pueden escuchar los suscriptores. Sin embargo, hay un pequeño detalle
que nos impide utilizar SNS para nuestros propósitos: ¡No soporta WebSocket
como protocolo!

SNS solo nos permite enviar mensajes a estos tipos de puntos finales de suscrip-
tor:

• HTTP/HTTPS
• correo electrónico
• Amazon Kinesis Data Firehose
• Amazon SQS
• una función personalizada de AWS Lambda
• un punto final de aplicación de plataforma
• SMS

Por lo tanto, desafortunadamente, WebSocket no está soportado por SNS. Po-


dríamos, por supuesto, seguir utilizando SNS y proporcionar un punto final
HTTP en nuestra aplicación de tareas y retransmitir cualquier comunicación
a un punto final WebSocket que proporcionamos desde nuestra aplicación de
tareas.

No obstante, esto no solo aumentaría innecesariamente la complejidad de nues-


tra arquitectura sino que también anularía el propósito de usar un servicio como
13. Notificaciones Push con Amazon MQ 309

SNS en primer lugar: ¿Por qué usar un servicio si realmente no nos beneficiamos
de hacerlo?

Por lo tanto, SNS, lamentablemente, tampoco es una opción viable para nuestro
escenario.

Amazon SQS

¿Y qué hay de SQS? Después de todo, ya hemos estado usándolo para compartir
tareas. Entonces, ¿por qué no usarlo también para comunicar el estado de una
tarea?

Para reiterar el propósito de SQS:

“Amazon Simple Queue Service (SQS es un servicio de cola de mensajes


totalmente administrado que te permite desacoplar y escalar microservicios,
sistemas distribuidos y aplicaciones sin servidor”.

De nuevo, esto suena como algo que podría prestarse a nuestro caso. Como
hemos visto cuando implementamos nuestra función de compartir tareas, un
suscriptor puede escuchar una cola SQS y enviar un correo electrónico para cada
mensaje entrante.

Esto podría usarse para informar a los suscriptores sobre las actualizaciones
entrantes de sus tareas también.

Desafortunadamente, sin embargo, SQS no soporta WebSocket como protocolo.


Mientras que consumir mensajes SQS desde clientes de navegador es posible,
esto requiere usar sondeo largo a través de HTTP, lo cual hemos decidido en
contra para nuestros propósitos.
13. Notificaciones Push con Amazon MQ 310

Amazon MQ

Entonces, ¿nos hemos quedado sin opciones? Afortunadamente, no, no lo he-


mos hecho. Con Amazon MQ, aún queda una opción que podría ayudarnos con
nuestro escenario de publicación-suscripción:

“Amazon MQ es un servicio administrado de corredor de mensajes para


Apache ActiveMQ y RabbitMQ que hace fácil la configuración y operación de
corredores de mensajes en AWS.”

Amazon MQ es diferente de los servicios discutidos anteriormente en que no


proporciona una API sin servidor de alto nivel para casos de uso específicos, sino
una instancia de servicio tradicional, en la que podemos ejecutar nuestro propio
corredor de mensajes. La ventaja de este enfoque es que no estamos limitados
por las limitaciones de una API o plataforma en particular, sino que podemos
usar la gama completa de características que proporcionan esos corredores de
mensajes, especialmente cuando se trata del protocolo WebSocket, que tanto
ActiveMQ como RabbitMQ admiten. Una desventaja de esto es que la configura-
ción de la infraestructura necesaria es más complicada que con los servicios de
AWS mencionados anteriormente.

A diferencia de las otras opciones de publicación-suscripción proporcionadas


por AWS, Amazon MQ opera bajo un modelo de negocio de pago por hora,
lo que significa que generará costos incluso cuando no lo estemos utilizando
activamente.

Aunque los costos potencialmente más altos y la configuración de una infraes-


tructura más compleja pueden considerarse desventajas importantes, Amazon
MQ es el único servicio de los mencionados que admite de forma nativa el
protocolo WebSocket.
13. Notificaciones Push con Amazon MQ 311

Por lo tanto, es el único que cumple con nuestros requisitos específicos, por lo
que utilizaremos Amazon MQ para implementar nuestra función de “notifica-
ciones push”.

Configuración de un corredor de mensajes con CDK

Habiendo elegido Amazon MQ, la próxima decisión a tomar es entre las dos
opciones de corredor de mensajes disponibles: Apache ActiveMQ, o RabbitMQ.

Ambos vienen con un conjunto similar de características y un perfil de rendi-


miento similar aproximadamente. Por lo tanto, decidir cuál usar se reduce a los
requerimientos específicos de tu proyecto o ambiente en particular.

Sin embargo, ActiveMQ no solo admite de forma nativa los protocolos Web-
Socket y STOMP, sino también la API del Servicio de Mensajes de Java (JMS) y
los protocolos MQTT y AMQP. Por lo tanto, parece ser la solución más versátil,
especialmente en un entorno basado en Java. Si surge la necesidad de conectar
directamente el corredor de mensajes a nuestra aplicación Spring Boot a través
de JMS, podríamos hacerlo sin cambiar la tecnología de corredor de mensajes
que subyace.

Por estas razones, utilizaremos ActiveMQ para nuestra función de notificacio-


nes push.

Entonces, empecemos configurando la infraestructura necesaria de Amazon


MQ. Como de costumbre, crearemos un nuevo stack en nuestro proyecto CDK
- este se llama ActiveMqStack. Aquí está el esqueleto de ese stack:
13. Notificaciones Push con Amazon MQ 312

public class ActiveMqStack extends Stack {

// ...

public ActiveMqStack(
final Construct scope,
final String id,
final Environment awsEnvironment,
final ApplicationEnvironment applicationEnvironment,
final String username
) {

// ...

this.applicationEnvironment = applicationEnvironment;
this.username = username;
this.password = generatePassword();

// ...
}

// ...
}

Creando un Usuario ActiveMQ

Primero, necesitamos crear una lista de usuarios que necesitan tener acceso a
nuestra instancia de ActiveMQ:

List<User> userList = new ArrayList<>();


userList.add(new User(
username,
password
));

Posteriormente, introduciremos la userList (que en nuestro caso contiene


exactamente un usuario) a la construcción de ActiveMQ para crear credenciales
de acceso. Claro está, podríamos permitir el acceso a usuarios anónimos, pero
13. Notificaciones Push con Amazon MQ 313

esto no resulta ser una buena idea en términos de seguridad y uso de recursos ya
que potencialmente expondría nuestra cola de mensajes al mundo, haciéndola
vulnerable a ataques DDoS.

Como vamos a ubicar nuestra instancia de ActiveMQ en nuestro VPC privado, un


ataque DDoS es más teórico que un problema real en este caso. Aún así, es buena
práctica proteger nuestros recursos. Especialmente si esto no implica ningún
costo adicional como en este caso. Si, por ejemplo, por error ubicáramos nuestra
instancia de ActiveMQ en una subred pública, aún contaríamos con una capa de
protección adicional.

El username se introduce en el stack mediante su constructor, mientras que


la contraseña se genera con un PasswordGenerator con algunas reglas que
garantizan que nuestra contraseña generada consista de 32 caracteres con al
menos 5 letras minúsculas y 5 letras mayúsculas, así como 5 dígitos:

public class ActiveMqStack extends Stack {


// ...

private String generatePassword() {


PasswordGenerator passwordGenerator = new PasswordGenerator();
CharacterData lowerCaseChars = EnglishCharacterData.LowerCase;
CharacterRule lowerCaseRule = new CharacterRule(lowerCaseChars);
lowerCaseRule.setNumberOfCharacters(5);
CharacterData upperCaseChars = EnglishCharacterData.UpperCase;
CharacterRule upperCaseRule = new CharacterRule(upperCaseChars);
upperCaseRule.setNumberOfCharacters(5);
CharacterData digitChars = EnglishCharacterData.Digit;
CharacterRule digitRule = new CharacterRule(digitChars);
digitRule.setNumberOfCharacters(5);
return passwordGenerator
.generatePassword(32, lowerCaseRule, upperCaseRule, digitRule);
}

// ...
}
13. Notificaciones Push con Amazon MQ 314

Definiendo un Grupo de Seguridad

A continuación, creamos un Grupo de Seguridad específico para nuestro stack


de ActiveMQ utilizando el CfnSecurityGroup.Builder de CDK. A este Grupo
de Seguridad le proporcionamos el ID de nuestro VPC ya existente (recuperado
de un stack de Network previamente desplegado a través de la clase Networ-
kOutputParameters). Esto garantizará que nuestra instancia de ActiveMQ se
encuentre dentro del mismo VPC que nuestra aplicación principal:

CfnSecurityGroup amqSecurityGroup = CfnSecurityGroup.Builder.create(


this,
"amqSecurityGroup")
.vpcId(networkOutputParameters.getVpcId())
.groupDescription("Security Group for the Amazon MQ instance")
.groupName(applicationEnvironment.prefix("amqSecurityGroup"))
.build();

Creando el Message Broker

Recopilamos los parámetros que hemos creado hasta ahora y los proporciona-
mos como parámetros de entrada a la clase CfnBroker.Builder de CDK para
crear nuestro ActiveMQ broker:
13. Notificaciones Push con Amazon MQ 315

CfnBroker broker = CfnBroker.Builder


.create(this, "amqBroker")
.brokerName(applicationEnvironment.prefix("stratospheric-amq-message-broker"))
.securityGroups(Collections.singletonList(this.securityGroupId))
.subnetIds(Collections.singletonList(
networkOutputParameters.getIsolatedSubnets().get(0)))
.hostInstanceType("mq.t2.micro")
.engineType("ACTIVEMQ")
.engineVersion("5.16.2")
.authenticationStrategy("SIMPLE")
.encryptionOptions(
CfnBroker.EncryptionOptionsProperty
.builder()
.useAwsOwnedKey(true)
.build()
)
.users(userList)
.publiclyAccessible(false)
.autoMinorVersionUpgrade(true)
.deploymentMode("SINGLE_INSTANCE")
.build();

Dado que por ahora queremos ahorrar costos, elegimos el tipo de instancia más
pequeño y económico posible aquí con mq.t2.micro.

Al haber optado por utilizar ActiveMQ, definimos ACTIVEMQ como nuestro tipo
de motor de ejecución. Utilizaremos 5.16.2 como la versión del motor ActiveMQ,
que hasta el momento de escritura, es la última versión soportada por Amazon
MQ.

Además, optamos por la estrategia de autenticación por defecto SIMPLE (basada


en contraseña) y proporcionamos la userList mencionada anteriormente como
la lista de inicios de sesión válidos. Otra opción habría sido utilizar LDAP.
Sin embargo, como esto agregaría una complejidad significativa, decidimos no
optar por esa opción.

También nos aseguramos de que nuestro ActiveMQ no sea publiclyAccessi-


ble. Por otro lado, Amazon MQ se encargará automáticamente de las actualiza-
13. Notificaciones Push con Amazon MQ 316

ciones de las versiones menores ya que hemos establecido autoMinorVersio-


nUpgrade a true.

Establecemos el modo de despliegue del broker en SINGLE_INSTANCE. Esta


es la configuración más simple posible, con solo una instancia de ActiveMQ
funcionando para toda nuestra aplicación. Otras opciones habrían sido:

• ACTIVE_STANDBY_MULTI_AZ: Un par de brokers, activo e inactivo, en múlti-


ples zonas de disponibilidad. Esto proporciona alta disponibilidad y capaci-
dades de fallo automático.
• CLUSTER_MULTI_AZ: Un cluster completo de brokers en múltiples zonas de
disponibilidad.

Aquí estamos utilizando solo un pequeño conjunto de opciones disponibles


con Amazon MQ. Consulte la guía oficial “Cómo funciona Amazon MQ” y la
documentación de referencia de la API de CloudFormation para obtener una lista
completa de las opciones disponibles al configurar un broker de mensajes con
Amazon MQ.

Exportación de Parámetros de Salida

Después de esto, exportamos algunos parámetros de salida, los cuales pasare-


mos más tarde a nuestro stack de servicios para configurar nuestra aplicación
Spring Boot para conectar con la instancia de ActiveMQ:
13. Notificaciones Push con Amazon MQ 317

StringParameter.Builder.create(this, PARAMETER_USERNAME)
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_USERNAME))
.stringValue(username)
.build();

StringParameter.Builder.create(this, PARAMETER_PASSWORD)
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_PASSWORD))
.stringValue(password)
.build();

StringParameter.Builder.create(this, PARAMETER_STOMP_ENDPOINT)
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_STOMP_ENDPOINT))
.stringValue(Fn.select(0, this.broker.getAttrStompEndpoints()))
.build();

StringParameter.Builder.create(this, PARAMETER_SECURITY_GROUP_ID)
.parameterName(createParameterName(
applicationEnvironment,
PARAMETER_SECURITY_GROUP_ID))
.stringValue(this.securityGroupId)
.build();

Usaremos el nombre de usuario y la contraseña más adelante para conectar al


broker desde el backend de la aplicación Spring Boot, mientras que usaremos
la URL del endpoint STOMP para conectar desde nuestra interfaz de usuario
Thymeleaf a través de JavaScript y WebSocket.

Una cosa a tener en cuenta aquí, en particular, es el ID del grupo de seguridad,


que usaremos para referirnos al grupo de seguridad de nuestro broker al permi-
tir el acceso de entrada desde nuestra aplicación.
13. Notificaciones Push con Amazon MQ 318

La aplicación ActiveMq CDK

Lo único que queda por hacer es crear una instancia de nuestro ActiveMqStack
y hacer que CDK sintetice una pila de CloudFormation a partir de esa pila:

public class ActiveMqApp {

public static void main(final String[] args) {


App app = new App();

// ...

new ActiveMqStack(
app,
"activeMq",
awsEnvironment,
applicationEnvironment,
username);

app.synth();
}

// ...
}

Conectando la Aplicación a ActiveMQ

Para configurar nuestra aplicación para acceder al broker, tenemos que modi-
ficar un poco nuestro ServiceApp. Primero, necesitamos proporcionar acceso
a nivel de red desde el contenedor administrado por ECS al broker. Para esto,
añadimos el grupo de seguridad del broker a la lista de grupos desde los cuales
se permite el ingreso:
13. Notificaciones Push con Amazon MQ 319

public class ServiceApp {

public static void main(final String[] args) {


// ...

List<String> securityGroupIdsToGrantIngressFromEcs = Arrays.asList(


databaseOutputParameters.getDatabaseSecurityGroupId(),
activeMqOutputParameters.getActiveMqSecurityGroupId()
);

// pass the list of security groups into the Service construct


}
}

Nuestra construcción Service utiliza esta lista internamente para establecer


las reglas de ingreso necesarias.

Además, añadimos algunos de los parámetros de salida del ActiveMqStack


como variables de entorno:

public class ServiceApp {

// ...

static Map<String, String> environmentVariables(


Construct scope,
PostgresDatabase.DatabaseOutputParameters databaseOutputParameters,
CognitoStack.CognitoOutputParameters cognitoOutputParameters,
MessagingStack.MessagingOutputParameters messagingOutputParameters,
ActiveMqStack.ActiveMqOutputParameters activeMqOutputParameters,
String springProfile
) {

Map<String, String> vars = new HashMap<>();

// ...

vars.put(
"WEB_SOCKET_RELAY_ENDPOINT",
activeMqOutputParameters.getStompEndpoint());
vars.put(
13. Notificaciones Push con Amazon MQ 320

"WEB_SOCKET_RELAY_USERNAME",
activeMqOutputParameters.getActiveMqUsername());
vars.put(
"WEB_SOCKET_RELAY_PASSWORD",
activeMqOutputParameters.getActiveMqPassword());

// ...
}
}

De esta manera, la aplicación Spring Boot puede leer esas variables de entorno
y conectarse al broker de mensajes.

Implementando Notificaciones Push en la Aplicación Todo

A continuación, finalmente implementaremos la función de notificaciones


push en la infraestructura de mensajes que hemos creado.

Primero, sin embargo, daremos un vistazo rápido a los protocolos que vamos a
utilizar para implementar esta función.

Protocolos: WebSocket y STOMP

WebSocket es un protocolo que - a diferencia de HTTP - permite una comunica-


ción full-duplex, es decir, las partes pueden comunicarse entre sí simultánea-
mente con ambas partes capaces de iniciar un flujo de comunicación. Antes de
la irrupción de WebSocket, si queríamos mostrar actualizaciones en un cliente
web, debíamos iniciar una petición de cliente y esperar la respuesta del servidor.
Dependiendo del número de actualizaciones, teníamos que recurrir a métodos
de sondeo frecuentes o a enfoques de sondeo prolongado, como Comet. Ambos
métodos resultaban deficientes, tanto en términos de rendimiento de la red
como de latencia.
13. Notificaciones Push con Amazon MQ 321

Para mitigar estos problemas, WebSocket proporciona una conexión siempre


activa entre el cliente y el servidor. El servidor puede enviar mensajes al cliente
por su cuenta sin ninguna solicitud explícita del cliente. Lo mismo es cierto a la
inversa.

Una conexión WebSocket es iniciada por el cliente a través de HTTP. Si el servi-


dor es capaz de comunicarse vía WebSocket, esa conexión HTTP se transforma
en una conexión WebSocket utilizando un encabezado de actualización HTTP.

Una vez que se ha establecido una conexión WebSocket, el servidor puede enviar
actualizaciones al cliente siempre que ocurran. Este modelo ha demostrado ser
mucho más eficiente que los modelos anteriores de sondeo y sondeo prolonga-
do.

STOMP (Simple Text Oriented Messaging Protocol) es un protocolo de mensa-


jería que proporciona semántica de mensajería con acciones como SUBSCRIBE,
UNSUBSCRIBE, o SEND. Aunque WebSocket nos permite establecer conexiones
full-duplex, es agnóstico en cuanto a cómo se transmiten los datos a través
de estas conexiones. Para comunicarse utilizando un patrón de publicación-
suscripción como el descrito anteriormente, necesitamos definir un protocolo,
comandos y formatos de carga útil. STOMP nos proporciona un conjunto simple
de comandos, encabezados y tramas de red, por lo que no tenemos que crear
algo desde cero.

Reenvío de Conexiones WebSocket

Como se mencionó anteriormente, ActiveMQ admite de forma nativa los proto-


colos WebSocket y STOMP. Sin embargo, para no exponer nuestro ActiveMQ a la
internet pública y para controlar el flujo de mensajes hasta cierto punto, usare-
mos nuestra aplicación Spring Boot como un reenvío entre nuestra interfaz de
usuario HTML y ActiveMQ.
13. Notificaciones Push con Amazon MQ 322

Para implementar este reenvío, primero debemos agregar estas dependencias a


la configuración Gradle build.gradle de nuestra aplicación:

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-activemq'

Estas librerías nos proporcionarán lo necesario para conectarnos a un servidor


ActiveMQ y exponer la conexión WebSocket a nuestros usuarios.

A continuación, creamos una nueva configuración de Spring llamada WebSoc-


ketConfig:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final Endpoint websocketEndpoint;


private final String websocketUsername;
private final String websocketPassword;
private final boolean websocketUseSsl;

public WebSocketConfig(
@Value("${custom.web-socket-relay-endpoint:#{null}}")
String websocketRelayEndpoint,
@Value("${custom.web-socket-relay-username:#{null}}")
String websocketUsername,
@Value("${custom.web-socket-relay-password:#{null}}")
String websocketPassword,
@Value("${custom.web-socket-relay-use-ssl:#{false}}")
boolean websocketUseSsl
) {
this.websocketEndpoint = Endpoint.fromEndpointString(websocketRelayEndpoint);
this.websocketUsername = websocketUsername;
this.websocketPassword = websocketPassword;
this.websocketUseSsl = websocketUseSsl;
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry
13. Notificaciones Push con Amazon MQ 323

.enableStompBrokerRelay("/topic")
.setAutoStartup(true)
.setClientLogin(this.websocketUsername)
.setClientPasscode(this.websocketPassword)
.setSystemLogin(this.websocketUsername)
.setSystemPasscode(this.websocketPassword);
}

// ...

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/websocket")
.withSockJS();
}

// ...
}

Esta configuración utiliza la anotación @EnableWebSocketMessageBroker para


habilitar la comunicación WebSocket. También implementa la interfaz Web-
SocketMessageBrokerConfigurer, la cual nos proporciona los métodos con-
figureMessageBroker() y registerStompEndpoints(). Estos métodos nos
permiten configurar la conexión a nuestro broker de mensajes ActiveMQ:

En nuestro método registerStompEndpoints() añadimos un endpoint /web-


socket con soporte para SockJS habilitado. SockJS es una biblioteca para la
conectividad y emulación WebSocket que sirve como opción de respaldo cuando
el soporte WebSocket no está disponible en el cliente o el servidor.

Los parámetros del constructor websocketRelayEndpoint, websocketUserna-


me y websocketPassword se toman de los parámetros de salida de ActiveMqS-
tack que hemos puesto a disposición de la aplicación a través de variables
de entorno. Estas variables de entorno son detectadas por Spring a través de
nuestro archivo de configuración application-aws.yml:
13. Notificaciones Push con Amazon MQ 324

custom:
# ...
web-socket-relay-endpoint: ${WEB_SOCKET_RELAY_ENDPOINT}
web-socket-relay-username: ${WEB_SOCKET_RELAY_USERNAME}
web-socket-relay-password: ${WEB_SOCKET_RELAY_PASSWORD}

Finalmente, debemos asegurarnos de que las conexiones WebSocket a nuestro


relevo no requieran que el usuario de nuestra aplicación Todo de muestra inicie
sesión antes. Con este objetivo, agregamos un “path matcher” /websocket/**
a la llamada ignoringRequestMatchers en nuestra WebSecurityConfig:

@Configuration
public class WebSecurityConfig {

// ...

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity)
throws Exception {

httpSecurity
.csrf()
.ignoringRequestMatchers(
"/stratospheric-todo-updates/**",
"/websocket/**"
)
.and()
// ...

return httpSecurity.build();
}
}

Al arrancar, nuestra aplicación de Spring Boot ahora abrirá una conexión a


nuestra instancia de ActiveMQ y funcionará como un relevo WebSocket bajo el
prefijo de la ruta /topic.
13. Notificaciones Push con Amazon MQ 325

Conectándose a un Servidor STOMP

Idealmente, eso habría sido todo lo que teníamos que hacer en el lado del ser-
vidor para proporcionar funcionalidad WebSocket y STOMP a través de nuestra
aplicación Spring Boot.

Desafortunadamente, la implementación del cliente TCP por defecto utilizada


por Spring Reactor, y viceversa Netty, carece de dos características esenciales:
soporte SSL y un resolutor DNS funcional (vea el problema correspondiente en
GitHub).

Dado que las instancias de Amazon MQ solo están disponibles -y con toda razón-
mediante conexiones encriptadas con SSL y nombres de dominio en lugar de di-
recciones IP (lo cual es prácticamente un requisito para usar SSL), tenemos que
recurrir a la creación de nuestro cliente TCP basado en el ReactorNettyTcpClient
de Reactor:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

// ...

private ReactorNettyTcpClient<byte[]> getCustomTcpClientWithSSLSupport() {


return new ReactorNettyTcpClient<>(configurer -> configurer
.host(this.websocketEndpoint.host)
.port(this.websocketEndpoint.port)
.resolver(DefaultAddressResolverGroup.INSTANCE)
.secure(), new StompReactorNettyCodec());
}

private ReactorNettyTcpClient<byte[]> getCustomTcpClientWithoutSSLSupport() {


// similar but not SSL support to connect to a local ActiveMQ
}

// ...
}
13. Notificaciones Push con Amazon MQ 326

La llamada al método del constructor secure() habilita la conectividad SSL


dependiendo del valor de la variable websocketUseSsl, que está controlada
por la propiedad web-socket-relay-use-ssl en el archivo de configuración
application.yml. Llamar al resolver(...) nos proporciona el resolutor DNS
operativo que nos permite resolver los nombres de dominio en nuestra VPC.

Después, este cliente TCP personalizado es utilizado al llamar a


setTcpClient(tcpClient) cuando registramos nuestro STOMP relay:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

// ...

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
ReactorNettyTcpClient<byte[]> customTcpClient =
this.websocketUseSsl ?
getCustomTcpClientWithSSLSupport() :
getCustomTcpClientWithoutSSLSupport();

registry
.enableStompBrokerRelay("/topic")
//...
.setTcpClient(customTcpClient);
}
}

Aunque solo estamos ejecutando una instancia de ActiveMQ en vez de un


conjunto de instancias activas y en espera, nuestro cliente TCP personalizado
también podría soportar varios hosts de forma consecutiva:
13. Notificaciones Push con Amazon MQ 327

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

// ...

private ReactorNettyTcpClient<byte[]>
createRoundRobinTcpClient(Endpoint endpoint) {
final List<InetSocketAddress> addressList = new ArrayList<>();

for (String hostURI : endpoint.activeStandbyHosts) {


String[] hostAndPort = hostURI.split(":");
addressList.add(
new InetSocketAddress(hostAndPort[0], Integer.parseInt(hostAndPort[1])));
}

final RoundRobinList<InetSocketAddress> addresses =


new RoundRobinList<>(addressList);

return new ReactorNettyTcpClient<>(builder ->


builder
.remoteAddress(addresses::get)
.secure()
.resolver(DefaultAddressResolverGroup.INSTANCE),
new StompReactorNettyCodec()
);
}

// ...

Un último aspecto a destacar en relación con nuestro WebSocketConfig es


cómo procesamos nuestra URL del punto final de ActiveMQ al llamar a End-
point.fromEndpointString(). Este método estático, a su vez, elimina la parte
del protocolo “stomp+ssl://” que se devuelve con las URLs de instancia de Ama-
zon MQ (replace("stomp+ssl://", "")). Esto es necesario debido a que, en
lugar de una URL completa que incluye el protocolo, el ReactorNettyTcpClient
espera únicamente el host y el puerto como parámetros.
13. Notificaciones Push con Amazon MQ 328

Agregar Notificaciones Push a la Interfaz

Ahora que hemos establecido la conectividad WebSocket en el lado del servidor,


podemos comenzar con lo más emocionante y finalmente implementar nuestra
característica de notificaciones push en el lado del cliente.

En primer lugar, incluimos estas dependencias de WebJars en la configuración


de Gradle que se encuentra en build.gradle:

implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'

Estas bibliotecas de JavaScript nos proporcionan el cliente SockJS WebSocket


(un cliente para la comunicación entre navegadores y servidores mediante
WebSockets) y soporte STOMP (Simple Text Oriented Messaging Protocol) en
el lado del cliente.

Luego, dado que usaremos JavaScript para conectarnos a nuestro intermedia-


rio WebSocket, agregamos /js/todo-updates.js a nuestro diseño Thymeleaf
layout.html para cargar el archivo JavaScript responsable de intercambiar men-
sajes con nuestro servidor WebSocket:

<!-- ... --->

<head>
<meta charset="UTF-8">
<title layout:title-pattern="$CONTENT_TITLE | $LAYOUT_TITLE">
Todo Application
</title>
<!-- ... --->
<script th:src="@{/js/todo-updates.js}" sec:authorize="isAuthenticated()"></script>
</head>
<body>
<div>

<!-- ... --->


13. Notificaciones Push con Amazon MQ 329

<script sec:authorize="isAuthenticated()">
connectToWebSocketEndpoint('[[${#authentication.principal.attributes.email}]]');
</script>
</div>
</body>
</html>

En caso de que el usuario haya iniciado sesión (ya que en nuestro caso de
uso solo tiene sentido que los usuarios autenticados reciban mensajes sobre el
progreso de sus tareas pendientes), llamamos a la función connectToWebSoc-
ketEndpoint() de ese archivo JavaScript con la dirección de correo electrónico
del usuario autenticado como argumento:

<script sec:authorize="isAuthenticated()">
connectToWebSocketEndpoint('[[${#authentication.principal.attributes.email}]]');
</script>

La dirección de correo electrónico aquí sirve como un identificador, así que


podemos saber a qué usuario mostrar qué mensajes.

La función connectToWebSocketEndpoint() de todo-updates.js crea un cliente


STOMP usando la biblioteca SockJS:

let stompClient = null;

function connectToWebSocketEndpoint(email) {
const socket = new SockJS('/websocket');

stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe('/topic/todoUpdates', function (message) {
$('#message').html(message.body);
$('#toast').toast('show');
});

if (email) {
stompClient.subscribe('/topic/todoUpdates/' + email, function (message) {
13. Notificaciones Push con Amazon MQ 330

$('#message').html(message.body);
$('#toast').toast('show');
});
}
});
}

function disconnectFromWebSocketEndpoint() {
if (stompClient !== null) {
stompClient.disconnect();
}
}

$(document).ready(function () {
$('#toast').toast({delay: 5000});
});

El cliente se suscribe tanto a un canal para actualizaciones generales como a


un canal específico para actualizaciones relevantes para el usuario autenticado
(identificado por el correo electrónico de ese usuario). Siempre que haya un
nuevo mensaje en cualquiera de estos canales, se mostrará una notificación tipo
toast durante 5 segundos.

Si el navegador no admite WebSocket, SockJS cambiará sin problemas a


HTTP. Sin embargo, estos días esta es una preocupación en gran medida
teórica, ya que prácticamente todos los navegadores modernos ahora
admiten WebSocket.

Al abrir la aplicación Todo de muestra en nuestro navegador y después de haber


iniciado sesión, nuestra consola de navegador debería mostrar mensajes de
registro similares a estos, lo que significa que la conexión a nuestro servidor
intermedio a través de WebSocket ha sido exitosa y ahora estamos suscritos a
los canales de actualización:
13. Notificaciones Push con Amazon MQ 331

Opening Web Socket...


stomp.min.js:8 Web Socket Opened...
stomp.min.js:8 >>> CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000

stomp.min.js:8 <<< CONNECTED


server:ActiveMQ/5.16.2
heart-beat:10000,10000
session:ID:b-6df78131-e12f-4ffd-94ab-1.mq.eu-central-1.amazonaws.com-39383-3:547
version:1.1
user-name:4a275ce6-c775-4651-8da7-f2a7f60fc667

stomp.min.js:8 connected to server ActiveMQ/5.16.2


stomp.min.js:8 send PING every 10000ms
stomp.min.js:8 check PONG every 10000ms
todo-updates.js:8 Connection to WebSocket endpoint was successful: CONNECTED
user-name:4a275ce6-c775-4651-8da7-f2a7f60fc667
version:1.1
session:ID:b-6df78131-e12f-4ffd-94ab-1.mq.eu-central-1.amazonaws.com-39383-3:547
heart-beat:10000,10000
server:ActiveMQ/5.16.2

stomp.min.js:8 >>> SUBSCRIBE


id:sub-0
destination:/topic/todoUpdates

stomp.min.js:8 >>> SUBSCRIBE


id:sub-1
destination:/topic/todoUpdates/test@example.com

El mensaje de tipo toast se encuentra definido en el fragmento toast.html,


ubicado en la carpeta src/main/resources/templates/fragments:
13. Notificaciones Push con Amazon MQ 332

<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<div th:remove="tag" th:fragment="toast">
<div
id="toast"
class="toast"
role="alert"
aria-live="assertive"
aria-atomic="true">
<div class="toast-header">
<strong class="mr-auto">Collaboration confirmed.</strong>
<button
type="button"
class="ml-2 mb-1 close"
data-dismiss="toast"
aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div id="message" class="toast-body">
Message
</div>
</div>
</div>
</body>
</html>

Este fragmento, a su vez, es cargado al agregar esta etiqueta a nuestra maque-


tación de Thymeleaf:

<!-- ... --->


<div th:replace="fragments/toast :: toast"></div>
<!-- ... --->

Ahora, cada vez que se llama al método confirmCollaboration() de nuestro


TodoCollaborationService, enviaremos un nuevo mensaje al propietario del
‘todo’ usando SimpMessagingTemplate:
13. Notificaciones Push con Amazon MQ 333

@Service
@Transactional
public class TodoCollaborationService {

// ...

public TodoCollaborationService(
@Value("${custom.sharing-queue}") String todoSharingQueueName,
TodoRepository todoRepository,
PersonRepository personRepository,
TodoCollaborationRequestRepository todoCollaborationRequestRepository,
SqsTemplate sqsTemplate,
SimpMessagingTemplate simpMessagingTemplate) {
this.todoRepository = todoRepository;
this.personRepository = personRepository;
this.todoCollaborationRequestRepository = todoCollaborationRequestRepository;
this.sqsTemplate sqsTemplate;
this.todoSharingQueueName = todoSharingQueueName;
this.simpMessagingTemplate = simpMessagingTemplate;
}

// ...

public boolean confirmCollaboration(


Long todoId, Long collaboratorId, String token) {

TodoCollaborationRequest collaborationRequest =
collaborationRequestRepository
.findByTodoIdAndCollaboratorId(todoId, collaboratorId);

if (collaborationRequest != null
&& collaborationRequest.getToken().equals(token)) {
// existing logic for confirming the collaboration
// ...

String name = collaborationRequest.getCollaborator().getName();


String subject = "Collaboration confirmed.";
String message = "User "
+ name
+ " has accepted your collaboration request for todo #"
+ collaborationRequest.getTodo().getId()
+ ".";
String ownerEmail = collaborationRequest.getTodo().getOwner().getEmail();
13. Notificaciones Push con Amazon MQ 334

simpMessagingTemplate.convertAndSend(
"/topic/todoUpdates/" + ownerEmail, subject + " " + message);

return true;
}

return false;
}
}

Un componente de tipo SimpMessagingTemplate está disponible en el contexto


de la aplicación a través de la dependencia a Spring Messaging, que fue cargado
de manera transitiva por las dependencias spring-boot-starter-websocket y
spring-boot-starter-activemq. Utilizaremos ese ejemplar para enviar men-
sajes a los suscriptores al canal.

Cuando un usuario que recibió una solicitud de colaboración por correo electró-
nico confirma esa solicitud haciendo clic en el enlace de ese correo electrónico,
el método confirmCollaboration() de nuestro TodoCollaborationService
será invocado desde el TodoCollaborationController:

@Controller
@RequestMapping("/todo")
public class TodoCollaborationController {

private final TodoCollaborationService todoCollaborationService;

public TodoCollaborationController(
TodoCollaborationService todoCollaborationService) {
this.todoCollaborationService = todoCollaborationService;
}

// ...

@GetMapping("/{todoId}/collaborations/{collaboratorId}/confirm")
public String confirmCollaboration(
@PathVariable("todoId") Long todoId,
13. Notificaciones Push con Amazon MQ 335

@PathVariable("collaboratorId") Long collaboratorId,


@RequestParam("token") String token,
RedirectAttributes redirectAttributes
) {
if (todoCollaborationService.confirmCollaboration(
todoId,
collaboratorId,
token)) {
redirectAttributes.addFlashAttribute(
"message",
"You've confirmed that you'd like to collaborate on this todo.");
redirectAttributes.addFlashAttribute(
"messageType",
"success");
} else {
redirectAttributes.addFlashAttribute(
"message",
"Invalid collaboration request.");
redirectAttributes.addFlashAttribute(
"messageType",
"danger");
}

return "redirect:/dashboard";
}
}

Finalmente, mostraremos un mensaje toast como el que se encuentra en la


esquina superior derecha de esta captura de pantalla al propietario de la tarea
cuando este mensaje se reciba del canal /topic/todoUpdates del propietario:

Toast message
13. Notificaciones Push con Amazon MQ 336

Activando el Desarrollo Local

Para un proceso de desarrollo fluido, aún falta un pequeño detalle: una instancia
de ActiveMQ funcionando localmente, de modo que no tengamos que conectar-
nos a una instancia remota, lo cual sería complicado y propenso a errores.

Por lo tanto, utilizando el mismo método que en el capítulo Conectándose a una


Base de Datos con RDS, correremos una instancia local de ActiveMQ con Docker.

Para conseguir esto, agregaremos una sección activemq a nuestro archivo


docker-compose.yml ya existente que se encuentra en el directorio raíz de
nuestra aplicación:

version: '3.3'

services:
# ...
activemq:
image: stratospheric/activemq-docker-image
ports:
- 5672:5672
- 61613:61613
- 61614:61614
- 61616:61616
# ...

Ahora, cuando se ejecuta docker-compose up en ese directorio, se iniciará una


instancia de ActiveMQ además de otros servicios (como Keycloak y PostgreSQL),
que ya estaban presentes anteriormente. Aunque se mapean múltiples puertos
aquí (5672, 61613, 61614, 61616), para nuestros propósitos solo utilizaremos
el puerto 61613 para conectarnos a la instancia local de ActiveMQ a través de
WebSocket.

Para apuntar nuestra aplicación local a la instancia local de ActiveMQ, configu-


ramos el endpoint en el archivo de configuración application-dev.yml:
13. Notificaciones Push con Amazon MQ 337

custom:
# ...
web-socket-relay-endpoint: localhost:61613
web-socket-relay-username: admin
web-socket-relay-password: admin
web-socket-relay-use-ssl: false

Dado que estamos ejecutando localmente, deshabilitamos el soporte SSL del


TcpClient subyacente configurando web-socket-relay-use-ssl en false.
Para obtener la conexión, utilizamos el usuario admin de ActiveMQ por defecto.

El código fuente para la imagen Docker de ActiveMQ stratospheric/activemq-


docker-image utilizada en el archivo docker-compose.yml está disponible en
GitHub.
14. Rastreando las Acciones del Usuario
con Amazon DynamoDB
En el capítulo Conectándose a una base de datos con RDS, hemos utilizado un
sistema de gestión de bases de datos relacionales (RDBMS) para almacenar datos en
nuestra aplicación de ejemplo Todo.

Como mencionamos anteriormente, las bases de datos relacionales no van a


desaparecer y aún pueden considerarse la piedra angular de la gestión de datos
cuando se trata de aplicaciones web.

Sin embargo, con el auge de las aplicaciones Web 2.0 y un renovado interés en las
bases de datos no relacionales desde alrededor de 2010, en estos días las bases
de datos relacionales a menudo van acompañadas y complementadas con las
llamadas bases de datos NoSQL.

El término (originalmente acuñado por Carlo Strozzi) fue reintroducido en 2009,


cuando Johan Oskarsson, quien era un desarrollador de software en Last.fm
en ese momento, organizó un evento sobre “bases de datos no relacionales
distribuidas de código abierto”.

Aunque a primera vista el término parece abogar contra el uso de bases de datos
SQL, simplemente se refiere a bases de datos “no SQL” o “no relacionales” que
a menudo se usan en conjunto con bases de datos relacionales más tradicionales
para satisfacer necesidades específicas de gestión de datos.

Las bases de datos NoSQL vienen en una variedad de sabores, tales como:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 339

• tiendas de clave-valor (por ejemplo, Amazon DynamoDB, Redis o Memca-


cheDB)
• tiendas de columnas anchas o bases de datos orientadas a columnas (por
ejemplo, Apache Cassandra)
• tiendas de documentos o bases de datos de tipo documento (por ejemplo,
Amazon DynamoDB, MongoDB o CouchDB)
• bases de datos de gráficos (por ejemplo, Neo4j)

Estos son bastante diferentes entre sí, tanto en términos de los casos de uso
que soportan como en lo que respecta a su implementación. Casi lo único que
tienen en común es que no utilizan SQL como lenguaje de consulta o conceptos
relacionales para gestionar y normalizar datos.

Dicho esto, hay algunas similitudes entre diferentes categorías de bases de


datos NoSQL. En particular, las bases de datos NoSQL se utilizan comúnmente
para datos relativamente no estructurados o para datos donde la estructura
exacta no se conoce de antemano. Un buen ejemplo de esto son las tiendas de
documentos, que están construidas alrededor de la premisa de colecciones de
datos complejas, anidadas y poco estructuradas, en lugar de datos tabulares con
relaciones estrictamente definidas entre diferentes tablas y entidades.

A continuación, daremos un vistazo más detallado a Amazon DynamoDB, un


servicio de base de datos NoSQL proporcionado por AWS. DynamoDB puede
utilizarse tanto como una tienda de clave-valor como una base de datos de
tipo documento. Es particularmente adecuado para casos de uso que requieren
un gran número y alta frecuencia de eventos almacenados en una base de
datos. DynamoDB es un servicio de base de datos escalable que, según AWS,
proporciona un “rendimiento en milisegundos de un solo dígito”.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 340

Caso de Uso: Rastreo de Acciones del Usuario

Los procesos que generan una gran cantidad de eventos a alta frecuencia son
casos de uso ideales para DynamoDB. Un caso de uso en el contexto de nuestra
aplicación de ejemplo Todo sería rastrear el recorrido del usuario a través de
nuestra aplicación, por ejemplo, para generar datos para optimizar la experien-
cia del usuario. Al saber qué enlaces se clickean más frecuentemente y qué ca-
racterísticas se utilizan más a menudo, podemos tomar decisiones informadas
sobre qué partes de nuestra aplicación optimizar en lugar de hacer suposiciones
sobre qué áreas de nuestra aplicación merecen primero nuestra atención.

Utilizando Eventos de Spring, generaremos estos datos emitiendo eventos cada


vez que un usuario ejecute una acción relevante en nuestra aplicación. Utiliza-
remos un @EventListener para almacenar estos datos en una tabla DynamoDB
mapeada a una entidad en nuestra aplicación.

Hay una advertencia importante con respecto a esta característica, sin embargo:
Dependiendo de la información personal identificable que almacene sobre los
usuarios individuales (por ejemplo, las direcciones IP de los usuarios) esto
podría violar su privacidad y leyes y regulaciones como la Regulación General
de Protección de Datos (GDPR). Por lo tanto, tanto por el bien de sus usuarios
como por el suyo propio, es posible que desee tener precaución al aplicar efecti-
vamente una función de rastreo del usuario en una aplicación de producción.

Con esa advertencia fuera del camino, comencemos con la implementación de


nuestra función de rastreo del usuario.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 341

Amazon RDS vs. Amazon DynamoDB

Repasemos por qué estamos usando DynamoDB en primer lugar en lugar de


nuestra base de datos relacional existente que se ejecuta en Amazon RDS.
Después de todo, agregar otro componente y servicio a la mezcla introduce una
complejidad adicional a nuestra arquitectura. Entonces, si lo estamos haciendo,
mejor que valga la pena.

Para reflexionar sobre por qué vamos a usar una base de datos NoSQL, demos un
paso atrás y consideremos qué características esperamos que tengan nuestras
bases de datos. Hay dos conceptos clave (y acrónimos útiles derivados de estos)
en ese sentido: El teorema CAP y ACID.

Teorema CAP

El teorema CAP (o “teorema de Brewer” en honor al científico informático Eric


Brewer) establece que un sistema distribuido solo puede garantizar dos de estas
tres condiciones simultáneamente:

• Consistencia: Cada nodo en un sistema distribuido responde con los datos


más recientes para una solicitud específica. Si una actualización está en
progreso, el sistema bloqueará la solicitud hasta que la actualización haya
terminado.
• Disponibilidad: Cada solicitud recibe una respuesta, incluso si esa solicitud
contiene datos desactualizados.
• Tolerancia al particionamiento: El sistema continúa operando incluso si
uno o más nodos fallan o se han perdido mensajes.

Mientras que los RDBMS normalmente garantizan el cumplimiento de las es-


tipulaciones de consistencia y disponibilidad, los sistemas de bases de datos
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 342

NoSQL generalmente proporcionan las dos últimas. No existe ningún almacén


de datos distribuido que pueda prometer mantener las tres garantías:

Consistencia, disponibilidad y tolerancia al particionamiento de las bases de datos RDBMS y


NoSQL (Teorema CAP).
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 343

ACID

Así, mientras los RDBMS se centran en que los datos sean consistentes y estén
disponibles, los sistemas de bases de datos NoSQL enfatizan la disponibilidad
de datos y la tolerancia a fallos.

Esto tiene repercusiones en otro conjunto de restricciones que a menudo se


espera que los sistemas de bases de datos satisfagan. Estas restricciones, co-
múnmente abreviadas como ACID, son:

• Atomicidad: Cada transacción se trata como una sola unidad. O tiene éxito
en su totalidad o no tiene éxito en absoluto.
• Consistencia: Cada transacción lleva una base de datos de un estado válido
a otro.
• Aislamiento: Las transacciones concurrentes producen el mismo resultado
global como si se hubieran ejecutado en secuencia.
• Durabilidad: Una vez que se ha comprometido una transacción, se man-
tendrá el estado resultante de ella incluso después de un fallo posterior del
sistema.

Estas propiedades se centran en las transacciones como su principal premisa.


No toman en cuenta explícitamente los comportamientos del sistema distribui-
do.

Por lo tanto, debido a que los sistemas de bases de datos NoSQL solo garantizan
la disponibilidad de datos y la tolerancia a fallos, renuncian al estricto requisito
de consistencia de ACID a cambio de disponibilidad, rendimiento y escalabili-
dad.

Aunque con los sistemas de bases de datos NoSQL las transacciones suelen ser
aún atómicas, duraderas y, hasta cierto punto y dependiendo de la implemen-
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 344

tación exacta, aisladas, estos sistemas generalmente no prometen consistencia


sino una propiedad llamada consistencia eventual.

Esta característica a veces se resume con el acrónimo BASE, que se deriva del
opuesto químico en el mundo real de un ácido. Este acrónimo a su vez representa
estas propiedades de un sistema de bases de datos:

• Básicamente disponible: El sistema siempre está disponible para operacio-


nes de lectura y escritura. Sin embargo, no hay garantía alguna respecto a
la consistencia de los datos que resultan de estas operaciones.
• Estado transitorio: El estado de un sistema de bases de datos distribuido
podría o no haber convergido entre nodos en cualquier momento dado.
Simplemente hay una probabilidad asociada a conocer un estado, en lugar
de certeza de poder hacerlo.
• Consistencia eventual: Si un sistema de bases de datos distribuido funciona
el tiempo suficiente, eventualmente alcanzará la consistencia, es decir, en
algún momento en el futuro podremos conocer con certeza el estado de
la base de datos, y después de eso ese estado permanecerá consistente y
duradero.

Escenarios

Debido a su enfoque en la disponibilidad y la tolerancia a fallos y solo un


requisito de consistencia suave - o eventual -, los sistemas de bases de datos
NoSQL sobresalen cuando se trata de casos de uso donde se necesita que un
sistema esté altamente disponible y sea altamente escalable.

Dado que los sistemas de bases de datos NoSQL ofrecen tolerancia al particiona-
miento, nos permiten no solo escalar fácilmente (es decir, hacerlo más rápido
agregando más RAM o un procesador más rápido a una sola instancia) sino
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 345

también escalar por medio de la adición de instancias adicionales (o: réplicas)


sin comprometer la disponibilidad general de nuestro sistema distribuido.

Esto a su vez es útil en situaciones en las que tenemos un número potencial-


mente grande de operaciones de escritura, pero no necesariamente requerimos
consistencia de datos en cualquier momento dado.

Beneficios de DynamoDB

Ahora que hemos abordado consideraciones más generales para cuándo optar
por un sistema de bases de datos NoSQL en lugar de uno RDBMS, es hora de
exponer los argumentos a favor del uso específico de DynamoDB.

Amazon DynamoDB se puede utilizar tanto como un almacén de valores clave


como una base de datos de documentos. Nos permite usar un esquema flexible
para nuestros datos, con cada elemento que potencialmente tenga un número
diferente de columnas/atributos.

Estos patrones de almacenamiento son más adecuados para situaciones en las


que tenemos estructuras de datos muy simples, como tablas de búsqueda (alma-
cén de valores clave), o altamente desestructuradas, incluso algo impredecibles
(base de datos de documentos).

Además de estos requisitos funcionales cubiertos por DynamoDB, este servicio


de base de datos NoSQL en particular también satisface requisitos no funciona-
les como:

• Rendimiento y escalabilidad: DynamoDB puede gestionar más de 10 billo-


nes de solicitudes al día con hasta 20 millones de solicitudes por segundo.
También podemos configurar DynamoDB para que replique automática-
mente nuestros datos en diferentes regiones de AWS.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 346

• Control de acceso: DynamoDB permite un control detallado sobre quién


puede acceder a qué entradas de datos de nuestras tablas DynamoDB. In-
cluso podemos definir subconjuntos de atributos para que solo sean visibles
para usuarios, grupos o roles de IAM específicos.

• Datos de transmisión de eventos: DynamoDB registra los cambios a nivel


de ítem realizados en las últimas 24 horas. Usando Amazon Kinesis Data
Streams para DynamoDB, podemos capturar estos cambios y persistirlos,
por ejemplo, para fines de registro o análisis de datos.
• Cifrado y copias de seguridad automáticas: Por defecto, DynamoDB crea
automáticamente copias de seguridad y cifra los datos en reposo. También
podemos crear y restaurar copias de seguridad completas de nuestras tablas
DynamoDB a demanda.
• Tiempo de vida (TTL): Esta característica de DynamoDB nos permite defi-
nir una columna de tabla que contenga tiempos de expiración individuales
para cada ítem. Cuando el valor definido en esta columna (como una marca
de tiempo de UNIX Epoch) es menor que la marca de tiempo actual, la
entrada se eliminará automáticamente sin necesidad de más lógica de
negocio.

Estas características son más adecuadas para escenarios donde estamos tra-
tando con un gran número de eventos cuya cantidad y/o frecuencia no se
conoce de antemano. El control de acceso detallado y un tiempo de vida para
cada ítem nos permiten automatizar procesos y comportamientos sin tener
que implementarlos nosotros mismos o ahogarnos en datos de transmisión de
eventos que no podríamos manejar manualmente.

Por lo tanto, utilizaremos DynamoDB para los datos de los eventos, aunque
para nuestro caso de almacenamiento de flujos de usuarios en una aplicación
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 347

de muestra con solo unos pocos usuarios, admitimos que realmente no nece-
sitamos DynamoDB y el rico conjunto de características y características de
rendimiento que proporciona.

Terminología de DynamoDB

Antes de adentrarnos en el diseño de una base de datos DynamoDB para nuestro


caso de uso, exploremos los conceptos clave de DynamoDB.

DynamoDB utiliza tablas para almacenar ítems. Cada uno de estos ítems puede
tener varios atributos escalares o anidados. Aparte de los atributos clave, las
tablas de DynamoDB son sin esquema predefinido, lo que significa que cada
ítem puede tener un conjunto diferente de atributos.

Una tabla de DynamoDB tiene un nombre único en una cuenta y región de AWS.
Cada tabla requiere un atributo de clave de partición, que sirve como clave
primaria para identificar ítems individuales en una tabla.

Para escalabilidad, DynamoDB almacena los ítems de una tabla en muchas par-
ticiones. Demasiadas operaciones de lectura o escritura en una sola partición en
un corto plazo pueden causar estrangulamiento - este efecto también se llama
partición caliente. Para evitar particiones calientes, las claves de partición de
los ítems en una tabla deben estar tan distribuidas como sea posible.

Las tablas también pueden definir un atributo de clave de ordenación opcional


(también referido a clave de rango). Si se proporciona tal atributo, DynamoDB
trata la clave de partición y la clave de ordenación como una clave compuesta.
Esto nos permite tener varios ítems con el mismo valor de clave de partición,
pero un valor diferente para la clave de ordenación, lo que a su vez nos permite
agregar datos relacionados.

Para consultar datos utilizando atributos distintos a la clave de partición, tam-


14. Rastreando las Acciones del Usuario con Amazon DynamoDB 348

bién podemos definir atributos de índice secundario. Estos nos permiten acce-
der a nuestros datos por atributos clave alternativos.

El rendimiento de las tablas de DynamoDB se mide en unidades de capacidad de


lectura y unidades de capacidad de escritura. Una tabla de DynamoDB se puede
tarifar a demanda, lo que significa que escala automáticamente sus particiones
con la carga de unidades de capacidad de lectura y escritura, o puede tener
una tarifa con capacidad provisionada, lo que significa que por encima de un
cierto umbral de unidades de capacidad de lectura y escritura la tabla DynamoDB
limitará el acceso (y protegerá tu cartera).

Diseñando Esquemas de Datos con DynamoDB

Cuando usamos un sistema de base de datos NoSQL como DynamoDB, debemos


abordar el diseño de nuestras estructuras de datos de manera diferente a si
estuviéramos usando un RDBMS para evitar problemas de mantenibilidad y
rendimiento en producción.

Por ejemplo, una consideración clave es qué atributos deberían tener en co-
mún los ítems en una tabla particular desde el principio. Si decidimos agregar
atributos a cada ítem ya existente más adelante, esto podría resultar en una
gran cantidad de operaciones de escritura costosas, dependiendo del número
de ítems en nuestra tabla en ese momento.

Trabajar con bases de datos NoSQL en general y con DynamoDB en particular


requiere una forma de pensar diferente a trabajar con RDBMS. Con los RDBMS,
generalmente trabajamos con datos normalizados y unimos los datos depen-
diendo de nuestro caso de uso, mientras que con las bases de datos NoSQL,
tenemos que preunir nuestros datos para lograr el mejor rendimiento posible.

Decidir qué atributos usar como claves para buscar e identificar ítems en nuestra
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 349

tabla es otro aspecto vital a considerar al diseñar una tabla DynamoDB.

Mientras que el diseño de bases de datos RDBMS tiende a priorizar la flexibilidad,


la independencia de la lógica de negocio específica y la normalización de los
datos para evitar la duplicación de datos y los posibles problemas de manteni-
miento que surgen de ello, el diseño de tablas NoSQL favorece la eficiencia de las
consultas y las estructuras de datos optimizadas para casos de uso específicos.

Por tanto, al trabajar con bases de datos NoSQL, buscamos diseñar nuestro es-
quema de datos de tal manera que podamos ejecutar las consultas y operaciones
más comunes de la forma más eficiente posible, a costa de la normalización de
los datos y la flexibilidad.

Esto resulta en dos principios principales para diseñar esquemas NoSQL:

1. No diseñes tu esquema hasta que conozcas las consultas específicas a las


que quieres que responda.
2. Usa la menor cantidad de tablas posible e intenta mantener los datos
relacionados juntos.

Los diseños y estructuras resultantes del segundo principio también pueden


describirse como diseño de tabla única.

En la siguiente sección, diseñaremos una tabla DynamoDB para nuestra función


de rastreo de usuario.

Si deseas saber más sobre las mejores prácticas para diseñar esquemas
DynamoDB, echa un vistazo a estos recursos: Charla de AWS re:Invent sobre
modelado de datos con Amazon DynamoDB, El Libro de DynamoDB, Diseño
NoSQL para DynamoDB, Mejores prácticas para diseñar y arquitecturar con
DynamoDB,
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 350

Implementación del Rastreo de Usuarios en la Aplicación


Todo

Ahora que sabemos de qué se trata DynamoDB, empecemos a utilizar realmente


DynamoDB en nuestra aplicación de muestra Todo.

Siguiendo la organización por funcionalidad, las nuevas clases de Java para esta
función están ubicadas en dev.stratospheric.todoapp.tracing.

Creación de Tablas DynamoDB con CDK

Entonces, comencemos creando la tabla DynamoDB para nuestro caso de uso


de rastreo. Como de costumbre, crearemos una nueva aplicación en nuestro
proyecto CDK, esta la llamaremos DynamoDbApp.

Aquí tenemos el esqueleto de ese stack:

public class DynamoDbApp {

public static void main(final String[] args) {


App app = new App();

// ...

Stack dynamoDbStack = new Stack(app, "DynamoDbStack", StackProps.builder()


.stackName(applicationEnvironment.prefix("DynamoDb"))
.env(awsEnvironment)
.build());

new BreadcrumbsDynamoDbTable(
dynamoDbStack,
"BreadcrumbTable",
applicationEnvironment,
new BreadcrumbsDynamoDbTable.InputParameter("breadcrumb")
);
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 351

app.synth();
}
}

Esta aplicación CDK crea una nueva pila llamada DynamoDbStack. Como parte de
esta pila, encargamos la creación de una tabla para almacenar nuestras trazas.

Hemos elegido el nombre “breadcrumb” aquí ya que, similar al rastro de miga-


jas de pan dejado en el bosque por Hansel y Gretel, la funcionalidad que vamos
a implementar nos permitirá rastrear los pasos de un usuario individual en
nuestra aplicación.

Esta tabla breadcrumb es creada por el componente BreadcrumbsDynamoDbTa-


ble:

public class BreadcrumbsDynamoDbTable extends Construct {

public BreadcrumbsDynamoDbTable(
final Construct scope,
final String id,
final ApplicationEnvironment applicationEnvironment,
final InputParameter inputParameters
) {

super(scope, id);

new Table(
this,
"BreadcrumbsDynamoDbTable",
TableProps.builder()
.tableName(applicationEnvironment.prefix(inputParameters.tableName))
.partitionKey(
Attribute.builder().type(AttributeType.STRING).name("id").build())
.encryption(TableEncryption.AWS_MANAGED)
.billingMode(BillingMode.PROVISIONED)
.readCapacity(10)
.writeCapacity(10)
.removalPolicy(RemovalPolicy.DESTROY)
.build());
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 352

record InputParameter(String tableName) {


}
}

La clase Table es una construcción de nivel 2 de AWS CDK que abstrae las
propiedades fundamentales de CloudFormation. Nuestra construcción perso-
nalizada toma el nombre de la tabla como parámetro de entrada y prefija el
nombre del entorno y de la aplicación, por ejemplo, production-todo-app-
breadcrumb. Esto nos permite crear la tabla para múltiples entornos y reutilizar
la construcción para diferentes aplicaciones.

A continuación, definimos la clave de partición. Esta clave de partición de tipo


String actúa como la clave primaria de nuestra tabla y se traduce en el atributo
id de nuestra entidad (también conocido como “elemento”).

Lo que queda es agregar un nuevo comando de deploy y destroy a nuestro


package.json, similar a nuestras apps CDK existentes.

Conectando a DynamoDB

Primero, añadimos el Spring Cloud AWS DynamoDB starter como una depen-
dencia al archivo build.gradle de nuestra aplicación:

implementation 'io.awspring.cloud:spring-cloud-aws-starter-dynamodb'

El uso de Spring Cloud AWS nos proporcionará autoconfiguraciones, APIs y


anotaciones útiles para definir, crear y acceder a entidades con DynamoDB.

Existe un proyecto comunitario adicional llamado Spring Data DynamoDB, que


se integra con Spring Data y aporta características adicionales como reposito-
rios, métodos CRUD, proyecciones y soporte REST a través de Spring Data REST.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 353

Aunque estas son funciones muy útiles, especialmente para casos de uso más
complejos, nuestro caso de uso es bastante simple. No necesitamos ninguna de
estas funciones, por eso evitamos la complejidad de añadir otra dependencia
aquí.

Para establecer la conectividad con DynamoDB, Spring Cloud AWS auto-


configura un cliente de DynamoDB para nosotros. Por lo tanto, no queda nada
más por hacer en ese aspecto.

Para interactuar con DynamoDB, utilizaremos el bean DynamoDbTemplate au-


toconfigurado de Spring Cloud AWS que proporciona una API para operaciones
CRUD básicas.

Ya que una instancia de DynamoDB está automáticamente disponible para cada


cuenta de AWS en cada región, esa es toda la configuración que necesitamos.

Mapeo de una Tabla de DynamoDB a Objetos Java

Para almacenar datos en nuestra nueva tabla DynamoDB desde nuestra aplica-
ción Java, debemos mapear dicha tabla a una clase modelo. Por lo tanto, creamos
esta nueva clase Breadcrumb:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 354

@DynamoDbBean
public class Breadcrumb {

private String id;


private String uri;
private String username;
private String timestamp;

@DynamoDbPartitionKey
public String getId() {
return id;
}

// getters and setters


// ...
}

La anotación @DynamoDbBean del SDK de DynamoDB marca una clase como una
representación de una tabla de DynamoDB.

La anotación @DynamoDbPartitionKey denota que se debe usar el atributo id


como la clave primaria de la tabla.

Sin más configuración, la integración de Spring Cloud AWS DynamoDB resuelve


el nombre de la tabla DynamoDB a partir del nombre de la clase Java a través del
DefaultDynamoDbTableNameResolver.

Para desplegar nuestra aplicación en múltiples etapas y acceder a una tabla


única por etapa, proporcionamos una implementación personalizada del Dyna-
moDbTableNameResolver. Esto nos permite prefijar nuestro nombre de tabla
con el entorno y el nombre de la aplicación. Esto, a su vez, nos permite desplegar
nuestra aplicación sin ningún cambio de configuración a múltiples entornos.

Añadiremos una nueva clase de configuración llamada AmazonDynamoDBConfig


y proporcionaremos un bean DynamoDbTableNameResolver a nuestro contexto
de Spring:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 355

@Configuration
public class AmazonDynamoDBConfig {

@Bean
public DynamoDbTableNameResolver
dynamoDbTableNameResolver(Environment environment) {

String environmentName = environment


.getProperty("custom.environment");

String applicationName = environment


.getProperty("spring.application.name");

return new DynamoDbTableNameResolver() {


@Override
public <T> String resolve(Class<T> clazz) {
String className = clazz.getSimpleName().replaceAll("(.)(\\p{Lu})", "$1_$2")\
.toLowerCase(Locale.ROOT);
return environmentName + "-" + applicationName + "-" + className;
}
};
}
}

Esta implementación personalizada prefija el nombre de la clase de mapeo de


DynamoDB, que ha sido previamente saneado y puesto en minúsculas, con el
nombre de la aplicación y el entorno.

Tomando en cuenta la siguiente configuración dentro de nuestro applica-


tion.yml:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 356

spring:
application:
name: todo-app

custom:
environment: production

Nuestra clase de entidad de mapeo de Java DynamoDB Breadcrumb se resolverá


como production-todo-app-breadcrumb.

Almacenando elementos en DynamoDB a través de eventos Spring

Ahora que podemos conectarnos a DynamoDB, disponer de nuestra tabla de


DynamoDB preparada, y una clase modelo mapeada a esa tabla, podemos in-
tegrarlo todo creando una nueva entidad Breadcrumb cada vez que un usuario
visita una página específica en nuestra aplicación de muestra Todo.

Para mantener el código de rastreo de usuarios separado de nuestra lógica de


negocios real y desacoplar el evento de un usuario visitando una ruta del alma-
cenamiento de los datos generados por ese evento, utilizaremos los eventos de
aplicación de Spring. Con este propósito, introduciremos una nueva clase de
evento llamada TracingEvent y haremos que herede de ApplicationEvent:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 357

public class TracingEvent extends ApplicationEvent {

private final String uri;


private final String username;

public TracingEvent(
Object source,
String uri,
String username
) {
super(source);

this.uri = uri;
this.username = username;
}

// getters
// ...
}

Proporcionado por la biblioteca de mensajería Spring Integration, los Applica-


tionEvents permiten el intercambio de información entre componentes con
bajo acoplamiento utilizando el conocido patrón de publicación-suscripción.

A continuación, creamos un objeto de acceso a datos (DAO) como un @Component


de Spring llamado TraceDao, que depende del bean DynamoDbTemplate para
crear un nuevo elemento Breadcrumb cada vez que se emite un TracingEvent.
En nuestro caso, usamos la anotación @EventListener de Spring para escuchar
un tipo específico de ApplicationEvent, el TracingEvent.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 358

@Component
public class TraceDao {

private final DynamoDbTemplate dynamoDbTemplate;

public TraceDao(DynamoDbTemplate dynamoDbTemplate) {


this.dynamoDbTemplate = dynamoDbTemplate;
}

@Async
@EventListener(TracingEvent.class)
public Breadcrumb create(TracingEvent tracingEvent) {
Breadcrumb breadcrumb = new Breadcrumb();
// ...

dynamoDbTemplate.save(breadcrumb);

return breadcrumb;
}
}

Utilizamos la anotación @Async para consumir un TracingEvent de manera


asíncrona sin bloquear el hilo y potencialmente retrasar la solicitud entrante.

Habilitamos este soporte para el comportamiento asíncrono en nuestra aplica-


ción Spring Boot con @EnableAsync y una clase de configuración personalizada
llamada AsyncConfig:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 359

@EnableAsync
@Configuration
public class AsyncConfig {

@Bean
@Primary
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setMaxPoolSize(10);
executor.initialize();
return executor;
}
}

Además, definimos nuestro TaskExecutor principal que se utiliza para procesar


operaciones asíncronas en una piscina de hilos dedicada. La razón de esta
definición manual del TaskExecutor es que ya tenemos componentes de tipo
TaskExecutor en nuestro ApplicationContext de la mensajería de Spring
Cloud AWS. Si no marcamos un ejecutor como @Primary, no seríamos capaces
de utilizar el mismo ejecutor de manera determinista.

Finalmente, podemos hacer que el ApplicationEventPublisher de Spring


emita un nuevo TracingEvent cada vez que un usuario visita una página que
deseamos rastrear. A continuación, un ejemplo de cómo hacer esto desde nues-
tro IndexController:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 360

@Controller
public class IndexController {

private final ApplicationEventPublisher eventPublisher;

// ...

@GetMapping
@RequestMapping("/")
public String getIndex(Principal principal) {
this.eventPublisher.publishEvent(
new TracingEvent(
this,
"index",
principal != null
? principal.getName()
: "anonymous"
)
);

return "index";
}
}

Este enfoque orientado a aspectos para el interés transversal del trazado de


usuario nos permite modificar o eliminar el código fácilmente sin afectar la
lógica de negocio. Si alguna vez necesitamos eliminar la función de trazado
de usuario o deshabilitarla en regiones específicas (por ejemplo, para cumplir
con las leyes de privacidad locales), podemos lograrlo fácilmente haciendo
que nuestro componente TraceDao esté disponible solo en regiones de AWS
específicas mediante el uso de una de las anotaciones de condición de Spring.

Estableciendo los Permisos de IAM Necesarios

Aunque no necesitamos ninguna infraestructura adicional para usar


DynamoDB, todavía falta algo: Los permisos de IAM para nuestra aplicación.
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 361

Para que el rol asumido por nuestra aplicación de ejemplo Todo pueda crear
nuevas tablas de DynamoDB y elementos en esas tablas, agregamos una nueva
PolicyStatement de IAM a la aplicación de servicio CDK ServiceApp:

public class ServiceApp {

public static void main(final String[] args) {


App app = new App();

// ...

new Service(
serviceStack,
"Service",
awsEnvironment,
applicationEnvironment,
new Service.ServiceInputParameters(
// ...
.withTaskRolePolicyStatements(List.of(
PolicyStatement.Builder.create()
.sid("AllowDynamoTableAccess")
.effect(Effect.ALLOW)
.resources(
List.of(String.format("arn:aws:dynamodb:%s:%s:table/%s",
region, accountId, applicationEnvironment.prefix("breadcrumb")))
)
.actions(List.of(
"dynamodb:Scan",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:BatchWriteItem",
"dynamodb:BatchWriteGet"
))
.build()
))
// ...
);

// ...
}
}
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 362

Con el próximo despliegue, esta política será añadida a las políticas IAM para
nuestra aplicación de ejemplo Todo, otorgándole los permisos necesarios.

Leyendo desde una tabla DynamoDB

Hasta ahora, estamos registrando eventos de “Breadcrumb” del usuario en


DynamoDB, pero no estamos leyendo ningún dato de esa tabla. Los casos de
uso para la lectura de los datos podrían ser:

• “Me gustaría ver todos los eventos de un usuario.”


• “Me gustaría ver todos los eventos de un usuario en un período de tiempo
específico.”

Para responder a estas solicitudes, utilizamos la clave de orden timestamp


en nuestra tabla DynamoDB. Como se explicó antes, una clave de orden nos
permite agrupar datos por una clave de partición. Los elementos de ese conjunto
de datos agrupados podrán distinguirse por la clave de orden. Esto, a su vez, nos
permite obtener elementos mediante una tupla [clave de partición, clave
de orden].

Nuestra clave de partición username nos permite solicitar una lista de todos los
elementos Breadcrumb para un usuario específico, así como una lista de eventos
para un usuario dentro de un marco de tiempo específico:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 363

@Component
public class TraceDao {

// ...

public List<Breadcrumb> findAllEventsForUser(String username) {


Breadcrumb breadcrumb = new Breadcrumb();
breadcrumb.setUsername(username);

return dynamoDbTemplate.query(
QueryEnhancedRequest
.builder()
.queryConditional(
QueryConditional.keyEqualTo(
Key
.builder()
.partitionValue(breadcrumb.getId())
.build()
)
)
.build(),
Breadcrumb.class
).items()
.stream()
.toList();
}

public List<Breadcrumb> findUserTraceForLastTwoWeeks(String username) {


ZonedDateTime twoWeeksAgo = ZonedDateTime.now().minusWeeks(2);

Breadcrumb breadcrumb = new Breadcrumb();


breadcrumb.setUsername(username);

return dynamoDbTemplate.query(
QueryEnhancedRequest
.builder()
.queryConditional(
QueryConditional.keyEqualTo(
Key
.builder()
.partitionValue(breadcrumb.getId())
.build()
)
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 364

)
.filterExpression(
Expression
.builder()
.expression("timestamp > :twoWeeksAgo")
.putExpressionValue(":twoWeeksAgo",
AttributeValue
.builder()
.s(twoWeeksAgo.toString())
.build()
)
.build()
)
.build(),
Breadcrumb.class
)
.items()
.stream()
.toList();
}
}

Ambos métodos usan un QueryEnhancedRequest para buscar una lista de ele-


mentos Breadcrumb que coinciden. En el método findUserTraceForLastTwo-
Weeks(), además proporcionamos un filtro Expression para consultar un lapso
de tiempo de dos semanas.

Habilitando el Desarrollo Local

Queremos que nuestro entorno de desarrollo local sea lo más parecido posible
a la producción. Por ejemplo, nos gustaría probar nuestra nueva característica
antes de desplegarla en nuestro entorno de ensayo, y mucho menos en el
entorno de producción.

Por lo tanto, incorporamos dynamodb como un servicio adicional a nuestra


configuración del entorno LocalStack en nuestro docker-compose.yml:
14. Rastreando las Acciones del Usuario con Amazon DynamoDB 365

services:
# ...
localstack:
# ...
environment:
- SERVICES=sqs,ses,dynamodb

Dado que Spring Cloud AWS, específicamente a través de su inicializador


DynamoDB, configura el cliente DynamoDB, solo necesitamos apuntar
el cliente a nuestra instancia de LocalStack. Al sobrescribir la propiedad
spring.cloud.aws.endpoint ya sobrescribimos esta configuración de
endpoint para todos los clientes del SDK de Java.

Lo que queda es crear la tabla como parte de la fase de arranque del contenedor
de LocalStack. Por lo tanto, extendemos nuestro script de inicio de LocalStack
con el siguiente comando:

#!/bin/sh

# ... further initialization

awslocal dynamodb create-table \


--table-name local-todo-app-breadcrumb \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=10,WriteCapacityUnits=10 \

echo "Initialized."
Parte III: Preparación para la
Producción con AWS

Hemos aprendido cómo desplegar una aplicación de Spring Boot en AWS y cómo
hacer que se comunique con diferentes servicios de AWS para implementar
ciertos casos de uso. ¡Eso ya es bastante impresionante!

Sin embargo, eso no es suficiente. ¿Cómo sabemos que nuestra aplicación está
funcionando correctamente y haciendo lo que esperamos que haga? En la nube,
ningún equipo de administración de sistemas nos notificará cuando algo vaya
mal. Nosotros lo construimos, nosotros lo mantenemos.

Por lo tanto, en la última parte de este libro, implementaremos algunos patro-


nes de monitorización y alerta para asegurarnos de que podemos dormir por
la noche, sabiendo que nuestra aplicación está funcionando sin problemas y
aportando valor a nuestros usuarios.

El principal servicio de AWS para la monitorización es Amazon CloudWatch.


Enviaremos datos estructurados de registro a CloudWatch y aprenderemos
cómo podemos consultar esos datos.

También aprenderemos cómo enviar métricas personalizadas a CloudWatch y


cómo consultarlas junto con las métricas predeterminadas de todos los servicios
de AWS.
367

Luego, configuraremos alarmas basadas en esas métricas, para que se nos


notifique cuando se superen ciertos umbrales. Esto nos permite responder de
manera rápida a cualquier incidente.

Finalmente, haremos que la aplicación esté lista para la producción asegurán-


dola con HTTPS y alojándola en un dominio personalizado.
15. Registro Estructurado con Amazon
CloudWatch
¿Cuál es la primera cosa que vemos cuando algo sale mal? Los registros. Con
una estrategia de registro adecuada, los registros son una fuente de información
muy valiosa sobre lo que los usuarios están haciendo en nuestra aplicación, así
como para investigar la causa raíz de los errores.

Yo (Tom) recuerdo proyectos en los que teníamos que enviar una solicitud por
correo electrónico al equipo de administradores del sistema, diciéndoles que
necesitábamos los registros de un cierto intervalo de tiempo. Un administrador
del sistema se conectaría a las máquinas de producción y copiaría el archivo
de registro de cada servidor en un lugar seguro donde podríamos descargarlo a
través de SFTP. A veces, este proceso llevaría horas porque el administrador del
sistema estaba en un descanso. Afortunadamente, en la nube, este engorroso
proceso es cosa del pasado. ¡Solo imagina a los administradores del sistema de
AWS respondiendo a las solicitudes de archivos de registro de sus millones de
clientes en la nube!

Una mejor solución es enviar información de los registros a un servidor central


que recopile y almacene eventos de registro de varias instancias. Los desarro-
lladores tienen acceso directo a ese servidor de registro y pueden usarlo para
consultar registros en tiempo real en cualquier momento. Ni siquiera tienen que
despertar a un administrador del sistema cuando tienen que investigar un error
en medio de la noche.

Idealmente, los eventos de registro no solo contienen texto plano sino infor-
15. Registro Estructurado con Amazon CloudWatch 369

mación estructurada que podemos usar para filtrar eventos de registro. Esta
información estructurada puede contener el ID del usuario actual, por ejemplo,
para que podamos agrupar eventos de registro del mismo usuario.

El objetivo de este capítulo es implementar una solución de registro que nos


permita buscar y filtrar eventos de registro estructurados de nuestra aplicación
Todo con Amazon CloudWatch, el principal servicio de observabilidad de Ama-
zon, que proporciona capacidades de consulta y panel de control para datos
de registro (entre otras capacidades de observabilidad que vamos a ver en el
próximo capítulo).

Registro con AWS

Con el auge de DevOps, un servidor de registro se ha convertido en un estándar


de facto en las operaciones de software modernas. Hay muchas soluciones de
registro disponibles, y aunque estamos ejecutando nuestra aplicación Todo en
AWS, esto no significa que tengamos que usar un servicio de AWS como nuestro
proveedor de registro.

Podríamos configurar igualmente nuestra aplicación Spring Boot para enviar


sus registros a otro proveedor de registro en la nube como Splunk, Loggly o
Logz.io. Dado que estos proveedores están especializados en registros, es muy
probable que ofrezcan un conjunto de características más completo en torno a
la ingesta de registros, la consulta de registros y la construcción de paneles de
control que lo que ofrece Amazon CloudWatch.

Sin embargo, una integración de Amazon CloudWatch está “incorporada” en


muchos otros servicios de AWS. Así que incluso si su conjunto de características
no coincide con el de otros proveedores de registro, ofrece el camino de menor
resistencia para implementar una solución de registro viable cuando nuestra
15. Registro Estructurado con Amazon CloudWatch 370

aplicación se está ejecutando en AWS.

Muchos servicios de AWS pueden enviar sus registros a CloudWatch, sin que
tengamos que configurar nada. Podemos hacer que RDS envíe registros de Post-
greSQL, por ejemplo. Si eligiéramos usar un proveedor de registro diferente, la
configuración podría volverse más complicada. En muchos casos, los registros
tienen que pasar por CloudWatch de todos modos y solo se envían al proveedor
de registro externo desde allí.

En este capítulo, nos enfocamos en los registros de aplicación, porque son los
más importantes en las operaciones diarias.

Terminología de Registro de CloudWatch

Antes de adentrarnos en los detalles del registro con CloudWatch, hablemos un


poco del vocabulario de CloudWatch:

Un flujo de registro es un flujo de eventos de registro emitido por un cierto


componente. En nuestro caso, cada tarea que se ejecuta en ECS (es decir, cada
instancia de nuestra aplicación) está emitiendo un flujo de registros.

Los flujos de registro se agrupan en grupos de logs. En nuestro caso, tenemos


un grupo de logs para nuestra aplicación Todo que agrupa los flujos de registro
de todas las instancias de la aplicación. Este grupo de logs nos permite consultar
los registros de nuestra aplicación Todo en diferentes instancias de la aplicación
que podrían estar funcionando al mismo tiempo.

CloudWatch Insights es un servicio que proporciona una interfaz de usuario y


un potente lenguaje de consulta para buscar en uno o más grupos de logs. Lo
usaremos más tarde para consultar nuestros registros estructurados.
15. Registro Estructurado con Amazon CloudWatch 371

Estado Actual: Registro de Texto No Estructurado

En el estado actual de nuestra aplicación Todo, ya estamos enviando registros


a CloudWatch. Todo lo que tuvimos que hacer para que esto funcionase fue
configurar ECS, donde se ejecutan nuestros contenedores Docker, para instalar
el agente de CloudWatch en nuestras instancias de EC2. El agente tomará la
salida estándar de nuestra aplicación y la enviará a CloudWatch.

Veamos cómo está configurado esto en nuestra configuración actual y aprenda-


mos algunos conceptos de CloudWatch en el camino.

Configuración de ECS para Enviar Registros a CloudWatch

En el capítulo Diseño de un Proyecto de Despliegue con CDK, hemos construido un


proyecto CDK que describe la infraestructura que nuestra aplicación necesita.
Una parte de esa infraestructura era la construcción de Service, que configura
una definición de tarea de ECS que toma nuestra imagen Docker y la despliega
en instancias de EC2 administradas por ECS.

En el Service constructo, ya hemos configurado ECS para enviar registros a


CloudWatch (consulta el código completo en GitHub):
15. Registro Estructurado con Amazon CloudWatch 372

Role ecsTaskExecutionRole = Role.Builder.create(this, "ecsTaskExecutionRole")


...
.inlinePolicies(Map.of(
applicationEnvironment.prefix("ecsTaskExecutionRolePolicy"),
PolicyDocument.Builder.create()
.statements(singletonList(PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.resources(singletonList("*"))
.actions(Arrays.asList(
...
"logs:CreateLogStream",
"logs:PutLogEvents"))
.build()))
.build()))
.build();

LogGroup logGroup = LogGroup.Builder.create(this, "ecsLogGroup")


.logGroupName(applicationEnvironment.prefix("logs"))
.retention(serviceInputParameters.logRetention)
.removalPolicy(RemovalPolicy.DESTROY)
.build()

CfnTaskDefinition.ContainerDefinitionProperty container =
CfnTaskDefinition.ContainerDefinitionProperty.builder()
...
.logConfiguration(CfnTaskDefinition.LogConfigurationProperty.builder()
.logDriver("awslogs")
.options(Map.of(
"awslogs-group", logGroup.getLogGroupName(),
"awslogs-region", awsEnvironment.getRegion(),
"awslogs-stream-prefix", applicationEnvironment.prefix("stream"),
"awslogs-datetime-format", serviceInputParameters.awslogsDateTimeFormat))
.build())
...
.build();

El rol que está ejecutando las tareas ECS - el ecsTaskExecutionRole - ne-


cesitará escribir registros en CloudWatch, por lo que le damos los permisos
logs:CreateLogStream y logs:PutLogEvents.

A continuación, creamos un LogGroup con un logGroupName específico. El nom-


15. Registro Estructurado con Amazon CloudWatch 373

bre del grupo de registros se verá algo así en nuestro ejemplo: prod-todo-app-
logs. Al crear el grupo de registros, podemos definir cuánto tiempo queremos
conservar los registros y una política de eliminación para decidir qué sucede si
el grupo de registros se elimina del proyecto CDK (con la política DESTROY, los
registros se eliminarán si se despliega una versión de la pila CDK que no incluya
el grupo de registros).

La parte más importante es la configuración de la definición de tarea ECS.


Configuramos las tareas ECS para usar el controlador de registros awslogs. Este
controlador es la infraestructura que tomará todo lo que nuestro contenedor
Docker registre en STDOUT y STDERR y lo enviará a CloudWatch. En las op-
ciones, pasamos el nombre de nuestro grupo de registros creado previamente,
la región de AWS y un prefijo de flujo para definir a dónde deben enviarse los
registros.

Una pieza importante de la configuración es el awslogs-datetime-format.


Esta opción toma un patrón de fecha y hora que le indicará al controlador de
registros cómo analizar los registros. El controlador de registros espera que
cada entrada de registro comience con una fecha y hora. Al conocer el patrón
de fecha y hora, el controlador de registros puede buscarlo y decidir dónde
termina un evento de registro (potencialmente de varias líneas) y comienza un
nuevo evento de registro. Un patrón de fecha y hora incorrecto hace que los
registros en CloudWatch se vuelvan muy (!) confusos, con eventos de registro
mezclados y marcas de tiempo en todas partes, por lo que es importante hacer
esto correctamente.

Una vez que desplegamos el constructo Service CDK con esta configuración,
los registros de nuestra aplicación se encontrarán disponibles en CloudWatch.
Sin embargo, aún no hemos configurado nuestra aplicación Spring Boot para
registrar logs en un formato específico. Además, en nuestro ServiceApp no
estamos pasando ninguna configuración de registro específica al constructo
15. Registro Estructurado con Amazon CloudWatch 374

Service y en su lugar confiamos en los valores predeterminados definidos por


el constructo Service. Veamos cómo se ven los registros en CloudWatch con
esta configuración al azar.

Consultando Registros No Estructurados con CloudWatch Logs


Insights

La pantalla de CloudWatch Logs Insights en la consola de AWS se ve algo así:

Ejecutando una consulta en CloudWatch Insights.

Podemos seleccionar uno o más grupos de logs para realizar una consulta de
búsqueda, filtrarla por un marco de tiempo y ver los resultados en forma de texto
15. Registro Estructurado con Amazon CloudWatch 375

o gráfica.

Para construir consultas, podemos aprovechar un lenguaje de consulta basado


en el operador pipe que tiene un significado muy similar en este contexto al
que tiene en Unix (es decir, reenviando la salida de un comando como entrada
al siguiente comando). Ten en cuenta que los datos de registro pueden tardar
unos segundos en ser ingeridos antes de estar disponibles para consultar.

Echemos un vistazo a la consulta predeterminada que CloudWatch usa para


completar previamente el campo de consulta:

fields @timestamp, @message


| sort @timestamp desc
| limit 20

La consulta combina múltiples comandos con el operador pipe:

• El comando fields extrae una lista de campos de los mensajes de registro.


Los campos que comienzan con @ son campos del sistema que CloudWatch
rellena para nosotros. En nuestro caso, estamos extrayendo los campos
@timestamp y @message de los datos de registro brutos.
• Redirigimos el resultado al comando sort para ordenar los datos por el
timestamp en orden descendente, de esta manera visualizamos primero los
eventos de registro más recientes.
• Finalmente, redirigimos los eventos de registro ordenados al comando
limit que limita el resultado a 20 eventos de registro.

El resultado es una tabla de eventos de registro con una columna @timestamp


que contiene el timestamp y una columna @message que contiene el contenido
del mensaje, ordenados por timestamp en orden descendente, limitado a 20
eventos.
15. Registro Estructurado con Amazon CloudWatch 376

Sin embargo, si observamos la columna @message, notamos que también con-


tiene el timestamp:

2021-05-01 21:18:22.010 INFO 1 --- [MessageBroker-1] ...

Tenemos una marca de tiempo en la columna @timestamp y otra marca de tiem-


po en la columna @message. Esto es el resultado de cómo hemos configurado el
controlador de registro awslogs. Configuramos la opción awslogs-datetime-
format con un patrón específico para una marca de tiempo, de manera que
el controlador de registro pudiera distinguir dónde comienza y termina cada
evento de registro. Y esa configuración resultó exitosa ya que los eventos de
registro se interpretan correctamente. Sin embargo, el controlador de registro
no elimina la marca de tiempo del mensaje de registro en sí, por lo que ahora la
tenemos dos veces. Es un poco incómodo, pero no es un impedimento.

Hasta ahora, todo va bien, pero los registros solo son valiosos si podemos buscar
en ellos palabras clave y patrones. Supongamos que queremos encontrar errores
en los registros. Podemos utilizar el comando filter para filtrar y mostrar solo
los eventos de registro que contienen la palabra “error”:

fields @timestamp, @message


| filter @message like /error/
| sort @timestamp desc
| limit 20

No es sorprendente que, después de ejecutar esta consulta, la tabla de resultados


solo muestre eventos de registro que contienen la cadena “error” en el campo
@message. Sin embargo, dado que el campo @message contiene el nivel de
registro así como el mensaje de registro actual, los resultados de esta consulta
incluirán eventos de registro que se han registrado en el nivel ERROR así como
eventos de registro que contienen la cadena “error” en el mensaje de registro
actual. Esto puede ser suficiente en aplicaciones con pequeñas cantidades de
15. Registro Estructurado con Amazon CloudWatch 377

tráfico de registro, pero será muy molesto para aplicaciones con muchos even-
tos de registro.

Intentemos corregir esto dividiendo el mensaje de registro en campos distintos


y haciendo que cada uno de esos campos sea filtrable y buscable. La configu-
ración de registro predeterminada de Spring Boot estructura los mensajes de
registro en un patrón que podemos interpretar con CloudWatch (se agregó un
salto de línea para facilitar la lectura):

fields @timestamp, @message


| parse @message / (?<level>[A-Z]+) (?<pid>[0-9]+) --- \
\[(?<thread>[^\]]+)\] (?<logger>[^ ]+) (?<message>.*)/
| display @timestamp, level, thread, logger, message
| filter level = 'ERROR'
| sort @timestamp desc
| limit 20

Hemos agregado el comando parse que interpreta el campo @message con


una expresión regular. En la expresión regular, podemos definir grupos de
coincidencias nombrados entre paréntesis de esta manera: (?<name>...). La
parte ?<name> asigna un nombre al grupo de coincidencias y la parte ... define
la expresión regular para hacer coincidir este grupo. Cada grupo nombrado se
extraerá a un nuevo campo con el nombre del grupo.

Con la expresión regular en la consulta anterior, estamos obteniendo los cam-


pos level, thread, logger y message del campo @message sin procesar.

Con el comando display, seleccionamos qué campos queremos mostrar en la


tabla de resultados de búsqueda. Usando el comando filter, filtramos estos
resultados para incluir solo eventos de registro con el nivel de registro ERROR.

Interpretar eventos de registro de esta manera es una característica bastante


potente y nos brinda muchas opciones para consultar eventos de registro. Sin
embargo, todavía hay algunas desventajas.
15. Registro Estructurado con Amazon CloudWatch 378

Construir una consulta con una expresión regular puede ser bastante engorroso
y propenso a errores. Un carácter incorrecto en la expresión regular a menudo
significa que todos los campos estarán vacíos.

Además, ejecutar una expresión regular bastante compleja a través de los datos
de registro de una aplicación con un volumen de registro alto no parece ser una
buena idea. El equipo de CloudWatch probablemente ha incorporado algunas
optimizaciones ingeniosas, pero aún así … interpretar registros ‘al vuelo’ con
una expresión regular no puede ser bueno para el rendimiento.

En resumen, la principal desventaja de la solución actual es que no podemos


registrar y realizar consultas de información estructurada. Podríamos simular
esto registrando todos los campos que nos interesan en un mensaje de registro
similar a un CSV para facilitar el uso del comando parse para buscar en esos cam-
pos. Sin embargo, todavía tendríamos que interpretar los mensajes de registro
‘en tiempo real’ con expresiones regulares, lo cual es incómodo en el mejor de
los casos. Veamos cómo podemos implementar un registro estructurado real,
en lugar de eso.

Registro y Consulta de Datos Estructurados

CloudWatch Logs Insights admite la lectura de objetos JSON de eventos de


registro. Analiza automáticamente el primer objeto JSON de cada evento de
registro y hace que los campos del objeto JSON estén disponibles como campos
en la consulta de Insights.

Para que esto funcione con nuestra configuración, tenemos que configurar
nuestra aplicación Spring para emitir eventos de registro en JSON. Para hacer el
registro estructurado aún más útil, añadiremos un campo personalizado a estos
eventos de registro que luego será consultable a través de CloudWatch Insights.
15. Registro Estructurado con Amazon CloudWatch 379

Finalmente, veremos cuánto más fácil se vuelve consultar estos eventos de


registro estructurados.

Agregando Campos Personalizados a los Eventos de Registro

Empecemos con la adición de datos estructurados a nuestros eventos de regis-


tro.

Agregar campos personalizados a los eventos de registro es una excelente


manera de mejorar la observabilidad de nuestra aplicación. Los campos per-
sonalizados en los eventos de registro marcan la diferencia entre encontrar
un problema al instante y tener que pasar días añadiendo más información de
registro y esperando hasta que el problema vuelva a ocurrir.

Algunos ejemplos de campos personalizados en los eventos de registro son:

• Un ID de usuario: Si agregamos el ID o el nombre de usuario del usuario que


ha iniciado sesión a cada evento de registro, podemos filtrar rápidamente
los eventos de registro por un usuario específico. De esta manera, si un
usuario tiene una solicitud de soporte, podemos filtrar rápidamente los
registros a los eventos de registro relevantes.
• Un punto de entrada: Aparte de una interfaz web, una aplicación puede
tener otros puntos de entrada para el trabajo entrante. Puede tener trabajos
programados, por ejemplo, que se ejecutan a intervalos regulares sin una
solicitud de usuario. También puede tener una API HTTP que no está orien-
tada al usuario. Si agregamos el punto de entrada como un campo a nuestros
eventos de registro, sabremos exactamente de qué camino proviene un
evento de registro, lo que ayuda enormemente en el análisis de los registros
de aplicaciones con múltiples puntos de entrada.
• La causa raíz de un error: Cuando ocurre una excepción, normalmente
vemos un rastreo de pila en los registros. Lo primero que usualmente
15. Registro Estructurado con Amazon CloudWatch 380

hacemos es desplazarnos hasta el final del rastreo de pila para ver la causa
raíz de la excepción. Podemos reducir el esfuerzo de analizar rastreos de
pila agregando el nombre de la excepción de causa raíz como un campo en
el evento de registro. De esta manera, podemos filtrar los registros por una
causa raíz específica y ver qué impacto tuvo esa causa raíz.

Hay muchos más campos que podemos agregar para hacer que los eventos de
registro sean más valiosos con campos personalizados, muchos de ellos muy
específicos a la aplicación que estamos construyendo.

Como ejemplo de nuestra aplicación Todo, añadiremos el nombre del usuario


que ha iniciado sesión a cada evento de registro. Podemos registrar información
personal identificable, como los nombres de usuario, sin tener que pensarlo dos
veces porque solo estamos construyendo una aplicación de demostración. En
una aplicación real, debes consultar a tu experto en seguridad para decidir si
registrar un nombre de usuario es aceptable o no (y tal vez registrar un UUID en
su lugar).

La abstracción de registro que las aplicaciones de Spring Boot utilizan por defec-
to es SLF4J. SLF4J soporta la noción de un “Contexto Diagnóstico de Mensajes”
(o MDC en corto) que nos permite agregar campos personalizados a los eventos
de registro.

Para agregar el nombre de un usuario a cada evento de log, necesitamos inter-


ceptar todas las solicitudes web entrantes, obtener el usuario que ha iniciado
sesión y que inició la solicitud, y luego agregar el nombre del usuario al MDC.
Podemos hacer esto al implementar un HandlerInterceptor de Spring:
15. Registro Estructurado con Amazon CloudWatch 381

class LoggingContextInterceptor implements HandlerInterceptor {

private final Logger logger =


LoggerFactory.getLogger(LoggingContextInterceptor.class);

@Override
public boolean preHandle(
final HttpServletRequest request,
final HttpServletResponse response,
final Object handler) {

Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
String userId = getUserIdFromPrincipal(authentication.getPrincipal());
MDC.put("userId", userId);
return true;
}

private String getUserIdFromPrincipal(Object principal) {


if (principal instanceof String) {
// anonymous users will have a String principal with value "anonymousUser"
return principal.toString();
}

if (principal instanceof OidcUser) {


try {
OidcUser user = (OidcUser) principal;
if (user.getClaimAsString("name") != null) {
return user.getClaimAsString("name");
} else {
logger.warn("could not extract userId from Principal");
return "unknown";
}
} catch (Exception e) {
logger.warn("could not extract userId from Principal", e);
}
}
return "unknown";
}

@Override
public void afterCompletion(
final HttpServletRequest request,
15. Registro Estructurado con Amazon CloudWatch 382

final HttpServletResponse response,


final Object handler,
final Exception ex) {
MDC.clear();
}
}

Este interceptor extrae el nombre de usuario del objeto Principal del usuario
actual, que puede ser una String o un OidcUser en nuestra aplicación. Luego
agrega el nombre de usuario al campo userId en el MDC. Si hay un error al
extraer el nombre de usuario, el interceptor establece el identificador de usuario
a “no conocido”.

Dado que el MDC está adjunto al thread actual, y dado que un thread podría ser
reutilizado más adelante (porque es parte de un pool de threads), tenemos que
eliminar el MDC una vez que se ha procesado la solicitud. Hacemos esto en el
método afterCompletion() de nuestro interceptor.

Lo único que queda por hacer es configurar Spring para utilizar nuestro Logging-
ContextInterceptor:

@Component
class LoggingContextConfiguration implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggingContextInterceptor());
}
}

Implementar un WebMvcConfigurer como el mencionado anteriormente


nos permite agregar nuestro LoggingContextInterceptor al Intercepto-
rRegistry de Spring, permitiendo así interceptar todas las solicitudes web
entrantes.
15. Registro Estructurado con Amazon CloudWatch 383

Con este código establecido, todos los eventos de registro que se emiten en
el camino del código que comienza con una solicitud web entrante ahora ten-
drán un campo userId. Pero la configuración de registro de Spring Boot no
registra campos personalizados por defecto, por lo que aún no hemos logrado
nada. Necesitamos configurar nuestra aplicación para generar registros JSON
estructurados para ver este campo.

Configurando Spring Boot para Registrar JSON

Como CloudWatch admite analizar el primer objeto JSON en un evento de


registro, simplemente configuraremos nuestro registro para que cada evento de
registro emita un solo objeto JSON que contenga el mensaje de registro y todos
los campos personalizados que podríamos haber agregado al MDC.

Afortunadamente, no tenemos que construir eso nosotros mismos. Hay muchas


bibliotecas diferentes por ahí que podemos usar para hacer eso. Hemos optado
por la biblioteca awslogs-json-encoder ya que está preconfigurada para crear
registros que CloudWatch puede ingerir. Por lo tanto, no tenemos que configu-
rar nada.

Para hacer esta biblioteca disponible para nuestra aplicación, la incorporamos


en nuestro archivo build.gradle:

implementation 'de.siegmar:logback-awslogs-json-encoder:1.1.0'

A continuación, tenemos que configurar el registro de sucesos de Spring Boot


para usar esta biblioteca. Spring Boot utiliza Logback como la biblioteca de regis-
tro de sucesos predeterminada. Logback usualmente se configura colocando un
archivo logback.xml en la ruta de clase. Spring Boot admite su propio archivo
de configuración logback-spring.xml, que añade algunas funciones dinámicas
15. Registro Estructurado con Amazon CloudWatch 384

al archivo de configuración de Logback. Nuestro archivo logback-spring.xml


se ve así:

<configuration>

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">


<encoder class="de.siegmar.logbackawslogsjsonencoder.AwsJsonLogEncoder"/>
</appender>

<appender name="PLAIN" class="ch.qos.logback.core.ConsoleAppender">


<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%cyan(%d{ISO8601}) %highlight(%-5level) [%blue(%-30t)] ...
</Pattern>
</layout>
</appender>

<springProfile name="dev">
<root level="INFO">
<appender-ref ref="PLAIN"/>
</root>
</springProfile>

<springProfile name="aws">
<root level="WARN">
<appender-ref ref="JSON"/>
</root>
</springProfile>

</configuration>

Excepto por las etiquetas <springProfile>, este archivo se ajusta al formato


estándar de un archivo logback.xml. Creamos un ‘appender’ llamado JSON
que utiliza el AwsJsonLogEncoder de la biblioteca awslogs-json-encoder que
acabamos de importar. Todos los eventos de log enviados a este appender
generarán logs en formato JSON. También creamos otro appender llamado
PLAIN que genera logs en texto plano.

Luego, configuramos el ‘appender’ root dependiendo del perfil de Spring en


15. Registro Estructurado con Amazon CloudWatch 385

el que se está ejecutando la aplicación. Si la aplicación se ejecuta en el perfil


dev, enviamos todos los eventos de log al appender PLAIN, para que todavía
tengamos los logs en un formato legible por humanos (aunque sin nuestros
campos personalizados). Si la aplicación se está ejecutando en el perfil aws,
enviamos todos los logs al JSON encoder para producir logs en formato JSON.

Podríamos, por supuesto, elegir también generar logs en JSON durante el desa-
rrollo local. Sin embargo, leer logs textuales es más fácil para nosotros los
humanos que leer JSON, y, al trabajar localmente, a menudo no disponemos de
un servidor de logs que analice estos registros por nosotros. Perder nuestros
campos personalizados en los logs textuales no es tan malo para el desarrollo
local, generalmente, ya que no estamos analizando incidentes en logs locales.
Pero si quisiéramos, también podríamos añadir algunos campos personaliza-
dos a nuestros logs textuales incluyendo %X{userId} en el patrón de log, por
ejemplo.

Para probar el formato de log JSON, podemos reemplazar el appender de log


PLAIN con el appender de log JSON en el perfil dev e iniciar la aplicación
localmente con ./gradlew bootrun. Veremos que los logs generados ahora se
ven algo así (se añadieron saltos de línea para facilitar la lectura):

{
"timestamp":"2021-05-15T07:43:37.860000+1000",
"level":"INFO",
"logger":"dev.stratospheric.todoapp.todo.TodoController",
"thread":"http-nio-8080-exec-5",
"message":"successfully created todo",
"mdc":{
"userId":"duke"
}
}

Ahora que la aplicación Spring Boot genera registros en formato JSON, tenemos
que asegurarnos de que nuestro contenedor ECS los envíe correctamente a
15. Registro Estructurado con Amazon CloudWatch 386

CloudWatch.

Configurando ECS para Procesar Registros

Cuando iniciamos la aplicación en el perfil aws, ahora enviará registros en


formato JSON a la salida estándar. Como hemos discutido anteriormente, ECS
recogerá estos registros y los enviará a CloudWatch. Solo un recordatorio rápido:
la configuración responsable de esto es el constructo CfnTaskDefinition en
nuestro constructo del Servicio:

CfnTaskDefinition.ContainerDefinitionProperty container =
CfnTaskDefinition.ContainerDefinitionProperty.builder()
...
.logConfiguration(CfnTaskDefinition.LogConfigurationProperty.builder()
.logDriver("awslogs")
.options(Map.of(
"awslogs-group", logGroup.getLogGroupName(),
"awslogs-region", awsEnvironment.getRegion(),
"awslogs-stream-prefix", applicationEnvironment.prefix("stream"),
"awslogs-datetime-format", serviceInputParameters.awslogsDateTimeFormat))
.build())
...
.build();

Como se discutió anteriormente, la configuración más importante aquí es la


awslogs-datetime-format. El controlador awslogs utilizará el patrón que de-
finimos aquí para distinguir un evento de registro del siguiente. Si una línea en
los registros no tiene una fecha en este formato, se considera parte del evento
de registro anterior. Esto es para facilitar eventos de registro de varias líneas.

Para trabajar correctamente con el AwsJsonLogEncoder que configuramos en el


archivo de configuración logback-spring.xml, necesitamos introducir el valor
correcto para el awslogs-datetime-format. Dado que la construcción Service
toma este valor como parte del objeto ServiceInputParameters, podemos
introducir el valor en nuestra aplicación CDK ServiceApp:
15. Registro Estructurado con Amazon CloudWatch 387

new Service(
serviceStack,
"Service",
awsEnvironment,
applicationEnvironment,
new Service.ServiceInputParameters(...)
.withAwsLogsDateTimeFormat("%Y-%m-%dT%H:%M:%S.%f%z")
...
.build()

El formato de fecha %Y-%m-%dT%H:%M:%S.%f%z es el que se utiliza en los even-


tos de registro en formato JSON (compárelo con la cadena de fecha 2021-05-
15T07:43:37.860000+1000 del evento de registro en el fragmento de JSON
anterior). Con esta configuración, nos hemos asegurado de que el controlador
de logs pueda analizar correctamente los logs y los eventos de registro serán
enviados a CloudWatch sin que se alteren.

Ahora, ¿qué podemos hacer con estos logs estructurados? Veamos cómo pode-
mos consultarlos.

Consultando Datos de Logs Estructurados con CloudWatch Insights

Ahora que los logs están formateados en JSON, CloudWatch permitirá que cada
campo de ese JSON esté disponible en nuestras consultas de logs. Esto nos da
mucho poder para escribir consultas para responder a nuestras preguntas de
logs.

Supongamos que el usuario duke ha informado sobre un error mientras navega-


ba por las páginas web de la aplicación Todo, y queremos investigar esto más a
fondo. Ahora podemos usar la siguiente consulta para mostrar todos los eventos
de log ERROR registrados durante una sesión web del usuario duke:
15. Registro Estructurado con Amazon CloudWatch 388

fields @timestamp, level, logger, message


| filter mdc.userId='duke'
| filter level='ERROR'
| sort @timestamp desc
| limit 20

Además del campo del sistema @timestamp, ahora también podemos incluir los
campos level, logger, message (sin el @), y todos los demás campos disponibles
en los eventos de registro JSON en nuestras consultas. La consulta anterior de-
vuelve una tabla estructurada con el timestamp, el nivel de registro, el nombre
del logger y el mensaje de cada evento de registro. El resultado se filtra por los
campos level y mdc.userId para mostrarnos solo los eventos de registro que
nos interesan en este momento.

El campo mdc.userId también nos permite tener una idea de cuántos usuarios
activos tiene la aplicación cada día, lo que a menudo se utiliza como una métrica
de éxito para una aplicación:

stats count_distinct(mdc.userId) as distinct_users by bin(24h)

Esta consulta utiliza el operador stats para contar el número de diferentes


usuarios en intervalos de 24 horas. Obtendremos una tabla con una fecha y una
columna de distinct_users que nos indica cuántos usuarios estuvieron activos
en esa fecha.

Podemos profundizar un poco más para descubrir cuántos de esos usuarios


fueron afectados por errores:

filter level='ERROR'
| stats count_distinct(mdc.userId) as distinct_users by bin(24h)

O, para obtener un sentido general de la salud de nuestros registros, simplemen-


te podemos contar el número de eventos de registro para cada nivel de registro:
15. Registro Estructurado con Amazon CloudWatch 389

stats count(*) by level

Esta consulta mostrará una tabla con el número de eventos de registro en cada
nivel de registro. Es importante notar que cada vez que ejecutamos una consulta,
seleccionamos un intervalo de tiempo en la interfaz de CloudWatch Insights,
por lo que podemos decidir el intervalo de tiempo para el cual deseamos visua-
lizar la cuenta de los niveles de registro.

Ahora también podemos filtrar por el campo logger, para investigar los errores
que un cierto componente ha registrado:

fields @timestamp, level, message


| filter level='ERROR'
| filter logger = 'dev.stratospheric.todoapp.todo.TodoController'
| sort @timestamp desc
| limit 20

Sin el campo estructurado logger, tendríamos que filtrar el campo @message


para el nombre del logger con el operador like. Eso sería engorroso de escribir
y con menos rendimiento para ejecutar.

Estos son solo algunos ejemplos de lo que podemos hacer con los logs estructu-
rados. Si agregamos más campos a nuestros eventos de log en JSON, como un
punto de entrada, una causa raíz en caso de una excepción, o cualquier campo
que sea significativo en el contexto de la aplicación, los logs se convierten en un
recurso de alto valor no solo para investigar errores sino también para obtener
métricas de uso y de negocio de nuestra aplicación.

Incluso podemos construir dashboards a partir de los resultados de las consultas


de logs, algo que veremos en el próximo capítulo.
16. Métricas con Amazon CloudWatch
Hasta ahora, nuestra aplicación Spring Boot está funcionando en producción
y está enviando registros a CloudWatch. La única forma de inspeccionar el
rendimiento de nuestra aplicación es revisando frecuentemente los registros.
Todavía estamos operando más o menos una caja opaca. No podemos determi-
nar con precisión, por ejemplo, si nuestras configuraciones de recursos de ECS
Fargate (CPU y memoria) coinciden con nuestras cargas de trabajo.

Además, al inspeccionar los registros de nuestra aplicación Todo, entendemos


lo que está sucediendo solo cuando estamos mirando activamente. Si bien es
importante revisar regularmente el registro de nuestra aplicación, no podemos
hacer esto 24/7 - sería aburrido y costoso. De manera similar, queremos estar
informados activamente cuando algo va mal. Darse cuenta de que tuvimos una
falla del sistema al leer las quejas de los usuarios no es exactamente deseable.

Es por eso que necesitamos un entendimiento constante de nuestra operación


a través de medidas cuantitativas. Necesitamos métricas tanto para nuestra
aplicación como para los servicios de AWS que estamos integrando. Querremos
admirar métricas visuales en un tablero agradable a la vista, apreciar que las
máquinas están haciendo el trabajo por nosotros y crear alarmas en métricas
clave para detectar defectos temprano y de manera proactiva.

Con la ayuda de las métricas, también podemos tomar decisiones de negocio


e impulsar la evolución de nuestra aplicación. Podemos medir las tasas de
adopción y el uso de las características para este propósito. Otro beneficio es el
apoyo adicional al investigar errores o respuestas lentas en el pasado. Teniendo
métricas para todas las partes clave de nuestro sistema en su lugar, podemos
16. Métricas con Amazon CloudWatch 391

comprender la utilización para cualquier punto en el tiempo en el pasado para


identificar la posible causa raíz.

Con Amazon CloudWatch, AWS ofrece una solución completa de monitoreo y


alerta. Como parte de este capítulo, presentaremos la parte relacionada con las
métricas de Amazon CloudWatch. También vamos a enviar métricas personali-
zadas desde nuestro backend Spring Boot y crear un tablero personalizado para
nuestra aplicación. Discutiremos la parte de alerta en el capítulo Alertando con
Amazon CloudWatch.

Comencemos con las métricas.

Introducción al Monitoreo de Métricas con Amazon


CloudWatch

Amazon CloudWatch es el servicio central de monitoreo y observabilidad de


AWS. Aparte de recopilar y consultar registros, CloudWatch también es un
repositorio de métricas. Podemos almacenar, agregar y visualizar métricas de
servicios de AWS como SQS o RDS, así como de nuestras propias aplicaciones.

Similar a otros servicios de AWS, Amazon CloudWatch es un servicio basado


en regiones. Publicamos nuestras métricas a una región específica, como eu-
central-1. Para evitar cambiar la región de AWS al analizar o depurar una falla
para un servicio que se extiende por varias regiones y zonas de disponibilidad
de AWS, CloudWatch permite analizar métricas entre regiones e incluso entre
cuentas.

Amazon CloudWatch utiliza el concepto de un espacio de nombres para agrupar y


aislar métricas del mismo origen o servicio. Cada punto de datos que enviamos
para nuestras métricas necesita tener un espacio de nombres. No hay un espacio
16. Métricas con Amazon CloudWatch 392

de nombres predeterminado disponible.

AWS usa el espacio de nombres AWS/<SERVICE_NAME> para sus servicios (por


ejemplo, AWS/Cognito, AWS/RDS o AWS/SQS). La mayoría de los servicios de AWS
ya publican métricas a CloudWatch de forma predeterminada.

Para un espacio de nombres personalizado, tenemos que elegir una cadena


de caracteres con solo caracteres XML válidos y una longitud máxima de 256
caracteres. Consideraremos esto al definir un espacio de nombres para nuestra
aplicación más adelante.

Una métrica en el contexto de Amazon CloudWatch es un conjunto ordenado en


el tiempo de puntos de datos. Cada punto de datos que publicamos en Amazon
CloudWatch requiere una marca de tiempo. Si no asociamos información de
tiempo con una métrica, Amazon CloudWatch usará el tiempo en que recibió el
punto de datos. Podemos proporcionar una marca de tiempo hasta dos semanas
en el pasado y dos horas en el futuro.

Para categorizar aún más nuestras métricas, podemos agregar hasta diez di-
mensiones a cada punto de datos. Estas dimensiones son pares de clave/valor
y nos permiten etiquetar métricas. De esta manera, podemos crear múltiples
variaciones de la misma métrica para contar tanto las inscripciones exitosas
como fallidas de los usuarios, por ejemplo.

La métrica NumberOfMessagesSent del espacio de nombres AWS/SQS, por ejem-


plo, incluye la dimensión QueueName para identificar a qué cola de Amazon SQS
pertenece esta métrica.

Agregaremos dimensiones a nuestras métricas personalizadas para identificar


el entorno de la aplicación (etapa o producción) y (opcionalmente) el resultado
de una operación.

Amazon CloudWatch diferencia dos resoluciones de métrica:


16. Métricas con Amazon CloudWatch 393

• resolución estándar con una granularidad que es de un minuto, y


• alta resolución con una granularidad que es en segundos.

La resolución estándar es la predeterminada, y todos los servicios de AWS


producen métricas con una resolución estándar de forma predeterminada. Para
nuestras métricas personalizadas, definimos la resolución por el marco de
tiempo en el que publicamos las métricas desde nuestra aplicación. Tenemos
que tener en cuenta que se nos factura por cada llamada a PutMetricData de la
API cuando usamos métricas de alta resolución.

No podemos eliminar activamente las métricas ya que expirarán automática-


mente después de 15 meses si no se publican nuevos puntos de datos. Las
métricas donde publicamos continuamente puntos de datos, expiran (es decir,
se sobrescriben y ya no están disponibles) después de un período de retención
predefinido dependiendo de su intervalo:

• Los puntos de datos con un intervalo de menos de 60 segundos están


disponibles durante 3 horas (métricas de alta resolución)
• Los puntos de datos con un intervalo de 60 segundos (1 minuto) están
disponibles durante 15 días
• Los puntos de datos con un intervalo de 300 segundos (5 minutos) están
disponibles durante 63 días
• Los puntos de datos con un intervalo de 3600 segundos (1 hora) están
disponibles durante 455 días (15 meses)

Cuando se trata de precios, el número de métricas y el número de solicitudes


de API (por ejemplo, PutMetricData para enviar métricas) son los principales
impulsores de costos. Además de esto, cada tablero personalizado que creamos
agrega $3 a nuestra factura mensual.
16. Métricas con Amazon CloudWatch 394

Amazon CloudWatch trata cada combinación única de dimensiones como una


métrica separada. Además, los cargos por métricas personalizadas se prorra-
tean por hora y solo se miden cuando enviamos métricas a CloudWatch. Eso es
importante tener en cuenta cuando publicamos métricas personalizadas desde
nuestra aplicación.

Para obtener información detallada sobre precios con cálculos de muestra,


considere el calculadora de precios de CloudWatch.

Enviando Métricas desde Servicios AWS

Casi todos los servicios de AWS ya publican métricas en Amazon CloudWatch de


forma predeterminada. Estas métricas predeterminadas ayudan a monitorear la
infraestructura subyacente de nuestra aplicación. De esta manera, por ejemplo,
obtenemos información sobre la utilización de la CPU de nuestra tarea ECS
Fargate o cuántos mensajes hay dentro de nuestra cola de reintentos de Amazon
SQS.

Dependiendo de los servicios de AWS, también podemos activar/desactivar el


monitoreo avanzado. Amazon RDS, por ejemplo, ofrece una opción de Enhanced
Monitoring que proporciona algunas métricas adicionales.

Con la ayuda del explorador de métricas de Amazon CloudWatch, podemos


investigar todas las métricas disponibles para los diferentes servicios de AWS:
16. Métricas con Amazon CloudWatch 395

Explorando métricas con Amazon CloudWatch.

Echaremos un vistazo breve a las métricas importantes para cada servicio de


AWS que ya hemos integrado en las siguientes secciones. Tenga en cuenta que la
siguiente lista no es completa. Sin embargo, hay más métricas para cada servicio
de AWS disponibles.

Amazon ECS

El clúster de Amazon ECS Fargate soporta la mayor parte de nuestra carga de


trabajo. Monitorear el uso de recursos de nuestros contenedores es esencial para
detectar la sobreutilización y la subutilización de la CPU y la RAM.

Si nuestra aplicación no está utilizando la mayoría de los recursos durante un


período continuo, podemos ajustar nuestras configuraciones de Fargate para
reducir nuestra factura mensual de AWS. Si vemos una utilización constante
de la CPU del 80-100%, podemos resolver los cuellos de botella agregando más
recursos.

Métricas clave para monitorear:

• CPUUtilization: El porcentaje de unidades de CPU utilizadas por el clúster


16. Métricas con Amazon CloudWatch 396

o un servicio en el clúster.
• MemoryUtilization: El porcentaje de memoria principal (RAM) utilizada
por el clúster o un servicio en el clúster.

AWS ELB

El Balanceo de Carga Elástico de AWS es el principal punto de entrada desde


Internet a nuestra aplicación. Al depurar un escenario en el que las solicitudes
no llegan a nuestro backend, es importante mirar las métricas del ELB.

AWS diferencia entre los códigos de respuesta del ELB y del destino. El código de
respuesta del ELB es el código de respuesta que ELB devolvió al cliente y el código
de respuesta del destino es el código de respuesta que el destino subyacente
(en nuestro caso, nuestra aplicación Spring Boot) devolvió a ELB. En caso de
una configuración incorrecta del equilibrador de carga, las solicitudes entrantes
pueden no llegar a nuestra aplicación, y por lo tanto, no veremos ningún fallo
dentro de nuestros registros de la aplicación.

Además, con las métricas del ELB, podemos obtener una primera visión general
del número de solicitudes y los tiempos de respuesta medios para un periodo
determinado.

Métricas clave para monitorear:

• HTTPCode_ELB_2XX_Count (y 3XX, 4XX, 5XX): El número de códigos de


respuesta HTTP devueltos por el equilibrador de carga.
• TargetResponseTime: El tiempo transcurrido, en segundos, después de que
la solicitud sale del equilibrador de carga hasta que se recibe una respuesta
del destino.
• RequestCount: El número de solicitudes procesadas a través de IPv4 e IPv6.
16. Métricas con Amazon CloudWatch 397

Amazon Cognito

Con Amazon Cognito como nuestro proveedor de identidad, podemos rastrear


y monitorizar los inicios de sesión y las altas por UserPool.

Métricas clave para monitorear:

• SignUpSuccess: El número de solicitudes exitosas de registro de usuarios


para un UserPool en particular.
• SignInSuccess: El número de solicitudes exitosas de autenticación de
usuarios realizadas a un UserPool en particular.

Amazon SQS

Para nuestra función de compartimiento de tareas pendientes, estamos utili-


zando Amazon SQS para desacoplar el envío de correos electrónicos del resto de
nuestra aplicación. Amazon SQS proporciona varias métricas para inspeccionar
el tamaño aproximado de la cola y la antigüedad del mensaje más antiguo. Esto
ayuda a entender si hay un problema con nuestro procesamiento.

Además, es importante mantener un seguimiento del número de mensajes


dentro de nuestra cola de mensajes no entregables para identificar el fallo en
la consumición de mensajes.

Métricas clave para monitorear:

• NumberOfMessagesReceived: El número de mensajes devueltos por las


llamadas a la acción ReceiveMessage.
• NumberOfMessagesDeleted: El número de mensajes eliminados de una
cola.
16. Métricas con Amazon CloudWatch 398

• ApproximateAgeOfOldestMessage: La edad aproximada del mensaje no


eliminado más antiguo en una cola.
• ApproximateNumberOfMessagesVisible: El número de mensajes disponi-
bles para su recuperación de una cola.

Amazon RDS

Cuando detectamos problemas de rendimiento en nuestra aplicación, la base de


datos suele ser un buen lugar para comenzar la investigación. Las consultas SQL
ineficientes pueden tener un impacto considerable en el rendimiento general.
Especialmente cuando se trabaja con marcos ORM como Hibernate, el problema
de selección N+1 puede drenar significativamente el rendimiento.

Monitorear la base de datos ayuda a descartar o identificar la base de datos como


el posible cuello de botella del rendimiento.

Métricas clave para monitorear:

• CPUUtilization: El porcentaje de utilización de la CPU.


• ReadIOPS: El número promedio de operaciones de lectura de disco por
segundo.
• WriteIOPS: El número promedio de operaciones de escritura en disco por
segundo.
• DatabaseConnections: El número de conexiones a la base de datos que se
están utilizando actualmente.
• FreeStorageSpace: La cantidad de espacio de almacenamiento disponible
en el disco.
16. Métricas con Amazon CloudWatch 399

Amazon DynamoDB

Con Amazon DynamoDB, tenemos que mantener un seguimiento de cómo


utilizamos la capacidad de lectura y escritura configurada. Necesitamos actuar
si vemos eventos de aceleración constante porque excedemos nuestra capacidad
configurada.

Métricas clave para monitorear:

• ReadThrottleEvents: El número de solicitudes que exceden las unidades


de capacidad de lectura provisionadas para una tabla o un índice secundario
global.
• WriteThrottleEvents: El número de solicitudes que exceden las unidades
de capacidad de escritura provisionadas para una tabla o un índice secunda-
rio global.

Amazon SES

La entregabilidad y los porcentajes de rechazo son métricas clave para moni-


torear cuando se envían correos electrónicos. Solo porque nuestra aplicación
envió con éxito un correo electrónico a un usuario no significa que el mensaje
llegó a su bandeja de entrada.

Métricas clave para monitorear:

• Delivery: El número de correos electrónicos entregados con éxito al servi-


dor de correo electrónico del destinatario.
• Reputation.BounceRate: El porcentaje de rechazo de nuestra cuenta. Esto
incluye tanto los rechazos duros (la dirección de correo electrónico no
existe) como los rechazos suaves (la dirección del destinatario está tempo-
ralmente incapaz de recibir mensajes).
16. Métricas con Amazon CloudWatch 400

• Send: El número de intentos de envío de correo electrónico desde nuestra


cuenta.

Amazon MQ

La instancia Amazon MQ actúa como el broker de WebSocket para nuestra


función de notificación en tiempo real. Es vital monitorear el rendimiento y la
utilización de recursos de la instancia del broker.

Métricas clave para monitorear:

• CpuUtilization: El porcentaje de unidades de cómputo de Amazon EC2


asignadas que el broker está utilizando actualmente.
• MessageCount: El número total de mensajes listos y no reconocidos en las
colas.
• TotalMessageCount: El número de mensajes almacenados en el broker.
• TotalConsumerCount: El número de consumidores de mensajes suscritos a
destinos en el broker actual.
• CurrentConnectionsCount: El número actual de conexiones activas en el
broker actual.

Amazon S3

Aunque no utilizamos Amazon S3 (almacenamiento en depósitos) para nuestra


aplicación de tareas pendientes, es un servicio de AWS comúnmente utiliza-
do. Es totalmente concebible mejorar nuestra aplicación de tareas pendientes
gestionando los archivos adjuntos para las tareas pendientes y almacenándolos
también dentro de Amazon S3.

Métricas clave para monitorear:


16. Métricas con Amazon CloudWatch 401

• BucketSizeBytes: La cantidad de datos en bytes almacenados en un depó-


sito.
• NumberOfObjects: El número de objetos almacenados en un depósito para
todas las clases de almacenamiento

AWS Lambda

Para nuestra secuenciación del despliegue, estamos utilizando AWS Lambda.


Como esta es una parte crucial de nuestro pipeline de CI/CD, debemos mantener
un seguimiento cercano del resultado de las invocaciones de Lambda. Una
Lambda puede fallar debido a un error o a un tiempo de espera, y como resultado,
no podremos desplegar en producción.

Métricas clave para monitorear:

• Invocations: El número total de invocaciones para una función.


• Duration: La cantidad de tiempo en milisegundos para una invocación.
• Errors: El número de invocaciones que resultan en un error.

Enviando métricas desde nuestra aplicación Spring Boot

Ahora es el momento de configurar nuestra aplicación de ejemplo Todo para


enviar métricas personalizadas a Amazon CloudWatch.

Configuración

Spring Boot proporciona un único módulo para habilitar características listas


para producción para nuestro backend: spring-boot-actuator. Solo se nece-
sita agregar el Spring Boot Starter como una dependencia adicional, y nuestra
aplicación está equipada con capacidades de auditoría, rastreo y monitoreo.
16. Métricas con Amazon CloudWatch 402

Una introducción detallada a todas las características del Spring Boot Actuator
está fuera del alcance de este libro. Nos vamos a centrar en las partes relaciona-
das con las métricas.

Con el mecanismo de autoconfiguración de Spring Boot y con Actuator en el


classpath, nuestra aplicación comienza a medir componentes clave como:

• métricas centrales (CPU, tiempo de actividad, información de JVM, etc.),


• métricas de Spring MVC (solicitudes entrantes por controlador),
• métricas de DataSource (información sobre el pool de conexiones JDBC,
etc.),
• métricas de HTTP Client (monitoreo de las solicitudes HTTP salientes reali-
zadas con el RestTemplate o WebClient),
• y mucho más.

Detrás de escena, el módulo Actuator utiliza Micrometer como una capa adicio-
nal de abstracción.

Micrometer se describe mejor con la siguiente cita de su documentación oficial:

“Micrometer proporciona una interfaz sencilla sobre los clientes de


instrumentación para los sistemas de monitoreo más populares, permi-
tiéndote instrumentar tu código de aplicación basado en JVM sin el blo-
queo de proveedor. ¡Piensa en SLF4J, pero para métricas de aplicación!
Las métricas de aplicación registradas por Micrometer están destinadas
a ser utilizadas para observar, alertar y reaccionar al estado operativo
actual/reciente de tu entorno.”

Con esta interfaz de métricas de aplicación, podemos definir métricas inde-


pendientes del proveedor para nuestra aplicación. Esto nos permite seguir
un estándar bien definido para definir métricas en lugar de seguir la API y
16. Métricas con Amazon CloudWatch 403

convenciones de métricas de un proveedor específico. En el lado de la aplicación,


las métricas se almacenan en un MetricRegistry y generalmente se envían a
una solución de monitoreo (como Amazon CloudWatch).

La conversión del formato de métrica estandarizado a un formato de proveedor


propietario ocurre dentro de una implementación concreta del MetricRegistry.
Existe una implementación de MetricRegistry de Micrometer para la mayoría
de los sistemas de monitoreo bien conocidos disponibles. Lo que nos resta hacer
es configurarlo.

El proyecto Spring Cloud AWS ofrece un módulo para una integración perfecta
entre Actuator y Amazon CloudWatch que está alineada con la configuración de
AWS de nuestra aplicación.

Todo lo que necesitamos son las siguientes dos dependencias:

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-cloudwatch'
}

Junto con la autoconfiguración de Spring Boot, Spring Cloud AWS configurará


para nosotros el cliente de Amazon CloudWatch Java SDK. Esta configuración
incluye la configuración global de las credenciales AWS. Además, inicializa
un Spring Bean del tipo CloudWatchMeterRegistry que Micrometer utiliza
internamente para publicar las métricas de manera frecuente.

Para evitar enviar métricas a AWS CloudWatch tanto para nuestras pruebas de
integración como cuando se ejecuta localmente, desactivamos la exportación en
nuestro archivo de configuración application-dev.yml:
16. Métricas con Amazon CloudWatch 404

management:
metrics:
export:
cloudwatch:
enabled: false

Para nuestro perfil de aplicación predeterminado (application.yml), activa-


mos y configuramos la exportación de métricas:

management:
metrics:
export:
cloudwatch:
enabled: true
namespace: stratospheric
step: 1m
tags:
environment: ${ENVIRONMENT_NAME}

La configuración de namespace es necesaria, ya que con este identificador vamos


a agrupar las métricas dentro de Amazon CloudWatch. Tan pronto como publi-
quemos la primera métrica desde nuestra aplicación, veremos este espacio de
nombres dentro de la consola de CloudWatch como parte de la sección “Espacios
de nombres personalizados”:
16. Métricas con Amazon CloudWatch 405

Espacio de nombres personalizado dentro de la consola de Amazon CloudWatch.

A continuación, configuramos la frecuencia de publicación de métricas utili-


zando el atributo step. Con 1m definimos una Duration de Java que exporta
las métricas cada minuto. Por favor, recuerde que esto afecta a cómo Amazon
CloudWatch define el tipo de resolución de nuestras métricas (estándar vs. alta
resolución).

Las métricas primero se almacenan en búfer dentro de nuestra aplicación y


luego se envían en lotes a Amazon CloudWatch cada minuto. En caso de que ne-
cesitemos información en tiempo real, podemos reducir esa duración. Al elegir
un paso de sub-minuto, estamos creando métricas de alta resolución y debemos
16. Métricas con Amazon CloudWatch 406

tener en cuenta las implicaciones de esto en cuanto a costos adicionales y la


reducción del periodo de retención de datos a tres horas.

Con el atributo tags, podemos definir un conjunto de metadatos para todas


nuestras métricas personalizadas. Estas etiquetas luego se convierten en di-
mensiones de Amazon CloudWatch. Al agregar etiquetas a nuestros conjuntos
de datos, debemos tener en cuenta que Amazon CloudWatch tiene un límite de
10 dimensiones por conjunto de datos.

Como alternativa a etiquetar las métricas con el nombre del entorno, también
podemos crear un espacio de nombres separado para cada entorno.

Falta una configuración final. Para enviar métricas desde nuestro back-end,
tenemos que agregar una nueva PolicyStatement a nuestro rol de tarea ECS
y permitir la acción PutMetricData de Amazon CloudWatch.

Para nuestra configuración, eso significa un cambio menor en el constructo


Service CDK en nuestra ServiceApp:

.withTaskRolePolicyStatements(
List.of(
// ... existings statements
PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.resources(singletonList("*"))
.actions(singletonList("cloudwatch:PutMetricData"))
.build()
)

CloudWatch no tiene ningún permiso a nivel de recurso y por lo tanto podemos


usar el comodín * como referencia a los recursos de forma segura.

Con esta configuración establecida, nuestra aplicación ya publica las métricas


Actuator por defecto en Amazon CloudWatch cada minuto:
16. Métricas con Amazon CloudWatch 407

Métricas por defecto de Actuator dentro de Amazon CloudWatch.

En la siguiente sección, crearemos métricas personalizadas para rastrear partes


específicas de nuestra aplicación Todo.

Emisión de Métricas Personalizadas desde la Aplicación Todo

Micrometer define tres tipos de métricas que demostramos como parte de este
capítulo: Counter, Timer, y Gauge.

Interactuamos con Micrometer para crear métricas personalizadas usando la


clase abstracta MeterRegistry. Podemos inyectar el MeterRegistry (con un
tipo en tiempo de ejecución de CloudWatchMeterRegistry) en cualquiera de
nuestros componentes Spring:

@Service
public class CognitoRegistrationService implements RegistrationService {

// other fields omitted

private final MeterRegistry meterRegistry;

public CognitoRegistrationService(MeterRegistry meterRegistry) {


this.meterRegistry = meterRegistry;
}
}

Con acceso al MeterRegistry, ahora queremos contar el número de inscripcio-


16. Métricas con Amazon CloudWatch 408

nes de usuarios para nuestra aplicación Todo. Podemos lograr esto creando una
instancia de la clase Counter de Micrometer e incrementándolo cada vez que se
registra un nuevo usuario:

@Override
public void registerUser(Registration registration) {

// prepare the payload to create a new user

awsCognitoIdentityProvider.adminCreateUser(registrationRequest);

Counter successCounter = Counter.builder("stratospheric.registration.users")


.description("Number of user registrations")
.tag("outcome", "success")
.register(meterRegistry);

successCounter.increment();
}

Mediante el uso de las etiquetas de Micrometer, que se transforman en di-


mensiones de Amazon CloudWatch, podemos añadir información acerca del
resultado de la inscripción. Si la inscripción falla, podemos añadir failure a la
etiqueta de outcome para identificar posibles problemas con nuestra inscripción
de usuario Cognito.

También podemos incorporar la creación de métricas directamente, de manera


que no necesitamos utilizar el constructor:

meterRegistry.counter(
"stratospheric.registration.users",
Tags.of("outcome", "success"))
.increment();

Aparte de este enfoque imperativo, también podemos usar anotaciones de Mi-


crometer para definir métricas personalizadas. Este enfoque declarativo facilita,
por ejemplo, medir el tiempo de invocación de nuestros métodos utilizando
@Timed:
16. Métricas con Amazon CloudWatch 409

@Timed(
value = "stratospheric.collaboration.sharing",
description = "Measure the time how long it takes to share a todo"
)
@PostMapping("/{todoId}/collaborations/{collaboratorId}")
public String shareTodoWithCollaborator() {
// ...
}

Al utilizar @Timed, Micrometer publica automáticamente cuatro métricas en


lugar de una. Agrega automáticamente los valores y también cuenta las invo-
caciones de este punto final del controlador. Es por eso que veremos cuatro
métricas diferentes dentro de Amazon CloudWatch:

stratospheric.collaboration.sharing.sum
stratospheric.collaboration.sharing.count
stratospheric.collaboration.sharing.avg
stratospheric.collaboration.sharing.max

En este caso particular, también obtenemos la información de tiempo y


contador para todos nuestros controladores de endpoints de la métrica
http.server.requests por defecto.

Para medir invocaciones donde la información de tiempo no es necesaria, pode-


mos usar la anotación @Counter.

Otro tipo de métrica que podemos crear con Micrometer es la denominada gauge.
Con los gauges, monitoreamos el valor actual de un componente. A diferencia
de un simple Counter, que aumenta monótonamente (como el número de
invocaciones de un método), un gauge representa el valor actual de algo que
puede aumentar y disminuir con el tiempo.

Idealmente, solo deberíamos usar gauges para componentes con un límite


superior natural como los pools de threads o el volumen de un tanque de gas.
Por lo tanto, rastrear el número de solicitudes entrantes del controlador no es
un buen caso de uso para un Gauge ya que no hay un límite superior natural.
16. Métricas con Amazon CloudWatch 410

Aunque no pudimos pensar en una métrica significativa para demostrar un


gauge para la aplicación de Todo, crearlo y rastrearlo es similar a lo que hemos
visto antes con temporizadores y contadores:

Gauge gauge = Gauge.builder(


"stratospheric.car.tank",
() -> myCar.getTank().getFillingRatio())
.description("Measuring the current size of a car tank")
.register(meterRegistry);

// measuring happens when actively observing the current value


gauge.measure();

El valor de un medidor se mide cuando se está tomando activamente la muestra


del valor actual. La documentación de Micrometer proporciona más informa-
ción sobre cuándo y cómo usar los medidores para fines de monitoreo.

Con estos tres tipos de métricas - Contador, Cronómetro e Indicador - podemos


medir cualquier métrica personalizada para nuestra aplicación Spring Boot.

Para nosotros, no queda nada por hacer, ya que Micrometer se encarga de enviar
regularmente lotes de los diferentes puntos de datos a Amazon CloudWatch en
segundo plano. Dependiendo de nuestra configuración de step en Micrometer,
las métricas aparecerán en segundos o en cuestión de minutos dentro de Ama-
zon CloudWatch.

Una vez que las métricas se envían a Amazon CloudWatch, es hora de visuali-
zarlas.

Monitoreo de Métricas con Amazon CloudWatch

Con nuestras métricas ahora disponibles dentro de AWS, podemos construir


tableros atractivos para visualizarlas y obtener información sobre nuestra ope-
ración.
16. Métricas con Amazon CloudWatch 411

Creación de Tableros con Amazon CloudWatch

Cada servicio de AWS que publica métricas en Amazon CloudWatch viene con un
tablero predefinido al que podemos acceder como parte de la consola de Amazon
CloudWatch:

Acceso a los tableros de servicio en Amazon CloudWatch.

Estos tableros de servicio predeterminados proporcionan una visión general


para inspeccionar las operaciones del servicio.

Si queremos combinar varias métricas de servicios de AWS e incluir nuestras


métricas personalizadas, tenemos que crear nuestros propios tableros.

Con fines demostrativos, vamos a crear un tablero mínimo que visualice algunas
métricas y nos brinde más información sobre nuestra aplicación.

Si bien Amazon CloudWatch proporciona un constructor de tableros visual de


16. Métricas con Amazon CloudWatch 412

arrastrar y soltar, preferimos el enfoque de infraestructura como código y nos


lanzaremos directamente a usar el AWS CDK para crear este tablero.

El constructor de tableros visuales es un lugar excelente para comenzar a


explorar las características de los tableros que queremos construir. Podemos
crear el primer borrador de nuestro tablero moviendo los mosaicos dentro del
constructor de tableros visuales y luego tener la funcionalidad de exportación
para crear un plano para nuestra configuración de AWS CDK.

Empecemos creando una nueva aplicación CDK que engloba toda la infraestruc-
tura de monitoreo.

Como parte de la MonitoringApp, estamos instanciando y sintetizando la pila


Monitoring:

public class MonitoringApp {

public static void main(final String[] args) {


App app = new App();

// context variables and environment object creation

new MonitoringStack(app,
"monitoring",
awsEnvironment,
applicationEnvironment);

app.synth();
}
}

Al igual que todas las demás aplicaciones CDK que hemos creado hasta ahora,
estamos añadiendo tanto monitoring:deploy como monitoring:destroy a
nuestro package.json para un mecanismo de sincronización eficaz como parte
de nuestro pipeline (flujo de trabajo) de CI/CD.

El proyecto CDK de Amazon CloudWatch proporciona constructos estables de


16. Métricas con Amazon CloudWatch 413

nivel 2 de CDK para la creación de tableros de control. Los constructos CDK


de CloudWatch son parte de aws-cdk-lib que sólo tenemos que añadir como
una nueva dependencia al pom.xml de nuestro proyecto cdk si no estamos
utilizando ya cdk-constructs:

<!-- Both imports can be omitted when using cdk-constructs -->


<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>${aws-cdk-lib.version}</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>${constructs.version}</version>
</dependency>

El proyecto CloudWatch CDK también ofrece estructuras para la creación de


alarmas, pero por ahora, nuestro enfoque será el constructo Dashboard. Explo-
raremos la creación de alarmas en el capítulo Alertando con Amazon CloudWatch.

Para el ejemplo que se aproxima, definiremos el tablero de ejemplo en un cons-


tructo CDK personalizado llamado SampleCloudWatchDashboard. Dado que es-
taremos enfocándonos en los bloques de construcción principales para crear un
tablero de CloudWatch con el AWS CDK, la creación de un tablero operacional
completo no está dentro del alcance de este capítulo. Hemos añadido un proto-
tipo para un tablero listo para usar dirigido a aplicaciones Java / Spring Boot en
el código fuente del constructo OperationalCloudWatchDashboard.

La clase Dashboard es el punto de entrada principal para la creación de un


tablero con el CDK. Como parte de la creación de un tablero nuevo, definimos
tanto el diseño como los metadatos mediante DashboardProps. Los metadatos
del tablero incluyen el nombre del mismo. Podemos especificar, de manera
opcional, el inicio y el final predeterminados del tablero:
16. Métricas con Amazon CloudWatch 414

new Dashboard(this, "sampleApplicationDashboard",


DashboardProps.builder()
.dashboardName(applicationEnvironment.getApplicationName()
+ "-sample-application-dashboard")
.start("-3H") // start three hours ago
// ...
.build();

Estructuramos el diseño y contenido del panel de control utilizando el méto-


do widgets() del constructor DashboardProps. Este método espera una lista
anidada (List<List<? extends IWidget>) donde la primera dimensión repre-
senta la fila del panel de control. La segunda dimensión representa la columna
dentro de la fila. Dentro de cada columna, colocamos un solo widget o varios
widgets.

Para diseños de panel de control más avanzados y complejos con AWS


CDK, por favor consulte la documentación oficial de Amazon CloudWatch
CDK.

El proyecto Amazon CloudWatch CDK define los siguientes tipos de widgets:

• TextWidget
• SingleValueWidget
• GraphWidget
• LogQueryWidget
• AlarmWidget
• AlarmStatusWidget

Para propósitos de demostración, vamos a llenar la primera fila de nuestro


panel de control personalizado con los primeros cuatro tipos de widgets. Los
widgets AlarmWidget y AlarmStatusWidget se discuten en el capítulo Alertando
con Amazon CloudWatch.
16. Métricas con Amazon CloudWatch 415

Vamos a empezar con el TextWidget. Con este widget, podemos mostrar infor-
mación basada en texto arbitrario utilizando la sintaxis Markdown:

new Dashboard(this, "applicationDashboard", DashboardProps.builder()


.dashboardName(applicationEnvironment + "-application-dashboard")
.widgets(List.of(
List.of(
TextWidget.Builder
.create()
.markdown(
"# Stratospheric Dashboard \n Created with AWS CDK." +
" \n * IaC \n * Configurable \n * Nice-looking")
.height(6)
.width(6)
.build()
)
)
.build();

En nuestro ejemplo, mostramos un mensaje de bienvenida simple. Podemos


usar estos widgets de texto para enlazar a nuestros runbooks, documentación,
o cualquier otra información estática que podría ser de ayuda durante un inci-
dente.

Por defecto, cada tipo de widget viene con un height y width predefinidos.
Podemos sobrescribir ambos valores para definir manualmente el tamaño del
widget o para alinear el ancho de todos los widgets, por ejemplo. Al especificar
manualmente el ancho de nuestros widgets, debemos tener en cuenta que cada
fila tiene un ancho total de 24.

Dentro de la siguiente columna del tablero, mostraremos un SingleValueWid-


get para hacer un seguimiento de cuántos usuarios se registraron para nuestra
aplicación en cualquier periodo de tiempo dado:
16. Métricas con Amazon CloudWatch 416

SingleValueWidget.Builder
.create()
.title("User Registrations")
.setPeriodToTimeRange(true)
.metrics(List.of(new Metric(MetricProps.builder()
.namespace("stratospheric")
.metricName("stratospheric.registration.signups.count")
.region(awsEnvironment.getRegion())
.statistic("sum")
.dimensionsMap(Map.of(
"outcome", "success",
"environment", applicationEnvironment.getEnvironmentName()
))
.build())))
.build()

Por defecto, Amazon CloudWatch muestra nuestras métricas con un período


predefinido. Este período define el ancho del rango de tiempo de cada punto
de datos en un gráfico. Supongamos que visualizamos el registro de usuarios de
las últimas 24 horas con un período de 1 hora. Obtendríamos un gráfico con 24
puntos de datos que muestran la sum agregada de nuestra métrica cada hora.

Para nuestro SingleValueWidget, no queremos este comportamiento. Quere-


mos mostrar un solo valor que represente el número de registros de las últimas
24 horas. Para evitar dividir la visualización de la métrica en múltiples períodos,
establecemos periodToTimeRange en true, y por lo tanto, el período (es decir,
el ancho de nuestros puntos de datos) es igual a todo el rango de tiempo.

La definición de la métrica ocurre dentro del método metrics del constructor del
widget. Este método espera una lista de instancias Metric. Para este ejemplo,
nos estamos refiriendo a una de nuestras métricas personalizadas y mostramos
la suma de los registros de usuarios. Es importante agregar la dimensión envi-
ronment correcta para la definición de la métrica, ya que estamos añadiendo la
etiqueta del entorno a cada métrica personalizada.

Al visualizar métricas personalizadas de nuestra aplicación, debemos tener en


16. Métricas con Amazon CloudWatch 417

cuenta que estamos ejecutando múltiples instancias de la aplicación. Todas


nuestras instancias de backend de Spring Boot publican métricas en Amazon
CloudWatch.

Esto no tiene efecto cuando se muestra un sum, pero podríamos no detectar


valores anómalos tan pronto como estamos visualizando un promedio (avg).
Imagina mostrar el consumo de memoria JVM con un único valor avg para
todas las instancias. Esto puede llevar a interpretaciones erróneas ya que no
detectaremos si una instancia está sobrecargada mientras las otras instancias
están en reposo.

En tales casos, agrupar la métrica por instancia puede ser útil.

El tipo de widget más complejo es el GraphWidget. Como el nombre indica,


podemos visualizar nuestras métricas utilizando gráficos de barras, de pastel o
de líneas. Para fines de demostración, visualizaremos dos métricas de Amazon
Cognito:

GraphWidget.Builder.create()
.title("User Sign In")
.view(GraphWidgetView.BAR)
.left(List.of(new Metric(MetricProps.builder()
.namespace("AWS/Cognito")
.metricName("SignInSuccesses")
.region(awsEnvironment.getRegion())
.period(Duration.minutes(15))
.dimensionsMap(Map.of(
"UserPoolClient", cognitoOutputParameters.getUserPoolClientId(),
"UserPool", cognitoOutputParameters.getUserPoolId()))
.statistic("sum")
.build())))
.right(List.of(new Metric(MetricProps.builder()
.namespace("AWS/Cognito")
.metricName("TokenRefreshSuccesses")
.region(awsEnvironment.getRegion())
.period(Duration.minutes(15))
.dimensionsMap(Map.of(
"UserPoolClient", cognitoOutputParameters.getUserPoolClientId(),
16. Métricas con Amazon CloudWatch 418

"UserPool", cognitoOutputParameters.getUserPoolId()))
.statistic("sum")
.build())))
.build()

Para referirnos al correcto UserPoolClient y UserPool, utilizamos los pará-


metros de resultado de nuestro CognitoStack de AWS CDK. Con left y right
definimos las métricas que son visualizadas al lado izquierdo y derecho del eje
Y. El resultado será un gráfico de barras que muestra la suma de los inicios de
sesión de usuario y las renovaciones de token que sucedieron en una ventana de
tiempo de 15 minutos.

Finalmente y no menos importante, mostraremos los últimos logs de nuestra


aplicación Spring Boot utilizando el LogQueryWidget:

LogQueryWidget.Builder
.create()
.view(LogQueryVisualizationType.TABLE)
.title("Backend Logs")
.logGroupNames(List.of(applicationEnvironment + "-logs"))
.queryString(
"fields @timestamp, @message" +
"| sort @timestamp desc" +
"| limit 20")
.build()

Al construir este widget, debemos especificar la cadena de consulta y la lista de


grupos de registros que queremos consultar. El ejemplo anterior muestra las
últimas 20 líneas de registro de nuestra aplicación del backend.

Reuniéndolo todo, nuestro panel personalizado se ve de la siguiente manera:


16. Métricas con Amazon CloudWatch 419

Un panel personalizado de Amazon CloudWatch.

Estos cuatro widgets representan un panel básico de Amazon CloudWatch. La


mayoría de los widgets permiten configuraciones avanzadas como coloración,
etiquetado de ejes, períodos personalizados, etc. La documentación del CDK de
Amazon CloudWatch proporciona más ejemplos de cómo usar los diferentes
widgets.

Equipados con estos cuatro tipos de widgets, ahora podemos construir paneles
interesantes para diversos propósitos, por ejemplo para mostrar:

• métricas de negocio (tasas de conversión, número de pedidos, etc.),


• operaciones de sistema de alto nivel (SLO y SLIs), o
• para monitorear sistemas externos (respondiendo preguntas como: “¿Có-
mo se desempeñan nuestros sistemas dependientes?”).

Para una plantilla de panel operacional lista para usar para la aplicación de
muestra Stratospheric, dirígete al repositorio de GitHub y echa un vistazo al
constructo OperationalCloudWatchDashboard.

El panel blueprint OperationalCloudWatchDashboard contiene gráficos para


las métricas clave de nuestra aplicación Spring Boot. Esto incluye los últimos re-
gistros de errores, la distribución de los diferentes niveles de registro, tiempos
de respuesta desde el equilibrador de carga y métricas esenciales para nuestra
base de datos RDS y cola SQS.
16. Métricas con Amazon CloudWatch 420

Alternativas a Amazon CloudWatch

Cuando se trata de alternativas para Amazon CloudWatch, tenemos que diferen-


ciar entre dos casos de uso: almacenar métricas y crear paneles.

Hay muchas herramientas disponibles que cubren ambos casos de uso mientras
que otras se enfocan en solo una parte. Prometheus, por ejemplo, es una base
de datos de series de tiempo que podría ser perfecta para una solución local de
autohospedaje. Aunque tiene una visualización básica de métricas incorporada,
generalmente estamos mejor eligiendo una herramienta como Grafana para
crear paneles elegantes.

El mercado para soluciones de monitoreo, especialmente ofertas de SaaS, es


enorme. Para nombrar solo algunos de los proveedores más conocidos, en
ningún orden particular o preferencia:

• Datadog
• New Relic
• Splunk
• Dynatrace
• Prometheus
• Grafana
• AppOptics
• etc.

Debido a la excelente integración de Amazon CloudWatch con toda la infraes-


tructura de AWS, uno podría comenzar con la solución de monitoreo preferible
de Amazon. Sin embargo, echar un vistazo más de cerca a los costos es impor-
tante al usar Amazon CloudWatch a gran escala.
16. Métricas con Amazon CloudWatch 421

Si decidimos cambiar de proveedores para nuestro monitoreo, migrar a una pila


de monitoreo diferente es, la mayoría de las veces, sencillo.

Gracias a la abstracción de Micrometer, podemos conectar una implementación


diferente de MeterRegistry en cualquier momento. De esta manera, comenza-
mos a enviar las mismas métricas de aplicación a Datadog o Prometheus con
solo un cambio de configuración.

Sin embargo, cualquier panel personalizado de Amazon CloudWatch tiene que


ser migrado a la sintaxis del panel del nuevo proveedor. Eso implica un único
esfuerzo de migración.

Cuando se trata de exportar métricas de servicios específicos de AWS como la


CPUUtilization de nuestra base de datos RDS, tenemos que exportarlas desde
AWS. La mayoría de las soluciones de monitoreo de terceros bien conocidas
proporcionan un adaptador para obtener periódicamente métricas de AWS.
Estos adaptadores solo necesitan ser configurados una vez. Vale la pena revisar
la documentación del proveedor de monitoreo en caso de que haya un plan para
alejarse de Amazon CloudWatch.

Grafana, por ejemplo, ya viene con soporte nativo para Amazon CloudWatch. En
caso de que nuestro proveedor no se integre con CloudWatch, también podemos
recurrir a los streams de métricas de Amazon CloudWatch para hacer que los
puntos de datos estén disponibles en sistemas de terceros.
17. Alertando con Amazon CloudWatch
Las métricas, los paneles y las percepciones en los registros de nuestra apli-
cación hacen que la operación de nuestra aplicación sea transparente. En todo
momento, sabemos lo que está sucediendo y podemos identificar los problemas
de nuestra infraestructura, arquitectura o implementación.

Revisar frecuentemente nuestros paneles y filtrar los registros de errores es


una rutina que vale la pena hacer. Cuando trabajamos en la misma oficina,
es beneficioso mostrar nuestros paneles en un monitor dedicado. Así, todos
pueden echar un vistazo rápido a la salud de nuestro sistema mientras van a
por un café.

Sin embargo, no deberíamos depender de identificar interrupciones e inciden-


tes mientras inspeccionamos activamente nuestros paneles y registros. Los
humanos tienden a olvidar cosas o pasan sus días en reuniones. Además, con
la tendencia actual hacia el trabajo prioritariamente remoto, ya no estamos
trabajando juntos desde la misma ubicación física. Todos están sentados en casa
y podrían esperar que otro miembro del equipo observe frecuentemente la salud
de la aplicación.

Aún peor, ¿qué pasa con la operación de nuestra aplicación por la noche? ¡No
queremos tener que mirar los registros toda la noche!

Necesitamos una solución automatizada que identifique las fallas en nuestros


sistemas lo antes posible. Esto nos permite comenzar a solucionar un incidente
antes de que el primer usuario se queje.

Afortunadamente, existe una solución automatizada para este caso de uso: las
17. Alertando con Amazon CloudWatch 423

alarmas.

Con las alarmas, podemos definir umbrales basados en nuestras métricas de


aplicación o infraestructura para identificar una posible falla de nuestro sistema.
Una vez que una métrica supera el umbral (por ejemplo, más de 100 respuestas
HTTP 5xx en 5 minutos), seremos notificados automáticamente.

Esa es la teoría. En la práctica, hay muchos matices en esto, y conseguir las


alarmas correctas con umbrales significativos no es trivial.

Con este capítulo, introduciremos las capacidades de alerta de Amazon Cloud-


Watch, veremos cómo podemos crear alarmas con el AWS CDK y discutiremos
las mejores prácticas para manejar fallas.

Comencemos.

Introducción a la alerta con Amazon CloudWatch

Con Amazon CloudWatch, podemos crear dos tipos de alarmas:

• Alarmas métricas: Evaluamos continuamente una sola métrica de Cloud-


Watch o el resultado de una expresión matemática basada en hasta diez
métricas de CloudWatch.
• Alarmas compuestas: Consideramos el estado de varias alarmas y defini-
mos una expresión lógica en ellas (por ejemplo ALARM("alarma-a") AND
(ALARM("alarma-b") OR ALARM("alarma-c"))).

Demostraremos ambos tipos de alarmas y crearemos al menos una para cada


tipo usando el AWS CDK en las siguientes secciones.

Cada alarma está en uno de los siguientes tres estados en cualquier momento
dado:
17. Alertando con Amazon CloudWatch 424

• OK: Yay - Todo está bien. Estamos por debajo de nuestro umbral definido.
• ALARM: Ouch - El umbral para nuestra métrica o expresión matemática ha
sido superado.
• INSUFFICIENT_DATA: No hay o no hay suficientes puntos de datos para la
métrica disponibles. Esto suele ser el caso cuando acabamos de crear la
alarma, o nuestra métrica ya no se está emitiendo.

Una vez que una alarma pasa a un nuevo estado, podemos activar acciones
automáticas con Amazon CloudWatch. Exploraremos las acciones disponibles
en la sección Añadiendo acciones de alarma a continuación.

En su núcleo, cada alarma evalúa continuamente una expresión (basada en


métricas, o en el estado de otras alarmas) para identificar una falla en nuestro
sistema. Cuatro componentes entran en esta detección de fallas:

• Periodo: ¿Con qué frecuencia evaluamos la métrica o la expresión matemá-


tica? Cuando el período se establece en cinco minutos, la alarma evalúa la
métrica cada cinco minutos.
• Períodos de evaluación: ¿Cuántos períodos recientes estamos evaluando
para determinar el estado de la alarma? Si se establece en tres, por ejemplo,
se evaluarán los datos de tres períodos consecutivos de cinco minutos.
• Umbral: ¿Qué cantidad identifica un posible incidente? Por ejemplo, pode-
mos establecer esto en 10 si queremos alertar sobre 10 o más respuestas
HTTP 5XX.
• Puntos de datos para alertar: ¿Cuántos puntos de datos dentro de nuestros
períodos de evaluación necesitan superar el umbral? Por ejemplo, podría-
mos decidir disparar una alarma sólo si el umbral ha sido superado cinco
veces dentro de los tres períodos de evaluación.

Ajustar estos cuatro valores es un proceso continuo. Como primer paso, es


importante entender cómo estos parámetros influyen en la evaluación de una
17. Alertando con Amazon CloudWatch 425

alarma. La documentación de Amazon CloudWatch explica cómo se afectan


entre sí con ejemplos concretos e ilustraciones de líneas de tiempo.

Otra configuración esencial que hacer para cada alarma es cómo maneja los
datos métricos ausentes. ¿Deberían los datos faltantes superar nuestro umbral,
podemos ignorarlo, o debería la alarma pasar al estado INSUFFICIENT_DATA?

Esto depende en gran medida de la métrica que estamos evaluando. Por ejemplo,
podríamos tratar los datos faltantes como una transgresión para métricas donde
esperamos datos continuos como la métrica de utilización de CPU de nuestra
tarea ECS. Por otro lado, podemos simplemente ignorar los datos faltantes para
métricas como el conteo de API 5xx ya que este es el estado esperado (no error).

La configuración de los datos faltantes tiene un impacto en la evaluación del es-


tado de nuestra alarma. Visita la documentación oficial de Amazon CloudWatch
para aprender sobre los diferentes escenarios de cómo se evalúan los datos que
faltan.

Amazon CloudWatch también ofrece una función de detección de anomalías.


Esto nos permite aplicar algoritmos estadísticos y de aprendizaje automático
a puntos de datos pasados para definir automáticamente un rango esperado de
nuestras métricas. No cubriremos la detección de anomalías en este capítulo,
ya que nos estamos enfocando en crear las alarmas nosotros mismos. Para más
información sobre esta prometedora función de detección de fallos, consulta la
documentación de detección de anomalías de CloudWatch.

Una última palabra sobre el costo de nuestras alarmas. Se nos factura depen-
diendo de la resolución (estándar vs. alta resolución) de la métrica de alarma que
estamos evaluando. Además, cada alarma compuesta y detección de anomalías
viene con un precio fijo por mes.

Ahora, vamos a crear nuestra primera alarma con AWS CDK.


17. Alertando con Amazon CloudWatch 426

Creando Alarmas con AWS CDK

Dado que nuestras alarmas entran en el ámbito de operación y monitoreo de


nuestra aplicación, enriqueceremos el MonitoringStack de CDK existente y
añadiremos más constructos a este. Puedes echar un vistazo a ese stack de CDK
en GitHub.

Creando Alarmas de Métricas

Comenzaremos con una alarma básica que monitorea nuestra aplicación Todo
para respuestas lentas. Las respuestas lentas pueden tener múltiples causas.
Por ejemplo, sistemas remotos que responden lentamente de los que depen-
demos, contratiempos generales, o alta presión sobre la base de datos. Para las
APIs críticas para la misión que incluso podríamos exponer a clientes externos,
es vital hacer un seguimiento de la rapidez con la que responde nuestro backend.

El proyecto Amazon CloudWatch CDK ofrece constructos de nivel 2 estables


para crear alarmas de manera conveniente. Estos constructos son parte de la
dependencia de Java aws-cdk-lib, que ya hemos incluido en nuestro proyecto
CDK como parte del capítulo Métricas con Amazon CloudWatch:
17. Alertando con Amazon CloudWatch 427

<!-- Both imports can be omitted when using cdk-constructs -->


<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>${aws-cdk-lib.version}</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>${constructs.version}</version>
</dependency>

Para este ejemplo de alarma, estamos utilizando una métrica que AWS ELB
emite de manera predeterminada. Aunque nuestro backend de Spring Boot
también emite información de tiempo sobre nuestros controladores, el tiempo
de respuesta del ELB es más relevante ya que esto incluye el tiempo total de ida
y vuelta para nuestros clientes y no solo cuánto tiempo tomó la invocación al
controlador.

La métrica TargetResponseTime que esta alarma verifica continuamente pro-


viene del espacio de nombres AWS/ApplicationELB. Como parte de nuestra
definición de alarma, tenemos que referirnos al balanceador de carga correcto
con precisión. Esto incluye tanto la región de AWS como el nombre del balan-
ceador de carga. Por lo tanto, pasamos ambos elementos como dimensiones de
la métrica.

La información sobre la región de AWS se encuentra disponible para todas


nuestras pilas como parte del objeto Environment que pasamos a nuestro
constructor MonitoringStack:
17. Alertando con Amazon CloudWatch 428

public class MonitoringStack extends Stack {

public MonitoringStack(
// ...
final Environment awsEnvironment) {
// ...
}
}

Lo que queda es determinar el nombre del balanceador de carga. Nuestra Net-


work devuelve el ARN del balanceador de carga como uno de sus parámetros de
salida. Un ARN de un balanceador de carga luce algo así:

arn:aws:elasticloadbalancing:{REGION}:{ACCOUNT_ID}:
loadbalancer/app/staging-loadbalancer/2280d6069948c49e

Tenemos que modificar ligeramente el ARN porque la métrica espera el nombre


del equilibrador de carga en un formato específico:

app/staging-loadbalancer/2280d6069948c49e

Para extraer la última parte del ARN del equilibrador de carga, estamos separan-
do el parámetro de salida del constructo Network:

Network.NetworkOutputParameters networkOutputParameters =
Network.getOutputParametersFromParameterStore(this,
applicationEnvironment.getEnvironmentName());

String loadBalancerName = Fn
.split(":loadbalancer/", networkOutputParameters.getLoadBalancerArn(), 2)
.get(1);

Como estamos almacenando los parámetros de salida de nuestro stack dentro


del AWS Parameter Store, estos parámetros se convierten en un Token durante
la fase de construcción de nuestro stack:
17. Alertando con Amazon CloudWatch 429

System.out.println(networkOutputParameters.getLoadBalancerArn());

${Token[TOKEN.60]}

Este token apunta al parámetro real almacenado en el SSM Parameter Store


pero se resuelve durante la fase de prepare. Es por eso que no podemos usar
la manipulación de cadenas en Java: El valor no se ha resuelto aún en la fase
de construcción. Por lo tanto, estamos utilizando la función incorporada de
CloudFormation Fn::Split para manipular el parámetro justo después de que
se resuelve.

Con las dimensiones de la métrica establecidas, podemos crear una instancia


del objeto Alarm y configurar nuestras reglas para las alertas:

Alarm elbSlowResponseTimeAlarm = new Alarm(this, "elbSlowResponseTimeAlarm",


AlarmProps.builder()
.alarmName("slow-api-response-alarm")
.alarmDescription("Indicating potential problems with the Spring Boot Backend")
.metric(new Metric(MetricProps.builder()
.namespace("AWS/ApplicationELB")
.metricName("TargetResponseTime")
.dimensionsMap(Map.of(
"LoadBalancer", loadBalancerName
))
.region(awsEnvironment.getRegion())
.period(Duration.minutes(5))
.statistic("avg")
.build()))
.treatMissingData(TreatMissingData.NOT_BREACHING)
.comparisonOperator(ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD)
.evaluationPeriods(3)
.threshold(2)
.actionsEnabled(true)
.build());

Además de establecer el nombre de la alarma y agregar un texto descripti-


vo, estamos definiendo la métrica que esta alarma monitorea continuamente.
17. Alertando con Amazon CloudWatch 430

Nuestra alarma evalúa el tiempo de respuesta promedio de nuestro ELB de AWS


en ventanas de tiempo de cinco minutos.

Lo que sigue es definir cómo nuestra alarma trata los datos faltantes. Para
nuestro ejemplo, no hay puntos de datos para esta métrica siempre que no haya
tráfico a nuestra aplicación (por la noche, por ejemplo). Por lo tanto, se espera
que falten datos y no se sobrepasará el límite.

Definimos el umbral de alerta mediante una combinación de comparisonOpe-


rator, evaluationPeriods y threshold. En nuestro ejemplo, la alarma pasa al
estado ALARM siempre que el tiempo de respuesta objetivo sea mayor o igual a
2 segundos en un periodo de quince minutos (tres períodos de evaluación de 5
minutos).

Con actionsEnabled habilitamos o deshabilitamos cualquier acción adjunta


que esta alarma activa cuando cambia el estado de la alarma.

Una vez que desplegamos nuestra CDK MonitoringApp con npm


monitoring:deploy, veremos el siguiente resultado en la consola de Amazon
CloudWatch dentro de la sección de Alarmas:
17. Alertando con Amazon CloudWatch 431

La alarma “Slow API Response” en la consola de Amazon CloudWatch.

En el contexto de nuestra aplicación Todo, otras alarmas que vale la pena crear
son las siguientes:

• Monitorear el número de mensajes dentro de nuestra DLQ (cola de letras


muertas) para identificar problemas con el procesamiento de solicitudes de
colaboración.
• Monitorear la tasa de entrega de emails de SES.
• Monitorear la utilización de recursos esenciales (CPU, RAM) de nuestras
tareas ECS.

Como próximo paso, agregaremos una acción a nuestra alarma para recibir
notificaciones activas cuando nuestro backend responde lentamente.
17. Alertando con Amazon CloudWatch 432

Añadiendo Acciones de Alarma

Podemos activar acciones automáticas siempre que nuestra alarma cambie a


un nuevo estado (OK, ALARM, INSUFFICIENT_DATA). Amazon CloudWatch actual-
mente admite cuatro acciones:

• Activar acciones de EC2, por ejemplo, para reiniciar o terminar una instan-
cia.
• Realizar una operación de autoscaling, por ejemplo, para escalar cuando la
utilización de recursos es alta.
• Enviar una notificación a un tema de SNS.
• Crear un incidente u OpsItem dentro del AWS Systems Manager.

Para nuestra alarma de tiempo de respuesta lento, enviaremos una notificación


a un tema de SNS cuando la métrica sobrepase nuestro umbral. De esta manera,
podemos enrutar la alarma a diferentes sistemas añadiendo varios suscriptores
al tema de SNS.

SNS ofrece varios tipos de suscriptores para notificar, enrutar, activar acciones
siempre que haya una nueva notificación:

• Activar una función AWS Lambda.


• Enviar un mensaje a una cola SQS de Amazon.
• Enviar un correo electrónico.
• Invocar un endpoint HTTP/HTTPS con la carga útil de la notificación.
• Enviar un SMS.

Vamos a informar a los desarrolladores de nuestra aplicación Todo enviándoles


un correo electrónico.

Como primer paso, creamos un tema de SNS de Amazon que actúa como un
centro para enrutar las notificaciones de alarma más allá:
17. Alertando con Amazon CloudWatch 433

Topic snsAlarmingTopic = new Topic(this, "snsAlarmingTopic", TopicProps.builder()


.topicName(applicationEnvironment + "-alarming-topic")
.displayName("SNS Topic to further route Amazon CloudWatch Alarms")
.fifo(false)
.build());

Configurar el topic SNS utilizando el constructo de nivel 2 del CDK es sencillo. No


necesitamos un ordenamiento estricto de las notificaciones de alarma porque
suelen llegar con poca frecuencia y no de manera concurrente (al menos, eso
esperamos). Por ende, deshabilitamos la opción FIFO.

A continuación, añadimos nuestras suscripciones. Para nuestro ejemplo, esta-


mos utilizando la EmailSubscription:

snsAlarmingTopic.addSubscription(EmailSubscription.Builder
.create(confirmationEmail)
.build()
);

Debemos confirmar cada suscripción de Amazon SNS antes de que po-


damos recibir notificaciones por este medio. En nuestro ejemplo, con-
firmamos la suscripción haciendo clic en un enlace que AWS envió a
info@stratospheric.dev.

Podemos añadir más suscripciones a nuestro tema de SNS e integrar herra-


mientas de gestión de incidentes de terceros añadiendo una UrlSubscription.
SNS realizará un POST HTTP con la carga útil del mensaje e informará a otros
sistemas sobre una alarma especificada:
17. Alertando con Amazon CloudWatch 434

snsAlarmingTopic.addSubscription(UrlSubscription.Builder
.create("https://my-alarming-tool.com/alarms/incoming")
.build());

Con el constructo UrlSubscription, también podemos integrar Slack/Micro-


soft Teams/Discord para hacer sonar una alarma cada vez que haya un cambio
en el estado de la alarma.

Inmediatamente después de crear el tema SNS, podemos integrarlo con nuestra


alarma de Amazon CloudWatch para enviar notificaciones cada vez que sobre-
pasemos nuestro umbral:

elbSlowResponseTimeAlarm.addAlarmAction(new SnsAction(snsAlarmingTopic));

También podemos definir acciones separadas para cada transición de estado de


alarma y manejarlas de manera diferente:

elbSlowResponseTimeAlarm.addInsufficientDataAction(new SnsAction(anotherTopic));

elbSlowResponseTimeAlarm.addOkAction(new AutoScalingAction(...));

Creando Alarmas Basadas en Registros

Como siguiente paso, crearemos una alarma basada en los registros de nuestra
aplicación Spring Boot. Gracias al trabajo de preparación del capítulo Registro es-
tructurado con Amazon CloudWatch, podemos filtrar y contar la salida del registro
por nivel de registro con facilidad.

Pondremos una alarma para nuestro próximo ejemplo que monitorea de forma
continua la cantidad de registros ERROR que produce nuestra aplicación.

Existen numerosas razones por las cuales nuestro backend podría generar
registros ERROR. No todas ellas justifican despertar a alguien en mitad de la
noche. Imagina un atacante malicioso intentando penetrar en nuestras APIs. El
17. Alertando con Amazon CloudWatch 435

StrictHttpFirewall (parte de la FilterChain de Spring Security) producirá


bastante ruido en stdout para cada solicitud maliciosa. Como esto es esperado
y como actualmente no está ocurriendo nada mal (excepto ser atacado, lo cual
también podría ser digno de mención), el desarrollador de guardia no tendría
necesariamente que levantarse de la cama.

Sin embargo, usualmente podemos correlacionar un incremento en los regis-


tros de error con un fallo en nuestro sistema. En resumen, ten en cuenta los
posibles falsos positivos al confiar (únicamente) en este tipo de alarma.

Antes de que podamos establecer un umbral para nuestros registros de error,


aún tenemos algunos trabajos preliminares por hacer.

Como nuestras alarmas se basan en métricas, de alguna manera necesitamos


una métrica basada en nuestros registros del backend. Amazon CloudWatch
proporciona una funcionalidad de filtro de métrica para tales casos de uso. Con
este filtro, definimos nuestra consulta de registro una vez (es decir, filtramos
todos los registros ERROR o contamos las ocurrencias de un término específico
dentro del mensaje). Luego, podemos crear una métrica personalizada (y alar-
ma) encima de ella.

El nivel de registro es directamente accesible desde el campo personalizado


level de nuestros registros en formato JSON. Por lo tanto, el siguiente patrón
filtra todos los registros de error y logra lo que estamos buscando:

{ $.level = "ERROR" }

Basándonos en este patrón de filtro, ahora podemos crear el filtro de métricas


para nuestro grupo de registros. El módulo CloudWatch CDK proporciona una
estructura de nivel 2 para esto:
17. Alertando con Amazon CloudWatch 436

MetricFilter errorLogsMetricFilter = new MetricFilter(this, "errorLogsMetricFilter",


MetricFilterProps.builder()
.metricName("backend-error-logs")
.metricNamespace("stratospheric")
.metricValue("1")
.defaultValue(0)
.logGroup(LogGroup
.fromLogGroupName(this, "applicationLogGroup",
applicationEnvironment + "-logs"))
.filterPattern(FilterPattern.stringValue("$.level", "=", "ERROR"))
.build());

Al crear el MetricFilter, definimos el nombre de la métrica, el namespace


personalizado al que pertenece e información sobre cómo medirla. Para nuestro
ejemplo, el valor de la métrica es numérico ya que se cuenta el número de líneas
de registro de error.

Con logGroup, nos referimos al grupo de registros de Amazon CloudWatch al


que queremos aplicar el patrón de filtro. El patrón de filtro métrico en sí se
define justo antes de construir la instancia de MetricFilter.

Ahora podemos crear la métrica en sí directamente desde la instancia de Me-


tricFilter y pasar otras opciones de configuración (por ejemplo, duración,
operador estadístico, etc.):

MetricFilter errorLogsMetricFilter = // ...

Metric errorLogsMetric = errorLogsMetricFilter.metric(MetricOptions.builder()


.period(Duration.minutes(5))
.statistic("sum")
.region(awsEnvironment.getRegion())
.build());

Como paso final, creamos una alarma basada en esta métrica. Construimos
la alarma directamente desde la instancia de la métrica (a diferencia de new
Alarm() que se utilizó para la alarma anterior). De esta forma, ahorramos en
17. Alertando con Amazon CloudWatch 437

escritura para la definición de la métrica y solo tenemos que configurar el límite


y los metadatos de la alarma:

MetricFilter errorLogsMetricFilter = // ...

Metric errorLogsMetric = errorLogsMetricFilter


.metric();

Alarm errorLogsAlarm = errorLogsMetric


.createAlarm(this, "errorLogsAlarm", CreateAlarmOptions.builder()
.alarmName("backend-error-logs-alarm")
.alarmDescription("Alert on multiple ERROR backend logs")
.treatMissingData(TreatMissingData.NOT_BREACHING)
.comparisonOperator(ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD)
.evaluationPeriods(3)
.threshold(5)
.actionsEnabled(false)
.build());

¡Eso es la belleza del CDK en acción!

Para este ejemplo, la alarma cambiará al estado ALARM una vez que tengamos
cinco o más registros de errores en tres puntos de datos dentro de un intervalo
de quince minutos.

Como ya se mencionó al inicio de esta sección, la fatiga de alarma para el monito-


reo de registros de errores puede ser bastante alta. Es por eso que desactivamos
cualquier acción que esta alarma pudiera desencadenar con actionsEnabled.

Quizás te estés preguntando: “¿Qué sentido tiene una alarma que no genera
alertas?”. Utilizaremos la alarma de registro de errores y la combinaremos con
otros estados de alarma en una Alarma Compuesta.

Creando Alarmas Compuestas

Con las Alarmas Compuestas, podemos reducir la fatiga de alarma al combinar


varios estados de alarma para identificar un incidente. Definimos una expresión
17. Alertando con Amazon CloudWatch 438

lógica para tener una regla más específica sobre cuándo deberíamos ser alerta-
dos en medio de la noche:

• Tenemos varios registros de errores y nuestras respuestas 5xx son altas.


• Tenemos varios registros de errores y todos los sistemas remotos están en
buen estado. Por lo tanto, el error no proviene de una dependencia externa
y debe residir en nuestra aplicación.
• Nuestras respuestas 5xx son altas y nuestra base de datos o el corredor de
mensajes reportan un alto uso de CPU.
• etc.

No necesitamos referirnos siempre al estado ALARM como parte de nuestra


expresión lógica. También podemos hacer referencia a cualquier otro estado de
alarma (OK o INSUFFICIENT_DATA) de una de las alarmas que estamos combi-
nando. Si lo deseamos, podemos incluso incluir otras Alarmas Compuestas.

Para fines de demostración, usaremos nuestras alarmas de Amazon CloudWatch


ya existentes para ocurrencias altas de registros de errores que no desencadenan
ninguna acción en este momento. Como un indicador adicional de fallas, crea-
mos una alarma para respuestas HTTP 5xx de nuestro ELB. La definición de esta
alarma es similar a nuestra elbSlowResponseTimeAlarm. La única diferencia es
que hacemos referencia a la métrica HTTPCode_ELB_5XX_Count y desactivamos
cualquier acción automática:
17. Alertando con Amazon CloudWatch 439

Alarm elb5xxAlarm = new Alarm(this, "elb5xxAlarm", AlarmProps.builder()


.alarmName("5xx-backend-alarm")
.alarmDescription("Alert on multiple HTTP 5xx ELB responses")
.metric(new Metric(MetricProps.builder()
.namespace("AWS/ApplicationELB")
.metricName("HTTPCode_ELB_5XX_Count")
.dimensionsMap(Map.of(
"LoadBalancer", loadBalancerName
))
.region(awsEnvironment.getRegion())
.period(Duration.minutes(5))
.statistic("sum")
.build()))
.treatMissingData(TreatMissingData.NOT_BREACHING)
.comparisonOperator(ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD)
.evaluationPeriods(3)
.threshold(5)
.actionsEnabled(false)
.build());

A continuación, queremos combinar ambas alarmas en una alarma compuesta


y recibir notificaciones solo cuando ambas alarmas estén en el estado ALARM:

Alarm elb5xxAlarm = // ...;


Alarm errorLogsAlarm= // ...;

CompositeAlarm compositeAlarm = new CompositeAlarm(this, "basicCompositeAlarm",


CompositeAlarmProps.builder()
.actionsEnabled(true)
.alarmDescription("Showcasing a Composite Alarm")
.compositeAlarmName("Backend API Failure")
.alarmRule(AlarmRule.allOf(
AlarmRule.fromAlarm(elb5xxAlarm, AlarmState.ALARM),
AlarmRule.fromAlarm(errorLogsAlarm, AlarmState.ALARM)
)
)
.build());

Nuestra AlarmRule crea una expresión lógica AND para ambas alarmas:
17. Alertando con Amazon CloudWatch 440

(ALARM("5xx-backend-alarm") AND ALARM("backend-error-logs-alarm"))

Dado que esta alarma combinada es ahora un mejor indicador de una posi-
ble interrupción, habilitamos las acciones que esta alarma desencadena con
actionsEnabled(true). Además, añadimos una SnsAction para publicar una
notificación en nuestro tema SNS existente siempre que esta alarma compuesta
esté en el estado ALARM:

compositeAlarm.addAlarmAction(new SnsAction(snsAlarmingTopic));

Siempre que ambas “alarmas secundarias” se encuentren en estado de ALARM


simultáneamente, Amazon CloudWatch nos notificará por correo electrónico.

Trabajando y Viviendo con Alarmas e Incidentes

En la sección anterior, vimos cómo podemos definir alarmas de Amazon Cloud-


Watch para nuestro sistema y así ser notificados proactivamente sobre un
posible incidente. Enviar correos electrónicos o notificaciones a tiempo es el
primer paso para tratar eficazmente con las alarmas e incidentes.

El proceso de gestión de incidentes no se detiene aquí.

Las acciones automatizadas (enviar una notificación y realizar autoscaling) son


en cierta medida limitadas y por sí solas podrían no ser suficientes para resolver
todas nuestras alarmas de forma automática. Mientras que el conocido “¿Has
probado a apagarlo y encenderlo de nuevo?” (es decir, reiniciar una instancia)
puede ayudar para algunas alarmas, en general, es necesaria la interacción
humana.

Apostar por la plena capacidad cerebral de un desarrollador que se despierta a


las 3 AM es algo que deberíamos evitar. La persona en turno de guardia podría
17. Alertando con Amazon CloudWatch 441

ni siquiera estar familiarizada con nuestro código. Como una buena práctica,
podemos (y deberíamos) colocar runbooks dentro de nuestro repositorio o
documentarlos en otro lugar.

Con los runbooks, nosotros…

“Permitimos respuestas consistentes y rápidas a eventos bien entendi-


dos documentando procedimientos […]. Los runbooks son los procedi-
mientos predefinidos para lograr un resultado específico. Los runbooks
deben contener la información mínima necesaria para realizar con éxi-
to el procedimiento. Comienza con un proceso manual efectivo válido,
impleméntalo en código y dispara la ejecución automatizada donde sea
apropiado. Esto asegura la consistencia, acelera las respuestas y reduce
los errores causados por procesos manuales.”
(Extracto del AWS Well-Architected Framework)

Los archivos Markdown básicos son suficientes para este propósito. Si es posi-
ble, cada alarma debería tener su propio runbook.

A continuación, podemos vincular el runbook de una alarma en particular como


parte de la descripción de la alarma. De esta manera, nuestro responsable no tie-
ne que buscar los documentos en primer lugar. ¡También debemos asegurarnos
de que la persona de turno tenga acceso a ellos!

Estos runbooks deben contener instrucciones claras sobre qué verificar in-
mediatamente y posibles sugerencias para mitigar el síntoma o solucionar el
problema subyacente. Algo similar a las listas de verificación que los pilotos
tienen en su cabina para manejar fallos del motor.

No podemos esperar que la persona trabaje inmediatamente con el 100% de su


capacidad cerebral justo después de una dura llamada de despertar por la noche.
El desarrollador responsable en turno no debe examinar a ciegas los archivos de
17. Alertando con Amazon CloudWatch 442

registro o observar numerosos gráficos en varios paneles de control. Necesitan


instrucciones claras con enlaces directos a consultas de registro preparadas o
instrucciones que se puedan copiar y pegar.

Como primer paso al analizar la causa de una alarma, es importante identificar


hasta qué punto el incidente actual se propaga a otros sistemas. Por ejemplo,
¿es solo un pequeño problema dentro de nuestro servicio que no afecta a toda
la empresa, o una API muy utilizada no está disponible y está paralizando las
operaciones de toda la empresa?

Una vez que identificamos el impacto a un alto nivel, la persona de guardia debe
decidir qué hacer a continuación. Por ejemplo, ¿vale la pena investigar más el
problema, despertar a otras personas para que lo solucionen, o volver a dormir?

Imagina una funcionalidad de informes semi-importante o un trabajo por lotes


que falla por la noche. En este caso, es suficiente abordarlo justo al comienzo
del día siguiente en lugar de solucionarlo con nuestra condición somnolienta a
medianoche.

Para una operación real de nuestra aplicación, también podemos definir políti-
cas de escalada para propagar la alarma a varios equipos o personas individuales
si no logramos reconocer la alarma a tiempo, por ejemplo, cuando la persona de
turno olvidó quitar el modo silencio de su teléfono.

Además, con reglas de enrutamiento de alarmas precisas, podemos activar


diferentes notificaciones en función de la prioridad de la alarma y la hora del
día. Las alarmas de baja prioridad, por ejemplo, deberían enviarnos un aviso
inmediatamente a través de Slack durante las horas de trabajo, pero solo enviar
un correo electrónico por la noche, ya que no son de vital importancia. Las
alarmas de alta prioridad, sin embargo, deberían llamar al desarrollador elegido
en cualquier momento del día.

Elaborar una lista de alarmas útiles es un proceso continuo y depende de nuestro


17. Alertando con Amazon CloudWatch 443

sistema y arquitectura. Técnicamente podemos crear una alarma para casi todo,
pero eso sería un simple ritual sin base lógica. Desde el punto de vista del
desarrollador, podríamos tener una intuición para aquellas partes de nuestro
sistema que - si fallan - tienen un impacto considerable en nuestra operación
general. Ese es un buen punto de partida para nuestras alarmas.

A continuación, el lado empresarial también puede (y debe) tener su definición


de medir el éxito o el fracaso de nuestro producto. Sin embargo, como regla
general, deberíamos al menos cubrir los SLOs (objetivos de nivel de servicio)
de nuestra aplicación con alarmas. El libro de SRE (Ingeniería de Fiabilidad del
Sitio) de Google, disponible gratuitamente, es una gran fuente de inspiración
para este tema y es muy recomendable.

Configurar los umbrales correctos, establecer la prioridad de alarma adecuada y


añadir instrucciones valiosas es algo que aprendemos con el tiempo. Si nuestras
alarmas se activan demasiado a menudo y no hay un daño real, debemos ajustar
la configuración. Si hay demasiado ruido de alarma, nuestros desarrolladores
podrían empezar a ignorarlas ya que carecen de significado.

Por otro lado, si no identificamos una interrupción de producción crítica porque


los umbrales son demasiado altos o bajos, también necesitamos ajustarlos.
Revisar nuestras alarmas es una parte esencial de un buen postmortem.

El proceso de gestión de incidentes es un tema amplio en sí mismo. Cada


empresa suele (esperamos) definir sus propios procedimientos estandarizados
y mejores prácticas.

Cuando se trata de soporte de herramientas, han surgido muchas aplicaciones


SaaS para la gestión eficiente de incidentes:

• Opsgenie
• Splunk On-Call
17. Alertando con Amazon CloudWatch 444

• AlertOps
• AWS Incident Manager (parte de AWS Systems Manager)
• etc.

La mayoría de estas herramientas proporcionan una integración perfecta con


nuestras Alarmas de Amazon CloudWatch ya sea directamente o configurando
una suscripción HTTPS para el tema de notificación SNS.

Con estas alarmas básicas establecidas en nuestra aplicación, echemos un


vistazo a una técnica para prevenir incidentes en primer lugar (o reducir su
probabilidad) en el próximo capítulo.
18. Monitoreo Sintético con Amazon
CloudWatch
Nuestra aplicación ahora funciona sin problemas en un entorno de producción y
de pruebas. Tenemos registros y métricas que nos proporcionan la información
que necesitamos para analizar problemas y tomar decisiones. También tenemos
alarmas en algunos registros y métricas para advertirnos cuando algo sale mal
antes de que el primer usuario pueda incluso enviar una solicitud de soporte.

Pero aún no podemos estar seguros en este punto si los casos de uso más
importantes de nuestra aplicación están funcionando como se esperaba. Puede
haber algo mal en la interfaz de usuario, en nuestra aplicación, o en un servicio
del que depende la aplicación que no crea registros de errores o métricas de las
que nos alertamos.

En este capítulo, vamos a completar nuestra estrategia de observabilidad aña-


diendo monitoreo sintético a nuestra aplicación Todo. Con el monitoreo sin-
tético, verificamos regularmente que las características principales de nuestra
aplicación están funcionando como se esperaba ejecutando un script automati-
zado. De esta manera, nos alertan lo antes posible cuando hemos roto algo con
nuestros recientes cambios de código o algo más ha dañado la aplicación.

Dado que estas comprobaciones sintéticas son a veces algo inestables, y por
lo tanto requieren cierto esfuerzo para mantener, no cubrimos todos los casos
de uso de nuestra aplicación con estas comprobaciones, sino sólo los más
importantes.
18. Monitoreo Sintético con Amazon CloudWatch 446

El monitoreo sintético también se conoce como “end-to-end testing” o “canary


testing”. En muchos contextos, estos términos significan lo mismo. Se le llama
“sintético” porque está generando tráfico artificial en nuestra aplicación (en
contraposición al tráfico “orgánico” que generan nuestros usuarios). El término
“canary testing” proviene de la desgraciada práctica de llevar canarios a las
minas para actuar como una alerta temprana de gases venenosos e inodoros.
Debido a su consumo más rápido de oxígeno, las aves caerían muertas antes de
que cualquier humano notara un cambio.

Empezaremos examinando las herramientas que AWS proporciona para el mo-


nitoreo sintético y luego construiremos un script canario para nuestra aplica-
ción Todo.

En este capítulo, el término “canario” se refiere a un script que eje-


cutamos contra nuestra aplicación que está desplegada en un entorno
de pruebas o de producción para verificar si la aplicación se comporta
como se esperaba. No debe confundirse con un “despliegue canario”
o “lanzamiento canario”, que significa que estamos desplegando una
segunda versión de la aplicación y distribuyendo parte del tráfico a esta
instancia canaria. Esto también es posible con AWS, pero no se cubre en
este libro.

Introducción a CloudWatch Synthetics

La solución que AWS ofrece para monitoreo sintético es parte del paraguas
CloudWatch y se llama “CloudWatch Synthetics”. Cualquier script de monito-
reo que creamos y ejecutamos con este servicio se llama “canarios”, así que nos
quedaremos con este término.

CloudWatch Synthetics ofrece una interfaz de usuario en la que podemos crear


18. Monitoreo Sintético con Amazon CloudWatch 447

canarios simples basados en un modelo:

• un heartbeat canary llama a una única URL y verifica su código de respuesta


HTTP,
• un canario de API llama a múltiples endpoints de la API HTTP de una
aplicación y verifica si responden como se esperaba,
• un verificador de enlaces rotos rastrea una página web en busca de enlaces
salientes y verifica si son válidos.

También podemos utilizar el constructor de flujo de trabajo de la interfaz


gráfica de usuario para construir un canario que atraviese paso a paso la interfaz
de usuario web de una aplicación. CloudWatch incluso ofrece un plugin de
navegador para registrar estos pasos, que luego utilizaremos para crear una
versión inicial de nuestro script canario. Podemos configurar los scripts para
tomar una captura de pantalla con cada paso y así obtener retroalimentación
visual.

En todos los casos, el resultado es un script de Node o Python que se ejecuta


contra la API de Synthetics. En el caso de un canario basado en Node, pode-
mos usar la biblioteca Puppeteer para controlar remotamente un navegador
sin cabeza. Para las bibliotecas basadas en Python, podemos usar Selenium.
Podemos modificar este script como queramos a través de la interfaz de usuario
de CloudWatch, o podemos copiarlo en un proyecto de CloudFormation o CDK
para automatizar el proceso de creación de los canarios.

Ahora que tenemos una comprensión compartida de lo que son los canarios,
vamos a repasar el proceso de crear uno para nuestra aplicación Todo.
18. Monitoreo Sintético con Amazon CloudWatch 448

Grabación de un Script Canario para la Aplicación Todo

Para nuestra aplicación Todo, queremos construir un canario de flujo de trabajo


de interfaz gráfica de usuario que atraviese el caso de uso principal de la
aplicación: la creación de un elemento todo.

He aquí cómo debería ser el flujo de trabajo:

1. Abra la página de inicio de la aplicación.


2. Haga clic en el botón “Iniciar sesión”.
3. Inicie sesión con un usuario de prueba previamente creado.
4. Haga clic en el botón “Crear un nuevo Todo”.
5. Rellene el formulario de todo y haga clic en el botón “Crear”.
6. Verifique que hemos sido redirigidos al panel de control.
7. Verifique que el nuevo todo aparece en el panel de control.

1. Haz clic en el botón “Delete” para la tarea que acabamos de crear.


2. Verifica que la nueva tarea ya no aparece en el panel.

Este script de canario verifica tres casos de uso principales de la aplicación: el


inicio de sesión, la creación de una tarea y la eliminación de una tarea. El canario
no detectará errores en otros casos de uso, pero podemos estar bastante seguros
de que si el canario se ejecuta con éxito, la mayoría de los usuarios podrán
trabajar con la aplicación.

Por supuesto, podríamos extender el script de canario para abarcar todos los
demás casos de uso también. Podríamos verificar si todos los botones en la
interfaz de usuario funcionan como se espera, si la paginación en la vista
del panel funciona correctamente, si podemos editar un elemento de tarea y
agregarle una nota, y si la funcionalidad de “compartir” funciona.
18. Monitoreo Sintético con Amazon CloudWatch 449

Sin embargo, los scripts de canario que dependen de la interfaz de usuario web
de una aplicación son muy vulnerables al cambio. Si el id de un botón o el
diseño de una página cambia, por ejemplo, un script de canario puede fallar
aunque un usuario real no se vea afectado en absoluto. Cuantos más casos de
uso cubran nuestros scripts de canario, más inestables se vuelven y más caros
son de mantener.

Entonces, el compromiso es seleccionar los pocos casos de uso que hacen que
la aplicación sea utilizable para un usuario y solo cubrir esos con uno o más
canarios. Es por eso que hemos elegido el script anterior para la aplicación de
tareas. Para otras aplicaciones, los casos de uso principales se verán completa-
mente diferentes. Si una aplicación tiene un impacto muy alto y cambia muy
raramente, incluso podría tener sentido cubrir todos los casos de uso.

Al final, la decisión de qué casos de uso cubrir con un canario se reduce a la


cuestión muy “suave” de sentirse confiado. Si nosotros, como equipo de desa-
rrollo, nos sentimos seguros de que un canario captará los peores problemas al
ejecutarse contra la aplicación cada pocos minutos, esto es una señal de que la
cobertura es suficiente. Una gran parte del monitoreo de aplicaciones se trata
de mejorar la calidad de vida de las personas que operan la aplicación, después
de todo.

Ten en cuenta que hemos creado un usuario de prueba específicamente para este
canario. En una prueba de canario, no queremos iniciar sesión como un usuario
real por dos razones: tendríamos que compartir la contraseña del usuario real
con el script de canario, y si algo cambia en la configuración del usuario real,
podría romper el canario.

Para construir el script de canario inicial, vamos a utilizar el “Grabador de


Canario”. En el diálogo “Crear Canario” de la Consola de CloudWatch Synthe-
tics, elegimos “Usar una plantilla” y luego “Grabador de Canario”. La inter-
18. Monitoreo Sintético con Amazon CloudWatch 450

faz mostrará instrucciones sobre cómo instalar el complemento del navega-


dor requerido, que se basa en el Grabador sin cabeza de código abierto. Una
vez iniciado, hacemos clic en “grabar” en el complemento y luego navega-
mos a la URL contra la cual ejecutar el canario - en nuestro caso, esta es
https://app.stratospheric.dev.

Luego pasamos por los pasos que queremos grabar en el navegador como si
fuésemos un usuario habitual de la aplicación. Una vez que la grabación está
hecha, podemos copiar el script generado desde el complemento del navegador
al interfaz de “Crear Canario” en la Consola de CloudWatch Synthetics y com-
pletar el resto del formulario.

El script se verá algo como esto:

var synthetics = require('Synthetics');


const log = require('SyntheticsLogger');

const recordedScript = async function () {


let page = await synthetics.getPage();

const navigationPromise = page.waitForNavigation()

await synthetics.executeStep('Go to home page', async function() {


await page.goto(
process.env.TARGET_URL,
{waitUntil: 'domcontentloaded', timeout: 30000})
})

await page.setViewport({ width: 1853, height: 949 })

await navigationPromise

await synthetics.executeStep('Click "Login" button', async function() {


await page.waitForSelector('.container > .section > .container > div > .btn')
await page.click('.container > .section > .container > div > .btn')
})

await navigationPromise
18. Monitoreo Sintético con Amazon CloudWatch 451

// ... more steps

};
exports.handler = async () => {
return await recordedScript();
};

El script importa los paquetes de AWS Synthetics y SyntheticsLogger y luego


ejecuta todos los pasos que realizamos durante la grabación. Los pasos inicial-
mente se nombran algo como “Click_1” (cuando el paso es sobre hacer clic en
un botón) o “Type_2” (cuando el paso es sobre escribir algo en un campo), por
lo que debemos renombrarlos manualmente después de la grabación. Puedes
encontrar el script completo en GitHub.

Por defecto, el grabador utiliza selectores CSS como .container > .section >
.container > div > .btn para identificar un elemento en el HTML de la pági-
na web. Es importante destacar que esto no es óptimo, ya que es muy susceptible
a cambios en el diseño del HTML. Si queremos hacerlo más robusto, deberíamos
asignar a cada elemento con el que queremos interactuar un atributo id y luego
usar el selector #id en el script, porque es mucho menos probable que cambie.

El paquete Synthetics se basa en Puppeteer, por lo que tenemos acceso a la API


de Puppeteer para interactuar con el sitio web.

Considerando que deberíamos poder ejecutar el canario en diferentes entornos


de aplicación, hacemos configurable la URL en la que el script debería ejecutarse
mediante el uso de la variable de entorno TARGET_URL.

De la misma manera, queremos configurar el nombre de usuario y la contraseña.


El script los utiliza para el paso de inicio de sesión, esto permite que el script sea
independiente de un usuario específico:
18. Monitoreo Sintético con Amazon CloudWatch 452

await synthetics.executeStep('Type username', async function() {


await page.type(
'div:nth-child(2) > div > div > .cognito-asf #signInFormUsername',
process.env.USER_NAME)
})

await synthetics.executeStep('Type password', async function() {


await page.type(
'div:nth-child(2) > div > div > .cognito-asf #signInFormPassword',
process.env.PASSWORD)
})

Podemos ingresar todas las variables de entorno en la interfaz de usuario de


CloudWatch. Desafortunadamente, CloudWatch Synthetics no soporta varia-
bles de entorno secretas, por lo que la contraseña es visible para cualquiera que
tenga el permiso para editar el script canario.

Después de cada cambio en el script, podemos guardar el script canario y dejar


que se ejecute a través de la interfaz de usuario de CloudWatch. El resultado
de cada paso se muestra en la interfaz de usuario para que podamos depurar el
script hasta que funcione como se esperaba.

Manteniéndolo Sencillo

Como podrías haber adivinado de la sección anterior, crear un script canario con
CloudWatch Synthetics implica editar un script en un área de texto en la interfaz
de usuario web de CloudWatch. Aunque ese área de texto tiene resaltado de
sintaxis, no reemplaza a un IDE y no es adecuado para editar scripts complejos.

Al momento de redactar este documento, no hay soporte para desarrollar y


probar scripts canarios en un IDE. Los paquetes de node Synthetics y Synt-
heticsLogger no están disponibles públicamente en NPM. Podríamos reem-
plazar, probablemente, el paquete Synthetics con un paquete equivalente de
18. Monitoreo Sintético con Amazon CloudWatch 453

Puppeteer para el desarrollo local, pero eso estaría en nosotros para explorar y
configurar ya que no hay soporte oficial para esto.

Por lo tanto, nuestra recomendación es utilizar CloudWatch Synthetics solo para


scripts bastante simples. El script de inicio de sesión es lo suficientemente
simple para construir y depurar a través de una interfaz de usuario web. Si
se complica más, es posible que desees elegir una herramienta diferente pa-
ra ejecutar tus verificaciones sintéticas. Puedes construir una tú mismo (por
ejemplo, construyendo una función Lambda personalizada disparada por cron
que ejecuta un script de navegador sin cabeza) o elegir una herramienta de un
proveedor como Datadog o Splunk.

Hay un argumento para mantener los scripts canarios lo suficientemente sim-


ples para que puedas usar CloudWatch Synthetics, sin embargo. A menudo,
una simple comprobación de estado HTTP para un punto final de la API REST
o un simple script de navegador sin cabeza como nuestro script de inicio de
sesión es suficiente para actuar como un canario. Después de todo, si el inicio
de sesión falla, sabemos que algo anda mal y tendremos que investigar. Esto es
exactamente para lo que sirve un script canario.

Si un script canario se vuelve demasiado complejo, ¡es posible que algo esté
mal con el propio script! El mantenimiento del script requerirá mucho esfuerzo
con cada cambio de la aplicación y la resistencia al cambio del equipo de
desarrolladores crecerá.

Automatizando el Despliegue del Script Canario con CDK

Una vez que hemos creado y probado nuestro script canario, queremos asegurar-
nos de que se despliegue cada vez que se despliega nuestra aplicación. Para esto,
ampliaremos nuestro proyecto de CDK existente para incluir el script canario.
18. Monitoreo Sintético con Amazon CloudWatch 454

Para obtener acceso a los componentes de CloudWatch Synthetics CDK y cons-


truir nuestro propio componente sobre ellos, tenemos que agregar las siguien-
tes dependencias al pom.xml de nuestro proyecto de CDK:

<!-- Both imports can be omitted when using cdk-constructs -->


<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>${aws-cdk-lib.version}</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>${constructs.version}</version>
</dependency>

Construiremos un CanaryStack y un CanaryApp en la estructura que introduci-


mos en el capítulo Diseñando un Proyecto de Despliegue con CDK. Puedes encontrar
el código completo de ambas clases en GitHub.

El CanaryApp simplemente sirve como un punto de entrada para crear un


CanaryStack, así que no vamos a examinar su código aquí.

El CanaryStack crea una cubeta S3, un rol de IAM y el canario en sí mismo.

La cubeta S3 es utilizada por CloudWatch para almacenar las capturas de pan-


talla que realiza durante una corrida de canario. Para crearla, utilizamos el
Constructo de nivel 2 Bucket, que es muy conveniente de usar:

Bucket bucket = Bucket.Builder.create(this, "canaryBucket")


.bucketName(applicationEnvironment.prefix("canary-bucket"))
.removalPolicy(RemovalPolicy.DESTROY)
.build();

Anteponemos el nombre del bucket con el nombre de la aplicación y el entorno


para hacerlo único y establecemos la política de eliminación en DESTROY para
que el bucket se elimine cuando eliminamos el CanaryStack. Si no hacemos
18. Monitoreo Sintético con Amazon CloudWatch 455

esto, el bucket seguirá existiendo incluso cuando eliminamos el stack, y la


próxima implementación no se realizará debido a un error de “El bucket ya
existe”.

A continuación, creamos el rol IAM que el script de canario asumirá mientras se


está ejecutando:

Role executionRole = Role.Builder.create(this, "canaryExecutionRole")


.roleName(applicationEnvironment.prefix("canary-execution-role"))
.assumedBy(new AnyPrincipal())
.inlinePolicies(Map.of(
applicationEnvironment.prefix("canaryExecutionRolePolicy"),
PolicyDocument.Builder.create()
.statements(singletonList(PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.resources(singletonList("*"))
.actions(Arrays.asList(
"s3:PutObject",
"s3:GetBucketLocation",
"s3:ListAllMyBuckets",
"cloudwatch:PutMetricData",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"))
.build()))
.build()))
.build();

Este es un rol simple con una política integrada que contiene exactamente
los permisos que un Canary necesita para realizar su trabajo. Necesita subir
capturas de pantalla a S3, y enviar métricas y registros a CloudWatch.

Finalmente, creamos el guion de Canary en sí:


18. Monitoreo Sintético con Amazon CloudWatch 456

String canaryName = applicationEnvironment.prefix("canary", 21);

CfnCanary.Builder.create(this, "canary")
.name(canaryName)
.runtimeVersion("syn-nodejs-puppeteer-3.9")
.artifactS3Location(bucket.s3UrlForObject("create-todo-canary"))
.startCanaryAfterCreation(Boolean.TRUE)
.executionRoleArn(executionRole.getRoleArn())
.schedule(ScheduleProperty.builder()
.expression("rate(15 minutes)")
.build())
.runConfig(RunConfigProperty.builder()
.environmentVariables(Map.of(
"TARGET_URL", targetUrl,
"USER_NAME", username,
"PASSWORD", password
))
.timeoutInSeconds(30)
.build())
.code(CodeProperty.builder()
.handler("recordedScript.handler")
.script(getScriptFromResource("canaries/create-todo-canary.js"))
.build())
.build();
}

Observa que estamos utilizando el construct CfnCanary de nivel 1 aquí, no


el más conveniente construct Canary de nivel 2. La razón de esto es que el
construct Canary está en modo experimental en el momento de escribir, por
lo que recurrimos al construct CfnCanary, que es más detallado, pero nos da
control total.

Pasamos la ubicación de la cubeta de S3 y el rol que acabamos de crear y fijamos


un horario para que el canary se ejecute cada 15 minutos. Luego pasamos las tres
variables de entorno que necesita el script y establecemos el tiempo de espera,
para que el script no espere más de 30 segundos para obtener una respuesta
antes de fallar. Como paso final, pasamos el script que creamos anteriormente
desde un archivo local en la base de código. El método getScriptFromResour-
18. Monitoreo Sintético con Amazon CloudWatch 457

ce() simplemente lee el script del archivo como una cadena.

En el archivo package.json del proyecto CDK, luego agregamos los scripts


canary:deploy y canary:destroy para que podamos usar esos scripts desde
la línea de comandos y nuestro pipeline de implementación continua.

Alerta sobre la Falla del Canary

Según nuestra configuración, el canary ahora se ejecutará cada 15 minutos. En


la interfaz de usuario de CloudWatch, podemos ver las ejecuciones exitosas y
fallidas del canary, pero como se discutió en el capítulo Alertando con Amazon
CloudWatch, no queremos monitorear activamente las fallas, sino más bien crear
una alarma que nos notificará las fallas.

Por lo tanto, vamos a agregar un construct Alarm a nuestro CanaryStack:

Alarm canaryAlarm = new Alarm(this, "canaryAlarm", AlarmProps.builder()


.alarmName("canary-failed-alarm")
.alarmDescription("Alert on multiple Canary failures")
.metric(new Metric(MetricProps.builder()
.namespace("CloudWatchSynthetics")
.metricName("Failed")
.dimensionsMap(Map.of(
"CanaryName", canaryName
))
.region(awsEnvironment.getRegion())
.period(Duration.minutes(50))
.statistic("sum")
.build()))
.treatMissingData(TreatMissingData.NOT_BREACHING)
.comparisonOperator(ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD)
.evaluationPeriods(1)
.threshold(3)
.actionsEnabled(false)
.build());
18. Monitoreo Sintético con Amazon CloudWatch 458

La alarma está configurada para monitorizar la métrica Failed en el espacio de


nombres CloudWatchSynthetics. Se activará en estado de falla si el canario ha
fallado tres veces consecutivas en un periodo de 50 minutos. Hemos establecido
este período en 50 minutos porque esperamos que el canario se ejecute tres
veces en 45 minutos. Si falla tres veces seguidas, estas tres fallas habrán
ocurrido en los últimos 45 minutos, que hemos redondeado a 50 minutos para
permitir cierto margen de error.

Este ejemplo muestra que, con todos los controles que tenemos a nuestra
disposición, no es sencillo configurar correctamente las alertas. Si queremos
recibir advertencias más tempranas, debemos reducir tanto el intervalo de
ejecución del canario como el período de evaluación de la alarma. Es por esto
que hemos situado la alarma en el CanaryStack y no junto a las demás alarmas
en el MonitoringStack que usamos en capítulos anteriores. Es recomendable
agrupar elementos que tienden a cambiar juntos, tanto en el código de software
como en el código de infraestructura.

Finalmente, querríamos añadir una acción a la alarma para notificar a las


personas, tal como se describió en el capítulo anterior.

Las alarmas creadas en el capítulo anterior y el monitoreo sintético establecido


en este nos permiten descansar tranquilos sabiendo que la aplicación funciona
correctamente y que seremos alertados si ocurre algún problema.
Reflexiones Finales
Domina la Nube
El desarrollo en la nube se trata de un enfoque de autoservicio. Como desarrolla-
dores, podemos aprovechar este enfoque para utilizar un conjunto de servicios
que se ajustan a nuestras necesidades sin tener que enfrentar obstáculos orga-
nizacionales.

El aspecto de autoservicio de CDK y CloudFormation es lo que nos enganchó y la


fácil integración de Spring Boot con todo tipo de servicios de AWS es lo que nos
llevó a escribir este libro.

Si aún no has construido software “en la nube”, esperamos que este libro haya
despertado tu interés en el desarrollo en la nube en general y en la construcción
de software con Spring Boot y AWS en particular. ¿Quizá incluso lo suficiente
como para sugerir el inicio del camino hacia el desarrollo en la nube en tu lugar
de trabajo?

Incluso si tu lugar de trabajo no está listo para el desarrollo en la nube, podrías


construir tu próximo proyecto paralelo de SaaS con Spring Boot y desplegarlo en
AWS. Por nuestra propia experiencia, sabemos que es muy divertido construir
un proyecto paralelo como este. ¡Haznos saber cuando generes tu primer millón
con tu negocio de SaaS!

Nos encanta afinar el software para mejorarlo continuamente, y un pipeline de


despliegue continuo como el que se discute en este libro es perfecto para eso.

Si ya eres un desarrollador en la nube, sabrás que el desarrollo de software en el


mundo de DevOps no solo se trata de escribir código, sino también de llevar el
software a producción. Esperamos que este libro te haya ayudado a obtener una
Domina la Nube 461

perspectiva más profunda sobre los temas de despliegue y observabilidad.

Finalmente, si eres quien toma decisiones en tu lugar de trabajo, esperamos que


el libro haya dejado claro que el desarrollo en la nube no es un misterio complejo
reservado únicamente para desarrolladores altamente capacitados que trabajan
para gigantes tecnológicos. Es bastante simple en su núcleo: usar y combinar
servicios en la nube para crear valor. El paradigma de autoservicio empodera a
los equipos de software para ser autosuficientes y autoorganizados, liberando
su creatividad y productividad. Pero introducir el desarrollo en la nube requiere
un cambio de cultura. Esperamos haber incentivado este cambio.

Si disfrutaste leyendo este libro y te gusta nuestro enfoque práctico, echa un


vistazo a nuestro curso online Stratospheric. Aunque este ebook constituye
una base para el curso online, durante el curso cubrimos varios temas con
más detalle y proporcionamos una experiencia de aprendizaje más dinámica, al
estilo “Elige tu propia aventura”.

De todas formas, ¡haznos saber cómo fue tu viaje a la nube! Envíanos un correo
electrónico a info@stratospheric.dev. ¡Nos encantaría escuchar tu historia!
Recursos Adicionales

Recursos recomendados y sugerencias de lecturas para desarrolladores entu-


siastas y motivados (incluyendo descuentos especiales y ofertas para los lecto-
res de Stratospheric):

• Get Your Hands Dirty on Clean Architecture (por Tom)


– Una guía práctica para crear aplicaciones web limpias con ejemplos de
código en Java.
– Este libro discute cómo el estilo de Arquitectura Hexagonal intenta
cumplir con este objetivo y traduce los conceptos en código real para
proporcionar inspiración a los desarrolladores de software.
– Si prefieres una versión impresa del libro, puedes obtenerla en Amazon.

• Java Testing Toolbox - 30 Testing Tools and Libraries Every Java Developer
Must Know (de Philip)
– Explora el ecosistema de pruebas de Java con introducciones breves a
las herramientas y bibliotecas.
– Piensa en Java Testing Toolbox como un libro de recetas: práctico, con-
ciso y con ejemplos prácticos.
– Cubre marcos de pruebas, bibliotecas de afirmaciones, marcos de simu-
lación, así como pruebas de rendimiento, infraestructura y bibliotecas
de utilidades.
– Obtén el ebook por $9 con el código de descuento u13VONHHTIy6
463

• Testing Spring Boot Applications Masterclass (por Philip)


– Despliega aplicaciones de Spring Boot con más confianza y menos
regresiones.
– Un curso de pruebas exhaustivo para aumentar tu confianza y producti-
vidad.
– Todas las mejores prácticas de pruebas de Spring Boot en un solo lugar.
– Obtén un 30% de descuento como lector de Stratospheric con este código
de cupón: STRATOSPHERIC30

• Curso de Spring Boot


– Conocimientos prácticos de Spring Boot
* Fundamentos: inyección de dependencias, convención sobre confi-
guración, Spring y el contexto de Spring
* APIs REST con Spring Boot
* Pruebas
* Trabajo con bases de datos
* Seguridad
– Para novatos de Spring Boot así como desarrolladores de Spring más
experimentados
– Idioma inglés y alemán
– Participa en línea a través de Zoom.
– Obtén un 10% de descuento como lector de Stratospheric con este código
de cupón: STRATOSPHERIC10

• Libro de fundamentos de AWS: Cubre todos los aspectos de AWS, desde có-
mo empezar hasta los bloques de construcción fundamentales. No depende
del lenguaje de programación ni del marco de trabajo:
– Cubre todas las categorías principales de servicios (Compute, Storage,
Networking, etc.).
464

– Desde la computación tradicional hasta la computación sin servidor.


– Sólo las opciones de configuración importantes que necesitas conocer.
– Los fundamentos de la infraestructura moderna como código (CloudFor-
mation, CDK, Serverless).
– Infografías atractivas y imprimibles para cada servicio cubierto en el
libro.
– Obtén un 30% de descuento como lector de Stratospheric.
Apéndice

Usuario Técnico de GitHub Actions IAM

Para reflejar nuestra configuración de GitHub Actions, crea un usuario IAM y


adjunta las siguientes políticas de permisos:

• AmazonRDSFullAccess
• AmazonEC2ContainerRegistryFullAccess
• SystemAdministrator
• AmazonEC2FullAccess
• AmazonECS_FullAccess
• AWSCertificateManagerFullAccess
• AWSLambda_FullAccess
• AWSCloudFormationFullAccess
• AmazonCognitoPowerUser
• AmazonS3FullAccess
• AmazonSSMFullAccess
• IAMReadOnlyAccess
• AWSKeyManagementServicePowerUser
• AWSCloudFormationFullAccess
• AWSLambda_FullAccess
• AmazonMQApiFullAccess
466

• CloudWatchSyntheticsFullAccess

Dado que AWS impone un límite de 10 políticas por usuario (“No puede exceder
la cuota para PoliciesPerUser: 10”), necesitas crear grupos de usuarios, adjuntar
las políticas a los grupos y agregar el usuario a esos grupos.

Por ejemplo, puedes crear un grupo llamado EC2_users, adjuntar las siguientes
políticas y posteriormente agregar tu usuario IAM a ese grupo (repite para
cualquier otra política de permisos requerida):

• AmazonEC2ContainerRegistryFullAccess
• AmazonEC2FullAccess
• AmazonECS_FullAccess

Si planeas automatizar el despliegue para los servicios de AWS que no cubrimos


en este libro, asegúrate de agregar los permisos requeridos a este usuario
técnico.

Guía de Despliegue

Sigue esta guía paso a paso si deseas desplegar toda la infraestructura Stratosp-
heric, incluyendo la aplicación de muestra, en tu cuenta de AWS.

Requisitos previos:

• Tienes un dominio personalizado (por ejemplo, mycompany.io) alojado den-


tro de Amazon Route53. También puedes alojar tu dominio en un proveedor
diferente (por ejemplo, GoDaddy, Namecheap, Hetzner, etc.). Sin embargo,
esto implica un esfuerzo manual adicional para configurar correctamente
SSL.
467

• Has creado un certificado SSL dentro del AWS Certificate Manager para ese
dominio y tienes el ARN para el certificado SSL.
• Has configurado un perfil con nombre para la CLI de AWS (por ejemplo,
stratospheric) con suficientes privilegios para crear / eliminar recursos.
• Tu Docker Engine está en funcionamiento.
• Tienes Node >= 16 instalado: node -v
• Tienes Java 17 instalado: java -version
• Estás usando un procesador x64, o puedes crear una imagen Docker para
esta arquitectura (mira este artículo si estás usando un Apple M1).

Arrancar toda la infraestructura desde cero lleva de 20 a 30 minutos. Puedes


acelerar el proceso aumentando los tamaños de las instancias para la base de
datos, ActiveMQ y las tareas de ECS.

NOTA IMPORTANTE: Desplegar esta infraestructura resultará en costos recu-


rrentes si no eliminas los recursos después. Sigue de cerca el progreso de la
eliminación de la pila y revisa la visión general de CloudFormation en la consola
de AWS después. No debería haber ninguna definición de pila restante.

Paso 1: Desplegar la Infraestructura Circundante

Suponemos que estás usando un perfil con nombre para la CLI de AWS llamado
stratospheric. Si estás usando el perfil predeterminado, puedes eliminar --
--profile statrospheric de todos los siguientes comandos:

1. Clona el repositorio Stratospheric de GitHub


2. Navega a la carpeta cdk
3. Ajusta la configuración en cdk.json:

• applicationName: El nombre de tu aplicación. Asegúrate de que la com-


binación de nombre de aplicación y nombre de entorno sea única, ya que
468

estamos creando recursos que requieren un nombre único, por ejemplo,


todo-app.
• region: La región a la que deseas desplegar la infraestructura, por ejemplo,
eu-central-1.
• accountId: El ID de cuenta de tu cuenta de AWS, por ejemplo,
221875718260.
• dockerRepositoryName: El nombre de tu repositorio Docker. A menos que
desees desplegar una imagen Docker de otro registro, esto debería ser igual
al applicationName, por ejemplo, todo-app.
• dockerImageTag: La imagen Docker que deseas desplegar. Actualiza la
etiqueta cada vez que deseas desplegar una nueva versión de la aplicación,
por ejemplo, 1.
• applicationUrl: La URL completa de la aplicación de tu aplicación, por
ejemplo, https://app.stratospheric.dev.
• loginPageDomainPrefix: Esto se convierte en el subdominio para el formu-
lario de inicio de sesión de Cognito, por ejemplo, stratospheric-staging.

• environmentName: El nombre del entorno de la aplicación, por ejemplo,


staging o prod.
• springProfile: El perfil de Spring que debería estar activado para el con-
tenedor ECS en ejecución, por ejemplo, aws.
• activeMqUsername: El nombre del usuario root de ActiveMQ, por ejemplo,
activemqUser.
• canaryUsername: (Opcional) Si planeas desplegar la pila Canary, añade
el nombre del usuario para iniciar sesión. Primero tienes que crear este
usuario manualmente a través de la aplicación de muestra, por ejemplo,
canary.
• canaryUserPassword: (Opcional) Si planeas desplegar la CanaryApp, añade
la contraseña del usuario para iniciar sesión, por ejemplo, s3cr3t.
469

• confirmationEmail: (Opcional) El correo electrónico para recibir alertas de


CloudWatch, por ejemplo, info@stratospheric.dev.
• applicationDomain: El dominio de tu aplicación, sin protocolo, por ejem-
plo, app.stratospheric.dev.
• sslCertificateArn: El arn para el certificado SSL de tu dominio persona-
lizado. El paso #5 explicará cómo obtener este parámetro,
por ejemplo, arn:aws:acm:REGION:ID:certificate/UUID.
• hostedZoneDomain: El nombre de dominio para la zona alojada dentro de
Route53, por ejemplo, stratospheric.dev.
• githubToken: (Opcional) Si planeas desplegar el sequenciador de Deploy-
ment, añade el token de acceso para GitHub para activar el flujo de trabajo
de GitHub Action, por ejemplo, s3cr3t.

4. Configura el CDK para tu cuenta de AWS:

npm run bootstrap -- --profile stratospheric

5. Crea un certificado SSL para tu dominio:

Si estás utilizando Route 53 para tu dominio, asegúrate de configurar los pará-


metros applicationDomain y hostedZoneDomain. A continuación, despliega el
CertificateApp ejecutando este comando:

npm run certificate:deploy -- --profile stratospheric

Después, copia el sslCertificateArn al archivo cdk.json.

Si no estás usando Route 53, sigue las instrucciones en el capítulo Creating


an SSL Certificate with CDK sobre cómo generar y validar un certificado SSL
manualmente.

6. Despliega la infraestructura que depende de NetworkStack:


470

npm run network:deploy -- --profile stratospheric


npm run database:deploy -- --profile stratospheric
npm run activeMq:deploy -- --profile stratospheric

7. (o en paralelo al #6) Desplegar la infraestructura independiente de Net-


workStack:

npm run repository:deploy -- --profile stratospheric


npm run messaging:deploy -- --profile stratospheric
npm run cognito:deploy -- --profile stratospheric

8. Dirigir el tráfico desde su dominio personalizado al ELB:

Si está utilizando Route 53 para su dominio, puede automatizar este proceso con
este comando:

npm run domain:deploy -- --profile stratospheric

De lo contrario, debes crear el registro A de DNS con tu registrador de dominios.


Este registro A debe apuntar al nombre DNS predeterminado del ELB. Puedes
obtener esta información desde la consola de AWS: Busca ‘EC2’ -> ‘Balanceado-
res de carga’ -> ‘Selecciona el balanceador de carga’

Paso 2: Construir y Publicar la Primera Imagen de Docker

Construye la primera imagen de Docker:


471

cd application
./gradlew build

docker build -t <accountId>.dkr.ecr.<region>.amazonaws.com/<applicationName>:1 .

aws ecr get-login-password --region <region> --profile stratospheric | docker login \


--username AWS --password-stdin <accountId>.dkr.ecr.<region>.amazonaws.com

docker push <accountId>.dkr.ecr.<region>.amazonaws.com/<applicationName>:1

En Apple M1:

docker buildx build --platform linux/amd64,linux/arm64 --push -t <accountId>.dkr.ecr\


.<region>.amazonaws.com/todo-app:1 .

Paso 3: Desplegar la Imagen Docker en el Clúster ECS

1. Personaliza la propiedad dockerImageTag dentro del archivo cdk/cdk.json


para que coincida con la etiqueta de la imagen Docker que acabas de subir:

npm run service:deploy -- --profile stratospheric

Después, podrás acceder a la aplicación desde tu dominio personalizado.

Por favor, considera lo siguiente:

La funcionalidad de compartir solo funciona si:

1. Solicitas acceso a producción para SES y verificas el dominio desde el cual


tu aplicación envía correos electrónicos.
2. Verificas manualmente todas las direcciones a las que estás a punto de
enviar correos electrónicos y también verificas el dominio desde el cual tu
aplicación envía correos electrónicos.
472

Paso 4: (Opcional) Desplegar la Infraestructura de Monitoreo

1. Desplegar el tablero y las alarmas de Amazon CloudWatch:

cd cdk
npm run monitoring:deploy -- --profile stratospheric

2. Recibirás un correo electrónico para verificar la suscripción a SNS en función


de lo que hayas configurado para confirmationEmail.

Paso 5: (Opcional) Desplegar la Stack de Canary

1. Crea un usuario de aplicación dentro de la aplicación ejemplo.


2. Actualiza el canaryUsername y canaryUserPassword dentro del archivo
cdk/cdk.json.
3. Despliega la Stack de Canary.

cd cdk
npm run canary:deploy -- --profile stratospheric

Paso 6: Destruye Todo

Ejecuta todos los scripts npm run *:destroy -- --profile stratospheric


en el orden inverso al que fueron creados los recursos.

Visita la consola web de CloudFormation para asegurarte de que todos los


apilamientos han sido eliminados.
Registro de cambios
Notas sobre los cambios en cada revisión de este libro.

• Revisión 0.1 (2020-11-08) - Primera versión publicada, que contiene los


capítulos Introducción, La Aplicación de Tareas de Ejemplo, Familiarizándose con
AWS, y Visión General de los Servicios de AWS.
• Revisión 0.2 (2020-12-20) - Añadidos los capítulos Gestionando Permisos
con IAM, La Evolución de Despliegues Automatizados, Primeros Pasos con CDK, y
Desarrollo Local.
• Revisión 0.3 (2021-02-14) - Añadidos los capítulos Diseñando un Proyecto
de Despliegue con CDK, Construyendo Registro de Usuarios y Login con Cognito, y
Conectando a una Base de Datos con RDS. Añadida la previsualización para los
capítulos en la Parte 3 (Operaciones).
• Revisión 0.4 (2021-04-10) - Añadidos los capítulos Construyendo un Pipeli-
ne de Despliegue Continuo, Compartiendo Tareas con SQS y SES, y Notificaciones
Push con Amazon MQ.
• Revisión 1.0 (2021-08-01) - Añadidos los capítulos Rastreando Acciones de
Usuarios con DynamoDB, Logging Estructurado con Amazon CloudWatch, Métri-
cas con Amazon CloudWatch, Alertas con Amazon CloudWatch, Monitoreo Sintéti-
co con Amazon CloudWatch, y Configurando HTTPS y un Dominio Personalizado
con Route 53 y ELB. Pulido y corregido todo.
• Revisión 1.1 (2021-09-28) - Aclaraciones para la sección sobre la esca-
labilidad horizontal de la Registro de Usuarios y Login con Cognito ya que
Spring Session también es una alternativa válida a las sesiones pegajosas.
Aclaraciones alrededor del uso de un certificado SSL en el capítulo Diseñando
Registro de cambios 474

un Proyecto de Despliegue con CDK. Unificación de la apariencia de algunos


diagramas usando Excalidraw.
• Revisión 1.2 (2021-10-24) - Nueva sección sobre protección de parámetros
con el Manager de Secretos de AWS como parte del capítulo Registro de Usua-
rios y Login con Cognito. Migración a Spring Cloud AWS 2.3.2 y su nuevo hogar
en io.awspring. Esta actualización de dependencia trae varias mejoras de
configuración, especialmente para los clientes SDK de AWS.
• Revisión 1.3 (2021-11-23) - Mejorada la Configuración de SSL y dominio perso-
nalizado; utilizando un archivo cdk.json para pasar contexto y variables a
los scripts CDK; añadida una nota sobre la característica de concurrencia
de GitHub Actions en el capítulo Construyendo un Pipeline de Despliegue
Continuo.
• Revisión 1.4 (2022-01-18) - Movida la sección de “recursos” de la parte de
atrás a la de adelante porque la gente seguía preguntando sobre cómo unirse
a la comunidad Slack. Migración a AWS CDK v2. Adaptando los capítulos
Primeros Pasos con CDK, Configuración de SSL y dominio personalizado, Registro
de Usuarios y Login con Cognito, Métricas con Amazon CloudWatch, Alertas con
Amazon CloudWatch, Monitoreo Sintético con Amazon CloudWatch para AWS
CDK v2.
• Revisión 1.5 (2022-02-13) - Se actualizó la aplicación de tareas de ejemplo
y el proyecto de infraestructura CDK a Java 17.
• Revisión 1.6 (2022-03-31) - Añadida información faltante sobre la confi-
guración inicial de un entorno AWS. Se añadieron dos nuevas secciones al
principio del libro: Configuración Inicial del Entorno AWS para un Despliegue con
CDK y Configuración Inicial de un Nuevo Entorno. Actualización de PostgreSQL
a la versión 12.9.
• Revisión 1.7 (2022-07-13) - Añadido soporte de desarrollo local para el
procesador Apple M1 (ARM64) actualizando la imagen Docker de Keycloak
y LocalStack. Refactorizado el TodoController y TodoService del capítulo
Registro de cambios 475

Conectando a una Base de Datos con RDS. Preferencia por la carga de diferentes
Spring beans a través de propiedades personalizadas en lugar de perfiles
(ver justificación aquí).

• Revisión 1.8 (2022-08-01) - Provisionar la tabla de DynamoDB con el


CDK de AWS y publicar eventos de Spring de forma asincrónica (Rastreo de
Acciones de Usuario con DynamoDB). Aplicar el principio del mínimo privilegio
al rol de IAM de la tarea de ECS eliminando cualquier comodín: * (ver
ServiceApp). Refactorizar la configuración de Spring Security (deprecia-
ción de WebSecurityConfigurerAdapter) para usar una configuración de
seguridad basada en componentes (Construcción de Registro y Login de Usuario
con Cognito).
• Revisión 1.9 (2022-10-04) - Agregar un modelo de un panel de Amazon
CloudWatch listo para usar, dirigido a aplicaciones de Java / Spring Boot, a
las fuentes (Creando Paneles con Amazon CloudWatch). Mencionar el curso en
línea complementario de Stratospheric en el Outro y Introducción. Corrección
de varios errores de tipeo y mejoras de estilo.
• Revisión 1.10 (2023-02-21) - Migración de la aplicación de muestra ‘Todo’
de Stratospheric a Spring Boot 3.0 y Spring Cloud AWS 3.0. Esto incluye los
cambios de nombre de espacio requeridos de javax a jakarta, así como
una transición de AWS Java SDK v1 a v2. Además, ahora estamos utilizando
el soporte de DynamoDB de Spring Cloud AWS para reducir los esfuerzos
de configuración manual en el capítulo Rastreo de Acciones de Usuario con
DynamoDB.
• Revisión 1.11 (2023-03-14) - Actualización de las políticas requeridas para
el usuario de GitHub Actions en el Apéndice. Corrección de errores de sin-
taxis en el manuscrito que provocaron problemas con la versión ePUB del
libro electrónico.
• Revisión 1.12 (2023-04-21) - Añadiendo una sección para Recursos Adicio-
Registro de cambios 476

nales al apéndice del libro electrónico.

También podría gustarte