Les Tutos Docker – Episode 2

BIENVENUE DANS CE 2ÈME ÉPISODE DES TUTOS DOCKER !

Dans ce tuto nous allons plonger dans les arcanes d’un cluster de containers.

MAIS POURQUOI UN CLUSTER ?

Tandis que le docker engine est fait pour lancer des applications dans des containers, il lui manque de sérieuses fonctionnalités pour aller en production. La principale est son exécution sur une seule machine, pas de mode distribué, le container est attaché à un seul nœud !

La seconde est sa fragilité puisqu’il ne peut pas compter sur une haute disponibilité de son infrastructure. En résumé, si on perd le nœud exécutant les containers, on perd tout !

LA SOLUTION ?

Avec les versions égales et postérieures du docker engine version 1.12, le gestionnaire de containers en mode cluster (appelé Orchestrateur) est directement embarqué avec le moteur, pas d’installation supplémentaire ????

En utilisant le mode Swarm, un cluster de différentes machines (distributed nodes) vont fonctionner dans un schéma “master / worker” que vous connaissez dans d’autres technologies (MongoDB, Hadoop, ElasticSearch ..).

L’ARCHITECTURE ?

Le Docker Swarm est un cluster de machines hébergeant docker Engine, connecté par un réseau Overlay qui va gérer le service de découverte..

Un cluster peut avoir un ou plusieurs managers mais aussi un ou plusieurs workers (quoique un cluster de 1 nœud n’a pas vraiment de sens).

Attention, les nœuds workers ne peuvent pas visualiser ou modifier la configuration du cluster, seuls les managers peuvent le faire.

Lorsque les nœuds Docker ne fonctionnent pas dans un cluster Swarm, on dit qu’ils sont dans un “single-engine mode”.

Dès qu’ils sont intégrés dans un cluster ils fonctionnent en “swarm mode”.

Dans un cluster Swarm nous ne parlons pas vraiment de container mais plutôt de Service.

Les services prenant en charge les containers (appelés “replica” ou “task”) vont être déployés à travers les nœuds du cluster, pour cela Swarm utilise un load balancer embarqué pour gérer la répartition de charge.

Les états attendus des différents services (running, stopped …) vont être gérés par le manager qui va jouer un vrai role de chef d’orchestre (d’ou le nom… ).

Toutes les opérations de scaling, rolling upgrades ou rollbacks se feront sur les services et non pas les containers !

La configuration et l’état de Swarm sont maintenu dans une database distribuée (etcd) stockée sur chaque Manager.

Attention ce n’est pas le manager qui télécharge et lance les containers mais bien les workers qui respectent les ordres du manager (comme dans une team).

Je ne rentrerai pas dans les détails dans ce tutoriel la notion de Raft Consensus et du Quorum qui maintient la haute disponibilité du cluster mais si vous souhaitez plus d’info, c’est dans la documentation de docker. Pour ne retenir qu’un seul conseil au sujet de la HA, je vous conseille très fortement trois ou cinq managers dans votre cluster.

ET LA SÉCURITÉ ?

Ce qui est vraiment appréciable avec Docker Swarm c’est l’automatisation de la sécurité. En effet, “out-of-the-box” vous avez une appli. qui gère automatiquement le protocole TLS pour l’encryption des communication et la gestion des nœuds autorisés ainsi que la gestion des tokens.

Ports ?

L’ouverture des ports doit être effectuée sur :

  • tcp:2377 (pour les clients)
  • tcp:7946 et udp:7946 (gossip protocole)
  • udp:4789 (pour la partie network)

Nous allons monter notre environnement Swarm pour expérimenter ce type de cluster.

Pour cela utilisons docker-machine qui se prête très bien à ça

#En bash vous pouvez le faire en une seule commande:
#for machine in master node1 node2; do docker-machine create -d virtualbox --virtualbox-memory "512" ${machine}; done
C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine create -d virtualbox --virtualbox-memory "512" master
Running pre-create checks...
(master) Default Boot2Docker ISO is out-of-date, downloading the latest release...
(master) Latest release for github.com/boot2docker/boot2docker is v18.02.0-ce
(master) Downloading C:\Users\eduquesnoy.DESKTOP-1QNJFRR\.docker\machine\cache\boot2docker.iso from https://github.com/boot2docker/boot2docker/releases/download/v18.02.0-ce/boot2docker.iso...
(master) 0%....10%....20%....30%....40%....50%....60%....70%....80%....90%....100%
Creating machine...
(master) Copying C:\Users\eduquesnoy.DESKTOP-1QNJFRR\.docker\machine\cache\boot2docker.iso to C:\Users\eduquesnoy.DESKTOP-1QNJFRR\.docker\machine\machines\master\boot2docker.iso...
(master) Creating VirtualBox VM...
(master) Creating SSH key...
(master) Starting the VM...
(master) Check network to re-create if needed...
(master) Windows might ask for the permission to configure a dhcp server. Sometimes, such confirmation window is minimized in the taskbar.
(master) Waiting for an IP...
Waiting for machine to be running, this may take a few minutes...
Detecting operating system of created instance...
Waiting for SSH to be available...
Detecting the provisioner...
Provisioning with boot2docker...
Copying certs to the local machine directory...
Copying certs to the remote machine...
Setting Docker configuration on the remote daemon...
Checking connection to Docker...
Docker is up and running!
To see how to connect your Docker Client to the Docker Engine running on this virtual machine, run: C:\Program Files\Docker\Docker\Resources\bin\docker-machine.exe env master
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine create -d virtualbox --virtualbox-memory "512" node1
...
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine create -d virtualbox --virtualbox-memory "512" node2
...
#Voila l'infrastrure est en place !

Remarque : Vous avez vu que la commande pour rejoindre un cluster en tant que manager ou worker est quasi-identique ?
C’est le Token qui différencie le role du nouveau nœud dans le cluster !


#Configurons le master et les workers maintenant
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine ip master
192.168.99.100
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine ssh master
## .
## ## ## ==
## ## ## ## ## ===
/"""""""""""""""""\___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\_______/
_ _ ____ _ _
| |__ ___ ___ | |_|___ \ __| | ___ ___| | _____ _ __
| '_ \ / _ \ / _ \| __| __) / _` |/ _ \ / __| |/ / _ \ '__|
| |_) | (_) | (_) | |_ / __/ (_| | (_) | (__| < __/ |
|_.__/ \___/ \___/ \__|_____\__,_|\___/ \___|_|\_\___|_|
Boot2Docker version 18.02.0-ce, build HEAD : 99245f4 - Thu Feb 8 17:43:39 UTC 2018
Docker version 18.02.0-ce, build fc4de44
docker@master:~$ docker swarm init --advertise-addr 192.168.99.100
Swarm initialized: current node (omv2dqtaj2t13brays02j8ock) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-3limxq362vpmv7w7mnq5b511s 192.168.99.100:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
docker@master:~$ exit
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine ssh node1
## .
## ## ## ==
## ## ## ## ## ===
/"""""""""""""""""\___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\_______/
_ _ ____ _ _
| |__ ___ ___ | |_|___ \ __| | ___ ___| | _____ _ __
| '_ \ / _ \ / _ \| __| __) / _` |/ _ \ / __| |/ / _ \ '__|
| |_) | (_) | (_) | |_ / __/ (_| | (_) | (__| < __/ |
|_.__/ \___/ \___/ \__|_____\__,_|\___/ \___|_|\_\___|_|
Boot2Docker version 18.02.0-ce, build HEAD : 99245f4 - Thu Feb 8 17:43:39 UTC 2018
Docker version 18.02.0-ce, build fc4de44
docker@node1:~$ docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-3limxq362vpmv7w7mn
q5b511s 192.168.99.100:2377
This node joined a swarm as a worker.
docker@node1:~$ exit
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine ssh node2
## .
## ## ## ==
## ## ## ## ## ===
/"""""""""""""""""\___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\_______/
_ _ ____ _ _
| |__ ___ ___ | |_|___ \ __| | ___ ___| | _____ _ __
| '_ \ / _ \ / _ \| __| __) / _` |/ _ \ / __| |/ / _ \ '__|
| |_) | (_) | (_) | |_ / __/ (_| | (_) | (__| < __/ |
|_.__/ \___/ \___/ \__|_____\__,_|\___/ \___|_|\_\___|_|
Boot2Docker version 18.02.0-ce, build HEAD : 99245f4 - Thu Feb 8 17:43:39 UTC 2018
Docker version 18.02.0-ce, build fc4de44
docker@node2:~$ docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-3limxq362vpmv7w7mnq5b511s 192.168.99.100:2377
This node joined a swarm as a worker.
docker@node2:~$ exit
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker-machine ssh master
## .
## ## ## ==
## ## ## ## ## ===
/"""""""""""""""""\___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\_______/
_ _ ____ _ _
| |__ ___ ___ | |_|___ \ __| | ___ ___| | _____ _ __
| '_ \ / _ \ / _ \| __| __) / _` |/ _ \ / __| |/ / _ \ '__|
| |_) | (_) | (_) | |_ / __/ (_| | (_) | (__| < __/ |
|_.__/ \___/ \___/ \__|_____\__,_|\___/ \___|_|\_\___|_|
Boot2Docker version 18.02.0-ce, build HEAD : 99245f4 - Thu Feb 8 17:43:39 UTC 2018
Docker version 18.02.0-ce, build fc4de44
docker@master:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
omv2dqtaj2t13brays02j8ock * master Ready Active Leader
jvqa0f8tha6t79cbtmw2wsygl node1 Ready Active
yybipmppfyixuh497voo5e3qv node2 Ready Active
docker@master:~$

Comme nous pouvons le voir dans la dernière commande nous avons un leader (manager) sur notre instance master et 2 workers sur les nodes 1 et 2.

Nous pouvons commencer à travailler avec les services maintenant !

les options “–advertise-addr” et “–listen-addr” sont optionnelles mais recommandées. Elles contiennent l’IP du nouveau nœud dans le cluster.

Exemple pour la dernière commande :

docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-3limxq362vpmv7w7mnq5b511s 192.168.99.100:2377 --advertise-addr 192.168.99.102 --listen-addr 192.168.99.102
# les 3 commandes fondamentales pour gérer un cluster Swarm :
$ docker swarm # Permet la gestion du cluster
$ docker service # Permet la gestion des conteneurs
$ docker node # Permet la gestion des Nodes

L’ARCHITECTURE DU CLUSTER

Lorsque le cluster possède plusieurs managers , un seul (le leader) gère la configuration du cluster et les autres (followers, status reachable) sont disponibles pour remplacer le leader en cas de défaillance.

ALLER PLUS LOIN DANS LA SÉCURITÉ DU CLUSTER

Il faut savoir qu’une restauration d’un vieux nœud manager (avec les certificats valides) peut mettre en péril la sécurité de votre serveur.

Le nouveau nœud aura accès à la database utilisé par le protocole Raft et aura donc accès à tout votre cluster.

Pour votre prémunir d’une telle situation, Docker vous permet de “locker” votre cluster pendant son fonctionnement.

# sécuriser un swarm cluster au moment de son "initialisation" => option --autolock
# si le cluster est déjà "running", un update est obligatoire :
docker swarm update --autolock=true
Swarm updated.
To unlock a swarm manager after it restarts, run the `docker swarm unlock`
command and provide the following key:
SWMKEY-1- MrE8NgAyKj5r3NcR4FiQMdgu 7W72urH0EZeSmP/0Y
Please remember to store this key in a password manager, since without it you
will not be able to restart the manager.
#relancer un noeud manager pour tester la sécurité maintenant:
service docker restart
# puis tenter une commande Swarm
$ sudo service docker restart
$ docker service ls
Error response from daemon: Swarm is encrypted and needs to be unlocked before it can be used. Use "docker swarm unlock" to unlock it.
# solution pour débloquer:
$ docker swarm unlock
Please enter unlock key => fournir la précédente clé générée
# maintenant vous allez pouvoir effectuer toutes les commandes dans le cluster.

COMMENT CRÉER UN SERVICE ?

La syntaxe est assez proche du docker run mais avec quelques options en plus …

# Commençons par la création d'un service nginx avec une seule réplica
docker service create --replicas 1 --name web -p 80:8080 nginx:mainline
web.1 nginx:mainline node1 Running Running 12 seconds ago
#Puis multiplions les replicas par 5
docker service scale web=5
web scaled to 5
#Vérifions la configuration du service web
docker@master:~$ docker service ls => liste tous les services du cluster
ID NAME REPLICAS IMAGE COMMAND
cwrfie1hxn8g web 5/5 nginx:mainline
# l’état désiré du service web est bien égal à son état actuel.
# et finalement son implémentation sur nos 3 nœuds (1 master et 2 workers)
docker@master:~$ docker service ps web => détaille le service web au niveau de son implementation
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR
7b2osbdqc3sg84mixgzxip1fk web.1 nginx:mainline node1 Running Running 5 minutes ago
cwpw2g0mw5gqnds0mvdem5gyx web.2 nginx:mainline master Running Running 26 seconds ago
2y4mhbc6liaohkls5io76vwbu web.3 nginx:mainline node2 Running Running 26 seconds ago
f01kh9kn8pprj528mnl3xnzj1 web.4 nginx:mainline node2 Running Running 26 seconds ago
avn8ukb1jt9zd7ct462h07e0l web.5 nginx:mainline node1 Running Running 26 seconds ago
#Nous pouvons voir que Swarm essaye de répartir "au mieux" les 5 replicas du service web.
#pour aller plus loin dans la configuration du service web nous pouvons faire une "inspection":
docker service inspect --pretty web (je conseille l'option pretty pour la visibilité du output)
ID: cwrfie1hxn8g
Name: Service web
...

Tous les services sont constamment monitorés par Swarm, il compare en permanence l’état désiré avec l’état actuel.

Si il y a un écart, Swarm entreprend les actions correctrices dans les plus brefs délais.

DIFFÉRENCE ENTRE LES SERVICES GLOBAUX ET LES SERVICES RÉPLIQUÉS

La différence est très simple à comprendre :

Lors de l’utilisation des services distribués vous spécifiez exactement le nombre de réplicas que vous souhaitez.

Swarm essaye de gérer au mieux votre demande (nous verrons les contraintes plus tard) sur tous les nœuds disponibles.

Quant aux services globaux, c’est bien plus simple : un seul réplica sur chaque nœud , point final !


 

# Pour configurer un service comme global il faut ajouter l'option --mode global, exemple :
$ docker service create \
--mode global \
--publish mode=host,target=80,published=8080 \
--name=nginx \
nginx:latest

COMMENT SUPPRIMER UN SERVICE ?

#super simple de supprimer un service du cluster :
docker@master:~$ docker service rm web
web
docker@master:~$ docker service ps web
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR

LA MISE À JOUR DES SERVICES OU LE FAMEUX ROLLING UPDATE

Imaginons que nous avons développé un microservice en version 1 et que nous avons lancé 12 replicas sur 6 nœuds (mgr1→3 , wrk1→ 3).

Après un moment en production, nous souhaitons mettre à jour ce microservice en version 2 sans interruption de service.

Pour cela nous allons utiliser la fonctionnalité Rolling Update de Docker Swarm :

# creation du service initial ms-svc en version 1
# le port 80 est exposé sur tous les noeuds et un réseau smart_ms est dédié au service.
# le service sur le port 80 sera joinable de partout car le service tourne en mode ingress
# Même les noeuds sans replicas pourront "rerouter" les demandes au service ms-svc sur les bons noeuds.
# le nom du service est ms-svc basé sur la version 1 de l'image microservice
docker service create \
--name ms-svc \
--replicas 12
--publish 80:80 \
--network smart_ms
smartwave/microservice:v1
$ docker service ls
ID NAME REPLICAS IMAGE
dhbtgvqrg2q4 ms-svc 12/12 smartwave/microservices:v1
# comme vous pouvez le voir le nombre de réplicas est 12 !
# Détaillons la répartition aec la commande docker service ps:
$ docker service ps ms-svc
ID NAME IMAGE NODE DESIRED CURRENT STATE
0v...7e5 ms-svc.1 smartwave/...:v1 wrk3 Running Running 1 min
bh...wa0 ms-svc.2 smartwave/...:v1 wrk2 Running Running 1 min
23...u97 ms-svc.3 smartwave/...:v1 wrk2 Running Running 1 min
82...5y1 ms-svc.4 smartwave/...:v1 mgr2 Running Running 1 min
c3...gny ms-svc.5 smartwave/...:v1 wrk3 Running Running 1 min
e6...3u0 ms-svc.6 smartwave/...:v1 wrk1 Running Running 1 min
78...r7z ms-svc.7 smartwave/...:v1 wrk1 Running Running 1 min
2m...kdz ms-svc.8 smartwave/...:v1 mgr3 Running Running 1 min
b9...k7w ms-svc.9 smartwave/...:v1 mgr3 Running Running 1 min
ag...v16 ms-svc.10 smartwave/...:v1 mgr2 Running Running 1 min
e6...dfk ms-svc.11 smartwave/...:v1 mgr1 Running Running 1 min
e2...k1j ms-svc.12 smartwave/...:v1 mgr1 Running Running 1 min
# Effectivement les containers sont répartis sur tous les noeuds possibles !
# Nous allons maintenant "updater" notre service avec l'image microservice version 2

docker service update \
--image smartwave/microservices:v2 \
--update-parallelism 2 \
--update-delay 20s ms-svc
# Comme vous pouvez le voir, l'update des replicas doit se faire 2 par 2 et avec 20s d'interval entre les updates
# Regardons l'operation "en live" grace à la même commadne docker service ps
$ docker service ps ms-svc
ID NAME IMAGE NODE DESIRED CURRENT STATE
7z...nys ms-svc.1 smartw...v2 mgr2 Running 14 secs
0v...7e5 \_ms-svc.1 smartw...v1 wrk3 Shutdown 14 secs
bh...wa0 ms-svc.2 smartw...v1 wrk2 Running 1 min
e3...gr2 ms-svc.3 smartw...v2 wrk2 Running 13 secs
23...u97 \_ms-svc.3 smartw...v1 wrk2 Shutdown 13 secs
82...5y1 ms-svc.4 smartw...v1 mgr2 Running 1 min
c3...gny ms-svc.5 smartw...v1 wrk3 Running 1 min
e6...3u0 ms-svc.6 smartw...v1 wrk1 Running 1 min
78...r7z ms-svc.7 smartw...v1 wrk1 Running 1 min
2m...kdz ms-svc.8 smartw...v1 mgr3 Running 1 min
b9...k7w ms-svc.9 smartw...v1 mgr3 Running 1 min
ag...v16 ms-svc.10 smartw...v1 mgr2 Running 1 min
e6...dfk ms-svc.11 smartw...v1 mgr1 Running 1 min
e2...k1j ms-svc.12 smartw...v1 mgr1 Running 1 min
# Effectivement 2 réplicas en v2 viennent d'être lancés sur le mgr2 et wkr2,
# tandis que que 2 autres en v1 sur les mêmes noeuds viennent de s'arreter (status "shutdown").
# la migration de v1 vers v2 se déroule correctement !

COMMENT GÉRER LES LOGS ?

Les événements du service Swarm pourront être visualisés avec la commande “docker service logs”.

Par défaut, le log driver “json-file” sera utilisé, mais il existe d’autres drivers compatibles avec Swarm :

  • gelf
  • splunk
  • graylog
  • syslog
  • jounald

Pour modifier le log-driver, il suffit de le spécifier dans le fichier daemon.json situé sur le docker host.

Ou pour les cas particuliers le forcer avec les options –log-driver et –log-opts dans la commande “docker service create”.

Vous avez plusieurs options pour “docker services logs” : –follow (pour le suivi du log) , –tail ou –details (facile à comprendre).

$ docker service create -d --name ping --replicas=3 alpine:latest ping 8.8.8.8
5x3enwyyr1re3hg1u2nogs40z
# Nous avons créer un service de 3 replicas qui "ping" sur l'adresse 8.8.8.8
# vérifions les logs de ses 3 replicas avec une seule commande !
$ docker service logs ping
ping.2.n0bg40kksu8e@m00 | 64 bytes from 8.8.8.8: seq=43 ttl=43 time=24.791 ms
ping.3.pofxdol20p51@w01 | 64 bytes from 8.8.8.8: seq=44 ttl=43 time=34.161 ms
ping.1.o07dvxfx2ou2@w00 | 64 bytes from 8.8.8.8: seq=44 ttl=43 time=30.111 ms
ping.2.n0bg40kksu8e@m00 | 64 bytes from 8.8.8.8: seq=44 ttl=43 time=25.276 ms
ping.3.pofxdol20p51@w01 | 64 bytes from 8.8.8.8: seq=45 ttl=43 time=24.239 ms
ping.1.o07dvxfx2ou2@w00 | 64 bytes from 8.8.8.8: seq=45 ttl=43 time=26.403 ms
# Great ! facile à monitorer 🙂

LA SÉCURITÉ DANS UN CLUSTER SWARM

La rotation des certificats entre les différents nœuds est automatique dans Swarm et s’effectue tous les 90 jours.

Les certificats maintiennent l’encryption des communications entre les nœuds et donc leur confidentialité.

Le manager ou le leader dans le cas ou nous avons plusieurs managers sera considéré comme “le root CA”.

Si vous suspectez que les certificats de un ou plusieurs de vos noeuds ont été compromis vous pouvez les révoquer très simplement.

Et ainsi en produire de nouveau pour sécuriser l’ensemble des noeuds.

Voici la procédure :

# Effectuons le changement de certificats pour les managers:
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker swarm join-token --rotate manager
Successfully rotated manager join token.
To add a manager to this swarm, run the following command:
docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-0wvx82se9fc72q2vhesuyvke3 192.168.99.100:2377
# Effectuons maintenant le changement de certificats pour les workers:
PS C:\Users\eduquesnoy.DESKTOP-1QNJFRR> docker swarm join-token --rotate worker
Successfully rotated worker join token.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-27ntq8roo5zoxfpqrjddvl85byjuln9tm2xj5w7fz3ymodi645-496wk05wdqcio1szrtd1dhi1a 192.168.99.100:2377

Si vous pensez que 90 jours est une période bien trop longue, vous pouvez modifier la date d’expiration avec une commande docker Swarm :

	
$ docker swarm update --cert-expiry 720h ====> 1 mois

Read also

-

Git : quelle stratégie de branching ?

Git est devenu le standard pour la gestion de code …

-

DevSecOps, ou comment placer la sécurité dans une approche DevOps ?

La sécurité fait partie intégrante des problématiques de conception et …

-

Docker Hands-on Lab Report

On the 11th of December 2018 SmartWave hosted its first …