Tutorial > Creazione di immagini Docker personalizzate

Creazione di immagini Docker personalizzate

Pubblicato il: 11 maggio 2021

Cloud Docker immagine immagini

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.

funzionamento dell'API di esempio

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.

Interfaccia web dell'API di esempio

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.