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
nella directory del progetto, che esploreremo passo dopo passo.docker-compose.yml
# 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
consente di specificare le variabili di environment per l’ambiente del container. I valori possono essere assegnati in due modi:environment
- 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 indocker/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.