Il software organizzato tramite container può essere spedito e lanciato su varie architetture e sistemi operativi con poca o nulla configurazione.

Source: https://www.pexels.com/photo/steel-container-on-container-dock-122164/


Nel nostro post precedente abbiamo visto come lanciare, creare documentazione e preparare test per un’applicazione server. Oggi capiremo come distribuire un’applicazione server tramite container docker e vedremo alcune idee su come scrivere una semplice interfaccia da riga di comando (Command Line Interface, CLI).

Introduzione ai Container

Distribuire e lanciare applicazioni su più piattaforme e sistemi operativi può diventare complicato. Per esempio, pacchetti creati per una distribuzione Linux potrebbero richiedere repackaging per essere utilizzati su un’altra e/o su Mac OS (o su Windows, che ha un set di build tools abbastanza diverso).

In linea di principio questo può essere risolto condividendo il codice sorgente e lasciando che l’utente crei/esegua il programma sul proprio sistema; tuttavia, questo trasferisce l’onere del build all’utente. La containerizzazione offre un’alternativa, semplificando la creazione e la configurazione di software su un’ampia varietà di piattaforme.

L’idea di base alla base della containerizzazione è creare un layer di emulazione per un ambiente, in cui verrà eseguito il programma. In linea di principio, questo è simile all’uso di una macchina virtuale, che consente di emulare un sistema operativo su di un altro, ma con alcune differenze fondamentali.

In primo luogo, la containerizzazione offre in generale migliori prestazioni, perché il layer di emulazione può essere più sottile, ovvero emulare meno software. Sono quindi possibili prestazioni quasi native, rendendo la containerizzazione un’opzione interessante.

Inoltre, dato che un container è in genere destinato a eseguire un solo processo, il processo di configurazione di un container è in genere più semplice, evitando problemi di cross-compatibility. Ad esempio, come vedremo, spesso gli ambienti virtuali Python non sono richiesti e i pacchetti Python si possono semplicemente installare a livello di sistema.

Infine, i servizi configurati con container possono essere collegati in rete: più contenitori, ognuno dei quali esegue un processo separato, possono essere configurati per comunicare e interagire tramite richieste HTTP. Ciò consente di implementare in modo semplice architetture a microservizi, in cui un singolo programma è suddiviso in unità più piccole e indipendenti che comunicano tramite un network per eseguire attività complesse (con maggiore complessità, ma possibili miglioramenti in flessibilità). Questi network (in gran parte) isolano i container dal sistema host, consentendo loro comunque di comunicare tra loro.

Container per il Server

Per semplificare l’installazione di sem, ho predisposto un sistema di container, che affianca il processo di installazione di default tramite poetry. Ho implementato la containerizzazione usando il sistema docker, nonché la sua utilità docker-compose per gestire l’interazione fra i multipli container richiesti (vedi sotto). Questi programmi consentono di creare container a partire da file di configurazione e di eseguire il relativo software.

Come di solito nel design di applicazioni server, ho preparato un container per il server (discusso in questa sezione) che comunicherà con un container separato dedicato specificamente al servizio di database.

Un container è la combinazione di un processo da eseguire e di un’immagine, un ambiente in cui vengono installati e implementati il software e la configurazione richiesti dal processo. Le immagini vengono solitamente costruite aggiungendo layer (passaggi di configurazione/installazione) al di sopra di immagini già esistenti: queste ultime solitamente sono costruite per offrire le funzionalità di un particolare programma, (interprete per) linguaggio di programmazione, o sistema operativo.

Docker consente di creare immagini e container personalizzati scrivendo dei Dockerfile, file di configurazione che contengono le istruzioni di costruzione, layer per layer. In sem, il container del database richiede poca configurazione e non richiede un Dockerfile dedicato; quello del server invece è costruito a partire dalle istruzioni in docker/Dockerfile, che esploreremo qui di seguito.

# docker/Dockerfile

 1	# syntax=docker/dockerfile:1
 2	
 3	# selecting python image
 4	FROM python:3.11-slim
 5	
 6	# creating a workdir
 7	WORKDIR /app
 8	
 9	# installing system packages, required for psycopg
10	RUN apt-get update
11	RUN apt-get -y install gcc postgresql postgresql-contrib libpq-dev

Il primo passo è scegliere un’immagine di base su cui verrà creata l’immagine del server, usando la direttiva FROM (di default, le immagini vengono scaricate dal repository online dockerhub). Dato che il programma server è scritto in Python, la scelta naturale è scegliere un’immagine Python, che dispone di un interprete Python preinstallato e del gestore di pacchetti pip. In molti casi sono disponibili più versioni di ciascuna immagine, consentendo di scegliere quella desiderata.

Il sistema emulato dall’immagine Python qui installata è basato su Debian Linux (con un minimo di software aggiunto, poiché ho scelto l’immagine slim), con il filesystem corrispondente. La direttiva WORKDIR crea una directory che fungerà da base per il resto delle istruzioni da eseguire (costruzione, copia di file esterni, ecc.).

A causa della nostra scelta del driver psycopg per accedere al database, dobbiamo installare alcuni pacchetti di sistema (non Python), che il driver utilizzerà. Questo viene fatto eseguendo comandi della shell del sistema, utilizzando il comando RUN. Dato che il container è basato su un sistema Debian, apt-get gestirà l’installazione dei pacchetti (notare l’opzione -y, che accetta quando viene richiesta conferma ed è necessaria a causa della natura non interattiva dell’installazione).

Ciascuno dei passaggi precedenti è uno dei layer di configurazione dell’immagine: docker salva automaticamente checkpoint per ciascun layer durante la costruzione, consentendo alle build successive di saltare la ricostruzione dei livelli fino al primo punto di modifica. L’ordine in cui i livelli vengono eseguiti consente quindi di semplificare il processo di costruzione durante lo sviluppo (se i livelli modificati più di frequente vengono posizionati alla fine del build, quando possibile).

# docker/Dockerfile

13	# preparing separately requirement file,
14	# installation will be performed iif requirements change
15	COPY docker/requirements.txt .
16	
17	# installing required packages
18	RUN pip install -r requirements.txt

La direttiva COPY consente di copiare file dal sistema host al filesystem del container (qui, nella workdir). Il file copiato (docker/requirements.txt) contiene le dipendenze del progetto, in un formato comprensibile dall’installer pip, e può essere generato da poetry con il comando

$ poetry run pip freeze | grep -v '^-e' > docker/requirements.txt

(qui, grep rimuove una riga dedicata all’installazione del pacchetto del progetto stesso). Chiamando pip install -r con il file dei requisiti come argomento verranno installate le dipendenze del progetto al livello di sistema (container).

Notare come pip, anziché poetry, viene usato per gestire le dipendenze del progetto. poetry è uno strumento basato su pip, che gestisce le dipendenze e crea ambienti virtuali per eseguire software senza problemi di dipendenza con altri programmi. Durante la containerizzazione, in genere quest’ultimo aspetto diventa superfluo, poiché nel container di solito verrà eseguito un solo programma. Per questo, in genere i container usano pip per installare i pacchetti Python al livello di sistema (container), dove verranno anche lanciati i programmi.

# docker/Dockerfile

20	# copy all other local content host -> container
21	COPY . .
22	
23	# launch command
24	CMD "docker/run.sh"

Alla riga 21, sto copiando l’intera directory del progetto nella workdir: questo consentirà al container di usare i file del progetto. Notare come ho copiato docker/requirements.txt separatamente: se avessi copiato l’intero progetto alla riga 15, modificare qualsiasi file di progetto forzerebbe docker ad eseguire ex novo il processo di installazione alla riga 18 (e tutti i layer successivi). Ciò evidenzia come la pianificazione dei livelli ottimizzi la costruzione di immagini durante lo sviluppo.

All’ultima riga, CMD è usato per specificare il comando che verrà eseguito durante l’esecuzione del container basato sull’immagine. Nel caso di sem, questo è lo script bash eseguibile docker/run.sh:

# docker/run.sh

1	#!/bin/bash
2	
3	if [[ $SEM_LAUNCH == "docs" ]]
4	then
5	  mkdocs build
6         mkdocs serve
7	else
8	  python -m modules.main
9	fi

In base al valore della variabile SEM_LAUNCH, il container servirà la documentazione o lancerà un’istanza del server che rimarrà attiva e in attesa di richieste HTTP.

Comporre Container

Come accennato in precedenza, il container del server interagirà con un container dedicato al database. I container e il network tramite il quale interagiranno sono configurati in sem usando il programma docker-compose. La sua configurazione è contenuta nel file docker-compose.yml nella directory del progetto, che esploreremo passo dopo passo.

# docker-compose.yml

1	version: "3"
2	
3	services:
4	  db:
5	    container_name: sem-db
6	    image: postgres:15.4-alpine
7	    restart: always

La sezione services contiene l’elenco dei servizi da impostare. Iniziamo con il servizio db, il cui container si chiamerà sem-db e sarà basato su un’immagine PostgreSQL. Il container verrà sempre riavviato quando docker-compose viene lanciato.

Se eseguito come container, il servizio db avvierà un’istanza del servizio database, che resterà in attesa di connessioni. Questa istanza non si sovrapporrà a quelle di sistema, anche se resterà in ascolto sulla stessa porta, perché sarà accessibile solo dall’interno del network dei container nel nostro setup.

# docker-compose.yml

 8	    volumes:
 9	      - ./sem-db-data:/var/lib/postgresql/data
10	    environment:
11	      POSTGRES_USER: sem
12	      POSTGRES_PASSWORD: sem
13	      # required for the check below - otherwise postgresql
14	      # will use undefined 'root' user and raise errors
15	      PGUSER: sem

Alla riga 8 creo un volume, ovvero una directory condivisa fra host e container, usata per archiviare in modo persistente i dati del container del database. Qui collego la cartella sem-db-data/ nella directory del progetto con la directory /var/lib/postgresql/data/ (dove PostgreSQL salva i dati) nel filesystem del container. In questo modo, sem-db-data/ memorizzerà in modo persistente i dati del database, consentendo di interrompere e riavviare l’esecuzione del container senza perdita di dati.

Alla riga 10, introduco alcune variabili di environment, che verranno dichiarate nell’ambiente del container del database. Quelle dichiarate qui sono variabili di default di PostgreSQL, contenenti il nome utente e la password usati per accedere al database, nonché i dettagli dell’utente per il controllo dello stato del container (discusso di seguito).

# docker-compose.yml

16	    healthcheck:
17	      # postgresql starts up, stops, and then restarts
18	      # => errors if the server connects before the stop
19	      # this checks if the system is ready
20	      test: [ "CMD-SHELL", "pg_isready" ]
21	      interval: 5s
22	      timeout: 5s
23	      retries: 5

La direttiva healthcheck consente di eseguire test una volta completato l’avvio di un container, per verificare che il lancio sia andato a buon fine: in questo caso, l’ho utilizzata per risolvere un problema con il container PostgreSQL.

Nello specifico, il servizio PostgreSQL si avvia e si arresta immediatamente, riavviandosi subito dopo. Se il servizio server (descritto di seguito) tenta di connettersi al database mentre quest’ultimo è inattivo, l’avvio del sistema complessivo fallirà. Il processo di healthcheck impostato qui fa sì che il container del database attenda alcuni secondi e quindi lanci il comando pg_isready, che conferma la corretta esecuzione del servizio del database (dopo aver fatto passare abbastanza tempo da permettere a PostgreSQL di ripartire).

Fatto questo, il container segnalerà che il suo stato è valido. In seguito, il servizio del server verrà impostato in modo che attenda l’esecuzione dell’healthcheck prima di partire.

# docker-compose.yml

25	  server:
26	    container_name: sem-server
27	    depends_on:
28	      db:
29	        condition: service_healthy
30	    image: sem-server

Questa sezione del file docker-compose descrive il servizio server, che verrà eseguito in un container chiamato sem-server, basato su un’immagine con lo stesso nome. Ho specificato che questo servizio dovrebbe attendere che il servizio db confermi di essere in uno stato valido prima di partire, per evitare di connettersi al servizio di database prima che questo sia pronto.

# docker-compose.yml

31	    build:
32	      context: .
33	      dockerfile: docker/Dockerfile
34	    ports:
35	      - 8000:8000
36	      - 8001:8001
37	    environment:
38	      - SEM_DOCKER=1
39	      - SEM_LAUNCH

La sezione build contiene informazioni su come costruire l’immagine associata al servizio. Qui ho specificato di usare il Dockerfile docker/Dockerfile, con la directory corrente come base.

Il contenitore esporrà le porte 8000 e 8001, consentendo al contenuto offerto su queste porte dal servizio (vale a dire, il server e le pagine di documentazione) di essere raggiunto anche dal sistema host, a localhost:8000 e localhost:8001.

Infine, la sezione environment consente di specificare le variabili di environment per l’ambiente del container. I valori possono essere assegnati in due modi:

  • Le variabili possono essere create ex novo e ricevere valori, come per SEM_DOCKER, che ho impostato a 1. Lo scopo di questa variabile è informare il server che deve essere usata una configurazione specifica per docker (mostrata di seguito).
  • In alternativa, le variabili di ambiente possono essere ricevute dall’ambiente host, come SEM_LAUNCH in questo caso. Questa variabile, come visto in docker/run.sh, specifica se il server del programma o il server di documentazione debba essere avviato, e viene specificata quando i container vengono lanciati dal sistema host (vedere di seguito).

Networks ed Esecuzione

Docker-compose configura automaticamente un network contenente i container descritti nel suo file di configurazione. Esso offre anche un Domain Name System (DNS), che consente di accedere ai container usando i loro nomi come URL, anziché l’indirizzo IP interno del network dei container. Questo potrebbe richiedere alcune modifiche alle impostazioni di rete (ad esempio, sostituire localhost con il nome del container).

L’esecuzione di queste modifiche è stata la parte finale del mio lavoro di containerizzazione, e riguarda il modulo di connessione (modules/session.py). Ho modificato la versione precedente di questo file come segue:

# modules/session.py

31	import os
...
41	def init_session(database: str) -> Session:
...
54	    DRIVER = "postgresql+psycopg"
55	
56	    if os.environ.get("SEM_DOCKER") == "1":
57	        USER = "sem"
58	        PASSWORD = "sem"
59	        HOST = "sem-db"
60	    else:
61	        USER = "postgres"
62	        PASSWORD = ""
63	        HOST = "localhost"
64	
65	    PORT = "5432"
66	
67	    DB = f"{DRIVER}://{USER}:{PASSWORD}@{HOST}:{PORT}/{database}"
...

Il valore della variabile di environment SEM_DOCKER viene estratto utilizzando il dizionario os.environ, che memorizza i valori delle variabili di environment come stringhe. Le coordinate del database specifiche per docker o quelle generiche vengono selezionate in base al valore di SEM_DOCKER, che può essere impostato all’avvio. Notare come il nome del container (sem-db) viene utilizzato come URL di base nell’ambiente containerizzato.

Ora si può lanciare il setup containerizzato discusso finora. Questo viene fatto utilizzando il comando docker-compose, specializzato di seguito rispettivamente per un’esecuzione normale del server e per il servizio di documentazione:

$ docker compose up --build
$ SEM_LAUNCH="docs" docker compose up --build

Questo comando ricostruirà i container ad ogni chiamata (grazie all’opzione --build) e avvierà l’insieme dei container con le opzioni della riga di comando passate.

Il processo può essere semplificato scrivendo un makefile:

# makefile

 1	.PHONY: docker docs
 ...
 6	docker-run:
 7		docker compose up --build
 8		
 9	docker-docs:
10		SEM_LAUNCH="docs" docker compose up --build
...
15	run:
16		poetry run sem
17	
18	test:
19		poetry run python3 -m pytest --ignore=sem-db-data/ -x -s -v .
20	
21	requirements:
22		poetry run pip freeze
                  | grep -v '^-e' > docker/requirements.txt
23	
24	docs:
25		poetry run mkdocs build
26		poetry run mkdocs serve

Il makefile automatizza la maggior parte dei passaggi necessari per avviare il server, sia localmente che utilizzando container. Ad esempio, il comando

$ make docker-run

(che potrebbe richiedere privilegi da amministratore) lancerà il setup containerizzato discusso sopra.

Interfaccia da Riga di Comando

Progettare un buon client richiede attenta considerazione per scegliere le migliori opzioni e framework in base alle esigenze dell’utente.

Un’opzione molto popolare e facile da usare è l’uso di interfacce grafiche web, ovvero pagine web che traducono l’input dell’utente (raccolto utilizzando controlli grafici) in richieste HTTP a un server. Qui esploreremo un’opzione molto più semplice ma comunque relativamente versatile, ovvero le interfacce da riga di comando.

Il framework standard per costruire questo tipo di interfaccia in Python è argparse, una libreria che consente di analizzare argomenti, opzioni e sottocomandi della riga di comando. I comandi possono quindi essere composti su una shell UNIX, con valori di default, funzioni di convalida dei valori passati, e altro ancora. Questo approccio è molto utile quando le funzionalità della shell (ad esempio, l’espansione dei caratteri jolly della shell, o l’interazione con altri suoi strumenti) interagiscono bene con il programma (ad esempio, se il programma accetta multipli file come argomento).

In sem, ho implementato un’interfaccia a riga di comando più semplice (ma per alcuni aspetti più versatile), che funziona tramite una shell Python interattiva. Nello specifico, il modulo modules/cli.py definisce alcune funzioni Python che ricevono input dall’utente e lo utilizzano per spedire richieste HTTP al server. Il modulo inizia con alcune impostazioni generali:

# modules/cli.py

48	import os
49	
50	from fastapi.encoders import jsonable_encoder
51
52	import requests
53	
54	# `rich` works for tables and general printing
55	from rich.console import Console
...
58	# `colorama` works for input() in docker
59	from colorama import Fore
60	from colorama import Style
61	
62	from modules.schemas import ExpenseAdd
...
66	console = Console()
...
69	# Emphasis formatting - colorama
70	EM = Fore.GREEN + Style.BRIGHT
71	NEM = Style.RESET_ALL
72	
73	if os.environ.get("SEM_DOCKER") == "1":
74	    server = "http://sem-server:8000"
75	else:
76	    server = "http://127.0.0.1:8000"

colorama e rich sono due librerie che consentono di stampare output con formattazione complessa (testo colorato, tabelle…). rich è un framework più completo, che normalmente sarebbe sufficiente da solo. In questo caso, tuttavia, li ho utilizzati entrambi poiché rich non funzionava a dovere nella configurazione containerizzata.

Dopo aver definito un oggetto di tipo console.Console, che stamperà l’output, e impostato i caratteri di enfasi e reset, definisco l’indirizzo del server che dovrà ricevere le richieste nella variabile server (notare la distinzione tra il caso containerizzato e quello non containerizzato).

Esamineremo qui la funzione add(), che permette all’utente di specificare i dati di una spesa da aggiungere al database.

# modules/cli.py

 87	def add():
 88	    """Add an Expense, querying the user for data."""
 89	    date = input(f"{EM}Date{NEM} (YYYY-MM-DD)   :: ")
 90	    typ = input(f"{EM}Type{NEM}                :: ")
 91	    category = input(f"{EM}Category{NEM} (optional) :: ")
 92	    amount = input(f"{EM}Amount{NEM}              :: ")
 93	    description = input(f"{EM}Description{NEM}         :: ")
 94	
 95	    response = requests.post(
 96	        server + "/add",
 97	        json=jsonable_encoder(
 98	            ExpenseAdd(
 99	                date=date,
100	                type=typ,
101	                category=category,
102	                amount=amount,
103	                description=description,
104	            )
105	        ),
106	    )
107	
108	    console.print(response.status_code)
109	    console.print(response.json())

Dopo aver ricevuto i dati dall’utente tramite la funzione Python input(), la funzione invia una richiesta al server all’URL /add. Ciò avviene tramite la funzione post() del modulo requests, che offre metodi per inviare richieste HTTP a degli URL (implementando in pratica funzionalità di tipo client).

L’argomento json della funzione consente di passare un corpo della richiesta (qui ottenuto serializzando un oggetto ExpenseAdd, costruito con i dati specificati dall’utente). Il codice di stato e il corpo della risposta alla richiesta, restituiti dalla funzione post(), vengono quindi stampati.

Il resto del modulo contiene funzioni simili, che consentono all’utente di inviare richieste a tutti gli endpoint dell’API. Il modulo può essere lanciato interattivamente con

$ poetry run python3 -im modules.cli

in un setup non containerizzato (notare l’opzione -i, che carica il modulo in una shell Python interattiva). In una configurazione containerizzata, si può usare

$ docker compose run server python -im modules.cli

che lancia un’istanza duplicata del container del server, dove il comando finale (normalmente, l’avvio del server) viene sovrascritto per caricare il modulo CLI in una shell interattiva. Questo semplifica la configurazione, rendendo non necessario un container dedicato per la CLI.

Sia i comandi di avvio della CLI in locale che quelli nel setup con container sono inclusi nel makefile del progetto (con i target cli e docker-cli rispettivamente). Un esempio di utilizzo della CLI può essere

nel quale chiamo la funzione add(), per aggiungere una spesa, chiamando poi la funzione query(), per elencare le spese contenute nel database in una tabella formattata (tramite rich). Nella chiamata a query(), nessun valore è stato passato ai filtri, facendo stampare al sistema tutte le spese memorizzate.


Autore: Adriano Angelone

Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.

In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.