Monitorización de Sistema de Microservicios en ECS
En este artículo voy a mostrar cómo se puede monitorizar un proyecto de microservicios que está corriendo en AWS ECS. Esto no es tan sencillo, debido a que Prometheus necesita saber dónde está el microservicio del que va a tomar las métricas, cuando se hace un despliegue nuevo los microservicios se levantan en otra instancia la cual tiene una IP diferente, por lo tanto la solución no es tan sencilla como agregar una IP estática en el archivo de configuración prometheus.yml.
Para solucionar este problema utilizo un componente de OpenTelemetry que me ayuda con el descubrimiento de la ubicación de los nuevos contenedores. Para ver la solución vamos a monitorizar un proyecto de reserva de mesas de un restaurante.
El proyecto
Es un sistema de reservas de mesas de restaurante, que consta de cuatro servicios en NestJS que se comunican por RabbitMQ.
- reservations-api: Es la puerta de entrada del sistema, el usuario se comunica mediante una petición HTTP y este se comunica con el resto de microservicios mediante una cola de RabbitMQ
- booking-worker: Es el que hace el trabajo de reservar las mesas, busca una mesa disponible para esa franja horaria y la crea
- occupancy-stats: Es un microservicio de entrega de información, tal como: "¿cuántas mesas quedan libres a las 21:00?"
- no-show-sweeper: Es un recolector de basura, nadie lo llama, sin embargo hace un trabajo importante, cuando alguien inicia una reserva se bloquea la mesa para evitar dobles reservas a la misma mesa en la misma franja horaria, este microservicio de forma recurrente está revisando qué reservas quedaron huérfanas para invalidarlas y desbloquear la mesa
La infraestructura
internet
│
┌───────▼───────┐
│ ALB │ :80
└───────┬───────┘
│ :8080
┌───────────────────────────┼─────────────────────────────┐
│ VPC │ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ Tarea A │ │
│ │ reservations-api│ :8080 :9090 │
│ └──────────────────┘ ▲ │
│ │ │
│ ┌──────────────────┐ │ │
│ │ Tarea B │ │ │
│ │ booking-worker │ :9091 │ │
│ │ occupancy-stats │ :9092 │ │
│ │ no-show-sweeper │ :9093 │ │
│ └──────────────────┘ ▲ │ │
│ │ │ │
│ scrape 9090-9093 │ │ │
│ ┌───────────────────────┴────┴───┐ │
│ │ EC2 │ │
│ │ OTel Collector │ │
│ │ Prometheus │ │
│ │ Grafana │ │
│ └────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
El tráfico entra por el ALB el cual hace la redirección al reservations-api el cual se encarga de manejar la petición y hace los llamados necesarios a los otros microservicios.
Por el lado de la monitorización necesitamos tener una VPC donde va a estar la instancia EC2 con toda la maquinaria para la monitorización.
- Grafana: Para la visualización de las métricas
- Prometheus: Para la recolección de métricas
- OTel Collector: Para el descubrimiento de las nuevas IP de los contenedores (Nuevos despliegues)
Configuración estática de Prometheus
# prometheus.yml
scrape_configs:
- job_name: reservations-api
static_configs:
- targets: ['10.20.2.98:9090']
- job_name: booking-worker
static_configs:
- targets: ['10.20.2.113:9091']
- job_name: occupancy-stats
static_configs:
- targets: ['10.20.2.113:9092']
Esta es una configuración estática de Prometheus. Como podemos ver en el valor del label targets podemos ver una IP con un puerto: allí se le está indicando a Prometheus cuál es la IP y de qué puerto tomar las métricas. Sin embargo, al hacer un nuevo despliegue en ECS estas direcciones cambian, y desde Prometheus se vería como un fallo del microservicio.
Cómo queda con autodescubrimiento
# prometheus.yml
scrape_configs:
- job_name: tablebook-ecs
file_sd_configs:
- files:
- /etc/prometheus/targets/ecs_sd_targets.yaml
refresh_interval: 15s
relabel_configs:
- source_labels: [prometheus_job]
target_label: job
Este archivo funciona diferente. Aquí Prometheus lee los targets de un archivo que OTel Collector se encarga de estar actualizando, para que Prometheus sepa de dónde leer las métricas, el archivo de los targets tiene este aspecto:
# ecs_sd_targets.yaml — generado por el collector, no se edita a mano
- targets:
- 10.20.2.157:9091
labels:
__meta_ecs_cluster_name: tablebook
__meta_ecs_container_name: booking-worker
__meta_ecs_task_definition_family: tablebook-workers
__meta_ecs_task_definition_revision: "2"
__meta_ecs_task_launch_type: FARGATE
__metrics_path__: /metrics
prometheus_job: booking-worker
- targets:
- 10.20.2.157:9092
labels:
__meta_ecs_container_name: occupancy-stats
__metrics_path__: /metrics
prometheus_job: occupancy-stats
Docker labels de las tareas de ECS
Para que el OTel Collector pueda saber qué microservicios exponen métricas, en la definición del contenedor dentro de la tarea de ECS agrego los siguientes docker labels. Además, también configuro el port mapping para que cada contenedor exponga las métricas en un puerto diferente.
{
"name": "booking-worker",
"image": "...",
"portMappings": [{ "containerPort": 9091 }],
"dockerLabels": {
"PROMETHEUS_EXPORTER_PORT": "9091",
"PROMETHEUS_EXPORTER_PATH": "/metrics",
"PROMETHEUS_EXPORTER_JOB_NAME": "booking-worker"
}
}
Estos nombres de labels son definiciones propias, es decir, no son nombres reservados por Prometheus, Grafana ni el OTel Collector, son nombres que establecí de esa forma y deben coincidir tanto en la configuración de OTel como en la definición de las tareas de ECS. Así queda la configuración de OTel
# otel-config.yaml
docker_labels:
- port_label: PROMETHEUS_EXPORTER_PORT
metrics_path_label: PROMETHEUS_EXPORTER_PATH
job_name_label: PROMETHEUS_EXPORTER_JOB_NAME
Un puerto distinto por servicio
Un detalle importante es que, a pesar de que tenemos diferentes contenedores, todos comparten la misma IP. Por esto es necesario habilitar un puerto diferente para cada contenedor, y también es importante establecer las reglas de acceso en los security groups para que desde la instancia EC2 se pueda comunicar con los puertos de los contenedores en ECS.

Levantar la monitorización con Docker Compose
En la máquina EC2, todo vive en una carpeta:
/opt/monitoring/
├── docker-compose.yml
├── otel-config.yaml configuración del collector
├── prometheus.yml configuración de Prometheus
└── targets/ ← el collector escribe aquí
└── ecs_sd_targets.yaml y Prometheus lee de aquí
Las rutas del Compose son relativas a esa carpeta, así que basta con entrar en
ella y levantar:
cd /opt/monitoring
docker compose up -d
Las tres piezas corren como contenedores:
# docker-compose.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
user: '65534:65534'
command: ['--config=/etc/otel/config.yaml']
volumes:
- ./otel-config.yaml:/etc/otel/config.yaml:ro
- ./targets:/etc/targets
environment:
- AWS_REGION=us-east-1
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./targets:/etc/prometheus/targets:ro
- prometheus-data:/prometheus
ports:
- '9090:9090'
restart: unless-stopped
grafana:
image: grafana/grafana:latest
ports:
- '3000:3000'
restart: unless-stopped
volumes:
prometheus-data:
De este Docker Compose podemos destacar el volumen compartido entre el OTel Collector y Prometheus: ./targets:/etc/targets y ./targets:/etc/prometheus/targets:ro. Si nos fijamos, Prometheus tiene el volumen solo de lectura y OTel lo tiene con permisos de escritura, porque OTel escribe y Prometheus solo necesita leer.
Y el user: '65534:65534' del collector no es un detalle de seguridad. La imagen de Prometheus corre con ese usuario, así que el collector tiene que escribir el fichero con el mismo, o Prometheus no podrá leerlo.
Además, el directorio ./targets de la máquina tiene que pertenecer a ese
usuario antes de arrancar:
mkdir -p targets && sudo chown 65534:65534 targets
La configuración del collector
# otel-config.yaml
extensions:
ecs_observer:
cluster_name: tablebook
cluster_region: us-east-1
result_file: /etc/targets/ecs_sd_targets.yaml
refresh_interval: 15s
job_label_name: prometheus_job
docker_labels:
- port_label: PROMETHEUS_EXPORTER_PORT
metrics_path_label: PROMETHEUS_EXPORTER_PATH
job_name_label: PROMETHEUS_EXPORTER_JOB_NAME
receivers:
nop:
exporters:
nop:
service:
extensions: [ecs_observer]
pipelines:
metrics:
receivers: [nop]
exporters: [nop]
El bloque docker_labels es donde se cierra el círculo: son los mismos nombres
que pusimos en la task definition.
Permisos de la instancia EC2
El collector le pregunta a la API de ECS, así que la máquina necesita permiso
para hacerlo. Con un rol de instancia y esta política:
{
"Effect": "Allow",
"Action": [
"ecs:ListTasks",
"ecs:DescribeTasks",
"ecs:ListServices",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeContainerInstances",
"ec2:DescribeInstances"
],
"Resource": "*"
}
Puedes ver más sobre mí y mi trabajo en joel-uzcategui.com