Monitoring a Microservices System on ECS

Monitoring a Microservices System on ECS

This article was translated from Spanish using AI. The original, written by the author, is here: https://ghost.joel-uzcategui.com/monitorizacion-de-sistema-de-microservicios-en-ecs/

In this article I am going to show how to monitor a microservices project running on AWS ECS. It is not that simple, because Prometheus needs to know where the microservice it will take the metrics from actually is, and when a new deployment happens the microservices come up on another instance with a different IP. So the solution is not as easy as adding a static IP to the configuration file prometheus.yml.

To solve this problem I use an OpenTelemetry component that helps me discover where the new containers are. To see the solution, we are going to monitor a restaurant table booking project.


The project

It is a restaurant table booking system made up of four NestJS services that talk to each other over RabbitMQ.

  • reservations-api: The entry point of the system. The user talks to it over HTTP, and it talks to the rest of the microservices through a RabbitMQ queue
  • booking-worker: The one that does the work of booking tables. It looks for a free table in that time slot and creates the reservation
  • occupancy-stats: A service that delivers information, such as "how many tables are still free at 21:00?"
  • no-show-sweeper: A garbage collector. Nobody calls it, but it does an important job: when someone starts a reservation the table is locked to avoid double bookings for the same table in the same slot, and this service periodically checks which reservations were left orphaned in order to invalidate them and release the table

The infrastructure

                          internet
                              │
                      ┌───────▼───────┐
                      │      ALB      │  :80
                      └───────┬───────┘
                              │ :8080
  ┌───────────────────────────┼─────────────────────────────┐
  │  VPC                      │                             │
  │                           │                             │
  │                  ┌────────▼─────────┐                   │
  │                  │ Task A           │                   │
  │                  │  reservations-api│  :8080  :9090     │
  │                  └──────────────────┘         ▲         │
  │                                               │         │
  │                  ┌──────────────────┐         │         │
  │                  │ Task B           │         │         │
  │                  │  booking-worker  │  :9091  │         │
  │                  │  occupancy-stats │  :9092  │         │
  │                  │  no-show-sweeper │  :9093  │         │
  │                  └──────────────────┘    ▲    │         │
  │                                          │    │         │
  │                       scrape 9090-9093   │    │         │
  │                  ┌───────────────────────┴────┴───┐     │
  │                  │  EC2                           │     │
  │                  │   OTel Collector               │     │
  │                  │   Prometheus                   │     │
  │                  │   Grafana                      │     │
  │                  └────────────────────────────────┘     │
  │                                                         │
  └─────────────────────────────────────────────────────────┘

Traffic comes in through the ALB, which forwards it to reservations-api, which handles the request and makes the calls it needs to the other microservices.

On the monitoring side we need a VPC holding the EC2 instance with all the machinery for monitoring.

  • Grafana: To visualise the metrics
  • Prometheus: To collect the metrics
  • OTel Collector: To discover the new container IPs on every deployment

A static Prometheus configuration

# 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']

This is a static Prometheus configuration. As we can see in the value of the label targets there is an IP with a port: that is where Prometheus is told which IP and which port to take the metrics from. However, a new deployment on ECS changes those addresses, and from Prometheus it would look like a broken microservice.

What it looks like with autodiscovery

# 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

This file works differently. Here Prometheus reads its targets from a file that the OTel Collector keeps up to date, so that Prometheus knows where to read the metrics from. The targets file looks like this:

# ecs_sd_targets.yaml — written by the collector, not edited by hand
- 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 on the ECS tasks

So that the OTel Collector can tell which microservices expose metrics, I add the following docker labels to the container definition inside the ECS task. I also set up the port mapping so that each container exposes its metrics on a different port.

{
  "name": "booking-worker",
  "image": "...",
  "portMappings": [{ "containerPort": 9091 }],
  "dockerLabels": {
    "PROMETHEUS_EXPORTER_PORT": "9091",
    "PROMETHEUS_EXPORTER_PATH": "/metrics",
    "PROMETHEUS_EXPORTER_JOB_NAME": "booking-worker"
  }
}

These label names are my own definitions, that is, they are not names reserved by Prometheus, Grafana or the OTel Collector. They are names I chose, and they have to match both in the OTel configuration and in the ECS task definition. This is how the OTel configuration looks

# otel-config.yaml
docker_labels:
  - port_label: PROMETHEUS_EXPORTER_PORT         
    metrics_path_label: PROMETHEUS_EXPORTER_PATH
    job_name_label: PROMETHEUS_EXPORTER_JOB_NAME

A different port per service

An important detail is that, even though we have different containers, they all share the same IP. That is why each container needs a different port, and why it is also important to set the security group rules so that the EC2 instance can reach the container ports on ECS.

Bringing the monitoring up with Docker Compose

On the EC2 machine, everything lives in one folder:

/opt/monitoring/
├── docker-compose.yml
├── otel-config.yaml            collector configuration
├── prometheus.yml              Prometheus configuration
└── targets/                    ← the collector writes here
    └── ecs_sd_targets.yaml        and Prometheus reads from here

The Compose paths are relative to that folder, so it is enough to go into
it and bring everything up:

cd /opt/monitoring
docker compose up -d

The three pieces run as containers:

# 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:

From this Docker Compose it is worth pointing out the volume shared between the OTel Collector and Prometheus: ./targets:/etc/targets and ./targets:/etc/prometheus/targets:ro. Note that Prometheus mounts it read-only while OTel mounts it with write permission, because OTel writes and Prometheus only needs to read.

And the collector's user: '65534:65534' is not a security detail. The Prometheus image runs as that user, so the collector has to write the file as the same one, or Prometheus will not be able to read it.

Also, the ./targets directory on the machine has to belong to that
user before starting:

mkdir -p targets && sudo chown 65534:65534 targets

The collector configuration

# 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]

The docker_labels block is where the circle closes: they are the same names
we put in the task definition.

Permissions for the EC2 instance

The collector queries the ECS API, so the machine needs permission
to do it. With an instance role and this policy:

{
  "Effect": "Allow",
  "Action": [
    "ecs:ListTasks",
    "ecs:DescribeTasks",
    "ecs:ListServices",
    "ecs:DescribeServices",
    "ecs:DescribeTaskDefinition",
    "ecs:DescribeContainerInstances",
    "ec2:DescribeInstances"
  ],
  "Resource": "*"
}

You can find more about me and my work at joel-uzcategui.com