giovanni.ciatto@unibo.itCompiled on: 2026-11-07 — printable version
[Software] Deployment $:=$ all of the activities that make a software system available for use
To govern the deployment of software systems onto some infrastructure
software system: multiple interacting software components, each one having its computational requirements
infrastructure: the available hardware and software substratum supporting the execution of software components
deployment: orderly instantiating software components onto the infrastructure, while
Short-lived tasks (a.k.a. jobs): tasks which eventually terminate
Long-lived tasks (a.k.a. services) tasks which run indefinitely
Short-lived tasks are bare computational activities aimed at processing/generating some data
Pure algorithms accepting data as input and/or producing data as output.
They will eventually reach their natural termination
Common workflow:
Parallelism may be exploited to speed up the computations
Long-lived are computational activities which are not meant to terminate automatically (unless explicitly stopped)
Infinite loops, just waiting for requests, and serving them as soon as possible
Interactivity is a key aspect:
Common workflow:
Parallelism may be exploited to support replication
Regardless of their life-span, computational tasks may require computational resources of various sorts:
specific hardware capabilities
specific operating systems (Linux, macOS, Windows, etc.) or specific architectures (x86, ARM, etc.)
specific runtime platforms (e.g. JVM, Python, .NET etc.)
specific infrastructural components (e.g. database, reverse proxy, load-balancer, broker, etc.)
specific libraries (e.g. CUDA, numpy, etc.)
Why can’t we simply configure bare-metal machines to host our tasks?
Need for multi-tenancy (i.e. sharing) on computational resources
Need for isolation
Need to formalize / control / reproduce the computational environment of applications
Need to automate deployment of applications into production / testing environments
Need for flexibility (in re-deployment), and scalability
Some abstraction is needed to encapsulate applications, and their computational environment, into a single unit
Encapsulation is the hiding of the implementation details of a component from its clients. Encapsulated components have a clear interface which is the only way to interact with them.
Virtual machines (VM) on top of VM hypervisors (VMH)
Containers on top of container engines (CE)
VMH run on/as the OS of a bare-metal machine, abstracting HW and SW peculiarities away
VMH may instantiate multiple VM on the same physical machine
each VM runs its own OS, and may host multiple applications
VMs may be paused, snapshotted (into a file), resumed, and possibly migrated
VM are coarse-grained encapsulation units
Many industry-ready technologies:
CE run on the OS of a bare-metal/virtual machine, abstracting the runtime, storage, and network environment away
one may instantiate multiple containers on the same machine
each container shares the OS kernel of the host, yet having its own runtime(s), storage, and network facilities
each container is an instance of an image, i.e. a read-only template containing deployment instructions
containers are fine-grained encapsulation units
Many industry-ready technologies:
Containers provide runtime isolation without operating system replication


Cluster $\approx$ a set of similarly configured machines (a.k.a. nodes) interconnected via a network in order to let users exploit their joint computational power
Users may want to deploy their applications on the cluster
Deployment shall allocate tasks on the cluster efficiently
Infrastructure-as-a-service (IaaS) cloud technologies support deploying tasks on clusters, as VM
Container orchestrators support deploying tasks on clusters, as containers


Containerisation $\approx$ the process of encapsulating an application into a container
Orchestration $\approx$ the process of deploying one or more containers onto one or more machines
Containerisation is a pre-requisite for orchestration
Two syntaxes involved:
Docker: the most famous container technology, actually consisting of several components
docker group
sudo usermod -aG docker $USERdocker group are de facto root on the hostsudo systemctl enable docker; sudo systemctl start dockerdocker run hello-worlddocker --help
docker <resource> <command> <options> <args>
<resource> can be omittedSubsequent examples are shown on Linux, but they should work on any platform
- provided that a
bashorzshshell is available
docker pull python:3.14docker run python:3.14
Every image provides a default command (for python:3.14, it is the python3 REPL); running without options runs that default in a non-interactive terminal
Running in interactive mode can be achieved with the -i option
-t option to run in a pseudo-tty (always use it whenever you use -i)docker run -it python:3.14 opens the Python REPL inside the containerRunning a custom command inside the container can be achieved by writing the command after the image name
docker run -it python:3.14 bash opens a shell inside the containerdocker run python:3.14 python -c "import sys; print(sys.version)"--rm to remove the container after usedocker ps lists running containers, docker ps -a lists all of them (including created and exited ones)docker start / docker stop / docker rm accept container names (cf. --name) or IDsA docker container runs in isolation, w.r.t. the host and other containers.
Environment variables, network ports, and file system folders are not shared.
Sharing must be explicit and requires options to be specified after docker run:
-e <name>=<value>-v <host>:<guest>:<options>
<host> is the path (or volume name) on the host system<guest> is the location where it will be mounted on the container<options> can be optionally specified as mount options (e.g., rw, ro)-p <host>:<guest>
<host> is the port on the host system<guest> is the corresponding port on the containerEvery image has a unique ID, and may have an associated name (including a tag)
The subcommand images lists the pulled images and their associated information
The subcommand image allows for running maintenance tasks, e.g.
docker image ls – same as docker imagesdocker image prune – removes unused imagesdocker image rm – removes images by namedocker image tag – associates a tag to an imageDocker images are written in a Dockerfile
Every filesystem-changing instruction (RUN, COPY, ADD) generates a new layer
ENV, EXPOSE, CMD, …) only change the image’s metadataThe final stack of layers creates the final image
The docker build command interprets the Dockerfile commands to produce a sequence of layers
Changes to a layer do not invalidate previous layers, but they do invalidate all subsequent ones
# Base image, pulled from Docker Hub ("scratch" = empty image)
FROM alpine:latest
# Runs a command
RUN apk add --no-cache nodejs npm
# Sets the working directory (created if missing)
WORKDIR /my-project
# Copies files from the build context into the image: dependencies first...
COPY package.json ./
# ... so this (slow) layer stays cached until package.json changes
RUN npm install
# Copies the rest of the project (relative to the build context)
COPY . .
# Adds a new environment variable
ENV SERVICE_PORT=8080
# Documents the port the service listens on
EXPOSE 8080
# Configures the default command to execute (exec form)
CMD ["npm", "run", "service"]
to be built by means of the command: docker build PATH
PATH is the host path containing the Dockerfile (a.k.a. the build context, e.g. path/to/my/nodejs/project)path/to/my/nodejs/project DIRECTORYDockerfile as for the previous slide
File .dockerignore
node_modules/
package.json{
"name": "example",
"version": "1.0.0",
"description": "Example Express WS for Docker",
"main": "index.mjs",
"scripts": {
"service": "node index.mjs"
},
"dependencies": {
"express": "^5.1.0"
},
"author": "gciatto",
"license": "ISC"
}
index.mjsimport { env } from 'process'
import express from 'express'
import { randomBytes } from 'crypto'
const port = 'SERVICE_PORT' in env ? env.SERVICE_PORT : 8080
const hostname = 'HOSTNAME' in env ? env.HOSTNAME : 'localhost'
// 16-char random string
const serverID = randomBytes(8).toString('hex')
const server = express()
let counter = 0
server.get('/', function (req, res) {
counter++
res.send(`[${serverID}@${hostname}:${port}] Hit ${counter} times`)
})
console.log(`Service ${serverID} listening on ${hostname}:${port}`)
server.listen(port)
i.e. a Web service listening on the port indicated by the SERVICE_PORT env var, showing Web pages of the form
[$SERVER_ID@$HOST_NAME:PORT] Hit $VIEW_COUNT times
Every filesystem-changing instruction in a Dockerfile generates a new layer, the others only set the image configuration:
A layer is a diff w.r.t. the previous one
In other words, Docker keeps track of what information is added to the image at each step
When a Dockerfile is built, Docker checks whether each layer is already in the build cache
COPY/ADD) same file contentsIf so, it reuses the cached layer, and skips the execution of the corresponding command
my-service after editing index.mjs only:What if the Dockerfile copied the whole project before installing dependencies?
COPY . . followed by RUN npm install, rather than COPY package.json ./, RUN npm install, COPY . .Rule of thumb: order instructions from the least to the most frequently changing, and copy dependency declarations (
package.json,requirements.txt,build.gradle.kts, …) separately from the source code
When the container is run, the image layers are read-only, and the container has a read-write layer on top of them
So, when a container is removed (not merely stopped), the read-write layer is discarded, and the image layers are kept
In this way, the space occupied by the container is minimal
Image naming is done via tags
The easiest way to do so is assigning tags at build time with the -t option of docker build
The option can be repeated to assign multiple tags (names must be lowercase)
docker build -t "my-image:latest" -t "my-image:0.1.0" path/to/build/context
latest is usually used to identify the most recent version of some image
For instance, you may build the image from a few slides ago with:
docker build -t my-service path/to/my/nodejs/project
my-service:latest is automatically createdImages get published in registries
The most famous, free for publicly available images, is Docker Hub
By default, Docker uses Docker Hub as registry (both for pull and push operations)
Docker Hub requires registration and CLI login:
docker login docker.ioOnce done, publication is performed via push, provided that the image name includes your namespace:
docker tag my-image:0.1.0 <username>/my-image:0.1.0docker push <username>/my-image:0.1.0Of course, like any other software, custom docker images should get built in CI
Several integrators use containers as build environments:
More generally: there is no inherent limit to nesting containers
For instance:
docker run --privileged --rm -it docker:dinddocker run -it --rm docker:cliVolumes are bridges between the host’s file system (or memory) and the container’s one
They support several use cases:
<host path>:<guest path> (use absolute paths, e.g. via $(pwd))
docker run -v /home/$USER/path/to/file:/data ...<host path> can be read within the container at <guest path><guest path> can be accessed on <host path>
docker volume create <name><name>:<guest path>docker run -v my-volume:/my-project .../var/lib/docker/volumes/<name>/_data--tmpfs option to docker run
docker run --tmpfs <guest path> ...docker run --mount type=tmpfs,destination=<guest path> ...db_data and wordpress_data persist the data of the db and wordpress containers
backup container mounting them read-only…docker run --rm \
-v db_data:/from/db:ro \
-v wordpress_data:/from/wp:ro \
-v /home/$USER/backups:/to \
alpine tar czf /to/backup.tar.gz -C / from
Let’s open a Linux shell in a container: docker run -it --rm alpine:latest sh
Let’s create a folder in there: mkdir -p /data
Let’s create a file in there: echo "Hello world" > /data/hello.txt
Does the file exist? ls -la /data
/ # ls -la /data
total 12
drwxr-xr-x 2 root root 4096 Nov 2 09:28 .
drwxr-xr-x 1 root root 4096 Nov 2 09:28 ..
-rw-r--r-- 1 root root 12 Nov 2 09:28 hello.txt
Let’s exit the container: exit
Let’s open a new shell in a new container: docker run -it --rm alpine:latest sh
Does the file exist? ls -la /data
ls: /data: No such file or directory
Why?
docker run ... command created a new containerLet’s create a folder on the host: mkdir -p $(pwd)/shared
Let’s create 10 containers
./shared/container-<i>.txt/dev/random, encoded in base64-d), with a bind mount (-v), and a parametric hostname (--hostname)for i in $(seq 1 10); do
docker run --rm -d \
-v $(pwd)/shared:/data \
--hostname container-$i \
alpine sh -c \
'cat /dev/random | head -n 1 | base64 > /data/$(hostname).txt'
done
Let’s have a look at the shared directory: ls -la ./shared
ls -la ./shared
totale 56
drwxr-xr-x 2 user user 4096 2 nov 10.51 .
drwxr-xr-x 79 user user 12288 2 nov 10.56 ..
-rw-r--r-- 1 root root 329 2 nov 10.51 container-10.txt
-rw-r--r-- 1 root root 333 2 nov 10.51 container-1.txt
-rw-r--r-- 1 root root 381 2 nov 10.51 container-2.txt
-rw-r--r-- 1 root root 167 2 nov 10.51 container-3.txt
-rw-r--r-- 1 root root 110 2 nov 10.51 container-4.txt
-rw-r--r-- 1 root root 102 2 nov 10.51 container-5.txt
-rw-r--r-- 1 root root 9 2 nov 10.51 container-6.txt
-rw-r--r-- 1 root root 106 2 nov 10.51 container-7.txt
-rw-r--r-- 1 root root 766 2 nov 10.51 container-8.txt
-rw-r--r-- 1 root root 203 2 nov 10.51 container-9.txt
root (inside the container)root on the host (outside the container)
useruser has no superuser privileges, they cannot edit the files
./shared is owned by user$(pwd) to get the current working directoryLet’s create a named volume: docker volume create my-volume
Same 10 containers as before, but using the named volume (-v my-volume:/data) instead of the bind mount
for i in $(seq 1 10); do
docker run --rm -d \
-v my-volume:/data \
--hostname container-$i \
alpine sh -c \
'cat /dev/random | head -n 1 | base64 > /data/$(hostname).txt'
done
How to access the data now?
docker run --rm -it -v my-volume:/data alpine shls -la /data
total 48
drwxr-xr-x 2 root root 4096 Nov 2 10:15 .
drwxr-xr-x 1 root root 4096 Nov 2 10:15 ..
-rw-r--r-- 1 root root 608 Nov 2 10:14 container-1.txt
-rw-r--r-- 1 root root 349 Nov 2 10:15 container-10.txt
-rw-r--r-- 1 root root 369 Nov 2 10:14 container-2.txt
-rw-r--r-- 1 root root 304 Nov 2 10:14 container-3.txt
-rw-r--r-- 1 root root 240 Nov 2 10:14 container-4.txt
-rw-r--r-- 1 root root 434 Nov 2 10:14 container-5.txt
-rw-r--r-- 1 root root 150 Nov 2 10:14 container-6.txt
-rw-r--r-- 1 root root 41 Nov 2 10:14 container-7.txt
-rw-r--r-- 1 root root 33 Nov 2 10:15 container-8.txt
-rw-r--r-- 1 root root 1212 Nov 2 10:15 container-9.txt
Alternatively, let’s find where the volume is stored on the host
docker volume inspect my-volumeLet’s analyse the JSON description of the volume:
[
{
"CreatedAt": "2023-11-02T11:13:56+01:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/my-volume/_data",
"Name": "my-volume",
"Options": null,
"Scope": "local"
}
]
The Mountpoint entry reveals the location of the volume on the host’s file system
Let’s look into that position (may require super-user access rights, Linux only):
sudo ls -la /var/lib/docker/volumes/my-volume/_data
totale 48
drwxr-xr-x 2 root root 4096 2 nov 11.15 .
drwx-----x 3 root root 4096 2 nov 11.13 ..
-rw-r--r-- 1 root root 349 2 nov 11.15 container-10.txt
-rw-r--r-- 1 root root 608 2 nov 11.14 container-1.txt
-rw-r--r-- 1 root root 369 2 nov 11.14 container-2.txt
-rw-r--r-- 1 root root 304 2 nov 11.14 container-3.txt
-rw-r--r-- 1 root root 240 2 nov 11.14 container-4.txt
-rw-r--r-- 1 root root 434 2 nov 11.14 container-5.txt
-rw-r--r-- 1 root root 150 2 nov 11.14 container-6.txt
-rw-r--r-- 1 root root 41 2 nov 11.14 container-7.txt
-rw-r--r-- 1 root root 33 2 nov 11.15 container-8.txt
-rw-r--r-- 1 root root 1212 2 nov 11.15 container-9.txt
Volumes are NOT automatically cleaned up when containers are deleted
Volumes must be explicitly deleted by the user
docker volume rm <volume name>In our example, remember to delete my-volume:
docker volume rm my-volume
Sometimes one may want to let a container access the Docker daemon of its host
To achieve that one may exploit bind mounts
Explanation:
/var/run/docker.sockdocker command is just a CLI program writing/reading to/from the socket to govern the daemonOne may simply create a Docker container with the Docker CLI tool installed on it
docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock --name sudo docker:cli shInside that container one may run the docker command as usual, to govern the host’s Docker daemon
docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3d9016cd067e docker:cli "docker-entrypoint.s…" 5 seconds ago Up 3 seconds sudo
sudo are siblings of sudo on the host, not nested inside it
sudo’s onedocker group)Docker may virtualise container’s networking facilities
This is useful to let containers communicate with any external entity
most commonly, other containers or the host
Networks are virtual entities managed by Docker
- they mimic the notion of layer-3 network
- in the eyes of the single container, they are virtual network interfaces
- overall, their purpose is to delimit the communication scope for containers
Different sorts of networks are available in Docker, depending on the driver:
Available drivers:
none: no networking facilities are provided to the container, which is then isolated
host: remove network isolation between the container and the Docker host, and use the host’s networking directly
bridge (default): a virtual network, internal to the host, letting multiple containers communicate with each other
overlay: a virtual network, spanning multiple hosts, letting multiple containers communicate with each other among different nodes of the same cluster
Other drivers are available as well, cf. https://docs.docker.com/engine/network/drivers/
No networking facilities are provided to the container at all, which is then isolated w.r.t. the external world
This is useful for containers that do not need to communicate via the network
Example: docker run --network none -it --rm alpine sh
/ # ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
ping: sendto: Network unreachable
No isolation between the container and the host
The main consequence is that the container will be assigned the same IP address as the host
Example (Linux hosts; Docker Desktop supports it only in recent versions, as an opt-in): docker run --network host my-service
Service 540c3b7bb7860c6a listening on <your pc's hostname>:8080
Virtual network spanning one or more containers on the same host
Each container gets assigned a private IP address on the network
172.x.y.zTo exemplify the usage of IP:
let’s create a container using the my-service image created a few slides ago
docker run --network bridge -d --rm --name my-container my-service8080my-container--network bridge is useless, because that’s the defaultlet’s inspect the container’s IP address
docker inspect my-container | grep IPAddress
172.17.0.2open your browser and browse to http://172.17.0.2:8080 (on Linux)
[$SERVER_ID@$HOST_NAME:PORT] Hit $VIEW_COUNT timesbridge-like networks can be contacted by IP, from the hosttry to contact the container:
Let’s create a non-trivial scenario with 2 containers attached to the same network:
dind) will run the Docker daemon (Docker-in-Docker)cli) will run the Docker CLImy-networklet’s create the network: docker network create --driver bridge my-network
let’s create the daemon:
docker run --privileged -d --rm --network my-network --hostname dind docker:dindlet’s create the client: docker run -it --rm --network my-network --hostname cli docker:cli
inside cli, let’s check if dind is contactable by hostname: ping dind
inside cli, let’s check if the Docker client works: docker ps
error during connect: Get "http://docker:2375/v1.24/containers/json": dial tcp: lookup docker on 127.0.0.11:53: no such host
apparently the docker:cli image is preconfigured to contact a daemon on the docker hostname
let’s stop dind:
docker ps on the host to get the name of the dind containerdocker stop <container name>docker stop $(docker ps -q --filter ancestor=docker:dind)
-q (quiet), only display container IDs--filter ancestor=X selects containers whose image is X--namelet’s restart a Docker-in-Docker daemon, using the docker hostname:
docker run --privileged -d --rm --network my-network --name dind --hostname docker docker:dind dockerd --host=tcp://0.0.0.0:2375--name X and --hostname X
dockerd --host=tcp://0.0.0.0:2375 is necessary to force the port the daemon will listen on
let’s restart the client: docker run -it --rm --network my-network --hostname cli docker:cli
let’s run a command on the CLI, say docker run hello-world
docker hostnameagents_network network of the Portainer stack, later in this lecture
docker network create --driver overlay --attachable my-overlay on a manager, or declared in a stack fileWhen a container wraps a service listening on layer-4 port…
When creating a Docker image, the EXPOSE command in the Dockerfile is used to declare which ports the service listens on
EXPOSE 8080The port exposed by a container can be inspected via docker ps
docker run -d --rm my-service; docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7a14f3bf95cc my-service "/bin/sh -c 'npm run…" 1 second ago Up Less than a second 8080/tcp wonderful_goldwasser
(look at the PORTS column)
When running a Docker container, the exposed port can be mapped to the host’s ports via the -P option
EXPOSEd port in the image mapped to some random port of the hostdocker run -d --rm -P my-service; docker psdocker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b1470831b62b my-service "/bin/sh -c 'npm run…" 6 seconds ago Up 5 seconds 0.0.0.0:32773->8080/tcp, :::32773->8080/tcp sad_benz
One may control the mapping of ports via the -p <host port>:<guest port> option
docker run -d --rm -p 8888:8080 my-service; docker ps8080 to the host’s port 8888CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f034da93c476 my-service "/bin/sh -c 'npm run…" 4 seconds ago Up 3 seconds 0.0.0.0:8888->8080/tcp, :::8888->8080/tcp bold_booth
EXPOSE only documents the port: it is publishing (-P or -p) that makes it reachable from outside the host-p also accepts an IP to bind to, e.g. -p 127.0.0.1:8888:8080 publishes the port on the loopback interface onlyOperating containers manually is error prone and low-level
Docker Compose lets you increase the abstraction level on the single machine
Starting from a user-provided specification of a stack…
… Docker Compose can:
The only thing the user must do is create a compose.yaml file
compose.yaml is the preferred file name nowadays, docker-compose.yml is still supportedversion attribute is obsolete, hence omitted in what followsThe example application is composed of the following parts:
webapp and databaseservices: # section defining services composing the stack
frontend: # name of the first service (frontend)
image: example/webapp # image to use for the first service
depends_on: # section for dependencies
- backend # short-form depends_on waits for start, not for health
environment: # section for environment variables
SERVICE_PORT: 8043 # port the service will listen on
DB_HOST: backend # hostname of the backend service (= service name)
DB_PORT: 3306 # port the backend service will listen on
ports: # section for ports published on the host
- "443:8043" # container port 8043 as host port 443
networks: # section for networks to be attached to
- front-tier # attach to front-tier network (by name)
- back-tier # attach to back-tier network (by name)
# configs are files copied into the container, with no need to build a new image
configs: # section for config files to be injected
- source: httpd-config # reference to the configuration file (by name)
target: /etc/httpd.conf # where the config is mounted (read-only)
# secrets are reified as read-only files in /run/secrets/<secret_name>
secrets: # section for secrets to be injected
- server-certificate # reference to the secret (by name)
- db-credentials # reference to the secret (by name)
# assumption: image example/webapp is programmed to load secrets
# from /run/secrets/<secret_name>
backend: # name of the second service (backend)
image: example/database # image to use for the second service
# command run inside the container to check whether it is healthy
healthcheck:
test: ["CMD", "command", "which", "should", "fail", "if", "db not healthy"]
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s
volumes: # section for volumes to be attached to the container
- db-data:/etc/data # the volume db-data (reference by name) will be mounted
# as read-write in /etc/data
networks:
- back-tier # attach to back-tier network (by name)
secrets:
- db-credentials # reference to the secret (by name)
volumes: # section for volumes to be created in the stack
db-data: # name of the volume, to be referenced by services
driver_opts:
type: "none"
o: "bind" # this volume is a bind mount
device: "/path/to/db/data/on/the/host"
configs: # section for configuration files to be created in the stack
httpd-config: # name of the configuration file, to be referenced by services
file: path/to/http/configuration/file/on/the/host
secrets: # section for secrets to be created in the stack
server-certificate: # name of the secret, to be referenced by services
file: path/to/certificate/file/on/the/host
db-credentials: # name of the secret, to be referenced by services
external: true # not created by the stack, but expected to exist
name: my-db-creds # name of the secret, as created by the user
networks:
# The presence of these objects is sufficient to define them
front-tier: {}
back-tier: {}
A stack is a set of inter-related services, networks, volumes, secrets, configs
compose.yaml fileFrom Docker Compose documentation:
services section of the compose.yaml file
compose.yaml fileFrom Docker Compose documentation:
configs section of the compose.yaml fileone config may either be created stack-wise out of a local file from the host…
configs:
config_name:
file: /path/to/config/on/the/host
… or (Swarm mode only) it can be manually created by means of the docker config create [OPTIONS] NAME PATH command
compose.yaml files:
configs:
config_name:
external: true
name: "the name used when creating the config"
# this may be different from config_name
in any case, the config’s mount point should be specified at the service level:
services:
my-service:
...
configs:
- source: config_name # reference to the configs section (by YAML name)
target: /path/to/config/on/the/container
From Docker Compose documentation:
secrets are configs containing sensitive data that should be kept secret
put simply, Docker makes it harder to inspect the content of secrets, both on the host and inside the container
one secret may either be created stack-wise out of a local file or an environment variable from the host…
secrets:
secret_name:
file: "/path/to/secret/on/the/host"
secrets:
secret_name:
environment: "NAME_OF_THE_VARIABLE_ON_THE_HOST"
… or (Swarm mode only) it can be manually created by means of the docker secret create [OPTIONS] NAME PATH|- command
compose.yaml files:
secrets:
secret_name:
external: true
name: "the name used when creating the secret"
# this may be different from secret_name
in any case, the secret’s mount point should be specified at the service level:
services:
my-service:
...
secrets:
# short syntax, mounted on /run/secrets/<secret_name>
- secret_name # reference to the secrets section (by YAML name)
# long syntax
- source: secret_name # reference to the secrets section (by YAML name)
target: /path/to/secret/on/the/container
Important remark:
PATHPATHFrom Docker Compose documentation:
volumes section of the compose.yaml filevolumes:
volume_name:
driver: local
driver_opts:
type: "none"
o: "bind"
device: "/path/to/folder/on/the/host"
volumes:
volume_name:
driver: local
driver_opts:
type: "nfs"
# meaning of options: https://wiki.archlinux.org/title/NFS#Mount_using_/etc/fstab
o: "addr=NFS_SERVER_ADDRESS,nolock,soft,rw" # cf. nfs
device: ":/path/on/the/nfs/server/"
… or it can be manually created by means of the docker volume create [OPTIONS] NAME command
compose.yaml files:
volumes:
volume_name:
external: true
name: "the name used when creating the volume"
# this may be different from volume_name
in any case, the volume’s mount point should be specified at the service level:
services:
my-service:
...
volumes:
- type: volume
source: volume_name # reference to the volumes section (by YAML name)
target: /path/to/folder/on/the/container
# alternatively, one may define a service-specific bind mount on the fly
- type: bind
source: /path/to/folder/on/the/host
target: /path/to/folder/on/the/container
From Docker Compose documentation:
networks in compose are no different from the ones we have already discussed
they are declared in the networks section of the compose.yaml file
one network may be declared stack-wise in the stack file…
networks:
network_name:
driver: overlay # default choice in swarm
driver_opts:
# many options, covering many advanced use cases for network engineering
… or it can be manually created by means of the docker network create [OPTIONS] NAME command
compose.yaml files:
networks:
network_name:
external: true
name: "the name used when creating the network"
# this may be different from network_name
in any case, the networks a service is attached to should be specified at the service level:
services:
my-service:
...
networks:
- network_name # reference to the networks section (by YAML name)
See all possibilities with docker compose --help
docker compose up starts the stack
--help)
-d (detached mode): run containers in the background--wait waits for services to be running or healthy (implies detached mode)docker compose down stops the stack
--help)
--remove-orphans removes containers for services not defined in the Compose file (e.g. residuals from previous versions)-v removes named volumes declared in the “volumes” section of the Compose file and anonymous volumes attached to containersAll commands assume a
compose.yaml(ordocker-compose.yml) file is present in the current working directory
compose.yaml (e.g. your-dir)
-p <project name> or the top-level name attributeservices:
db:
image: mariadb:latest
volumes:
- db_data:/var/lib/mysql
restart: always
networks:
- back-tier
environment:
MYSQL_ROOT_PASSWORD: "..Password1.."
MYSQL_DATABASE: wordpress_db
MYSQL_USER: wordpress_user
MYSQL_PASSWORD: ",,Password2,,"
healthcheck:
test: [ "CMD", "healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
start_period: 1m
start_interval: 10s
interval: 1m
timeout: 5s
retries: 3
wordpress:
depends_on:
db:
condition: service_healthy # wait for the healthcheck to pass
image: wordpress:latest
volumes:
- wordpress_data:/var/www/html
ports:
- "8000:80"
restart: always
networks:
- back-tier
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress_user
WORDPRESS_DB_PASSWORD: ",,Password2,,"
WORDPRESS_DB_NAME: wordpress_db
volumes:
db_data: {}
wordpress_data: {}
networks:
back-tier: {}
Beware: this is unsafe as passwords are in clear text!
Copy-paste this code in a file named your-dir/compose.yaml
cd your-dir
docker compose up and then inspect the logs
open your browser and browse to http://localhost:8000
docker ps and have a look at the running containers
docker network lsdocker volume lsPress Ctrl+C to stop the stack
docker compose down --volumes to delete the stack
Example of docker compose up logs:
docker compose up
[+] Building 0.0s (0/0) docker:default
[+] Running 5/5
✔ Network your-dir_back-tier Created 0.1s
✔ Volume "your-dir_wordpress_data" Created 0.1s
✔ Volume "your-dir_db_data" Created 0.0s
✔ Container your-dir-db-1 Created 0.8s
✔ Container your-dir-wordpress-1 Created 0.9s
Attaching to db-1, wordpress-1
db-1 | 2023-11-23 14:24:57+00:00 [Note] [Entrypoint]: Entrypoint script for MariaDB Server 1:11.2.2+maria~ubu2204 started.
db-1 | 2023-11-23 14:24:57+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db-1 | 2023-11-23 14:24:57+00:00 [Note] [Entrypoint]: Entrypoint script for MariaDB Server 1:11.2.2+maria~ubu2204 started.
db-1 | 2023-11-23 14:24:57+00:00 [Note] [Entrypoint]: Initializing database files
wordpress-1 | WordPress not found in /var/www/html - copying now...
wordpress-1 | Complete! WordPress has been successfully copied to /var/www/html
wordpress-1 | No 'wp-config.php' found in /var/www/html, but 'WORDPRESS_...' variables supplied; copying 'wp-config-docker.php' (WORDPRESS_DB_HOST WORDPRESS_DB_NAME WORDPRESS_DB_PASSWORD WORDPRESS_DB_USER)
wordpress-1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.3. Set the 'ServerName' directive globally to suppress this message
wordpress-1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.3. Set the 'ServerName' directive globally to suppress this message
wordpress-1 | [Thu Nov 23 14:24:58.289485 2023] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.56 (Debian) PHP/8.0.30 configured -- resuming normal operations
wordpress-1 | [Thu Nov 23 14:24:58.289516 2023] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'
...
Example of docker ps logs:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4691a9641135 wordpress:latest "docker-entrypoint.s…" 9 minutes ago Up 9 minutes 0.0.0.0:8000->80/tcp, :::8000->80/tcp your-dir-wordpress-1
314feb9d42ab mariadb:latest "docker-entrypoint.s…" 9 minutes ago Up 9 minutes (healthy) 3306/tcp your-dir-db-1
Example of docker network ls logs:
NETWORK ID NAME DRIVER SCOPE
b304d1ae5404 bridge bridge local
1bb39315ff98 host host local
03c8700dee6a none null local
470d81d26296 your-dir_back-tier bridge local
Let’s create a file containing the DB root password, say db_root_password.txt:
echo -n "..Password1.." > your-dir/db_root_password.txtLet’s create a file containing the DB user password, say db_password.txt:
echo -n ",,Password2,," > your-dir/db_password.txtLet’s edit the stack as described on the right
MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD, and WORDPRESS_DB_PASSWORD variables_FILE counterparts, pointing to the secretsservices:
db:
...
environment:
...
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MYSQL_PASSWORD_FILE: /run/secrets/db_password
...
secrets:
- db_root_password
- db_password
wordpress:
...
environment:
...
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
...
secrets:
- db_password
volumes:
db_data: {}
wordpress_data: {}
networks:
back-tier: {}
secrets:
db_password:
file: db_password.txt
db_root_password:
file: db_root_password.txt
Docker Compose is also useful for software testing
E.g. whenever the system under test relies on some infrastructural component
Usual workflow for testing:
Docker Compose may be coupled with some test framework of choice to automate the process
interface Customer {
+ id: CustomerID
+ firstName: String
+ lastName: String
+ birthDate: LocalDate
...
+ name: String
+ isPerson: Boolean
+ isCompany: Boolean
}
note bottom: Entity
interface CustomerID {
+ value: Any
}
note right: Value Object
interface TaxCode {
+ value: String
}
note left: Value Object
interface VatNumber {
+ value: Long
}
note right: Value Object
VatNumber -d-|> CustomerID
TaxCode -d-|> CustomerID
Customer *-r- CustomerID
interface CustomerRepository {
+ findById(id: CustomerID): Iterable<Customer>
+ findByName(name: String): Iterable<Customer>
+ add(customer: Customer)
+ update(oldId: CustomerID, customer: Customer)
+ remove(id: CustomerID)
}
note left: Repository
CustomerRepository o-r- Customer
interface CustomerRepository {
+ findById(id: CustomerID): Iterable<Customer>
+ findByName(name: String): Iterable<Customer>
+ add(customer: Customer)
+ update(oldId: CustomerID, customer: Customer)
+ remove(id: CustomerID)
}
class SqlCustomerRepository {
- connections: ConnectionFactory
- tableName: String
+ createTable(replaceIfPresent: Boolean = false)
}
SqlCustomerRepository --|> CustomerRepository
package "db" {
interface ConnectionFactory {
+ connect(): Connection
}
interface MariaDB {
+ database: String
+ username: String
+ password: String
+ host: String
+ port: Int
}
class MariaDBConnection
MariaDBConnection --|> MariaDB
MariaDBConnectionFactory --|> ConnectionFactory
MariaDBConnectionFactory ..> MariaDBConnection: creates
}
SqlCustomerRepository .r.> MariaDBConnectionFactory: uses
package "java sql" {
interface Connection
ConnectionFactory ..> Connection: creates
MariaDBConnection --|> Connection
}
class TestMariaDBCustomerRepository {
companion object {
@BeforeClass @JvmStatic fun setUpMariaDb() { TODO("compose up") }
@AfterClass @JvmStatic fun tearDownMariaDb() { TODO("compose down") }
}
private val connectionFactory = MariaDBConnectionFactory(DATABASE, USER, PASSWORD, HOST, PORT)
private lateinit var repository: SqlCustomerRepository
@Before
fun createFreshTable() {
repository = SqlCustomerRepository(connectionFactory, TABLE)
repository.createTable(replaceIfPresent = true)
}
private val taxCode = TaxCode("CTTGNN92D07D468M")
private val person = Customer.person(taxCode, "Giovanni", "Ciatto", LocalDate.of(1992, 4, 7))
private val person2 = person.clone(
birthDate = LocalDate.of(1992, 4, 8),
id = TaxCode("CTTGNN92D08D468M"),
)
private val vatNumber = VatNumber(12345678987L)
private val company = Customer.company(vatNumber, "ACME", "Inc.", LocalDate.of(1920, 1, 1))
@Test
fun complexTestWhichShouldActuallyBeDecomposedInSmallerTests() {
assertEquals(
expected = emptyList(),
actual = repository.findById(taxCode),
)
assertEquals(
expected = emptyList(),
actual = repository.findById(vatNumber),
)
repository.add(person)
repository.add(company)
assertEquals(
expected = listOf(person),
actual = repository.findById(taxCode),
)
assertEquals(
expected = listOf(company),
actual = repository.findById(vatNumber),
)
repository.remove(vatNumber)
repository.update(taxCode, person2)
}
}
Unit test for the SqlCustomerRepository class
Requires an instance of MariaDB to be tested
Let’s use Docker Compose to start / stop MariaDB
Try to complete the implementation of the setUpMariaDb and tearDownMariaDb methods
ProcessBuilders to call docker compose commandsTemplate for Docker Compose files available among test resources
docker-compose.yml.template
Code for the exercise:
exercise)In practice, libraries like Testcontainers automate this very pattern
Goal: make
TestMariaDBCustomerRepositorystart MariaDB before the tests, and stop it after them, viadocker compose
Get the code, on the exercise branch (master contains the solution):
git clone --branch exercise https://github.com/unibo-spe/docker-compose-testing
cd docker-compose-testing
Ensure Docker is running (docker info should not fail)
Run the tests: ./gradlew test
NotImplementedError: the TODO()s in the companion object of TestMariaDBCustomerRepository are our jobSteps (one per slide, below):
docker compose from Kotlinsrc/test/resources/it/unibo/spe/compose/docker-compose.yml.template
# Template for the Docker Compose file used by TestMariaDBCustomerRepository.
# Each __NAME__ placeholder is replaced by the test code, before calling `docker compose`,
# so that each run of the test suite gets its own port and credentials.
services:
db:
# We use a mariadb image which supports both amd64 & arm64 architecture
image: mariadb:__VERSION__
volumes:
- db_data:/var/lib/mysql
restart: always
environment:
- MYSQL_ROOT_PASSWORD=__ROOT_PASSWORD__
- MYSQL_DATABASE=__DB_NAME__
- MYSQL_USER=__USER__
- MYSQL_PASSWORD=__PASSWORD__
ports:
# host ports are global to the machine: never hard-code them, or parallel runs will clash
- "__PORT__:3306"
# TODO: once the test code is complete, run the tests: do they pass reliably?
# `docker compose up --wait` waits for services to be "running" or "healthy": which one applies here?
# is MariaDB ready to accept connections as soon as its container is running?
# healthcheck:
# test: [ "CMD", "healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
# start_period: 1m
# start_interval: 10s
# interval: 1m
# timeout: 5s
# retries: 3
volumes:
db_data:
One service, db, running the mariadb image
__NAME__ strings are placeholders, not Docker Compose syntax
docker composeThe test already lists the substitutions, in setUpMariaDb:
"VERSION" to "11",
"ROOT_PASSWORD" to UUID.randomUUID().toString(),
"DB_NAME" to DATABASE,
"USER" to USER,
"PASSWORD" to PASSWORD,
"PORT" to "$PORT",
PORT is also the port the MariaDBConnectionFactory connects toThe db_data named volume keeps the DB files
docker compose down, unless explicitly removed (cf. volumes)The healthcheck is commented out: keep it in mind for step 5
Why not a static docker-compose.yml, used in place?
What would clash between two runs, with a static file?
3306
__PORT__ is a parameter, and each run picks a free port: ServerSocket(0).use { it.localPort }<project>-db-1, <project>_db_data)
up reuses the containers of the first, and the first down removes them under the feet of the secondHence: generate the Compose file from the template, into a fresh temporary directory per run
ROOT_PASSWORD) private fun createComposeFile(vararg assignments: Pair<String, String>): File {
val directory = Files.createTempDirectory("docker-compose-testing").toFile()
val path = directory.resolve("docker-compose.yml")
val template = TestMariaDBCustomerRepository::class.java
.getResource("docker-compose.yml.template")
?.readText()
?: error("docker-compose.yml.template resource not found")
path.writeText(
assignments.fold(template) { acc, (key, value) -> acc.replace("__${key}__", value) },
)
return path
}createComposeFile(assignments) must:
Files.createTempDirectory)
File.createTempFile puts every file in the same system temp directory, i.e. the same project for all runsgetResource(...) on the test classerror(...)) if the resource is missing__KEY__ with its valuedocker-compose.yml, inside the temporary directory, and return itdocker compose from Kotlin /**
* Calls `docker compose -f $composeFile $arguments`, waits for its completion,
* and makes the test fail if the exit value is not 0.
*/
private fun executeDockerCompose(vararg arguments: String) {
val command = listOf("docker", "compose", "-f", composeFile.absolutePath) + arguments
val exitValue = ProcessBuilder(command).inheritIO().start().waitFor()
assertEquals(
expected = 0,
actual = exitValue,
message = "Command `${command.joinToString(" ")}` returned non-zero exit code: $exitValue",
)
}Command line: docker compose -f <composeFile> <arguments>
-f is needed, as the file is not in the working directory, and not named compose.yamlProcessBuilder(command).start() spawns the process
inheritIO() forwards its output to the test’s output, i.e. Docker’s logs end up in Gradle’s test reportwaitFor() blocks until the process terminates, and returns its exit value
The exit value must be 0, otherwise the test must fail
assertEquals(0, exitValue, ...), with an informative message @BeforeClass
@JvmStatic
fun setUpMariaDb() {
composeFile = createComposeFile(
"VERSION" to "11",
"ROOT_PASSWORD" to UUID.randomUUID().toString(),
"DB_NAME" to DATABASE,
"USER" to USER,
"PASSWORD" to PASSWORD,
"PORT" to "$PORT",
)
executeDockerCompose("up", "--wait")
executeDockerCompose("logs", "db", "--tail", "1")
}
@AfterClass
@JvmStatic
fun tearDownMariaDb() {
executeDockerCompose("logs", "db")
executeDockerCompose("down", "--volumes")
composeFile.parentFile.deleteRecursively()
}@BeforeClass / @AfterClass (JUnit 4): run once, before / after all tests of the class
@JvmStatic functions in the companion object@Before, which re-creates the tableup --wait: starts the stack (detached), and returns only when services are running / healthy
--wait $\rightarrow$ up would block forever, attached to the containers’ logsdown --volumes: stops and removes containers, networks, and named volumes
--volumes $\rightarrow$ db_data is left on the machine, at each runThen, delete the temporary directory
logs db: not mandatory, but very useful to debug failures
./gradlew test with the template as is $\rightarrow$ likely failure of createFreshTable, with a connection error:
up --wait returns as soon as the container is running…Answer to “does the template require edits? why?”: yes, un-comment the healthcheck
test command periodically, inside the containerhealthcheck.sh ships with the official MariaDB imageup --wait now waits for healthy (cf. healthcheck docs)Afterwards, docker ps -a and docker volume ls should show no leftovers
Try running the test suite twice, in parallel (e.g. from two clones of the repository)
Template, as in the solution:
ports:
# host ports are global to the machine: never hard-code them, or parallel runs will clash
- "__PORT__:3306"
# lets `docker compose up --wait` return only once MariaDB actually accepts connections
healthcheck:
test: [ "CMD", "healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
start_period: 1m
start_interval: 10s
interval: 1m
timeout: 5s
retries: 3
Possible failures, and their causes:
Cannot connect to the Docker daemon $\rightarrow$ Docker is not runningNotImplementedError $\rightarrow$ some TODO() is still therecreateFreshTable $\rightarrow$ MariaDB not ready yet (see step 5)port is already allocated $\rightarrow$ another process took the free port, between its choice and docker compose up: just re-runPossible improvements:
"3306", so that Docker picks a free host port, then ask which one (docker compose port db 3306)complexTestWhichShouldActuallyBeDecomposedInSmallerTests into smaller testsFull solution on the master branch of unibo-spe/docker-compose-testing
Testcontainers provides the same pattern as a library (also for Docker Compose)
Recall: jobs eventually terminate, and may exploit parallelism to speed up the computation
Experiments (simulations, data analyses, ML trainings, benchmarks…) are typical jobs
Why containerise them?
Running example: https://github.com/unibo-spe/containerized-experiment, whose commit history we follow step by step,
from a Jupyter notebook to parallel, containerised experiments
No background in machine learning (ML) is needed: what matters is the shape of the computation
For us, an experiment is: input data + parameters $\rightarrow$ many independent trainings $\rightarrow$ results (scores, plots, the best model)
.ipynb)jupyter lab)078562f)What is great for exploration is bad for reproducibility and automation:
.ipynb (with paths like /home/gciatto/...), or in the current directoryGoal: turn the notebook into an experiment which anybody can (re-)run, in parallel, with one command
Inspect each step via git checkout <commit> in the repository:
| Step | Commit | What changes |
|---|---|---|
| 0 | 078562f |
the experiment is a notebook (the bad situation) |
| 1 | 84d3822 |
notebook $\rightarrow$ script, parameters from environment variables |
| 2 | 9345702 |
dockerise: Dockerfile + Compose file |
| 3 | 9e78fce, b6823d3 |
fix the ownership of results on the host |
| 4 | 0fc62ff |
skip experiments which were already executed |
| 5 | 8293534 |
move the grids of hyper-parameters out of the code |
| 6 | 19ba9fe |
one Compose service per configuration $\Rightarrow$ parallelism |
| 7 | f63c762 |
polishing: resource limits, updated dependencies, robust scripts |
DATASET_ID = int(os.getenv('DATASET_ID', "61"))
TEST_SIZE = float(os.getenv('TEST_SIZE', "0.2"))
RANDOM_STATE = int(os.getenv('RANDOM_STATE', "42"))
K_FOLDS = int(os.getenv('K_FOLDS', "5"))
SCORING = os.getenv('SCORING', 'accuracy')
PICTURE_SIZE = tuple(map(int, os.getenv('PICTURE_SIZE', '10,6').split(',')))
OUTPUT_DIR = Path(os.getenv('OUTPUT_DIR', '.'))
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)full script here
jupyter nbconvert --to script experiment.ipynb gives a starting point: the code of all cells, in orderplt.show())OUTPUT_DIR)python experiment.pyDockerfile
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}
RUN mkdir -p /experiment
VOLUME "/data"
ENV OUTPUT_DIR=/data
WORKDIR /experiment
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY * /experiment/
CMD python experiment.py | tee ${OUTPUT_DIR}/output.log
docker-compose.yml
services:
experiment:
build:
context: .
dockerfile: Dockerfile
args:
PYTHON_VERSION: 3.12
volumes:
- ./data:/data
environment:
# DATASET_ID: 61 # openml dataset id
# TEST_SIZE: 0.2
# RANDOM_STATE: 42
# K_FOLDS: 5
# SCORING: accuracy
# PICTURE_SIZE: 10, 6
ARG), pinned dependencies (requirements.txt)/data, bind-mounted onto ./data on the hostenvironment)docker compose upRecall: files written by containers via bind mounts are owned by
rooton the host
First attempt (9e78fce): chown at the end of the run
ENV OWNER=1000:1000
CMD python experiment.py | tee ${OUTPUT_DIR}/output.log && \
chown -R ${OWNER} ${OUTPUT_DIR}Final solution (b6823d3): run as the host user
# run as the host user, so that results in ./data are owned by them (cf. README)
user: &user "${HOST_UID:-1000}:${HOST_GID:-1000}"chown at the end is fragile: if the experiment fails, files stay owned by rootuser, or docker run --user)
export HOST_UID=$(id -u) HOST_GID=$(id -g) before docker compose upUID: in Bash it is read-only and not exported, while GID does not existENV HOME=/tmp, for libraries’ caches./data must exist before starting, otherwise Docker creates it as root (hence data/.gitkeep)docker compose up should only run the missing experiments# Compute a unique experiment ID based on the hyper-parameters
FOOTPRINT_KEYS = {'DATASET_ID', 'TEST_SIZE', 'RANDOM_STATE', 'K_FOLDS', 'SCORING', 'PICTURE_SIZE', 'MODELS'}
EXPERIMENT_FOOTPRINT = {k: v for k, v in locals().items() if k in FOOTPRINT_KEYS}
EXPERIMENT_FOOTPRINT_YAML = yaml.dump(EXPERIMENT_FOOTPRINT, sort_keys=True)
EXPERIMENT_ID = sha512(EXPERIMENT_FOOTPRINT_YAML.encode()).hexdigest()
# Ensure the output directory exists
DATA_DIR = Path(os.getenv('DATA_DIR', './data'))
OUTPUT_DIR = Path(os.getenv('OUTPUT_DIR', str(DATA_DIR)))
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Do not waste time if the experiment has already been run
# by looking for the existence of a .yaml file named after the EXPERIMENT_ID
for file in DATA_DIR.glob('**/*.yaml'):
if file.is_file() and file.stem == EXPERIMENT_ID:
print(f'Experiment with ID {EXPERIMENT_ID[:8]} already exists in {file.parent}', file=sys.stderr)
exit(0)yaml.dump(..., sort_keys=True) makes the footprint canonical: same parameters $\Rightarrow$ same ID MODEL_DecisionTree: |
sklearn.tree.DecisionTreeClassifier:
max_depth: [null, 10, 20]
min_samples_split: [2, 10, 20]
MODEL_MLP: |
sklearn.neural_network.MLPClassifier:
hidden_layer_sizes:
- [50]
- [100,]
- [50, 50]
activation: ['tanh', 'relu']
solver: ['adam', 'sgd']
MODEL_LogisticRegression: |
sklearn.linear_model.LogisticRegression:
C: [0.1, 1, 10]
solver: ['liblinear', 'lbfgs']
MODEL_SVM: |
sklearn.svm.SVC:
C: [0.1, 1, 10]
kernel: ['linear', 'rbf']MODEL_<name>, whose value is a YAML document:
MODEL_* variables (via yaml.safe_load), and creates the models by name (via importlib)services:
experiment-dt:
build: &experiment_build
context: .
dockerfile: Dockerfile
args:
PYTHON_VERSION: "3.14"
volumes: &volumes
- ./data:/data
# run as the host user, so that results in ./data are owned by them (cf. README)
user: &user "${HOST_UID:-1000}:${HOST_GID:-1000}"
# cap the resources of each experiment, so that parallel ones do not starve each other
deploy: &deploy
resources:
limits:
cpus: "1"
memory: 2G
hostname: dt
environment:
MODEL_DecisionTree: |
sklearn.tree.DecisionTreeClassifier:
max_depth: [null, 10, 20]
min_samples_split: [2, 10, 20]
experiment-mlp:
build: *experiment_build
volumes: *volumes
user: *user
deploy: *deploy
hostname: mlp
environment:
MODEL_MLP: |
sklearn.neural_network.MLPClassifier:
hidden_layer_sizes:
- [50]
- [100,]
- [50, 50]
activation: ['tanh', 'relu']environment (and hostname)&name) and aliases (*name) avoid repeating the shared parts
build, volumes, user, deployMODEL_*)
experiment.py via yaml.safe_loadshown in its final version (after step 7), full file here
docker compose up: builds the image (once), then runs all experiments in paralleldocker compose up experiment-svm: runs just one of themdocker compose logs -f experiment-mlp: follows the output of one experimentDockerfile
ARG PYTHON_VERSION=3.14
FROM python:${PYTHON_VERSION}
ENV DATA_DIR=/data
VOLUME "/data"
WORKDIR /experiment
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY experiment.py run.sh ./
# The container is meant to run as the host user (cf. compose.yaml),
# who has no home directory in the image: let libraries (e.g. openml,
# matplotlib) put their caches and configs in a writable place
ENV HOME=/tmp
CMD ["bash", "run.sh"]
run.sh
#!/usr/bin/env bash
# Runs the experiment, storing its outputs in a fresh sub-directory of $DATA_DIR
set -euo pipefail
export OUTPUT_DIR="$DATA_DIR/$(date +%Y-%m-%d-%H-%M-%S)-$(hostname)"
mkdir -p "$OUTPUT_DIR"
python experiment.py | tee "$OUTPUT_DIR/output.log"
# If the experiment was skipped (as already executed), there is nothing worth keeping
if ! compgen -G "$OUTPUT_DIR/*.yaml" > /dev/null; then
rm -r "$OUTPUT_DIR"
fi
run.sh:
hostnameset -o pipefail: failures of python are not masked by teeCMD in exec form, HOME=/tmp for the host user (cf. step 3) # cap the resources of each experiment, so that parallel ones do not starve each other
deploy: &deploy
resources:
limits:
cpus: "1"
memory: 2Gdeploy.resources.limits caps each container
cpus: "1": at most one CPU’s worth of timememory: 2G: the container is killed if it exceeds itGridSearchCV(n_jobs=...) should not exceed cpusdocker stats shows the actual usage of running containersservices:
experiment-svm:
image: <username>/containerized-experiment:1.0.0
hostname: svm
environment:
MODEL_SVM: |
sklearn.svm.SVC:
C: [0.1, 1, 10]
kernel: ['linear', 'rbf']
volumes:
- results:/data
deploy:
mode: replicated-job # run to completion
replicas: 1
restart_policy:
condition: none
resources:
limits: { cpus: "1", memory: 2G }
# ... other experiments ...
volumes:
results:
driver: local
driver_opts:
type: "nfs"
o: "addr=NFS_SERVER_ADDRESS,rw"
device: ":/path/to/results/"
build: the image must be pushed to a registry firstmode: replicated-job: tasks run until they complete (exit code 0), rather than foreverrestart_policy: none: failed experiments are not restarted in a loopdocker service ls reports progress, e.g. 0/1 (1/1 completed)Repository: https://github.com/unibo-spe/containerized-experiment
Select some experiment of your choice
Containerise it, in such a way that:
Bonus: build the image and run the experiments in CI (cf. Continuous Integration)
docker stack deploy), which keep the cluster state consistent via RaftDisclaimer: in this lecture, the Swarm cluster is made of Docker-in-Docker (DinD) containers, all running on your own machine
Each node is a docker:dind container, i.e. a full Docker daemon, with its own images, containers, volumes, and networks
master, worker-1, …)Why? DinD is the smoother way to obtain a reproducible and stable didactical environment:
Caveats:
master (the only manager) via DOCKER_HOST, your browser reaches the Swarm’s services via gatewayx-node: &node
image: docker:dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: "" # plain TCP on port 2375
services:
master:
<<: *node
hostname: master
ports:
- "127.0.0.1:2375:2375" # Docker API
worker-1:
<<: *node
hostname: worker-1
worker-2: ... # same as worker-1
worker-3: ... # same as worker-1
gateway:
image: alpine/socat
entrypoint: ["sh", "-c"]
command:
- |
for port in 9443 8889 8890; do
socat TCP-LISTEN:$$port,fork,reuseaddr \
TCP:master:$$port &
done
wait
ports:
- "9443:9443" # Portainer
- "8889:8889" # later examples
- "8890:8890"
(full file here)
A Compose file describing a cluster of 4 nodes: master + 3 workers
x-node is a YAML anchor, i.e. the settings shared by all nodes, merged into each service via <<: *nodehostname gives each node its symbolic name in the clustermaster publishes port 2375, i.e. its Docker API, so that the CLI on your machine can act as a client of the manager
127.0.0.1 only, as it is unauthenticated!gateway publishes the ports of the services deployed later on the Swarm
master (via socat)master directly? Docker Desktop’s port forwarding produces packets that the routing mesh of a nested daemon drops, when the service runs on another node
gateway opens fresh connections, which are handled correctlySave the file of the previous slide as compose.yaml, in an empty directory, and start the nodes:
docker compose up -d
docker compose logs master should say API listen on [::]:2375)Initialise Swarm mode on master, and let the workers join the cluster:
docker compose exec master docker swarm init
TOKEN=$(docker compose exec master docker swarm join-token -q worker)
for i in 1 2 3; do
docker compose exec worker-$i docker swarm join --token $TOKEN master:2377
done
Let your Docker CLI connect to master (rather than to your local daemon), in the current shell:
export DOCKER_HOST=tcp://localhost:2375 (Bash) or $env:DOCKER_HOST="tcp://localhost:2375" (PowerShell)docker commands (e.g. docker node ls, docker stack deploy) are executed by master:
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
h7kxqxjgxqnr5bc4zp4pxfsgc * master Ready Active Leader 29.9.2
9ig5gfqmiwslf637gzidl9y6d worker-1 Ready Active 29.9.2
mlipjs2czcoapauecp1haswlf worker-2 Ready Active 29.9.2
7or20sft5peegyexrb1m9yuax worker-3 Ready Active 29.9.2
Whenever done, destroy the cluster: unset DOCKER_HOST (or Remove-Item Env:DOCKER_HOST), then docker compose down -v
Not needed for this lecture (cf. DinD Swarm), but this is how a real cluster is built
On one node running the Docker daemon (say, the teacher’s machine), initialise Swarm mode as follows:
docker swarm initSwarm initialized: current node (<NODE_ID>) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token <SECRET_TOKEN> <NODE_ADDRESS>:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
On other nodes running the Docker daemon (say students’ machines), initialise Swarm mode as follows:
docker swarm join --token <SECRET_TOKEN> <NODE_ADDRESS>:2377
SECRET_TOKEN and NODE_ADDRESS are the ones provided by the docker swarm init command aboveOn the manager node, one may inspect the node composition of the cluster via docker node ls (sample output)
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
4q3q3q3q3q3q3q3q3q3q3q3q3q * node-1 Ready Active Leader 24.0.7
5q4q4q4q4q4q4q4q4q4q4q4q4q node-2 Ready Active 24.0.7
6q5q5q5q5q5q5q5q5q5q5q5q5q node-3 Ready Active 24.0.7
The manager node may promote other nodes to manager nodes via docker node promote <NODE_ID>…
docker node demote <NODE_ID>docker node rm <NODE_ID>Whenever done, one may leave the Swarm via docker swarm leave --force
There may be a firewall blocking the communication among nodes
docker swarm join hangs or fails with a timeout
2377/tcp, 7946/tcp, 7946/udp, 4789/udpYou use Docker on Windows or Mac, hence the Docker daemon runs on a virtual machine
NODE_ADDRESS in docker swarm join --token <SECRET_TOKEN> <NODE_ADDRESS>:2377 returned by docker swarm init is the IP of the virtual machineipconfig on Windows, ifconfig on Mac), let’s call it ACTUAL_IPACTUAL_IP towards NODE_ADDRESS
2377, 7946, 4789)ACTUAL_IP instead of NODE_ADDRESS in docker swarm join when joining the clusterdocker swarm init (NODE_ADDRESS) belongs to the Docker Desktop VM, which is not reachable from the LANACTUAL_IP), and the PC must forward Swarm traffic to the VM (cf. next slide)netsh is partial (TCP only): prefer Linux machines as Swarm nodes, or Docker Engine in WSL2 (cf. “Troubleshooting example on Windows (pt. 2)”)On an administrator Powershell, run the following commands:
netsh advfirewall firewall add rule name="Docker Swarm Intra-manager" dir=in action=allow protocol=TCP localport=2377
netsh advfirewall firewall add rule name="Docker Swarm Overlay Network Discovery TCP" dir=in action=allow protocol=TCP localport=7946
netsh advfirewall firewall add rule name="Docker Swarm Overlay Network Discovery UDP" dir=in action=allow protocol=UDP localport=7946
netsh advfirewall firewall add rule name="Docker Swarm Overlay Network Traffic" dir=in action=allow protocol=UDP localport=4789
On an administrator Powershell, run the following commands:
netsh interface portproxy add v4tov4 listenport=2377 listenaddress=$ACTUAL_IP connectport=2377 connectaddress=$NODE_ADDRESS
netsh interface portproxy add v4tov4 listenport=7946 listenaddress=$ACTUAL_IP connectport=7946 connectaddress=$NODE_ADDRESS
portproxy only forwards TCP: UDP traffic (7946/udp gossip, 4789/udp overlay data) cannot be forwarded this way (cf. next slide)
this operation should be done on the very first manager node of the cluster, if it’s running on Windows
netsh cannot forward UDP: rather than forwarding, let the engine use the PC’s own network interfaces
Enable mirrored networking for WSL2 (Windows 11 22H2 or later), in %UserProfile%\.wslconfig:
[wsl2]
networkingMode=mirrored
then restart WSL via wsl --shutdown
Allow inbound traffic towards WSL2 through the Hyper-V firewall (administrator PowerShell):
Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow
Install Docker Engine inside a WSL2 distribution (e.g. Ubuntu), and use it instead of Docker Desktop
From that distribution, use the PC’s address when creating / joining the Swarm, for both TCP and UDP ports:
docker swarm init --advertise-addr $ACTUAL_IP, ordocker swarm join --advertise-addr $ACTUAL_IP --token <SECRET_TOKEN> <MANAGER_ADDRESS>:2377On macOS, the analogous solution is a Linux VM with a bridged network adapter, running Docker Engine
One may deploy a stack on a Swarm cluster via docker stack deploy -c <COMPOSE_FILE_PATH> <STACK_NAME>
COMPOSE_FILE_PATH is the path to a .yml file following the Docker Compose syntaxSTACK_NAME is the name of the stack to be deployedThe command must be run on a client connected to some manager node
Other operations one may do on stacks (via a manager node):
docker stack ls to list stacksdocker stack ps <STACK_NAME> to list containers in a stackdocker stack services <STACK_NAME> to list services in a stackdocker stack rm <STACK_NAME> to remove a stackSemantics of stack deployment on Swarms is different from Docker Compose:
overlay driver, to support inter-node communication among containersdepends_on, restart, build), others only work in Swarm (e.g. deploy)services:
agent:
image: portainer/agent:lts
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/volumes:/var/lib/docker/volumes
networks:
- agents_network
deploy:
mode: global # i.e., one (and only one) replica per node
webservice:
image: portainer/portainer-ce:lts
command: -H tcp://tasks.agent:9001 --tlsskipverify
ports:
- "9443:9443"
volumes:
- portainer_data:/data
networks:
- agents_network
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.role == manager
networks:
agents_network:
driver: overlay
attachable: true
volumes:
portainer_data:
Portainer is a Web-based dashboard for cluster administration
It is composed of two services:
webservice, the actual dashboardagent, a service running on each node, which exposes the Docker API to the webservice serviceLet’s save the code on the left in a file named portainer.yml
Let’s deploy the stack on the DinD Swarm (i.e. with DOCKER_HOST pointing to master) via docker stack deploy -c portainer.yml portainer
Let’s then browse to https://localhost:9443 (via gateway)
deploy.mode: global $\rightarrow$ one agent per node, placement.constraints $\rightarrow$ webservice on the only manager (master)
let’s use software-process-engineering as the password for the admin user
the setup token is printed in the logs of the dashboard:
docker service logs portainer_webservice | grep setup_token
this page must be filled within 5 minutes from the deployment, otherwise Portainer locks itself
docker service update --force portainer_webserviceupon creation of the user, Portainer proposes to set up Edge Compute: let’s just skip it
Upon login, you are presented with the home page, which asks you to select one environment
Upon environment selection, one is presented with the dashboard, showing the status of the cluster
After selecting the only environment available, one may observe:
which and how many nodes compose the cluster (Dashboard section)
master, worker-1, worker-2, and worker-3which stacks are running on the cluster (Services section)
portainer stack is runningwhich services are running on the cluster (Services section)
portainer stack is composed of two services: agent and webservicewhich containers are running on the cluster (Containers section)
portainer_agent service is composed of one container per node (i.e. 4 containers)portainer_webservice service is composed of one container which is running on master (the only manager)which volumes are present on the cluster (Volumes section)
portainer_portainer_data volume is present on the same node of portainer_webservice, i.e. masterwhich networks are present on the cluster (Networks section)
portainer_agents_network network is present, using the overlay driverNodes may be labelled with
key=valuepairs
Labels may be used to mark nodes with respect to their properties
capabilities.cpu=yescapabilities.web=yescapabilities.storage=yesThis can be done programmatically….
docker node update --label-add KEY=VALUE NODE (where NODE is the node’s ID or hostname)for i in 1 2 3; do docker node update --label-add capabilities.cpu=yes worker-$i; done
… or via Portainer:
labels can be used to set placement constraints or preferences for services:
Full stack: wordpress-swarm.yaml (continued in the right column).
version: "3.8"
services:
db:
image: mariadb:latest
volumes:
- db_data:/var/lib/mysql
networks:
- back-tier
environment:
MYSQL_ROOT_PASSWORD: "..Password1.."
MYSQL_DATABASE: wordpress_db
MYSQL_USER: wordpress_user
MYSQL_PASSWORD: ",,Password2,,"
healthcheck:
test: ["CMD", "healthcheck.sh",
"--su-mysql", "--connect",
"--innodb_initialized"]
start_period: 1m
interval: 1m
timeout: 5s
retries: 3
deploy:
restart_policy:
condition: any
placement:
constraints:
- node.labels.capabilities.storage==yes
wordpress:
image: wordpress:latest
volumes:
- wordpress_data:/var/www/html
ports:
- "8889:80"
networks:
- back-tier
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress_user
WORDPRESS_DB_PASSWORD: ",,Password2,,"
WORDPRESS_DB_NAME: wordpress_db
deploy:
restart_policy:
condition: any
placement:
constraints:
- node.labels.capabilities.web==yes
volumes:
db_data: {}
wordpress_data: {}
networks:
back-tier:
driver: overlay
Let’s define a stack for Wordpress, with placement constraints
db service on a node with storage capabilitieswordpress service on a node with web capabilitiesFull file: wordpress-swarm.yaml
docker stack deploy -c wordpress-swarm.yaml wordpressdepends_on is ignored by SwarmE.g., on the DinD Swarm (nodes master, worker-N):
worker-1 has storage capabilities (docker node update --label-add capabilities.storage=yes worker-1)worker-2 has web capabilities (docker node update --label-add capabilities.web=yes worker-2)The deployment is as follows:
When a service exposes a port, and that’s deployed on a Swarm:
the service is exposed on all nodes in the Swarm (managers and workers)
so, the wordpress service above could be visited via any node of the cluster, e.g.:
http://master:8889 (i.e. http://localhost:8889 on your machine, via gateway)http://worker-1:8889http://worker-2:8889 (the node actually hosting the service)Services may be replicated for the sake of scalability / load-balancing / fault-tolerance, etc.
Two types of services:
global: replicated exactly once per nodereplicated: replicated N times (with N=1 by default)General case:
services:
service-name:
...
deploy:
mode: replicated
replicas: N # this should be a number!
placement:
constraints:
- node.role == manager
- node.labels.mylabel==myvalue
preferences:
- spread: node.labels.mylabel
max_replicas_per_node: M # this should be a number!
How to read it:
N replicas of the service will be deployedmylabel label set to myvalueM replicas will be put on the same nodeglobal services follow the cluster: when a node joins, it gets a replica; when it leaves, its replica is gonereplicated services keep exactly N replicas alive, re-scheduling them when a node fails (if constraints allow)services:
ws:
image: pikalab/hitcounter:latest
ports:
- "8890:8080"
deploy:
replicas: 10
placement:
constraints:
- node.labels.capabilities.cpu==yes
preferences:
- spread: node.labels.capabilities.cpu
Try to query it multiple times:
while true; do
curl http://localhost:8890; # published by master
echo
sleep 1
done
expected outcome:
[9a6f89a39f8fd379@a3838ab6a606:8080] Hit 1 times
[26c39b9f3d7f0cab@4ad25c3e148c:8080] Hit 1 times
[97945332d20e71ed@969586109dee:8080] Hit 1 times
[3b3256852103312a@5d3479044fac:8080] Hit 2 times
[49e75aaae823c1e4@36e31b946486:8080] Hit 2 times
[497583c21d17ad58@7c10a84858f3:8080] Hit 2 times
[cf82689c910dfdf3@8aaa34535824:8080] Hit 2 times
[9a456666c413f4a4@5ba876916f68:8080] Hit 2 times
[41cadc9e0d7b0225@6cf54f65cda6:8080] Hit 2 times
[84dbbff08ac91e74@4006352af1e0:8080] Hit 2 times
[9a6f89a39f8fd379@a3838ab6a606:8080] Hit 2 times
[26c39b9f3d7f0cab@4ad25c3e148c:8080] Hit 2 times
[97945332d20e71ed@969586109dee:8080] Hit 2 times
[3b3256852103312a@5d3479044fac:8080] Hit 3 times
[49e75aaae823c1e4@36e31b946486:8080] Hit 3 times
...
Several replicas of the same service are created, spread over the CPU-capable nodes (i.e. the 3 workers):

Volumes are always local w.r.t. the node the current container is deployed onto
services:
simulation:
image: pikalab/fakesim:latest
deploy:
restart_policy:
condition: on-failure # restart is ignored by Swarm
replicas: 3
placement:
constraints:
- node.labels.capabilities.cpu==yes
max_replicas_per_node: 1
volumes:
- type: volume
source: shared_volume
target: /data
volumes:
shared_volume: {}
Several replicas of the simulation are created…

… as well as several volumes!

How to share data? (cf. next slide)
Since volumes are local to nodes, replicas on different nodes do not see each other’s data
To share data, volumes must be backed by some network file system reachable from all nodes
services:
simulation:
...
volumes:
- type: volume
source: shared_volume
target: /data
volumes:
shared_volume:
driver: local
driver_opts:
type: "nfs"
o: "addr=NFS_SERVER_ADDRESS,nolock,soft,rw"
device: ":/path/on/the/nfs/server/"
Each node still creates its own local volume, but all of them point to the same remote folder
Compiled on: 2026-11-07 — printable version