Elaborazione di pipeline di big data utilizzando Amazon ECS

Questo è il primo post di una serie di due articoli in cui mostrerò come utilizzare Amazon Elastic Container Service (aka ECS) per realizzare una pipeline di elaborazione di big data in grado di rispondere autonomamente ai cambiamenti di carico. Mentre questa prima parte mostrerà genericamente come realizzare un cluster con uso di istanze on-demand e, in parte, anche spot, nel prossimo articolo mostrerò invece come utilizzare esclusivamente lo spot market per le macchine EC2, e i tool che abbiamo dovuto creare per gestire l’aumentata complessità di gestione.

Nel progetto da cui ho preso spunto per questi articolo, il nostro obiettivo era elaborare circa 7 TB di dati al giorno leggendoli da vari stream Amazon Kinesis sparsi su 5 regioni AWS, comprimendo i dati in piccoli pacchetti zstd da caricare poi su un bucket S3. Il sistema da noi progettato doveva sostituire una esistente versione in Apache Storm che aveva vari problemi di stabilità e, nel farlo, ci era richiesto un sostanziale risparmio in termini di costi.

Abbiamo innanzitutto sostituito l’applicativo con uno scritto in Go, cosa che ci ha immediatamente permesso di poter notevolmente diminuire il numero e la dimensione dei server necessari. Abbiamo inoltre deciso di utilizzare ECS per fornire il servizio, eseguendo gli applicativi Go in container Docker e lasciandoci spazio di manovra per aumentare o diminuire i server al variare del carico di lavoro. Inoltre abbiamo realizzato non un singolo cluster ma un cluster in ogni regione da cui dovevamo leggere i dati. In questo modo abbiamo potuto comprimere i dati direttamente nella regione sorgente, risparmiando moltissima banda nel caricare i file sul bucket S3 di destinazione (che è in Oregon). Basti pensare che Zstd è in grado di comprimere tra i 20 e le 30 volte i nostri dati e che, ad esempio, il costo di trasferimento dei dati da AWS Tokyo verso un’altra regione è di 0,09$/GB. Facendo due calcoli questo ci ha permesso di passare da un costo medio di trasferimento dati di 180$/giorno a circa 9$/giorno…

Infine, ma non meno importante, l’uso esclusivo di spot instances invece di istanze on-demand. Dedicherò interamente a questo tema il prossimo blog post, ma vi anticipo che ci ha permesso di risparmiare circa il 65%, niente male 🙂

Creiamo insieme un cluster ECS

Per agevolare le configurazioni AWS abbiamo utilizzato Terraform, un prodotto di HashiCorp per la configurazione di infrastrutture cloud attraverso codice. È un ottimo strumento DevOps per applicare configurazioni riproducibili all’infrastruttura AWS senza perdersi tra CLI, le mille web-UI e altri strumenti sempre molto eterogenei. Premetto che non mostrerò tutte le configurazioni necessarie, ma una sintesi delle stesse per non divagare eccessivamente dall’argomento di questo post. In particolare non tratterò delle configurazioni dei permessi IAM, senza i quali non è possibile applicare le configurazioni mostrate. Conto in futuro di tornare sull’argomento con un altro articolo.

Breve introduzione ai tipi di istanze EC2

Amazon Elastic Compute Cloud (Amazon EC2) è lo strumento di AWS per fornire capacità di calcolo nel cloud. Tale capacità è erogata da macchine con tagli molto diversi tra loro (da 1 vCPU e 2 GB di RAM fino a centinaia vCPU e decine di TB di RAM), così come i prezzi. Ogni tipo di macchina (o istanza) può essere di due tipi: riservata oppure on-demand. Le istanze on-demand forniscono una grande dinamicità, dato che possono essere accese e spente a richiesta (anche per pochi minuti), ma hanno un costo elevato. Le istanze riservate, d’altra parte, offrono prezzi più vantaggiosi ma devono essere acquistate per 1 o 3 anni. C’è una terza opzione, che ho citato nell’introduzione, ovvero lo spot market, ma approfondiremo ampiamente il tema nel prossimo post.

Il cluster ECS

Amazon Elastic Container Service, ECS è un prodotto della famiglia AWS che permette di orchestrare cluster di container Docker, integrato ovviamente con l’ecosistema Amazon Web Services. È un’ottima soluzione per gestire carichi di lavoro dinamici, che si tratti di un prodotto rivolto al pubblico web o, come nel nostro caso, una pipeline di calcolo ad alte prestazioni, dato che risulta immediato modificare le risorse in uso per adattarsi al carico del momento. I container possono essere eseguiti su Fargate (che non tratteremo adesso), un sistema che permette di non doversi occupare delle macchine su cui vengono eseguiti i container, o su macchine EC2, che siano riservate, on-demand o spot. A livello operativo un cluster ECS raggruppa un insieme di servizi, formati a loro volta da uno o più task, ovvero istanze di container Docker.

Per creare un cluster di esempio con un servizio e un task che esegue due identici container Docker, questa è la configurazione Terraform necessaria:

resource "aws_ecs_cluster" "my_cluster" {
  name = "my_cluster"
}

resource "aws_ecs_task_definition" "my_task" {
  family                = "task_name"
  container_definitions = data.template_file.my_task_definition.rendered
}

resource "aws_ecs_service" "my_service" {
  name            = "service_name"
  cluster         = aws_ecs_cluster.my_cluster.id
  task_definition = aws_ecs_task_definition.my_task.arn
  desired_count   = 2
  launch_type     = "EC2"
  ordered_placement_strategy {
    type  = "spread"
    field = "instanceId"
  }
}

Ci sono alcune cose da notare in queste poche righe:

data "template_file" "my_task_definition" {
  template = "${file("${path.module}/task-definition.json")}"
  vars = {
    task_name         = "my_task"
    image             = "<immagine Docker in ECR>"
    memoryReservation = 128
    cpu               = 9
  }
}

Le variabili passate alla definizione possono essere usate dentro al file JSON di definizione del task:

[
    {
      "essential": true,
      "cpu": ${cpu},
      "memoryReservation": ${memoryReservation},
      "image": "${image}",
      "name": "${task_name}"
    }
  ]

Le opzioni per la definizione del task sono molte, ma la gestione della CPU e della memoria è particolarmente importante, nonché non banale da calcolare. Personalmente ho usato la strategia suggerita da questo articolo ovvero: inizia da valori che ti sembrano sensati, poi aggiusta.

Terminata questa configurazione il cluster ECS è pronto(*) ad eseguire i servizi configurati, basterà “agganciarvi” una o più istanze EC2 ed automaticamente verranno lanciati i container Docker.
(*) Come anticipato sto mostrando estratti di configurazione, per renderle funzionanti sarà necessario aggiungere altre sezioni

La configurazione delle istanze EC2

Launch template

Il launch template rappresenta un insieme di configurazioni da usare quando si vuol eseguire un’istanza EC2, così da non doverle specificare tutte al momento del lancio:

resource "aws_launch_template" "my_launch_template" {
  name      = "launch_template_name"
  image_id  = "<ami-id>"
  user_data = base64encode(data.template_file.userdata.rendered)
  ...
}

data "template_file" "userdata" {
  template = "${file("${path.module}/user_data.sh")}"
  vars = {
    cluster_name = "my_cluster"
  }
}

Ho ridotto il file alle configurazioni essenziali, anche se ce ne sono davvero molte a disposizione, vi consiglio una lettura attenta della documentazione ufficiale.

Ho riportato, oltre al nome, il parametro <image_id> che definisce quale immagine AMI usare per lanciare le istanze e user_data, uno script (bash nel mio caso) che verrà esequito all’avvio delle istanze.

Per quanto riguarda l’AMI potete ovviamente usare una di quelle fornite da Amazon o da terze parti, all’inizio era stata anche la nostra scelta, ma dopo poco lo script in user_data era diventato molto lungo e di conseguenza anche l’avvio delle istanze era lento. Abbiamo quindi deciso di creare una versione custom di un’AMI di base con Packer spostando in quella fase gran parte delle istruzioni dello script di avvio.

Parlerò in un post futuro di Packer, per il momento ecco uno script user_data essenziale:

#!/bin/bash
set -eu

# Configure ECS
mkdir -p /etc/ecs
echo ECS_CLUSTER=${cluster_name} >> /etc/ecs/ecs.config
echo ECS_CONTAINER_STOP_TIMEOUT=120s >> /etc/ecs/ecs.config
echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config

# Install ECS stuff
yum install -y ecs-init

# Add ssh user to docker group
usermod -a -G docker ec2-user
service docker restart
start ecs
sleep 90

# Update ECS agent image
docker stop ecs-agent
docker rm ecs-agent
docker pull amazon/amazon-ecs-agent:latest
start ecs

# More stuff ...

Il file configura la macchina per far parte del cluster ECS usando la variabile cluster_name che gli è stata passata dal template Terraform per agganciarsi al cluster corretto. Le due successive configurazioni ECS_CONTAINER_STOP_TIMEOUT e ECS_ENABLE_SPOT_INSTANCE_DRAINING sono utili per il corretto funzionamento del cluster con le istanze spot. Riprenderò nel prossimo blog post queste configurazioni. Esistono molte altre configurazioni che è possibile inserire nel file /etc/ecs/ecs.config, rimando per questo alla guida ufficiale.

Rimani aggiornato!

Ricevi novità su Develer e sulle tecnologie che utilizziamo iscrivendoti alla nostra newsletter.

Iscriviti alla newsletter

EC2 Fleet

EC2 fleet è un modo per definire una flotta di istanze (reserved, on-demand o spot) che utilizzano un launch template, decidendo che tipi di istanze usare, le availability zone, ecc.

Riporto di seguito una configurazione relativamente semplice che utilizza istanze miste spot e on-demand. Data la lunghezza del file aggiungo i commenti direttamente nel file:

resource "aws_ec2_fleet" "my_ec2_fleet" {
  # Questo è un comando interno di terraform che definisce come gestire la risorsa. In questo caso,
  # quando c'è una modifica che richiede di eliminare e ricreare la flotta, viene prima creata la
  # nuova versione e poi eliminata la vecchia. Per la flotta questo risulta molto utile dato che
  # l'eliminazione richiede diversi minuti. Creando prima la nuova risorsa si evitano inutili
  # downtime del servizio.
  lifecycle {
    create_before_destroy = true
  }

  terminate_instances_with_expiration = false
  terminate_instances                 = true
  replace_unhealthy_instances         = true
  type                                = "maintain"

  launch_template_config {
    # Usiamo il launch template definito prima, nella sua ultima versione
    launch_template_specification {
      launch_template_id = aws_launch_template.my_launch_template.id
      version            = aws_launch_template.my_launch_template.latest_version
    }

    # Definisco degli override rispetto al launch template. Scelgo 3 diversi tipi di istanze
    # dando ad ognuno un peso (in base alle risorse fornite) e una priorità. Per alcuni definisco
    # anche un prezzo massimo che voglio spendere nello spot market. Dove non definito il prezzo
    # potrà al massimo essere equivalente all'istanza on-demand dello stesso tipo
    override {
      instance_type     = "c4.large"
      weighted_capacity = 1
      priority          = 2
    }

    override {
      instance_type     = "c4.xlarge"
      weighted_capacity = 2
      priority          = 1
      max_price         = 0.10
    }

    override {
      instance_type     = "c4.2xlarge"
      weighted_capacity = 4
      priority          = 0
      max_price         = 0.20
    }
  }

  # Per le istanze on-demand scelgo di utilizzare quelle a costo più basso, per quelle spot invece
  # preferisco diversificare, per minimizzare il numero di interruzioni
  on_demand_options {
    allocation_strategy = "lowestPrice"
  }
  spot_options {
    allocation_strategy = "diversified"
    instance_interruption_behavior = "terminate"
  }

  # Definisco come distribuire il carico tra spot e on-demand
  target_capacity_specification {
    default_target_capacity_type = "spot"
    total_target_capacity     = 3
    on_demand_target_capacity = 1
    spot_target_capacity      = 2
  }
}

In questa configurazione è importante valutare bene il peso delle istanze e la gestione dei costi, consiglio per questo la lettura di due risorse: la guida ufficiale e un interessante blog post.

Come detto questa configurazione è mista spot e on-demand, nel prossimo articolo spiegherò come gestire questi valori per usare solo istanze spot.

Autoscaling group (aka ASG)

La funzionalità di autoscaling permette di aumentare le risorse a disposizione nel caso in cui i consumi aumentino. Nel nostro caso scaleremo il numero di istanze a disposizione del cluster, senza modificare i task nel servizio. Questo aspetto dipende molto dal tipo di applicazione che si sta costruendo. Nel caso di un’applicazione web potrebbe aver senso anche scalare sui task in modo da alleggerire il carico sui server web che stanno servendo troppi utenti. Nel nostro caso, che è un po’ particolare, c’era la necessità di distribuire un numero costante di container sulle macchine. Nel paragrafo successivo entrerò nel dettaglio di questo aspetto e le soluzioni che abbiamo applicato.

Tornando all’autoscaling group, si inizia definendo il gruppo e i trigger di notifica:

resource "aws_autoscaling_group" "my_asg" {
  name     = "my_asg"
  max_size = 4
  min_size = 0
  termination_policies = ["OldestInstance", "OldestLaunchTemplate"]
  default_cooldown = 900 # 15 min
  mixed_instances_policy {
    launch_template {
      launch_template_specification {
        launch_template_id = aws_launch_template.my_launch_template.id
        version            = "$Latest"
      }
      override {
        instance_type = "m5.xlarge"
      }
      override {
        instance_type = "c5.xlarge"
      }
      ...
    }
    instances_distribution {
      on_demand_base_capacity                  = 0
      on_demand_percentage_above_base_capacity = 0
      spot_allocation_strategy                 = "lowest-price"
      spot_instance_pools                      = 4
    }
  }
}

In questa sezione di configurazione viene definito l’autoscaling group, indicando il numero minimo e massimo di istanze che si desiderano, il launch template da usare e i tipi di istanza che si desiderano. Ho indicato due tipi, ma trattandosi di spot market esclusivo (si noti la configurazione di instances_distribution), suggerisco di inserire una lista più ampia in modo da non trovarsi senza istanze a disposizione. Unica nota è quella di utilizzare istanze con risorse tra loro compatibili altrimenti l’applicazione potrebbe cambiare molto il proprio comportamento (ad esempio se si dimezza o raddoppia CPU o memoria). default_cooldown è impostato ad un valore abbastanza alto, 15 minuti: quel parametro definisce quanto tempo deve passare tra un’azione di autoscaling e un’altra. Dato che la nostra applicazione richiede un po’ di tempo per allinearsi ad un valore semi costante, con questo numero alto evito che l’ASG inizi a lanciare e poi fermare istanze prima che il consumo di risorse si assesti. Non mi soffermo adesso su termination_policies, configurazione molto importante che spiegherò in dettaglio nel paragrafo “Sostiture un’istanza senza downtime”.

Terminato il setup di base dell’autoscaling, provvediamo a configurare le policy di scaling:

resource "aws_autoscaling_policy" "my_asg_autoscale_up" {
  name                   = "my-asg-autoscale-up"
  autoscaling_group_name = aws_autoscaling_group.my_asg.name
  adjustment_type        = "ChangeInCapacity"
  policy_type            = "StepScaling"

  estimated_instance_warmup = 420 # 7 minutes
  metric_aggregation_type   = "Average"

  step_adjustment {
    scaling_adjustment          = 1
    metric_interval_lower_bound = 0.0
    metric_interval_upper_bound = 10.0
  }

  step_adjustment {
    scaling_adjustment          = 2
    metric_interval_lower_bound = 10.0
  }
}

resource "aws_autoscaling_policy" "my_asg_autoscale_down" {
  name                   = "my-asg-autoscale-down"
  autoscaling_group_name = aws_autoscaling_group.my_asg.name
  adjustment_type        = "ChangeInCapacity"
  policy_type            = "StepScaling"

  estimated_instance_warmup = 600 # 10 minutes
  metric_aggregation_type   = "Average"

  step_adjustment {
    scaling_adjustment          = -1
    metric_interval_lower_bound = -20.0
    metric_interval_upper_bound = 0.0
  }

  step_adjustment {
    scaling_adjustment          = -2
    metric_interval_upper_bound = -20.0
  }
}

Con queste policy definiamo le condizioni per lo scaling positivo o negativo del numero di istanze (ChangeInCapacity). Con i diversi valori di step_adjustment, i cui bound sono calcolati come differenza rispetto alle metriche CloudWatch (definite sotto), possiamo scalare di una o anche due istanze in caso di consumi elevati o ridotti.

Ultimo step, definiamo la metrica CloudWatch:

resource "aws_cloudwatch_metric_alarm" "my_asg_metric_autoscale_up" {
  alarm_name          = "my-asg-scale-up"
  comparison_operator = "GreaterThanThreshold"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = "300"
  evaluation_periods  = "3"
  statistic           = "Maximum"
  threshold           = 80

  dimensions = {
    ClusterName = "${aws_ecs_cluster.my_cluster.name}"
  }
  alarm_description = "Monitor CPU utilization"
  alarm_actions     = [aws_autoscaling_policy.my_asg_autoscale_up.arn]
}

Con questa configurazione utilizziamo l’80% di uso della CPU come limite, scalando quindi di una istanza se è tra 80% e 90% per 5 minuti (period = 300) o anche di due se è tra 90% e 100%. Configurazioni analoghe possono essere fatte anche per diminuire il numero di istanze e per monitorare anche l’uso di memoria oltre alla CPU. In questo caso bisogna stare molto attenti a valutare bene l’interazione tra CPU e memoria altrimenti si potrebbe incappare in un tiro alla fune tra richiesta di scalare in alto per una metrica e in basso per un altra, con risultati imprevedibili. Nel nostro caso, avendo un uso di memoria relativamente stabile e una CPU più “ballerina” abbiamo deciso di scalare in alto per entrambe le metriche ma usare solo la CPU per la scalabilità in basso.

I prossimi step

Arrivati a questo punto si è in grado (eccezion fatta per i ruoli IAM) di configurare un cluster ECS per eseguire processi personalizzati in container Docker, adattando la capacità di calcolo del cluster alle necessità del momento. Come anticipato nell’introduzione, nel nostro progetto abbiamo voluto fare un passo in avanti decidendo di utilizzare soltanto istanze spot, questo ci ha fatto risparmiare molto (circa il 65%), ma ha introdotto una certa complessità di gestione, che abbiamo dovuto affrontare. Il prossimo blog post è interamente dedicato a questo argomento, quindi, a presto!

Leggi l’articolo Ridurre i costi di un cluster ECS Amazon del 65% con l’uso esclusivo di spot market