Amazon ECS: ridurre i costi del 65% grazie allo 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:
- Come comportarsi quando un’istanza spot viene interrotta. In particolare è necessario evitare la perdita di dati che potrebbero trovarsi in quel momento nell’istanza in fase di shutdown.
- Come bilanciare il numero di task sulle istanze (quando vengono sostituite o quando l’ASG reagisce ad un cambio di carico), dato che ECS non sposta i task tra le macchine per rendere il carico omogeneo.
- Come sostituire le istanze senza downtime (ad esempio se si aggiorna la AMI). Questo punto non è legato alla natura spot ma è comunque importante nel ciclo di vita del cluster.
Cerchi consulenza su AWS?
Develer ti offre tutto il supporto necessario.
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:
- il costo che siamo disposti a spendere è inferiore all’attuale prezzo di quell’istanza
- il numero di istanze spot disponibili nella availability zone è diminuito e AWS prende il controllo delle istanze attualmente usate
- le condizioni scelte al momento della richiesta dell’istanza non sono più disponibili
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:
- Gli ID dei container da fermare sono ottenuti con
docker ps --filter 'name=<container prefix>' -q
.<container prefix>
va correttamente valorizzato con un prefisso comune a tutti i container che girano sul cluster, così da poter fermarli tutti. Nell’esempio iniziale, quando ho configuratoaws_ecs_task_definition.my_task
, ho usatotask_name
. Nel caso in cui si abbiano più task è sufficiente decidere un prefisso comune, ad esempiomy_task_<task name>
. Questa scelta ci permette di spengere tutti i nostri task ma non il containerecs-agent
, quello che utilizza il cluster per capire se e cosa sta effettivamente girando su un’istanza. Spengere quel task, sebbene nei 2 minuti prima dello spegnimento dell’istanza, può portare a effetti indesiderati, quindi è bene evitarlo. - Un secondo dettaglio da notare nello script è come viene killato il container:
docker kill --signal=SIGTERM "${i}"
. In questo modo i container ricevono il segnaleSIGTERM
invece diSIGKILL
, che viene invece usato quando il demone Docker si spenge. L’applicazione che sta girando nel container, nel nostro caso scritta in Go, è in grado di gestire questo segnale e terminare le operazioni correttamente prima di uscire (nel caso specifico, effettuare l’upload su S3 di alcuni file per non perderli).
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:
- Impostare tutte le istanze del cluster nello stato
DRAINING
. Non accadrà nulla dato che non ce ne sono altre su cui spostare i task. - 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
- Attendere che sulle vecchie istanze tutti i task siano stati spenti, il che significa che sono attivi su quelle nuove
- 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:
- come creare l’AMI di base con Packer
- come eseguire il deploy con Terraform su più regioni e più availability zones.
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