Twelve factor app

Twelve factor app

Negli ultimi mesi sto lavorando a una riorganizzazione del sito del quotidiano per cui lavoro. Uno degli obiettivi principali che ci siamo posti è migliorare la velocità di caricamento delle pagine. Al di là del lavoro sul frontend, abbiamo ripensato completamente la parte del CMS che gestisce il rendering delle pagine per renderlo più veloce. In particolare stiamo costruendo un'architettura a microservices con uno strato di API di fronte al CMS e ad altri servizi, chiamato da uno strato di rendering vero e proprio scollegato dal CMS.

Iniziando su questa china, abbiamo fatto pratica con Docker per ospitare le istanze dei microservices. La mia precedente esperienza con Docker non è stata entusiasmante, ma si trattava di un'applicazione mastodontica che occupava 1GB di RAM.

Dopo un paio di settimane di lavoro con Docker e quattro tipi di container comunicanti tra loro inseriti in un ambiente di test, ho iniziato a rivalutare questa tecnologia. Al di là delle difficoltà iniziali di definizione dei container, in gran parte dovute al mio approccio mani in pasta senza tanto studio preliminare, mi sembra un modo molto più pulito per il deploy di applicazioni web rispetto a quello a cui sono abituato.

Nella mia esperienza di sviluppatore Django, un ambiente di deploy è un server (fisico o virtuale) in cui sono caricate tutte le applicazioni. Se si vuole scalare orizzontalmente, si aggiunge un server, si ricaricano tutte le applicazioni e si configura il balancer per utilizzare le nuove risorse.

Secondo la mia esperienza questo approccio porta a sovradimensionare le risorse utilizzate. Ad esempio, in occasione di un picco di traffico per una applicazione, abbiamo aggiunto due macchine virtuali. Una volta passata l'emergenza, le macchine virtuali sono rimaste, anche per evitare lo sbattimento di toglierle dai balancer (tutte procedure manuali).

Non entro neanche nell'affrontare il tema della divisione dei compiti tra sviluppatori e sistemisti, perché nella pratica sono sempre gli sviluppatori a gestire i deploy. E dato che i sistemisti tendono a dare la responsabilità all'applicazione quando le risorse si vanno esaurendo, alla fine anche il dimensionamento è responsabilità degli sviluppatori.

Da questo punto di vista, avere tanti container Docker che possono essere aggiunti e rimossi con facilità su poche grandi macchine host mi sembra il modo migliore di utilizzare le risorse. Rimane il problema di come automatizzare il processo di deploy, che con tante piccole applicazioni replicate e connesse tra loro rischia di essere molto delicato.

Questo compito è solitamente svolto da softare PaaS, Platform as a Service. Da una short list abbiamo tirato fuori Deis e abbiamo iniziato a fare dei test.

Deis è un PaaS ispirato a Heroku, una delle soluzioni più utilizzate. In passato mi sono trovato a scegliere tra AWS e Heroku (che a sua volta usa AWS). La considerazione principale che ho fatto all'epoca è stata che se sai configurare una macchina virtuale, AWS ha prezzi molto minori e nessuna delle limitazioni che pone Heroku.

Il sito di Deis, al contrario di quello di Heroku, non è concentrato soltanto su dettagli tecnici e tutorial su come lanciare un'applicazione in pochi minuti, ma fa riferimento in modo molto evidente alla filosofia progettuale che adotta, che è appunto quella di Heroku: 12-factor methodology.

Leggere questa sorta di manifesto mi ha dato la stessa sensazione di chiarezza che mi ha dato molto tempo fa leggere i suggerimenti per la scalabilità di Django, tanto che mi sembra la naturale evoluzione delle applicazioni web. Il manifesto in sé, scaricabile anche in formato epub, è molto veloce da leggere e consiglio di farlo. Anche perché i commenti che seguono ad alcuni passaggi chiave che ho sottolineato presuppongono che lo abbiate fatto.

The twelve-factor app

A twelve-factor app never relies on implicit existence of system-wide packages. It declares all dependencies, completely and exactly [...]. Dependency declaration and isolation must always be used together [...]. If the app needs to shell out to a system tool, that tool should be vendored into the app.
II. Dependencies

Affidarsi alle librerie installate a livello di sistema operativo significa legare tutte le applicazioni che girano sullo stesso server alla stessa versione delle librerie. Aggiornare le librerie diventa un'impresa improba perché bisogna verificare che le nuove versioni siano compatibili con ciascuna applicazione. Anche usando virtualenv in Python è possibile che le librerie locali facciano affidamento a librerie di sistema per compiti specifici. A me capita spesso con Pillow e il decoder JPEG.

Docker racchiude tutte le dipendenze, compresi pacchetti del sistema operativo, in un unico contenitore isolato.

Apps sometimes store config as constants in the code. This is a violation of twelve-factor, which requires strict separation of config from code.
III. Config

La soluzione più tipica in Django per distinguere i vari ambienti di sviluppo/stage/produzione è avere diversi file di settings nella repo. Questa soluzione è buona ma perfettibile. Se si deve spostare il database ad esempio si deve fare un commit con la modifica. Usare variabili d'ambiente è il modo corretto di gestire i riferimenti ai servizi usati.

Ancora più importante, avere una configurazione esterna al codice costringe a dichiarare esplicitamente cosa può variare tra gli ambienti di deploy. Supponiamo che nascosto dentro un file di configurazione ci sia il prefisso da usare per la cache. Cosa succede si tiriamo su un ambiente di stage senza modificare questo parametro di configurazione? Molto probabilmente in breve inizieremo a servire in produzione pagine di stage prese dalla cache.

Run stage should be kept to as few moving parts as possible, since problems that prevent an app from running can cause it to break in the middle of the night when no developers are on hand. The build stage can be more complex, since errors are always in the foreground for a developer who is driving the deploy.
V. Build, release, run

Non fa una piega: se qualcosa si deve rompere, si deve rompere in fase di build, non quando si avvia in produzione.

Asset packagers use the filesystem as a cache for compiled assets. A twelve-factor app prefers to do this compiling during the build stage.
VI. Processes

Un'altra obiezione alla cache locale è che ogni processo ha la sua. Molto meglio compilare gli statici (css, js, etc.) e spostarli in un file system (o CDN) da cui poi verranno serviti.

Questo approccio consente anche di sfruttare al meglio la cache lato client: ad ogni build si aggiunge un numero di versione ai file statici, utilizzato anche nell'html per richiamare esattamente la versione compatibile. In questo modo è possibile impostare una cache senza scadenza per tutti gli statici.

The twelve-factor app is completely self-contained and does not rely on runtime injection of a webserver into the execution environment to create a web-facing service.
VII. Port binding

In pratica l'applicazione deve contenere un server. Non sono ammesse soluzioni come mod_python o mod_wsgi di Apache, bisogna utilizzare server come Gunicorn o uWSGI.

HTTP is not the only service that can be exported by port binding. Nearly any kind of server software can be run via a process binding to a port and awaiting incoming requests.
VII. Port binding

Ad esempio si può avere un container con Redis, anche se non so se abbia molto senso in questo paradigma.

Il server Python uWSGI implementa un protocollo binario (uwsgi protocol) che è utilizzabile da Nginx. Anche se a prima vista questa opzione sembra compatibile con una 12-factor app, mi sembra del tutto scorretto: l'unica applicazione che potrebbe comunicare con le applicazioni uwsgi sarebbe Nginx stesso, rendendo quindi le applicazioni non autocontenute.

The share-nothing, horizontally partitionable nature of twelve-factor app processes means that adding more concurrency is a simple and reliable operation.
VIII. Concurrency

Non solo possibile, ma anche semplice. Non deve servire uno sviluppatore per aggiungere risorse, ma è un problema sistemistico.

Processes should strive to minimize startup time. [...] Processes shut down gracefully when they receive a SIGTERM signal from the process manager. For a web process, graceful shutdown is achieved by ceasing to listen on the service port (thereby refusing any new requests), allowing any current requests to finish, and then exiting. [...] For a worker process, graceful shutdown is achieved by returning the current job to the work queue. [...] Implicit in this model is that all jobs are reentrant, which typically is achieved by wrapping the results in a transaction, or making the operation idempotent.
IX. Disposability

Da questo punto di vista Docker sembra calzare a pennello. Da quanto ho potuto vedere l'avvio di un'immagine Docker è istantanea, mentre lo spegnimento impiega solitamente alcuni secondi.

The twelve-factor developer resists the urge to use different backing services between development and production, even when adapters theoretically abstract away any differences in backing services.
X. Dev/prod parity

Sviluppando in Django, grazie all'ORM, è molto comodo usare sqlite3 in sviluppo e MySQL in produzione. Ci possono essere anche differenze più sottili, tipo avere MyISAM in produzione e InnoDB in stage. O ancora usare una cache in memoria in sviluppo e memcache in produzione. È in questo modo ad esempio che ho scoperto che memcached ha un limite di 1MB alla dimensione degli oggetti in cache: server di produzione lentissimi perché non riuscivano a mettere in cache un oggetto più grande di 1MB.

Attualmente quello che sto cercando di capire è se si può usare Docker anche per lo sviluppo in locale in un contesto in cui una modifica al codice (ad esempio ad un html) deve essere immediatamente verificata dallo sviluppatore nel browser. Risolverebbe molti problemi anche agli sviluppatori che usano Windows.

A twelve-factor app never concerns itself with routing or storage of its output stream. It should not attempt to write to or manage logfiles. Instead, each running process writes its event stream, unbuffered, to stdout.
XI. Logs

Mi chiedo da questo punto di vista come trattare Sentry, che può essere considerato un tool evoluto di analisi dei log (con Django di solito lo configuro nel logging).

Conclusioni

Siamo nel mezzo di una grande riorganizzazione della nostra architettura. Al momento abbiamo molti punti di domanda e alcune certezze che si vanno consolidando. Leggere Twelve-factor app mi ha dato la sensazione che non stiamo solamente inseguendo un software dopo l'altro, ma che tutto questo ha un senso e può stare in piedi.


Cover by CameliaTWU, some rights reserved