Bene, è il momento di concludere questa serie di articoli in cui ho deciso di raccontare, scendendo un po’ più nel dettaglio, di come ho fatto il deploy di un’applicazione Laravel su un cluster Kubernetes.
Dove eravamo rimasti? Facciamo un piccolo recap, prima di procedere:
- Nella prima parte di questa serie mi sono dedicato a quelle che sono state le mie necessità prima di arrivare a considerare una soluzione Kubernetes managed per la mia app. Non sono partito da Kubernetes da subito, non ne avevo bisogno e col senno di poi è stata la scelta giusta;
- Nella seconda parte, invece, ho iniziato a mettere le mani in pasta. Ho spiegato come ho creato i vari componenti di cui avevo bisogno, dal cluster ai database, per poi passare dalla creazione dell’immagine da usare ed, infine, arrivare alla creazione delle varie risorse sul cluster;
Oggi ci occuperemo di quello che rimane. Vedremo, infatti:
- come gestire la questione dei worker delle nostre code: l’applicazione che ho realizzato, infatti, fa un uso intensivo delle code. Su questo ho anche una chicca da condividervi, quindi continuate a leggere…
- come gestire il discorso dei cronjob: altra parte fondamentale della mia app, smanettando e sperimentando ho scoperto un altro paio di cose interessanti che vi faranno risparmiare qualche mezz’ora di grattacapi;
- lo spiegare a Kubernetes come e quando lanciare le migration, al termine di un deploy, nel modo “giusto”, o comunque più coerente;
Il Deployment – Parte 2
Nell’articolo precedente abbiamo dovuto creare tutto il necessario per la web app. Adesso, invece, abbiamo bisogno di un altro deployment dedicato alle code ed ai processi (i worker). I modi possibili di procedere, qui, sono due.
Usiamo Horizon (e basta)!
Il primo, più “easy” è molto più utile per le applicazioni che hanno sì bisogno delle code, ma che non ne fanno un uso intensivo.
Mi vengono in mente quelle app che fanno uso delle code più che altro per operazioni on-demand (esempio: l’utente si iscrive e ho la solita coda default che manda qualche notifica e una mail di benvenuto). Ecco il nostro deployment:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
apiVersion: apps/v1 kind: Deployment metadata: name: workers spec: replicas: 3 strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 1 minReadySeconds: 0 selector: matchLabels: app: workers template: metadata: labels: app: workers spec: imagePullSecrets: - name: docker.pkg.github.com containers: - name: laravel image: docker.pkg.github.com/ORGANIZATION/APP-IMAGE:latest imagePullPolicy: Always # Eseguiamo "php artisan horizon" all'avvio. command: ["/usr/local/bin/php", "artisan", "horizon"] # Con questo comando buttiamo giù i worker in modo "soft" prima di un deploy. lifecycle: preStop: exec: command: ["/usr/local/bin/php", "artisan", "horizon:terminate"] envFrom: - configMapRef: name: laravel-config |
Non cambia molto rispetto all’altro deployment che abbiamo creato prima. Chiaramente, non abbiamo più bisogno di esporre porte e non abbiamo più bisogno di Nginx. Abbiamo cambiato il comando all’avvio, dato che avremo bisogno di eseguire php artisan horizon.
Tramite l’item lifecycle > preStop > exec, chiederemo a Kubernetes di eseguire, in ogni pod, php artisan horizon:terminate prima di tirarlo giù al successivo deploy. In questo modo, ogni eventuale job “in esecuzione” verrà portato a termine senza intoppi.
A volte, però, tutto questo non basta…
Usiamo Horizon (e non solo)!
La seconda modalità è più “intensiva” ed utile per quelle situazioni in cui si fa un uso più pesante delle code. Probabilmente non è la soluzione ottimale per eccellenza, ma nel mio caso (dalle decine alle centinaia di migliaia di job ogni ora) è molto più performante.
Una domanda sorge spontanea: perché due modi diversi di fare la stessa cosa? Purtroppo, ho dovuto fare di necessità virtù e studiarmi un modo diverso di fare le cose.
Di base, infatti, PHP non è fantastico per i long-running job. La mia app si occupa di cambiare dei prezzi nei negozi degli utenti. Deve farlo il più velocemente possibile e, soprattutto, deve fallire il meno possibile.
Ora, bisogna dire che i worker di Horizon sono spaventosamente comodi e l’interfaccia a disposizione dell’utente è fantastica. Con un carico di lavoro più pesante, però, ho fatto caso che le performance peggiorano di ora in ora. La RAM si riempie ed il rate di fallimenti, inoltre, aumenta a dismisura.
Last but not least… nei momenti di carico più pesanti mi ritrovo sul server a dover tirare giù Horizon e farlo ripartire! Non è un gran modo di far crescere un business, no?
Così mi sono ricordato di una cosa vista in passato: in questi casi, per PHP è molto più comodo e veloce buttare giù il processo, farlo ripartire, fargli processare un messaggio e poi di nuovo giù. E così via.
Per l’occasione, ho tirato fuori un vecchio amico… Supervisord! In poche parole, ho creato un file di configurazione per supervisord (rileggi bene l’articolo precedente per sapere dove salvare questo file…) Come questo di seguito:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[program:consumers-default] directory=/var/www/ command=php artisan queue:work --once --queue=default process_name=%(program_name)s_%(process_num)02d autostart=true autorestart=true user=forge numprocs=5 redirect_stderr=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 startsecs=0 |
Questo file, ad esempio, spiega di:
- tirare su un worker tramite il comando php artisan queue:work sulla coda default;
- quel flag –once è la chiave di tutto, dato che spiega a supervisord di eseguire un solo job di quelli nella coda “default“;
- una volta terminata l’esecuzione, il processo va giù e supervisord è pronto a ritirarne su un altro ad-hoc su quella coda, così ce ne sono sempre 5 attivi che stanno lavorando oppure sono in ascolto;
Il risultato? L’uso della CPU è aumentato, certo (ma non è un gran problema se si “dosano” bene il numero di processi di cui abbiamo bisogno) ma la RAM rimane finalmente stabile.
Soprattutto, le performance dell’app ne hanno guadagnato in modo impressionante e sono arrivato a triplicare la velocità di esecuzione dei miei cambi prezzi. Not bad.
Sia chiaro: il deployment di prima per una cosa del genere non va più bene. Tocca adattarlo, in questa parte:
1 2 3 4 5 6 7 8 9 10 11 12 |
... containers: - name: laravel # L'immagine è la stessa, nell'articolo precedente # possiamo vedere quando abbiamo caricato il file di configurazione. image: docker.pkg.github.com/ORGANIZATION/APP-IMAGE:latest imagePullPolicy: Always # Niente Horizon, facciamo partire supervisord! command: ["/usr/bin/supervisord"] ... |
Lo Scheduler
Per lo Scheduler di Laravel avremo bisogno di un Cronjob di Kubernetes. Niente di complesso, anzi, è davvero semplice configurarlo. Creiamo un file cronjob.yml il cui contenuto sarà:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
apiVersion: batch/v1beta1 kind: CronJob metadata: name: cronjob spec: schedule: "*/1 * * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: laravel image: docker.pkg.github.com/ORGANIZATION/APP-IMAGE:latest command: ["/usr/local/bin/php", "artisan", "schedule:run"] |
Non ci rimane che andare a configurare quali task eseguire e quando tramite apposito codice, come spiegato dalla documentazione.
Le Migration
Ultimo pezzettino del nostro puzzle sono le migration. Per eseguirle usiamo un altro elemento di Kubernetes, i Job. Creiamo un altro file, che chiameremo jobs_run_migrations.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
apiVersion: batch/v1 kind: Job metadata: name: artisan-migration spec: ttlSecondsAfterFinished: 300 backoffLimit: 3 template: spec: restartPolicy: Never containers: - name: laravel image: docker.pkg.github.com/ORGANIZATION/APP-IMAGE:latest envFrom: - configMapRef: name: laravel-envvars command: ["/usr/local/bin/php", "artisan", "migrate", "--force"] |
Nota: nel momento in cui creeremo questo Job su Kubernetes, un pod verrà creato con il solo scopo di eseguire il comando. Ovviamente, ad ogni nuovo deploy dovremo rimuovere il Job esistente e crearne uno nuovo.
Nota: per questi ultimi due pezzetti super kudos a Lorenzo Aiello ed al suo articolo. Fondamentale per “chiudere il cerchio” della mia app. Grazie mille di cuore, Lorenzo!
Concludiamo?
Sì dai. A questo punto delle cose i pezzi del puzzle ci sono tutti. Certo, alcuni setting cambiano di cluster in cluster, nel mio caso ho usato un DigitalOcean managed. In altri casi ci sono cose che potrebbero cambiare, così come nel caso di Minikube se volete testare tutto in locale.
Penso ne scriverò ancora in futuro, quindi rimanete sintonizzati! Se volete, potete iscrivervi alla newsletter usando il form qui a lato, oppure cliccando qui.
Kubernetes, per me, è un mondo davvero affascinante. Se dovessi trovare del tempo, in futuro, spero di approfondirlo ancora di più.
Spero di esservi stato d’aiuto in qualche modo. Mi rendo conto che, a differenza di altri articoli o serie che ho scritto in passato, questa non è esattamente step by step. Ho preferito fare in modo che potesse essere usata più come una traccia che altro.
A presto!