Monitorización de Sistema de Microservicios en ECS

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