Una semplice applicazione
Per analizzare la creazione di un'immagine Docker custom, partiamo da una elementare applicazione di esempio.
Abbiamo realizzato un'API REST con un singolo metodo (get_hello
). Invocando questo metodo, è possibile passare il parametro “name”: se la stringa ha lunghezza maggiore di 0, l'API risponde con un saluto inserito all'interno del JSON; altrimenti restituisce un errore.
L'applicazione è stata realizzata usando Swagger Editor (https://editor.swagger.io/), che, sulla base di una definizione in formato YAML, consente di produrre velocemente API autodocumentate, conformi alle specifiche OpenAPI. Tra i vari framework disponibili per la realizzazione del server (più di 30), abbiamo scelto Flask (linguaggio Python).
La definizione dell'API (swagger.yaml) è la seguente:
swagger: "2.0"
info:
description: "Applicazione di prova"
version: "1.0.0"
title: "Applicazione di prova"
tags:
- name: "hello"
schemes:
- "https"
- "http"
paths:
/hello:
get:
tags:
- "hello"
operationId: "get_hello"
produces:
- "application/json"
parameters:
- name: "name"
in: "query"
description: "Your name"
required: true
type: "string"
collectionFormat: "multi"
responses:
"200":
description: "successful operation"
"400":
description: "error"
x-swagger-router-controller: "swagger_server.controllers.hello_controller"
Possiamo osservare la descrizione dell'unico metodo disponibile (get_hello
): il parametro name è contenuto nella query string; le possibili risposte sono codice HTTP 200, in caso di successo, e 400 in caso di errore.
A partire dalla descrizione YAML, Swagger Editor ha generato la seguente struttura per il progetto:
python-flask-server
│ .dockerignore
│ .gitignore
│ .swagger-codegen-ignore
│ .travis.yml
│ Dockerfile
│ git_push.sh
│ README.md
│ requirements.txt
│ setup.py
│ test-requirements.txt
│ tox.ini
│
├───.swagger-codegen
│ VERSION
│
└───swagger_server
│ encoder.py
│ util.py
│ __init__.py
│ __main__.py
│
├───controllers
│ hello_controller.py
│ __init__.py
│
├───models
│ base_model_.py
│ __init__.py
│
├───swagger
│ swagger.yaml
│
└───test
test_hello_controller.py
__init__.py
La semplice logica del metodo get_hello
è contenuta nel file hello_controller.py (compilato manualmente, partendo dal template vuoto fornito da Swagger):
import connexion
import six
from swagger_server import util
def get_hello(name): # noqa: E501
"""get_hello
# noqa: E501
:param name: Your name
:type name: str
:rtype: None
"""
if len(name.strip())>0:
return {'message':f'Hello from Aruba Cloud, {name}'},200
else:
return {'error':'name not valid'},400
Se il parametro name
, una volta rimossi gli spazi iniziali e finali, ha lunghezza maggiore di 0, il metodo get_hello
restituisce un saluto in formato JSON (codice 200 altrimenti un errore (codice 400).
Vediamo come installare le dipendenze e lanciare in locale l'esecuzione dell'API:
# directory principale (python-flask-server)
$ pip install –r requirements.txt
…
$ python -m swagger_server
* Serving Flask app "__main__" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)
A questo punto, vogliamo inserire la nostra API in un container Docker, che potrà essere rilasciato con facilità in qualsiasi ambiente e che, se necessario, potrà sfruttare le possibilità offerte dal cloud per scalare.
Costruzione dell'immagine Docker
Il comando docker build
avvia la creazione di un'immagine personalizzata.La sintassi è la seguente: docker build [OPTIONS] PATH | URL
.
Nel path (o nell'URL) fornito deve essere presente un Dockerfile: un file di testo, contenente le istruzioni che governano la costruzione dell'immagine. Nella directory indicata dal path (o dall'URL), detta context, sono generalmente contenuti tutti i files necessari per il processo di building: il context viene esplorato in modo ricorsivo, considerando anche tutte le sottodirectory, perciò è opportuno includervi solo i files necessari; è anche possibile definire un file .dockerignore, contenente i files, appartenenti al context, che devono essere ignorati nella build.
Tra le opzioni più utili, ricordiamo --tag (-t)
, che permette di specificare nome e tag per l'immagine generata, secondo la sintassi name:tag
.
Il Dockerfile è contenuto nella directory principale della nostra applicazione ed è stato generato automaticamente da Swagger editor. Nel prossimo paragrafo, prenderemo in esame il Dockerfile e le istruzioni che lo compongono.
Nella directory principale del nostro progetto, lanciamo docker build
.
root@server-prova:/opt/apps/python-flask-server# docker build -t applicazione_prova .
Sending build context to Docker daemon 27.65kB
Step 1/9 : FROM python:3-alpine
3-alpine: Pulling from library/python
540db60ca938: Already exists
d037ddac5dde: Pull complete
05d0edf52df4: Pull complete
54d94e388fb8: Pull complete
b25964b87dc1: Pull complete
Digest: sha256:2a9b93b032246dabbec008c1527bd0ef31947e7fd351a200aec5a46eea68d776
Status: Downloaded newer image for python:3-alpine
---> 1ae28589e5d4
Step 2/9 : RUN mkdir -p /usr/src/app
---> Running in c52d19608cd6
Removing intermediate container c52d19608cd6
---> 0d24f26d2829
Step 3/9 : WORKDIR /usr/src/app
---> Running in fdefc2dc0371
Removing intermediate container fdefc2dc0371
---> 39b722487cf1
Step 4/9 : COPY requirements.txt /usr/src/app/
---> 52bb57b76347
Step 5/9 : RUN pip3 install --no-cache-dir -r requirements.txt
---> Running in 90a33b81da7b
Collecting connexion==2.6.0
[...]
Successfully installed Jinja2-2.11.3 MarkupSafe-1.1.1 PyYAML-5.4.1 Werkzeug-1.0.1 attrs-20.3.0 certifi-2020.12.5
chardet-4.0.0 click-7.1.2 clickclick-20.10.2 connexion-2.6.0 flask-1.1.2 idna-2.10 inflection-0.5.1 isodate-0.6.0
itsdangerous-1.1.0 jsonschema-3.2.0 openapi-schema-validator-0.1.5 openapi-spec-validator-0.3.0 pyrsistent-0.17.3
python-dateutil-2.6.0 requests-2.25.1 six-1.15.0 swagger-ui-bundle-0.0.8 urllib3-1.26.4
[...]
Removing intermediate container 90a33b81da7b
---> ee54453e166a
Step 6/9 : COPY . /usr/src/app
---> e7ce53f8ad1f
Step 7/9 : EXPOSE 8080
---> Running in 8c179f5aaebf
Removing intermediate container 8c179f5aaebf
---> d127c64f16bc
Step 8/9 : ENTRYPOINT ["python3"]
---> Running in 45bcbe06bd82
Removing intermediate container 45bcbe06bd82
---> 862674cfb715
Step 9/9 : CMD ["-m", "swagger_server"]
---> Running in f53bd51af7bc
Removing intermediate container f53bd51af7bc
---> 66b17f19a6ee
Successfully built 66b17f19a6ee
Successfully tagged applicazione_prova:latest
root@server-prova:/opt/apps/python-flask-server#
All'inizio, siccome l'immagine di partenza specificata dall'istruzione FROM python:3-alpine
non è disponibile nel sistema, questa viene scaricata.
Per ogni istruzione del Dockerfile, viene generato un layer immagine read-only (1ae28589e5d4, 0d24f26d2829...). A posteriori possiamo ottenere una lista di questi layer usando il comando docker history IMAGE
.
Quando l'immagine creata è usata per istanziare un container (mediante il comando docker run
), un layer scrivibile viene sovrapposto ai layer immagine di sola lettura. Per informazioni più dettagliate sui layer e sulla struttura di immagini e container è possibile consultare il terzo capitolo di questa serie.
Se si modifica il Dockerfile e si rilancia la costruzione dell'immagine Docker tenta di riutilizzare tutti i layer immagine intermedi che non hanno subito modifiche accelerando il processo di building.
Verifichiamo che l'immagine generata sia tra quelle disponibili e avviamo un container a partire da questa immagine.
root@server-prova:~# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
applicazione_prova latest 66b17f19a6ee 35 minutes ago 77.4MB
python 3-alpine 1ae28589e5d4 10 days ago 44.7MB
root@server-prova:~# docker run -p 8080:8080 applicazione_prova
* Serving Flask app "__main__" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)
La nostra applicazione è in esecuzione.
Prendiamo nota dell'IP del nostro server presso Aruba Cloud, e raggiungiamo da browser, http://IP-SERVER:8080/ui : ecco l'interfaccia della nostra API.
Al momento, l'immagine realizzata risiede nel nostro sistema. Se vogliamo salvarla in un registro (di default, nel Docker Hub), è disponibile il comando docker push NAME[:TAG]
.
Sintassi del Dockerfile
Il Dockerfile, generato automaticamente da Swagger editor, è il seguente:
FROM python:3-alpine
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
RUN pip3 install --no-cache-dir -r requirements.txt
COPY . /usr/src/app
EXPOSE 8080
ENTRYPOINT ["python3"]
CMD ["-m", "swagger_server"]
Presentiamo le istruzioni contenute nel Dockerfile analizzandole una per volta nel dettaglio.
FROM
La prima linea è: FROM python:3-alpine
.
FROM <image> [AS <name>]
indica l'immagine di base (obbligatoria), sulla base della quale viene costruita la nuova immagine. Nel caso in esame l'immagine di partenza è Alpine Linux con Python 3 installato.
In linea generale, si consiglia di scegliere un'immagine di base ufficiale che sia il più possibile vicina ai requisiti dell'applicazione, al fine di ridurre l'impegno per installare le dipendenze e di limitare gli errori. Nella situazione in esame, ad esempio, avremmo anche potuto impiegare l'immagine di Alpine Linux, installando manualmente Python: il Dockerfile sarebbe stato molto più esteso, i tempi di build più lunghi e avremmo avuto maggiore possibilità di commettere errori.
RUN
La seconda istruzione è: RUN mkdir -p /usr/src/app
. Questa istruzione esegue tramite shell il comando mkdir -p /usr/src/app
, creando una nuova directory; il comando viene eseguito in un nuovo layer, sovrapposto a quello precedente; l'immagine generata costituirà la base per l'istruzione successiva del Dockerfile.
L'istruzione RUN
può essere espressa con due forme alternative:
RUN <command>
: la shell form (usata in questo caso), per cui il comando è eseguito usando una shell; la shell di default è /bin/sh -c
ma può essere cambiata mediante l'istruzione SHELL ["executable", "parameters"]
;
RUN ["executable", "param1", "param2"]
: la exec form, per cui il comando viene eseguito senza invocare la shell.
Volendo esprimere lo stesso comando visto in precedenza, usando la exec form, avremmo potuto scrivere: RUN ["mkdir", "-p", "/usr/src/app"]
.
WORKDIR
La terza linea è: WORKDIR /usr/src/app
.
L'istruzione WORKDIR /path/to/workdir
imposta la directory per ogni successiva istruzione di tipo RUN
, CMD
, ENTRYPOINT
, COPY
o ADD
.
Se la directory non esiste viene creata. Un Dockerfile può contenere svariate istruzioni WORKDIR
.
Se il path fornito è relativo (sconsigliato), è valutato in relazione alla precedente istruzione WORKDIR
.
COPY
La quarta istruzione è: COPY requirements.txt /usr/src/app/
.
Quindi COPY <src> <dest>
copia files o directory dal context al filesystem del container.
Nel Dockerfile di esempio abbiamo copiato all'interno della directory /usr/src/app/
del container il file dei requisiti, contenente le librerie Python da installare.
ADD
L'istruzione ADD <src> <dest>
ha lo stesso effetto di copy ma un campo di applicazione più vasto. <src>
non deve necessariamente appartenere al context ma può anche essere costituito da un URL. Se <src>
è un archivio tar locale compresso, viene decompresso e copiato nel filesystem del container.
Nel caso precedente, avremmo potuto sostituire l'istruzione COPY
con ADD requirements.txt /usr/src/app/
.
RUN
La quinta istruzione è RUN pip3 install --no-cache-dir -r requirements.txt
.
Viene eseguito il comando che installa le librerie Python specificate nel file requirements.txt.
Successivamente si ha COPY . /usr/src/app
con cui l'intero context viene copiato nell'immagine, all'interno della directory indicata.
EXPOSE
La sesta linea è: EXPOSE 8080
.
L'istruzione EXPOSE <port>
informa Docker che il container sarà in ascolto sulle porte specificate; documenta che le porte indicate sono disponibili alla pubblicazione, ma non le pubblica. I protocolli supportati sono TCP (default) e UDP.
Solo con il comando docker run -p 8080:8080 applicazione_prova
mappiamo effettivamente la porta 8080 del container sulla porta 8080 dell'host (il primo numero di porta è riferito all'host, mentre il secondo indica la porta del container). Rimuovendo l'istruzione EXPOSE
dal Dockerfile è comunque possibile esporre le porte desiderate senza incorrere in errori.
ENTRYPOINT
La settima riga è: ENTRYPOINT ["python3"]
.
L'istruzione ENTRYPOINT
specifica un comando che viene sempre invocato all'avvio del container, rendendo il container eseguibile. Nel caso in esame all'esecuzione del container viene lanciato Python.
Sono disponibili due forme per ENTRYPOINT
:
ENTRYPOINT ["executable", "param1", "param2"]
: la exec form, che è preferibile;
ENTRYPOINT command param1 param2
: la shell form, in cui il comando viene lanciato usando una shell.
Quando il container si avvia con docker run IMAGE [COMMAND]
, COMMAND
viene passato come argomento all'ENTRYPOINT
. Lo stesso avviene per un eventuale istruzione CMD
nel Dockerfile.
Per lanciare un container, sovrascrivendone l'ENTRYPOINT
, è disponibile l'opzione --entrypoint
.
CMD
L'ultima istruzione del Dockerfile è: CMD ["-m", "swagger_server"]
.
In generale, CMD
ha lo scopo di fornire un default per il container.
Sono disponibili tre forme per CMD
:
CMD ["executable", "param1", "param2"]
: la exec form, che è preferibile alla shell form;
CMD command param1 param2
: la shell form;
CMD ["param1", "param2"]
, in cui vengono forniti parametri a ENTRYPOINT
(come nel nostro caso).
Sostanzialmente CMD
può lanciare un eseguibile oppure fornire dei parametri ad un comando espresso da ENTRYPOINT
. Quando il container viene eseguito con docker run IMAGE [COMMAND]
, COMMAND
sostituisce il default espresso da CMD.
Nel caso della nostra applicazione di esempio, la sintassi esposta nel Dockerfile per l'esecuzione dell'API è quella più elegante e funzionale ma anche le seguenti alternative producono lo stesso effetto (a patto che il container sia lanciato senza specificare COMMAND):
ENTRYPOINT ["python3", "-m", "swagger_server"]
CMD ["python3", "-m", "swagger_server"]
Per chiarire meglio i ruoli complementari di ENTRYPOINT
e CMD
vale la pena riportare un ulteriore un esempio. Consideriamo il semplice Dockerfile a seguire.
FROM alpine
ENTRYPOINT ["ping"]
CMD ["127.0.0.1"]
Usando il Dockerfile precedente, costruiamo l'immagine prova_ping. In questo caso, lanciando il container, vogliamo eseguire il comando ping
(eseguibile specificato da ENTRYPOINT
). Di default, viene realizzato il ping del localhost (come indicato da CMD
); se COMMAND
è specificato in docker run
, viene effettuato il ping del parametro espresso da COMMAND
(nell'esempio, “aruba.it”).
root@server-prova:/opt/apps/pingc# docker run prova_ping
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: seq=0 ttl=64 time=0.058 ms
64 bytes from 127.0.0.1: seq=1 ttl=64 time=0.069 ms
64 bytes from 127.0.0.1: seq=2 ttl=64 time=0.079 ms
64 bytes from 127.0.0.1: seq=3 ttl=64 time=0.070 ms
^C
--- 127.0.0.1 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 0.058/0.069/0.079 ms
root@server-prova:/opt/apps/pingc# docker run prova_ping aruba.it
PING aruba.it (62.149.188.200): 56 data bytes
64 bytes from 62.149.188.200: seq=0 ttl=123 time=0.978 ms
64 bytes from 62.149.188.200: seq=1 ttl=123 time=2.611 ms
64 bytes from 62.149.188.200: seq=2 ttl=123 time=1.018 ms
^C
--- aruba.it ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.978/1.535/2.611 ms
Altre istruzioni
In questa sezione presentiamo brevemente altre istruzioni utili che possono comparire nel Dockerfile.
-
LABEL <key>=<value> <key>=<value> …
Permette di aggiungere metadati ad un'immagine.
Nell'immagine della nostra semplice applicazione, avremmo potuto specificare:
LABEL version="1.0.0"
LABEL description="Applicazione di esempio"
ENV <key>=<value> …
Imposta il valore di una variabile d'ambiente. Oltre a valere nella fase di build, la variabile d'ambiente risulta definita anche nel container eseguito a partire dall'immagine.
Per lanciare un container con un valore personalizzato per una variabile d'ambiente, si può usare il comando docker run --env <key>=<value> IMAGE
-
ARG <name>[=<default value>]
Definisce una variabile da usare esclusivamente nel processo di building; non è inclusa nell'immagine finale. Il suo valore può essere specificato dall'utente lanciando docker build
con il flag --build-arg <varname>=<value>
-
USER <user>[:<group>]
imposta lo username e lo user group da usare per lanciare le successive istruzioni RUN, CMD ed ENTRYPOINT
e per l'esecuzione del container. In assenza di specifiche, l'immagine viene eseguita con utente root.
-
VOLUME ["/data"]
crea un path nell'immagine, che va a costituire un mount point.
All'esecuzione del container in tale path verrà montato un volume derivante dall'host o da altri container.