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

Con questo articolo proseguo quanto iniziato nella prima parte, in cui ho mostrato come realizzare un cluster ECS con uso di istanze miste on-demand e spot. Adesso passeremo all’uso esclusivo di server spot. Mostrerò inoltre le principali problematiche che abbiamo dovuto affrontare in questo passaggio e come le abbiamo risolte.

Lo spot market

Lo spot market di AWS, disponibile dal 2009, è un bacino di macchine EC2 non utilizzate da Amazon in un dato momento. Ne viene concesso l’uso finché AWS non ne ha di nuovo bisogno (in meno del 5% dei casi secondo Amazon), dopodichè si hanno 120 secondi di preavviso prima che la macchina venga spenta. Il prezzo è molto basso (finanche il 90%) ma varia nel tempo a seconda della disponibilità e della richiesta di quel tipo di istanza. L’utente che ne richiede una ha la possibilità di definire un prezzo massimo che è disposto a spendere per quell’unità, oppure adeguarsi al costo di mercato sapendo che non sarà mai superiore al prezzo della stessa istanza on-demand.

È quindi chiaro che lo spot market è una utile risorsa per poter risparmiare molti soldi rispetto all’uso di EC2 on-demand, senza rinunciare alla flessibilità come si è costretti a fare con le istanze riservate (che devono essere prenotate per 1 o 3 anni). Il risparmio si porta però dietro un necessario bisogno di gestire la natura stessa dell’ambiente spot, compito non impossibile che mostreremo in questo post prendendo spunto da un progetto su cui abbiamo lavorato.

La nostra sfida era quella di realizzare dei cluster ECS in 4 diverse regioni utilizzando solo spot market senza comunque risentire delle interruzioni tipiche delle unità spot. Sebbene l’uso di tali istanze in ECS sia ampiamente suggerito e documentato dalla stessa Amazon, gli strumenti a supporto danno per scontato che le macchine spot rappresentino una percentuale dell’intero cluster, lasciando che una buona fetta di calcolo sia comunque su base on-demand. Nel nostro caso le macchine che sostengono i cluster sono generalmente 1 o 2, ecco quindi che utilizzare un ambiente misto avrebbe vanificato o annullato ogni risparmio. Non potendo quindi utilizzare gli strumenti standard ci siamo dovuti “inventare” una serie di tool di supporto che ci hanno infine permesso di basarci soltanto su spot market.

Passiamo ad uso esclusivo di istanze spot

Riprendendo la configurazione mostrata nel blog post precedente, dove utilizzavamo sia istanze on-demand che spot, ci sono da fare alcuni piccoli cambiamenti per passare soltanto ad istanze spot. Si tratta in pratica di azzerare le istanze fornite dalla EC2 fleet, dato che al momento non è in grado di fornire soltanto istanze spot, e spostare l’intera gestione delle macchine sull’autoscaling group.

Modifichiamo quindi le due configurazioni interessate:

resource "aws_ec2_fleet" "my_ec2_fleet" {
  # Ometto il resto della configurazione che rimane identica
  target_capacity_specification {
    default_target_capacity_type = "spot"
    total_target_capacity     = 0
    on_demand_target_capacity = 0
    spot_target_capacity      = 0
  }
}
resource "aws_autoscaling_group" "my_asg" {
  # Ometto il resto della configurazione che rimane identica
  min_size = 3 # Il valore prima usato in total_target_capacity
}

Dopo aver applicato questa configurazione con terraform apply verranno spente tutte le istanze del cluster lanciate dalla EC2 fleet e verranno invece lanciate le istanze spot fornite dall’ASG. ATTENZIONE! Non fatelo prima di aver concluso la lettura di questo articolo.
Infatti non è tutto così semplice, ECS purtroppo non è ottimizzato per essere eseguito esclusivamente su questo tipo di istanze, e allo stato attuale ci sono tre problemi principali:

I tool per la gestione spot

Gestire l’interruzione di un’istanza spot

Un’istanza spot, per sua natura, può essere interrotta da Amazon in un qualsiasi momento. Iniziamo analizzando quali sono le condizioni che portano ad una interruzione:

In ognuno dei tre casi, quando AWS decide di interrompere un’istanza, vengono concessi 120 secondi prima che il sistema operativo entri in fase di shutdown. Durante questi 2 minuti dobbiamo fare uno shutdown graceful dei servizi e salvare tutto ciò che è nella macchina, altrimenti andrà perso (c’è comunque modo di recuperare dati se si utilizzano dischi non “volatili”, ma non tratterò questo argomento adesso).

La soluzione che abbiamo trovato è quella di controllare quando questa interruzione ha inizio ed eseguire lo shutdown controllato dei container. Per farlo abbiamo usato i metadata delle istanze EC2, ovvero un sistema di AWS per poter accedere ad una serie di informazioni direttamente dall’istanza, interrogando degli endpoint all’indirizzo  http://169.254.169.254/<endpoint>. Nello specifico, quello che ci interessa è  http://169.254.169.254/latest/meta-data/spot/instance-action: un endpoint un po’ particolare che non è disponibile durante la vita standard dell’istanza spot (si ottiene un codice HTTP 404), ma che invece risponde con un codice 200 quando l’istanza è in fase di interruzione (il payload di risposta contiene l’esatto momento di shutdown, che comunque è 120 secondi dopo l’inizio dell’interruzione quindi non ci interessa più di tanto).

Questo è uno script che controlla ogni 5 secondi quell’endpoint e spegne i container Docker nel modo desiderato:

while true; do
    CODE=$(curl -LI -o /dev/null -w '%{http_code}\n' -s http://169.254.169.254/latest/meta-data/spot/instance-action)
    if [ "${CODE}" == "200" ]; then
        for i in $(docker ps --filter 'name=<container prefix>' -q); do
            docker kill --signal=SIGTERM "${i}"
        done
        sleep 120 # Wait until shutdown
    fi
    sleep 5
done

Lo script, piuttosto semplice, gira come demone e viene lanciato automaticamente all’avvio della macchina. È un loop infinito che controlla il codice di ritorno dell’endpoint citato in precedenza e, in caso sia un codice 200, spegne tutti i container docker che ci interessano in maniera graceful. Qui ci sono due cose da notare:

Il lettore più attento adesso dovrebbe però farsi una domanda: killando i container nell’istanza, chi ci garantisce che il cluster non li faccia immediatamente ripartire sulla stessa istanza? L’osservazione è corretta, lo strumento che abbiamo a disposizione per evitarlo è cambiare lo stato dell’istanza nel cluster da RUNNING a DRAINING, ovvero comunicare al cluster di non lanciare nuovi container sulla macchina. Questo può essere fatto manualmente dallo script ma una recente aggiunta ad ECS ci permette di farlo automaticamente all’inizio dell’interruzione spot e, anzi, lo abbiamo già fatto con l’istruzione echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config che avevamo inserito nel launch template.

Bilanciare il carico sulle istanze

Quando un’istanza viene interrotta, ne viene lanciata una nuova dall’autoscaling group. In pochi minuti è di nuovo disponibile nel cluster, ma nel frattempo tutti i task che hanno trovato spazio nelle istanze già esistenti (ovvero con sufficienti CPU e memoria rispetto a quanto specificato nel task definition), sono stati lanciati sulle altre istanze. Quindi la nuova macchina sarà vuota o al massimo con i task rimanenti dall’operazione precedente. Come già anticipato, ECS non fornisce strumenti per il bilanciamento automatico dei task nel cluster, quindi resta a noi farlo. La soluzione che abbiamo trovato è quella di impostare un task schedulato (un cron) nel cluster che periodicamente controlli il numero di task in ogni istanza RUNNING e li bilanci se la differenza è troppo ampia.

La configurazione di Terraform per impostare un task schedulato è composta di tre elementi:

resource "aws_cloudwatch_event_target" "tasks_balancing_cloudwatch_event" {
  rule      = aws_cloudwatch_event_rule.tasks_balancing_rule.name
  target_id = "run_tasks_balancing"
  arn       = aws_ecs_cluster.my_cluster.arn
  role_arn  = "my role"
  ecs_target {
    task_count          = 1
    task_definition_arn = aws_ecs_task_definition.tasks_balancing.arn
  }
}

resource "aws_cloudwatch_event_rule" "tasks_balancing_rule" {
  name                = "tasks_balancing_rule"
  description         = "Run tasks_per_container.py every 10 minutes"
  schedule_expression = "cron(0/10 * ? * * *)"
}

resource "aws_ecs_task_definition" "tasks_balancing" {
  family                = "my-cluster-scheduled-tasks-balancing"
  container_definitions = data.template_file.task_definition_tasks_balancing.rendered
  task_role_arn         = "my_role_arn"
  execution_role_arn    = "my_role_arn"
}

La definizione del container dovrà occuparsi di lanciare all’avvio lo script di bilanciamento:

[
    {
      "essential": true,
      "memoryReservation": ${memory},
      "image": "${image}",
      "name": "${name}",
      "command": ["python3", "/app/tasks_per_container.py", "-s", "-b"]
    }
]

NOTA: L’immagine Docker usata in image dovrà essere adeguatamente preparata per contenere lo script e l’ambiente di esecuzione.

Vediamo quindi le parti essenziali di tasks_per_container.py:

#!/usr/bin/env python3
import datetime
import boto3

def main():
    # parse args...
    ecs_client = boto3.client("ecs", "eu-west-1")
    tasks_list = ecs_client.list_tasks(cluster='my_cluster')
    tasks_per_instance = _tasks_per_instance(
        ecs_client, 'my_cluster', tasks_list['taskArns'])

    # Nothing to do if we only have one instance
    if len(tasks_per_instance) == 1:
        return

    if _is_unbalanced(tasks_per_instance.values()):
        for task in tasks_list['taskArns']:
            ecs_client.stop_task(
                cluster='my_cluster',
                task=task,
                reason='Unbalanced instances' # Shown in the ECS UI and logs
            )

if __name__ == '__main__':
    main()

La funzione principale, oltre ai dovuti setup, calcola il numero di task nel cluster (tasks_list) e la loro divisione nelle istanze. Nel caso in cui si abbia una sola istanza lo script non deve fare nulla, altrimenti viene valutato un eventuale sbilanciamento (_is_unbalanced, in cui uso una differenza del 30% come limite) e, in caso affermativo, vengono riavviati tutti i task. Questo passaggio permette di ribilanciare il carico in quanto, ripartendo tutti insieme, i task vengono naturalmente bilanciati tra le istanze a disposizione, grazie anche alla configurazione di ordered_placement_strategy  in  aws_ecs_service , come visto in precedenza.

Lo script con le funzioni omesse è disponibile all’indirizzo  https://gist.github.com/tommyblue/daa7be987c972447c7f91fc8c9485274

Sostiture un’istanza senza downtime

Capiterà prima o poi di dover sostituire le istanze del cluster con nuove versioni, tipicamente perché si è fatto un aggiornamento alla AMI. Di per sè l’operazione non è particolarmente complicata, in fondo basta spengere manualmente le macchine che verranno sostituite in automatico con nuove unità che utilizzano la nuova versione del launch template. Il problema è che questa operazione comporta un downtime del servizio, ovvero tra il momento in cui le istanze vengono spente e quando, dopo essere ripartite, tutti i task sono stati automaticamente lanciati. Nei nostri test questo tempo varia tra i 4 e gli 8 minuti, inaccettabile per la nostra SLA. Abbiamo quindi trovato una soluzione che ci permette di lanciare tutti i task nelle nuove macchine prima di spengere quelli vecchi e quindi le vecchie istanze, sfruttando il funzionamento degli autoscaling group. Ovviamente i servizi erogati dai task devono essere in grado di gestire questa concomitanza (seppure breve), ma ciò dipende totalmente dall’applicazione che gira nei container ed è al di fuori del contenuto del post.

All’inizio del post avevamo già modificato  aws_ec2_fleet e aws_autoscaling_group per passare tutta la gestione delle istanze all’ASG. Per sua natura l’ASG mantiene il numero di istanze al numero configurato come desired, quindi se una macchina viene spenta, entro pochi secondi ne verrà lanciata un’altra per sostituirla. Questo risulta fondamentale nei passaggi che si devono eseguire per effettuare la sostituzione:

  1. Impostare tutte le istanze del cluster nello stato DRAINING. Non accadrà nulla dato che non ce ne sono altre su cui spostare i task.
  2. Raddoppiare il numero di istanze desiderate nell’autoscaling group. Queste nuove richieste vengono lanciate in stato ACTIVE e tutti i task vengono lanciati su di esse grazie allo step precendente. Via via che vengono lanciati, i task analoghi vengono anche spenti nelle unità DRAINING
  3. Attendere che sulle vecchie istanze tutti i task siano stati spenti, il che significa che sono attivi su quelle nuove
  4. Riportare il numero dell’ASG al numero precedente. L’ASG spenge le macchine in eccesso riportando la situazione allo stato iniziale.

Su quest’ultimo punto merita soffermarsi un’attimo. Infatti lo spengere le macchine in eccesso non garantisce di per sè che non vengano spente le macchine appena lanciate. Ecco il perché della configurazione Terraform di aws_autoscaling_group dove avevo impostato il valore di termination_policies a ["OldestInstance", "OldestLaunchTemplate"]. Sto infatti dicendo all’ASG che, in caso debba terminare un’unità, la scelta deve ricadere sulla più vecchia e quella col launch template più vecchio. Grazie ad essa, in quest’ultimo step, vengono spente le due istanze più vecchie, esattamente ciò che desideriamo!

Ecco quindi che con questo insieme di configurazioni siamo riusciti a sostituire le unità senza che i task siano mai stati in numero minore di quanto desiderato, ovvero senza downtime.

La procedura può facilmente essere eseguita a mano, ma ho creato uno script python che la automatizza, potete trovarlo in questo gist.

Miglioriamo la velocità di sostituzione delle istanze interrotte

Prima di avviare l’articolo alla conclusione voglio mostrare un’ulteriore step di miglioramento che permette di diminuire il downtime che si crea quando un’istanza spot viene interrotta. L’autoscaling group infatti lancia una nuova istanza non appena si accorge che una è stata spenta, ma questo richiede circa 1 o 2 minuti. Per migliorare questo tempo di interruzione l’idea è semplice: quando un’istanza viene interrotta, lanciamo subito un’altro server.

Il posto migliore per farlo è lo script bash che abbiamo realizzato precedentemente, quello che ogni 5 secondi controlla se l’istanza è in fase di interruzione:

#!/bin/bash

REGION=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document|grep region|awk -F\" '{print $4}')
ASG="<asg_name>"

while true; do
    CODE=$(curl -LI -o /dev/null -w '%{http_code}\n' -s http://169.254.169.254/latest/meta-data/spot/instance-action)
    if [ "${CODE}" == "200" ]; then
        # ...

        # Immediately increase the desired instances in the ASG
        CURRENT_DESIRED=$(aws autoscaling describe-auto-scaling-groups --region "${REGION}" --auto-scaling-group-names ${ASG} | \
            jq '.AutoScalingGroups | .[0] | .DesiredCapacity')
        NEW_DESIRED=$((CURRENT_DESIRED + 1))
        aws autoscaling set-desired-capacity --region "${REGION}" --auto-scaling-group-name "${ASG}" --desired-capacity "${NEW_DESIRED}"
        # ...
    fi
    sleep 5
done

Lo script è semplice: utilizzando AWS-cli ottiene il numero di istanze desiderate nell’ASG (sarà necessario installare jq sulla macchina) e lo aumenta di 1. Dato che l’istanza in fase di interruzione è nello stato DRAINING, non appena la nuova istanza viene lanciata (generalmente prima dei 120 secondi di interruzione spot), i task vengono spostati, azzerando completamente il downtime. Unica pecca di questa soluzione è che l’ASG avrà un valore desired maggiorato rispetto allo standard, quindi quando l’istanza interrotta verrà effettivamente spenta, una nuovo server sarà comunque lanciato, quando non sarebbe indispensabile. Ad ogni modo dopo un po’ di tempo la configurazione di aws_autoscaling_policy riporterà il valore desired al numero precedente, ripristinando la situazione di normalità. Una soluzione forse non perfetta ma siamo comunque riusciti ad azzerare il downtime al costo di avere un’istanza di troppo per qualche minuto, la bilancia pende decisamente dal nostro lato 🙂

Conclusione

Giunti al termine di questi due articoli abbiamo quindi visto come realizzare un cluster ECS ottimizzandone poi la configurazione per utilizzare solo istanze EC2 spot.
Nel farlo abbiamo anche dovuto implementare una serie di strategie per minimizzare eventuali downtime dovuti alla natura stessa dello spot market e, infine, ho mostrato un paio di “trucchetti” per rendere il cluster ancora più stabile e sotto controllo.

Sebbene il tutto sia estremamente stabile (dopo vari mesi di utilizzo non abbiamo avuto alcun problema), un ulteriore miglioramento sarebbe quello di garantire che, nel malaugurato caso in cui tutte le istanze spot che abbiamo selezionato non siano disponibili, si possa comunque garantire il servizio su istanze on-demand. Tornerò sicuramente sul tema in futuro.

Per ragioni di spazio sono inoltre rimasti fuori da questo post due argomenti che vorrei trattare prossimamente per completare il quadro, ovvero:

Vorrei concludere dando un’occhiata ai costi e al risparmio ottenuto con le nostre configurazioni.

Numeri alla mano, il solo passare ad istanze spot, rispetto ad on-demand, ci ha fatto risparmiare in media il 65%.
L’intero progetto, che comprende anche il deploy simultaneo su più regioni per il risparmio di banda e la sostituzione di Apache Storm con un applicativo scritto in Go, ha portato ad un risparmio totale per il cliente dell’82%.
Numeri davvero notevoli, non possiamo che considerare il progetto un vero successo.

Vai all’articolo Elaborazione di pipeline di big data utilizzando Amazon ECS