Aller au contenu
Relier et exposer : les réseaux Docker

Relier et exposer : les réseaux Docker

200 · Pratiquer ⏱ 1 h 05 dockerlinuxiptables

À la fin, vous saurez

  • Relier une application à sa base de données par un réseau défini par l'utilisateur et son DNS interne
  • Expliquer le chemin d'un paquet : veth, pont, NAT sortant, DNAT entrant
  • Publier un port sur la bonne interface et vérifier ce qui écoute réellement
  • Démontrer et corriger le contournement d'UFW par les ports publiés
  • Isoler un service avec un réseau interne ou sans réseau

Prérequis

Testé avec docker 29.8.1 ubuntu 24.04 ufw 0.36 , vérifié le 1 octobre 2026

Pourquoi

Notre application Signalements a besoin de joindre PostgreSQL, et les habitants ont besoin de joindre l'application. Deux questions en apparence simples, qui cachent la partie de Docker la plus riche en incidents :

  • une base de données « exposée sur Internet » alors que le pare-feu UFW du serveur l'interdisait, découverte par un scan automatisé le surlendemain ;
  • une application qui ne trouve plus sa base après un redémarrage, parce qu'elle s'y connectait par une adresse IP qui a changé ;
  • un conteneur qui n'a « pas d'Internet » sur un serveur, et un autre qui en a alors qu'il ne devrait pas.

Ces trois situations ont la même origine : on utilise le réseau Docker sans savoir ce qu'il fait au réseau de l'hôte. La leçon 2 a montré qu'un namespace réseau neuf ne contient rien. Voyons ce que Docker y met, et ce qu'il change autour.

Les concepts

Les pilotes de réseau

Docker range les conteneurs dans des réseaux, chacun géré par un pilote :

PiloteCe qu'il faitUsage
bridgeUn pont virtuel sur l'hôte, un sous-réseau privé, du NAT vers l'extérieurCas général sur un hôte
hostPas d'isolation réseau : le conteneur utilise la pile de l'hôtePerformances extrêmes, outils réseau
noneSeulement l'interface de boucle localeTraitements sans réseau
overlayUn réseau réparti sur plusieurs hôtes (Swarm)Orchestration Docker Swarm
macvlan, ipvlanLe conteneur reçoit une adresse sur le réseau physiqueCas particuliers (équipements, réseaux existants)

Le pilote bridge couvre l'essentiel des besoins, mais il existe en deux saveurs qui ne se comportent pas pareil :

  • le réseau bridge par défaut (le pont docker0), sur lequel arrivent les conteneurs lancés sans --network. Il ne fournit pas de résolution de noms entre conteneurs ;
  • les réseaux définis par l'utilisateur (docker network create), qui fournissent un DNS interne : chaque conteneur y est joignable par son nom. Ils isolent aussi les conteneurs d'un réseau de ceux des autres réseaux.

La recommandation de Docker est sans ambiguïté : n'utilisez pas le réseau par défaut pour vos applications. Créez un réseau par application.

Le chemin d'un paquet

    flowchart LR
  subgraph app["Conteneur app"]
    E1["eth0<br/>172.20.0.3"]
  end
  subgraph pg["Conteneur pg"]
    E2["eth0<br/>172.20.0.2"]
  end
  subgraph Hôte
    V1["veth...a"] --- BR["Pont br-17df289db2cd<br/>172.20.0.1"]
    V2["veth...b"] --- BR
    BR -- "NAT sortant<br/>(MASQUERADE)" --> ETH["Interface physique"]
    ETH -- "DNAT entrant<br/>(port publié)" --> BR
  end
  E1 === V1
  E2 === V2
  ETH --- NET(("Réseau"))
  
  • Chaque conteneur reçoit une paire d'interfaces virtuelles veth, comme les deux bouts d'un câble : eth0 dans le namespace du conteneur, l'autre bout sur l'hôte, branché à un pont Linux (un commutateur logiciel) propre au réseau.
  • Entre conteneurs du même réseau, les paquets passent par le pont, sans jamais sortir de l'hôte.
  • Vers l'extérieur, le pont sert de passerelle ; une règle de NAT sortant (MASQUERADE) remplace l'adresse privée du conteneur par celle de l'hôte. Cela exige que l'hôte fasse du routage (net.ipv4.ip_forward = 1, activé par Docker).
  • De l'extérieur vers un conteneur, rien ne passe, sauf les ports publiés : une règle de DNAT réécrit la destination hôte:8080 en conteneur:80.

Publier n'est pas exposer

Deux notions se confondent souvent :

  • EXPOSE 8000 dans un Dockerfile documente le port d'écoute. Il n'ouvre rien.
  • -p (--publish) au lancement publie un port du conteneur sur l'hôte. Sa forme complète est -p [adresse_hôte:]port_hôte:port_conteneur[/protocole]. Sans adresse, Docker écoute sur toutes les interfaces de l'hôte, en IPv4 et en IPv6.

Entre conteneurs d'un même réseau, aucune publication n'est nécessaire : tous les ports sont joignables directement. On ne publie que ce qui doit être atteint depuis l'extérieur de Docker.

En pratique

Un réseau pour l'application

$ docker network create signalements
17df289db2cd72be8499306a254a2dd56eaad9f9f3800bd0eba7c641913db784
$ docker network inspect signalements --format '{{.Driver}} {{range .IPAM.Config}}{{.Subnet}} gw={{.Gateway}}{{end}}'
bridge 172.20.0.0/16 gw=172.20.0.1

Docker a choisi le premier sous-réseau libre de sa réserve (la leçon 4 explique comment la définir). Branchons-y PostgreSQL, sans publier de port :

$ docker run -d --name pg --network signalements -e POSTGRES_PASSWORD=essai \
    -v pgdata:/var/lib/postgresql postgres:18

Depuis un autre conteneur du même réseau, le nom pg se résout :

$ docker run --rm --network signalements alpine:3.22 sh -c 'cat /etc/resolv.conf; nslookup pg; ping -c1 -W1 pg'
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 127.0.0.11
search .
options edns0 trust-ad ndots:0

# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [host(127.0.0.53)]
# Overrides: []
# Option ndots from: internal
...
Name:	pg
Address: 172.20.0.2

PING pg (172.20.0.2): 56 data bytes
64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.156 ms

Le résolveur du conteneur est 127.0.0.11, le DNS embarqué de Docker. Il répond aux noms des conteneurs du réseau, et transmet les autres requêtes aux serveurs DNS de l'hôte (ici, systemd-resolved en 127.0.0.53, que Docker interroge depuis l'hôte puisque cette adresse n'a pas de sens dans le conteneur).

Le même essai sur le réseau par défaut échoue :

$ docker run -d --name pg-defaut -e POSTGRES_PASSWORD=x postgres:18
$ docker run --rm alpine:3.22 ping -c1 -W1 pg-defaut
ping: bad address 'pg-defaut'

Relier l'application

L'application reçoit l'adresse de la base par la variable DATABASE_URL, en utilisant le nom du conteneur, et seul son port est publié, sur l'interface locale :

$ docker run -d --name app --network signalements -p 127.0.0.1:8000:8000 \
    -e DATABASE_URL=postgresql://postgres:essai@pg:5432/postgres \
    -e APP_VERSION=1.0 signalements:1.0
$ curl -s localhost:8000/
{"application":"signalements","conteneur":"e8117363614f","stockage":"postgresql","version":"1.0"}
$ curl -s -X POST localhost:8000/signalements -H 'Content-Type: application/json' \
    -d '{"lieu":"Place de la Mairie","description":"Banc cassé"}'
{"description":"Banc cassé","id":1,"lieu":"Place de la Mairie"}
$ curl -s localhost:8000/signalements
[{"description":"Banc cassé","id":1,"lieu":"Place de la Mairie"}]

L'application stocke désormais ses données dans PostgreSQL, et le problème d'état partagé entre workers de la leçon 8 a disparu. Arrêtons la base pour voir ce que voit l'application :

$ docker stop pg
$ curl -s localhost:8000/sante
{"erreur":"failed to resolve host 'pg': [Errno -3] Temporary failure in name resolution","etat":"degrade"}
$ docker start pg
$ curl -s localhost:8000/sante
{"etat":"ok"}

Un conteneur arrêté disparaît du DNS. L'erreur n'est donc pas « connexion refusée » mais « nom introuvable », ce qui est déroutant la première fois. Au redémarrage, le nom revient, avec éventuellement une autre adresse IP : c'est pourquoi on ne code jamais une adresse IP de conteneur en dur.

L'isolation entre réseaux

Un conteneur branché sur un autre réseau ne voit pas pg, ni par son nom ni par son adresse :

$ docker network create autre
$ docker run --rm --network autre alpine:3.22 sh -c 'nslookup pg; nc -zv -w2 172.20.0.2 5432'
** server can't find pg: SERVFAIL

nc: 172.20.0.2 (172.20.0.2:5432): Operation timed out
$ docker network rm autre

Docker installe des règles de pare-feu qui bloquent le trafic entre ponts différents. Un réseau par application, c'est donc aussi une frontière : une autre application compromise sur le même hôte ne peut pas atteindre votre base. Un conteneur peut appartenir à plusieurs réseaux (docker network connect), ce qui permet par exemple à un reverse proxy d'atteindre plusieurs applications qui ne se voient pas entre elles.

Ce que voit l'hôte

Les interfaces créées par Docker sont visibles sur l'hôte, sans droits particuliers :

$ ip -br addr show br-17df289db2cd
br-17df289db2cd  UP             172.20.0.1/16 fe80::782f:34ff:fe35:a57d/64
$ ip -o link show master br-17df289db2cd | awk -F': ' '{print $2}'
veth7ced625@if2
veth60090fb@if2

Le pont porte le début de l'identifiant du réseau ; deux interfaces veth y sont branchées, une pour pg, une pour app. Pour savoir laquelle est celle d'app, on lit dans le conteneur l'index de l'interface paire :

$ docker exec app cat /sys/class/net/eth0/iflink
429
$ ip -o link | awk -F': ' '$1==429 {print $1": "$2}'
429: veth7ced625@if2

C'est la technique pour capturer le trafic d'un conteneur précis avec tcpdump -i veth7ced625 sur l'hôte.

Publier un port : ce qui écoute vraiment

Comparons une publication restreinte et une publication par défaut, avec ss, qui liste les sockets en écoute sur l'hôte :

$ ss -ltn '( sport = :8000 )'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096       127.0.0.1:8000      0.0.0.0:*
$ docker run -d --name web-public -p 8081:80 nginx:1.29
$ ss -ltn '( sport = :8081 )'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096         0.0.0.0:8081      0.0.0.0:*
LISTEN 0      4096            [::]:8081         [::]:*
$ docker port web-public
80/tcp -> 0.0.0.0:8081
80/tcp -> [::]:8081
$ docker rm -f web-public

-p 8081:80 écoute sur toutes les adresses IPv4 et IPv6 de la machine : si le serveur a une adresse publique, nginx est sur Internet. -p 127.0.0.1:8000:8000 n'est joignable que depuis l'hôte lui-même, typiquement par un reverse proxy installé sur l'hôte.

Le piège d'UFW, démontré

Sur Ubuntu, beaucoup d'administrateurs protègent leurs serveurs avec UFW. Vérifions ce qu'il vaut face à Docker. Dans une VM de laboratoire Ubuntu 24.04 (adresse 172.17.0.2 vue depuis la machine de test), activons UFW en n'autorisant que SSH :

labo# ufw default deny incoming
labo# ufw allow 22/tcp
labo# ufw --force enable
Firewall is active and enabled on system startup
labo# ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
22/tcp (v6)                ALLOW IN    Anywhere (v6)

Lançons deux services : un programme ordinaire qui écoute sur le port 9090, et un conteneur nginx publié sur le port 8080.

labo# nc -l -p 9090 -k &
labo# docker run -d --name web -p 8080:80 nginx:1.29

Depuis une autre machine :

$ curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://172.17.0.2:9090/
000
$ curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://172.17.0.2:8080/
200

Le port 9090 est bloqué, comme prévu (000 : pas de réponse avant le délai). Le port 8080 répond 200 : le conteneur est joignable malgré le deny (incoming) et même le deny (routed) d'UFW. La raison est dans l'ordre des règles du noyau :

labo# iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.200.0.2:80
labo# iptables -S FORWARD | head -8
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward

Le paquet destiné au port 8080 est réécrit par le DNAT avant le filtrage (table nat, chaîne PREROUTING) : sa destination devient 10.200.0.2:80, une adresse de conteneur. Il n'est donc plus traité comme un paquet entrant pour l'hôte (chaîne INPUT, où UFW applique ses règles ALLOW IN), mais comme un paquet routé (chaîne FORWARD). Et dans FORWARD, les chaînes de Docker passent avant celles d'UFW : DOCKER-FORWARD accepte le paquet, UFW ne le voit jamais.

Deux corrections, à combiner selon le cas.

1. Ne publier que sur l'interface nécessaire. C'est la plus simple et la plus robuste :

labo# docker rm -f web
labo# docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29
labo# iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -d 127.0.0.1/32 ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.200.0.2:80
labo# curl -s -o /dev/null -w "local: %{http_code}\n" http://127.0.0.1:8080/
local: 200
$ curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://172.17.0.2:8080/
000

La règle DNAT ne concerne plus que les paquets destinés à 127.0.0.1. Le service reste joignable localement (pour un reverse proxy sur l'hôte), plus de l'extérieur.

2. Filtrer dans la chaîne DOCKER-USER. Docker la crée vide et l'appelle en premier, précisément pour que l'administrateur y place ses règles, que Docker ne modifiera jamais. Pour n'autoriser le port publié 8080 que depuis le réseau d'administration 192.168.10.0/24 :

labo# iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 192.168.10.0/24 -j DROP
labo# iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER ! -s 192.168.10.0/24 -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j DROP
$ curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://172.17.0.2:8080/
000

Une subtilité : dans FORWARD, le paquet a déjà été réécrit, son port de destination est 80. Le module conntrack permet de filtrer sur le port d'origine (--ctorigdstport 8080), celui que le client a demandé. Ces règles ne survivent pas à un redémarrage : sur un serveur, on les persiste avec la gestion du pare-feu de la distribution (iptables-persistent, ou le fichier after.rules d'UFW selon la méthode retenue).

Les réseaux sans sortie

Une base de données n'a aucune raison de joindre Internet. Un réseau interne n'a pas de passerelle :

$ docker network create --internal interne
$ docker run --rm --network interne alpine:3.22 sh -c 'wget -q -T 3 -O /dev/null http://deb.debian.org/ && echo sortie ok || echo "pas de sortie"; ip route'
wget: bad address 'deb.debian.org'
pas de sortie
172.22.0.0/16 dev eth0 scope link  src 172.22.0.2
$ docker run --rm --network signalements alpine:3.22 sh -c 'wget -q -T 3 -O /dev/null http://deb.debian.org/ && echo sortie ok; ip route'
sortie ok
default via 172.20.0.1 dev eth0
172.20.0.0/16 dev eth0 scope link  src 172.20.0.4
$ docker network rm interne

Sur le réseau interne, pas de route par défaut, et le DNS embarqué ne résout que les noms internes. Les conteneurs d'un même réseau interne communiquent normalement entre eux. Pour un traitement qui n'a besoin d'aucun réseau, --network none ne laisse que la boucle locale :

$ docker run --rm --network none alpine:3.22 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever

À l'opposé, --network host supprime l'isolation réseau : le conteneur voit et utilise toutes les interfaces de l'hôte, et ses ports écoutent directement sur l'hôte, sans publication ni NAT (et sans les pièges de DNAT décrits plus haut, mais aussi sans aucune séparation).

Sous le capot

Le NAT sortant. Pour chaque réseau, Docker ajoute une règle de la forme :

labo# iptables -t nat -S POSTROUTING
-P POSTROUTING ACCEPT
-A POSTROUTING -s 10.200.0.0/24 ! -o docker0 -j MASQUERADE
labo# sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1

Les paquets venant du sous-réseau du pont et sortant par une autre interface prennent l'adresse de l'hôte. Le routage IP activé par Docker vaut pour toute la machine : un hôte Docker est un routeur, ce qui peut surprendre dans un réseau où les serveurs ne sont pas censés router.

Le proxy d'espace utilisateur. En plus des règles DNAT, Docker lance pour chaque port publié un petit processus docker-proxy (leçon 3), qui écoute réellement sur le port de l'hôte. C'est lui que montre ss. Il sert aux cas que le DNAT ne couvre pas, comme les connexions depuis l'hôte lui-même vers 127.0.0.1. Il peut être désactivé ("userland-proxy": false dans daemon.json) au profit de règles noyau supplémentaires.

Le DNS embarqué écoute en 127.0.0.11 dans le namespace de chaque conteneur, sur un port aléatoire vers lequel Docker redirige le port 53 avec des règles de NAT internes au conteneur. Il connaît les noms et alias de tous les conteneurs du réseau, et ne publie que ceux des conteneurs en cours d'exécution.

Le moteur de pare-feu. Docker 29 utilise iptables par défaut et propose un moteur nftables expérimental (option firewall-backend). Avec nftables, Docker n'active plus lui-même le routage IP : c'est à l'administrateur de le faire. Les principes (NAT sortant, DNAT entrant, chaînes de l'administrateur évaluées en premier) restent les mêmes.

Pièges courants

Se connecter par adresse IP. Les adresses des conteneurs changent à chaque recréation. Utilisez les noms, sur un réseau défini par l'utilisateur.

localhost dans un conteneur. Dans un conteneur, localhost désigne le conteneur lui-même, pas l'hôte ni les autres conteneurs. Une application configurée avec DATABASE_URL=...@localhost:5432 ne trouvera pas la base d'un autre conteneur. Pour joindre un service de l'hôte depuis un conteneur, Docker propose le nom spécial host.docker.internal, à déclarer sur Linux avec --add-host host.docker.internal:host-gateway.

Écouter sur 127.0.0.1 dans le conteneur. Une application qui écoute sur 127.0.0.1 à l'intérieur du conteneur n'est joignable que depuis le conteneur : la publication de port amène le trafic sur l'interface eth0 du conteneur, où rien n'écoute. Symptôme : curl: (56) Recv failure: Connection reset by peer. C'est pourquoi la commande de Gunicorn de la leçon 8 utilise --bind 0.0.0.0:8000.

Le port publié sans adresse. -p 5432:5432 sur un serveur expose la base au monde entier, quoi qu'en dise UFW. Ne publiez jamais une base de données ; si un accès d'administration est nécessaire, publiez sur 127.0.0.1 et passez par un tunnel SSH.

Un conflit de port. Bind for 0.0.0.0:8000 failed: port is already allocated : un autre conteneur, ou un autre programme, utilise déjà ce port de l'hôte. docker ps --filter publish=8000 et ss -ltnp permettent de trouver lequel.

Sécurité

  • Un réseau par application, rien de publié par défaut. Les bases, caches et files de messages restent sur des réseaux non publiés, idéalement --internal. Seul le point d'entrée (reverse proxy ou application) publie un port.
  • Publiez sur une adresse précise. 127.0.0.1 derrière un reverse proxy sur l'hôte, ou l'adresse privée du serveur derrière un répartiteur de charge. Le réglage "ip": "127.0.0.1" dans daemon.json change l'adresse par défaut des publications sans adresse, filet de sécurité utile sur un serveur.
  • Ne comptez pas sur UFW ou firewalld pour les conteneurs. Filtrez dans DOCKER-USER, ou mieux, en amont : groupes de sécurité du fournisseur de cloud (les security groups des instances Scaleway), pare-feu réseau. La défense en profondeur s'applique aussi ici.
  • Le trafic entre conteneurs n'est pas chiffré. Sur un même hôte, il ne quitte pas la machine. Entre hôtes, ou pour des exigences de conformité, il faut TLS de bout en bout ou un maillage de services (service mesh) avec mTLS.
  • Contrôlez aussi la sortie. Un conteneur compromis qui peut joindre Internet peut exfiltrer des données ou télécharger des outils. Les réseaux internes, et en production les politiques réseau (NetworkPolicy dans Kubernetes), limitent ce risque.

En production

  • Un reverse proxy en frontal. En production, les applications ne publient pas chacune leur port : un reverse proxy (Traefik, Caddy, nginx) reçoit tout le trafic HTTP(S) sur les ports 80 et 443, termine TLS et route vers les conteneurs par le réseau Docker, sans publication. C'est l'architecture du cours Reverse proxies et répartition de charge, et celle du cluster Kapsule de Lyneko, où Traefik joue ce rôle.
  • Kubernetes remplace tout cela. Sur un cluster, chaque pod a une adresse routable dans le cluster, les Services fournissent un nom DNS et une répartition de charge, les Ingress jouent le rôle du reverse proxy, et les NetworkPolicy celui de l'isolation entre réseaux. Les problèmes de fond (noms plutôt qu'adresses, n'exposer que le point d'entrée, filtrer la sortie) restent identiques.
  • Planifiez les plages d'adresses avec l'équipe réseau (leçon 4) : chaque réseau Docker consomme un sous-réseau, et un conflit avec une route existante rend des services injoignables de façon très difficile à diagnostiquer.

Exercices

1. Diagnostic de connexion. Un collègue a lancé docker run -d --name base -e POSTGRES_PASSWORD=x postgres:18 puis docker run -d --name api -e DATABASE_URL=postgresql://postgres:x@base:5432/postgres signalements:1.0. Le conteneur api s'arrête aussitôt avec le code 3, et ses journaux se terminent ainsi :

psycopg.OperationalError: failed to resolve host 'base': [Errno -2] Name or service not known
[2026-10-01 10:23:43 +0000] [1] [ERROR] Shutting down: Master
[2026-10-01 10:23:43 +0000] [1] [ERROR] Reason: Worker failed to boot.

Expliquez pourquoi et corrigez sans recréer la base.

Solution

Les deux conteneurs sont sur le réseau bridge par défaut, qui n'a pas de DNS : base ne se résout pas. Correction sans recréer la base :

$ docker network create appli
$ docker network connect appli base
$ docker rm -f api
$ docker run -d --name api --network appli -e DATABASE_URL=postgresql://postgres:x@base:5432/postgres signalements:1.0

docker network connect branche un conteneur existant sur un réseau supplémentaire, à chaud. L'application crée sa table au démarrage : sans base joignable, ses workers échouent et Gunicorn s'arrête. Une application qui tolère l'absence temporaire de sa base au démarrage (en réessayant) est plus robuste ; c'est aussi ce que l'ordre de démarrage de Compose (leçon suivante) aide à garantir.

2. Ce qui écoute. Sur une machine où tournent plusieurs conteneurs, listez tous les ports publiés et l'adresse sur laquelle chacun écoute. Repérez ceux qui écoutent sur toutes les interfaces.

Solution
$ docker ps --format '{{.Names}}\t{{.Ports}}'
$ ss -ltn

Dans docker ps, les publications ouvertes à tous apparaissent sous la forme 0.0.0.0:8081->80/tcp, [::]:8081->80/tcp, les publications restreintes sous la forme 127.0.0.1:8000->8000/tcp. ss -ltn le confirme du point de vue du système. Toute ligne 0.0.0.0 ou [::] sur un serveur doit avoir une justification.

3. Le reverse proxy (niveau 200). Lancez l'application Signalements sans publier son port, puis un conteneur nginx:1.29 publié sur 127.0.0.1:8080, sur le même réseau, configuré pour relayer vers l'application. Vérifiez avec curl que la chaîne fonctionne.

Solution

Fichier proxy.conf :

server {
    listen 80;
    location / {
        proxy_pass http://app:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
$ docker rm -f app
$ docker run -d --name app --network signalements -e DATABASE_URL=postgresql://postgres:essai@pg:5432/postgres signalements:1.0
$ docker run -d --name proxy --network signalements -p 127.0.0.1:8080:80 \
    --mount type=bind,src=./proxy.conf,dst=/etc/nginx/conf.d/default.conf,readonly nginx:1.29
$ curl -s localhost:8080/

Le proxy résout app par le DNS du réseau. Seul le proxy est publié ; l'application n'est joignable que par lui.

4. Isoler la base (niveau 200). Modifiez l'architecture de l'exercice 3 pour que PostgreSQL soit sur un réseau interne que seule l'application rejoint, et que le proxy ne puisse pas joindre la base. Prouvez-le.

Solution
$ docker network create --internal donnees
$ docker network connect donnees pg
$ docker network disconnect signalements pg
$ docker network connect donnees app
$ docker exec proxy getent hosts pg || echo "le proxy ne voit pas la base"

L'application est sur les deux réseaux (signalements pour le proxy, donnees pour la base), la base seulement sur donnees, qui n'a pas de sortie vers l'extérieur. L'image nginx est basée sur Debian et fournit getent. Pensez à vérifier que l'application répond toujours : curl -s localhost:8080/sante.

Récapitulatif

  • Un réseau défini par l'utilisateur par application : DNS embarqué (127.0.0.11), isolation des autres réseaux. Jamais le réseau bridge par défaut pour une application.
  • On se connecte par nom, jamais par adresse IP ; un conteneur arrêté disparaît du DNS.
  • Chaque conteneur a une paire veth branchée sur un pont ; Docker ajoute un NAT sortant et, pour chaque port publié, un DNAT entrant.
  • -p sans adresse publie sur toutes les interfaces, en IPv4 et IPv6. Publiez sur 127.0.0.1 ou une adresse précise.
  • Les ports publiés contournent UFW, parce que le DNAT les fait passer par FORWARD, où les règles de Docker sont évaluées avant celles d'UFW. Filtrez dans DOCKER-USER ou en amont.
  • --internal coupe la sortie, --network none coupe tout, --network host supprime l'isolation.

Pour aller plus loin

  • La page Packet filtering and firewalls de la documentation Docker, qui décrit toutes les chaînes créées et leur ordre.
  • Le dépôt ufw-docker, qui documente en détail le conflit et propose une intégration des règles dans UFW.
  • La documentation réseau de Kubernetes, pour voir comment les mêmes problèmes sont résolus à l'échelle d'un cluster.
  • Leçon suivante : décrire l'application, sa base, ses volumes et ses réseaux dans un seul fichier, avec Docker Compose.

Sources