Sicurezza Docker: Rootless Mode, Seccomp e AppArmor su VPS

15 min di lettura·Matthieu·vpscontainersapparmorseccomprootless-dockersecuritydocker|

Sette livelli di hardening per Docker su VPS. Ogni sezione spiega la minaccia, mostra la soluzione con sintassi CLI e Compose, e verifica che funzioni.

La configurazione predefinita di Docker sacrifica la sicurezza per la comodità. I container girano come root sull'host. Tutte le 14 Linux capabilities restano attive. Seccomp blocca solo 44 delle oltre 300 syscall. Il traffico tra container scorre liberamente.

Su un VPS, questo conta più che su una macchina di sviluppo locale. Condividi un host fisico con altri tenant. Un container escape significa che un attaccante atterra come root sul kernel esposto all'hypervisor. Ogni livello di hardening che aggiungi riduce il raggio dell'impatto.

Questo tutorial copre sette misure di hardening. Ogni sezione spiega la minaccia che previene, mostra l'implementazione (sia con flag docker run che con sintassi Compose) e include un passaggio di verifica. Abbiamo testato ogni comando su Ubuntu 24.04 con Docker Engine 29.x.

Prerequisiti: Un VPS con Debian 12 o Ubuntu 24.04 e Docker Engine installato. Accesso SSH come utente non-root con sudo. Se non hai ancora messo in sicurezza l'host, parti dalla nostra guida alla sicurezza VPS Linux. Per problemi di firewall con Docker, vedi Docker UFW Firewall Fix.

Questo articolo fa parte della serie .

Come configuro rootless Docker su Ubuntu 24.04 o Debian 12?

Rootless Docker esegue il daemon e tutti i container sotto un account utente normale invece che root. Se un attaccante evade dal container, atterra come utente senza privilegi sull'host. Nessun accesso root. Tra tutte le misure di questa guida, questa ha l'impatto maggiore.

Installare rootless Docker

Installa i pacchetti necessari. Il pacchetto uidmap fornisce newuidmap e newgidmap, che gestiscono la mappatura degli UID/GID subordinati:

sudo apt-get update && sudo apt-get install -y uidmap docker-ce-rootless-extras

Verifica che il tuo utente abbia almeno 65.536 UID e GID subordinati:

grep "^$(whoami):" /etc/subuid
grep "^$(whoami):" /etc/subgid

Dovresti vedere un output come deploy:100000:65536. Se le voci mancano, aggiungile:

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)

Ferma il daemon Docker di sistema. Non ti serve per la modalità rootless:

sudo systemctl disable --now docker.service docker.socket

Esegui lo strumento di setup rootless come utente normale (non root):

dockerd-rootless-setuptool.sh install

Lo script stampa le variabili d'ambiente da impostare. Aggiungile al tuo profilo shell:

echo 'export PATH=/usr/bin:$PATH' >> ~/.bashrc
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc
source ~/.bashrc

Abilita il linger così il daemon rootless parte al boot, non solo quando fai login:

sudo loginctl enable-linger $(whoami)

Verificare la modalità rootless

docker context use rootless
docker run --rm hello-world

Controlla che il processo del daemon giri col tuo utente, non root:

ps aux | grep dockerd

L'output deve mostrare il tuo nome utente, non root. Conferma anche che Docker info riporta rootless:

docker info --format '{{.SecurityOptions}}'

Dovresti vedere rootless nella lista.

Quando rootless Docker crea problemi

La modalità rootless ha limitazioni reali. Conoscerle evita ore di debug.

Limitazione Causa Soluzione
Non può fare bind su porte sotto la 1024 Gli utenti non-root non possono fare bind su porte privilegiate Imposta sysctl net.ipv4.ip_unprivileged_port_start=0 o usa un reverse proxy sull'host
Errori di permessi sui bind mount I file dell'host di proprietà di root sono inaccessibili all'UID rimappato Cambia ownership al tuo utente, o usa named volumes
Filesystem overlay più lento Rootless usa fuse-overlayfs invece del nativo overlay2 Accetta l'overhead (5-15% per workload pesanti in I/O) o usa overlay2 nativo con --privileged (vanifica lo scopo)
Niente --net=host Il networking rootless usa slirp4netns o pasta, non lo stack di rete dell'host Usa il port mapping (-p). Per prestazioni migliori, installa pasta come driver di rete
ping fallisce nei container CAP_NET_RAW è limitata Installa slirp4netns >= 0.4.0 o usa pasta

Nota sulle prestazioni di rete: Per impostazione predefinita, rootless Docker usa slirp4netns per il networking, che aggiunge overhead NAT. Il driver pasta copia la configurazione di rete dell'host nel namespace del container senza NAT e offre un throughput migliore. Su Debian 12 e Ubuntu 24.04, installalo con:

sudo apt-get install -y passt

Docker rileva pasta automaticamente se è installato.

Quando usare user namespace remapping invece di rootless Docker?

User namespace remapping (userns-remap) mappa l'UID 0 dentro i container a un UID senza privilegi sull'host. A differenza di rootless Docker, il daemon stesso gira ancora come root. Questo significa che mantieni tutte le funzionalità Docker (porte privilegiate, host networking, overlay2 nativo) impedendo comunque che root nel container equivalga a root sull'host.

Scegli userns-remap quando la modalità rootless non funziona col tuo workload ma vuoi comunque l'isolamento UID. Scegli rootless quando puoi convivere con le sue limitazioni.

Funzionalità Rootless Docker userns-remap Docker standard
Il daemon gira come Utente Root Root
Root nel container = root sull'host No No
Porte privilegiate Serve workaround Funziona Funziona
--net=host No
Storage driver fuse-overlayfs overlay2 overlay2
Complessità del setup Media Bassa Nessuna

Configurare userns-remap

Crea l'utente dockremap o usa la scorciatoia default che lo crea automaticamente:

sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
  "userns-remap": "default"
}
EOF

sudo systemctl restart docker

Docker crea l'utente dockremap e aggiunge i range di UID/GID subordinati a /etc/subuid e /etc/subgid.

Verifica che funzioni:

sudo ls -ld /var/lib/docker/

Dovresti vedere una nuova sottodirectory con il nome del range UID rimappato, ad esempio /var/lib/docker/100000.100000/. Il numero esatto dipende dal range di UID subordinati assegnato all'utente dockremap in /etc/subuid.

Esegui un container e controlla l'UID del processo sull'host:

docker run -d --name test-userns nginx:alpine
ps aux | grep nginx

Il processo nginx deve mostrare un UID alto (corrispondente al primo numero da /etc/subuid per dockremap), non 0.

Pulizia:

docker rm -f test-userns

Attenzione alla ownership dei volumi: I file montati con bind di proprietà di root (UID 0) appaiono come nobody dentro il container perché l'UID 0 viene mappato a un range diverso. Usa named volumes o fai chown dei file all'UID rimappato.

Come rimuovo le Linux capabilities da un container Docker?

Docker concede 14 Linux capabilities ai container per impostazione predefinita. Ogni capability è un permesso kernel che un attaccante può sfruttare dopo un container escape o dentro un container compromesso. Rimuovere tutte le capabilities e riaggiungere solo quelle necessarie riduce la superficie d'attacco.

Capabilities Docker predefinite

Capability Cosa consente Tenere o rimuovere?
CAP_CHOWN Cambiare la ownership dei file Rimuovere se non necessaria
CAP_DAC_OVERRIDE Bypassare i controlli di permessi in lettura/scrittura Rimuovere se non necessaria
CAP_FOWNER Bypassare i controlli di permessi sul proprietario del file Rimuovere se non necessaria
CAP_FSETID Impostare i bit setuid/setgid Rimuovere
CAP_KILL Inviare segnali a qualsiasi processo Rimuovere se non necessaria
CAP_SETGID Cambiare il GID del processo Tenere per la maggior parte delle app
CAP_SETUID Cambiare l'UID del processo Tenere per la maggior parte delle app
CAP_SETPCAP Modificare le capabilities del processo Rimuovere
CAP_NET_BIND_SERVICE Fare bind su porte sotto la 1024 Tenere se fai bind su porta 80/443
CAP_NET_RAW Usare raw socket (creare pacchetti) Rimuovere a meno che servano ping/traceroute
CAP_SYS_CHROOT Usare chroot Rimuovere
CAP_MKNOD Creare file di device Rimuovere
CAP_AUDIT_WRITE Scrivere nel log di audit del kernel Rimuovere se non necessaria
CAP_SETFCAP Impostare le capabilities dei file Rimuovere

Rimuovi tutto, riaggiungi quello che serve

Sintassi CLI:

docker run -d \
  --cap-drop ALL \
  --cap-add CHOWN \
  --cap-add NET_BIND_SERVICE \
  --cap-add SETUID \
  --cap-add SETGID \
  --name hardened-nginx \
  nginx:alpine

Nginx ha bisogno di CHOWN perché il suo entrypoint script cambia la ownership delle directory di cache all'avvio. Senza, il container si ferma subito con un errore chown: Operation not permitted.

Sintassi Compose:

services:
  web:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - NET_BIND_SERVICE
      - SETUID
      - SETGID

Verificare che le capabilities siano state rimosse

docker exec hardened-nginx sh -c 'cat /proc/1/status | grep Cap'

Confronta la bitmask CapEff (effective capabilities). Con tutte rimosse e quattro riattivate, il valore è molto più basso del predefinito 00000000a80425fb.

Per un output leggibile, installa capsh sull'host e decodifica l'esadecimale:

docker exec hardened-nginx sh -c 'cat /proc/1/status | grep CapEff' | awk '{print $2}' | xargs -I{} capsh --decode=0x{}

Dovresti vedere solo cap_chown,cap_setgid,cap_setuid,cap_net_bind_service nell'output.

Pulizia:

docker rm -f hardened-nginx

Cosa previene il flag no-new-privileges?

Il flag no-new-privileges impedisce ai processi dentro un container di acquisire privilegi aggiuntivi tramite binari setuid o setgid. Senza questo flag, un processo compromesso può eseguire un binario setuid (come su o sudo) e scalare a root. Con il flag attivo, il kernel rifiuta l'escalation dei privilegi.

Applicare per container

CLI:

docker run -d --security-opt no-new-privileges:true --name no-priv-test nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - no-new-privileges:true

Applicare come default del daemon

Aggiungilo a daemon.json così ogni container riceve questo flag automaticamente:

{
  "no-new-privileges": true
}

Riavvia Docker dopo la modifica:

sudo systemctl restart docker

Verificare che funzioni

docker exec no-priv-test grep NoNewPrivs /proc/1/status

Output atteso:

NoNewPrivs:	1

Il valore 1 significa che non possono essere acquisiti nuovi privilegi. Il valore 0 significa che il flag non è impostato.

Pulizia:

docker rm -f no-priv-test

Come creo un profilo seccomp personalizzato per Docker?

Il profilo seccomp predefinito di Docker blocca circa 44 syscall su oltre 300. Un profilo personalizzato ti permette di limitare i container alle sole syscall che la tua applicazione usa effettivamente. Se un attaccante compromette il container, non può sfruttare vulnerabilità del kernel tramite syscall bloccate.

Scoprire quali syscall servono alla tua applicazione

Usa strace su un container in esecuzione per catturare le syscall effettuate durante il funzionamento normale:

docker run -d --security-opt seccomp=unconfined --name trace-target nginx:alpine

# Install strace on the host
sudo apt-get install -y strace

# Get the container's PID
PID=$(docker inspect --format '{{.State.Pid}}' trace-target)

# Trace syscalls for 30 seconds during normal operation
sudo strace -f -p "$PID" -o /tmp/nginx-syscalls.log -e trace=all &
STRACE_PID=$!
sleep 30

# Send some test traffic to exercise the application
curl -s http://localhost:80 > /dev/null 2>&1 || true

kill "$STRACE_PID" 2>/dev/null
wait "$STRACE_PID" 2>/dev/null

Estrai i nomi delle syscall uniche:

grep -oP '^\[pid \d+\] \K\w+|^\w+' /tmp/nginx-syscalls.log | sort -u

Questo ti dà il set minimo di syscall necessarie alla tua applicazione.

Costruire il profilo personalizzato

Crea un file JSON. La defaultAction è SCMP_ACT_ERRNO (nega tutto ciò che non è esplicitamente consentito). La lista di syscall deve includere sia quelle necessarie alla tua applicazione SIA quelle usate dal container runtime (runc) durante l'inizializzazione. Il profilo qui sotto è stato testato con nginx:alpine su Docker Engine 29.x:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64",
    "SCMP_ARCH_X86",
    "SCMP_ARCH_AARCH64"
  ],
  "syscalls": [
    {
      "names": [
        "accept4",
        "access",
        "arch_prctl",
        "bind",
        "brk",
        "capget",
        "capset",
        "chdir",
        "chown",
        "clone",
        "clone3",
        "close",
        "close_range",
        "connect",
        "copy_file_range",
        "dup",
        "dup2",
        "dup3",
        "epoll_create1",
        "epoll_ctl",
        "epoll_pwait",
        "epoll_pwait2",
        "epoll_wait",
        "eventfd2",
        "execve",
        "exit",
        "exit_group",
        "faccessat",
        "faccessat2",
        "fchmod",
        "fchmodat",
        "fchown",
        "fchownat",
        "fcntl",
        "fork",
        "fstat",
        "fstatfs",
        "futex",
        "getcwd",
        "getdents",
        "getdents64",
        "getegid",
        "geteuid",
        "getgid",
        "getpgrp",
        "getpid",
        "getppid",
        "getrandom",
        "getrlimit",
        "getsockname",
        "getsockopt",
        "gettid",
        "getuid",
        "io_destroy",
        "io_getevents",
        "io_setup",
        "io_submit",
        "ioctl",
        "kill",
        "listen",
        "lseek",
        "lstat",
        "madvise",
        "memfd_create",
        "mkdir",
        "mkdirat",
        "mmap",
        "mount",
        "mprotect",
        "mremap",
        "munmap",
        "nanosleep",
        "newfstatat",
        "open",
        "openat",
        "pipe",
        "pipe2",
        "pivot_root",
        "poll",
        "ppoll",
        "prctl",
        "pread64",
        "prlimit64",
        "pwrite64",
        "read",
        "readlink",
        "readlinkat",
        "recvfrom",
        "recvmsg",
        "rename",
        "renameat",
        "rseq",
        "rt_sigaction",
        "rt_sigprocmask",
        "rt_sigreturn",
        "sched_getaffinity",
        "sched_yield",
        "seccomp",
        "sendfile",
        "sendmsg",
        "sendto",
        "set_robust_list",
        "set_tid_address",
        "setgid",
        "setgroups",
        "sethostname",
        "setitimer",
        "setsockopt",
        "setuid",
        "sigaltstack",
        "socket",
        "socketpair",
        "stat",
        "statfs",
        "statx",
        "symlink",
        "symlinkat",
        "sysinfo",
        "tgkill",
        "umask",
        "umount2",
        "uname",
        "unlink",
        "unlinkat",
        "unshare",
        "utimensat",
        "wait4",
        "write",
        "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Perché così tante syscall? La lista include syscall necessarie a tre livelli: il container runtime (runc) per il setup dei namespace (clone3, mount, pivot_root, unshare, seccomp, statx), la shell Alpine e gli entrypoint script (fork, open, pipe, wait4), e nginx stesso (accept4, bind, listen, io_setup). Un'immagine basata su glibc avrebbe bisogno di meno syscall relative alla shell, ma di più interne alla libc.

Salva il file come /etc/docker/seccomp-nginx.json.

Applicare il profilo personalizzato

CLI:

docker run -d \
  --security-opt seccomp=/etc/docker/seccomp-nginx.json \
  --name seccomp-test \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - seccomp=/etc/docker/seccomp-nginx.json

Verificare che il profilo sia attivo

docker inspect --format '{{.HostConfig.SecurityOpt}}' seccomp-test

L'output mostra il JSON del profilo seccomp applicato al container. Docker legge il file al momento della creazione del container e incorpora il contenuto del profilo.

Testa che un'operazione limitata fallisca:

docker exec seccomp-test unshare --mount /bin/sh -c 'echo escaped'

Deve fallire con "Operation not permitted". Il container non ha CAP_SYS_ADMIN (non concessa per impostazione predefinita), e il profilo seccomp fornisce un secondo livello di difesa consentendo solo le syscall necessarie al funzionamento normale.

Pulizia:

docker rm -f seccomp-test
rm -f /tmp/nginx-syscalls.log

Consiglio per la produzione: Parti dal profilo predefinito di Docker e rimuovi syscall piuttosto che costruire da zero. L'approccio con strace ti dà il profilo più restrittivo possibile ma richiede test approfonditi. Esercita ogni percorso di codice della tua applicazione durante la cattura con strace: avvio, richieste normali, gestione errori, spegnimento controllato (docker stop) e rotazione dei log.

Approccio iterativo: Se costruire da strace sembra troppo rischioso, usa questo workflow più sicuro:

  1. Copia il profilo seccomp predefinito di Docker come punto di partenza.
  2. Esegui il container con questo profilo copiato. Si comporta in modo identico a quello predefinito.
  3. Rimuovi syscall un gruppo alla volta (ad es. tutte le syscall key*, tutte le syscall swap*).
  4. Testa il container dopo ogni rimozione. Se si rompe, riaggiungi l'ultima syscall rimossa.
  5. Ripeti finché non hai tagliato tutto ciò che la tua applicazione non usa.

Questo è più lento del metodo con strace ma più sicuro per container di produzione dove mancare una syscall potrebbe causare errori intermittenti.

Come scrivo un profilo AppArmor per un container Docker?

AppArmor limita i file, le risorse di rete e le capabilities a cui un processo container può accedere. Docker applica automaticamente un profilo predefinito docker-default. Un profilo personalizzato permette di limitare ulteriormente i container ai soli percorsi del filesystem e alle operazioni di rete necessarie.

Verificare che AppArmor sia attivo

sudo aa-status

Dovresti vedere docker-default nella lista dei profili caricati. Se AppArmor non è installato:

sudo apt-get install -y apparmor apparmor-utils

Scrivere un profilo personalizzato

Crea /etc/apparmor.d/containers/docker-nginx:

#include <tunables/global>

profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Capabilities needed by nginx
  capability chown,
  capability setuid,
  capability setgid,
  capability net_bind_service,
  capability dac_override,

  # Network access
  network inet tcp,
  network inet udp,
  network inet6 tcp,
  network inet6 udp,

  # Shell and entrypoint scripts
  /bin/** rix,
  /usr/bin/** rix,
  /usr/sbin/** rix,
  /lib/** mr,
  /usr/lib/** mr,
  /docker-entrypoint.sh rix,
  /docker-entrypoint.d/ r,
  /docker-entrypoint.d/** rix,
  /dev/null rw,
  /dev/stdout rw,
  /dev/stderr rw,

  # Nginx binary
  /usr/sbin/nginx ix,

  # Config files (read only)
  /etc/ r,
  /etc/nginx/ r,
  /etc/nginx/** r,

  # Web root (read only)
  /usr/share/nginx/html/** r,

  # Temp and cache directories
  /var/cache/nginx/ rw,
  /var/cache/nginx/** rw,
  /var/log/nginx/ rw,
  /var/log/nginx/** rw,
  /run/ rw,
  /run/** rw,
  /tmp/ rw,
  /tmp/** rw,

  # Proc filesystem (needed for nginx worker management)
  /proc/** r,

  # Deny sensitive files
  deny /etc/shadow r,
  deny /etc/passwd w,
  deny /proc/*/mem r,
  deny /sys/** w,
}

Il profilo ha bisogno delle dichiarazioni capability perché AppArmor controlla l'uso delle capability in modo indipendente dai flag --cap-add di Docker. I percorsi degli entrypoint script (/docker-entrypoint.sh, /docker-entrypoint.d/) e dei binari shell (/bin/**) devono essere esplicitamente consentiti, altrimenti il container non parte. Il permesso rix significa lettura, ereditarietà del contesto di esecuzione e permesso di esecuzione.

Caricare e applicare il profilo

sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx

Verifica che sia caricato:

sudo aa-status | grep docker-nginx

Esegui un container con il profilo personalizzato:

CLI:

docker run -d \
  --security-opt apparmor=docker-nginx \
  --name apparmor-test \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - apparmor=docker-nginx

Verificare l'enforcement di AppArmor

docker exec apparmor-test cat /etc/shadow

Deve restituire "Permission denied" perché il profilo nega esplicitamente la lettura di /etc/shadow.

Controlla lo stato AppArmor del container:

docker inspect --format '{{.AppArmorProfile}}' apparmor-test

Output atteso: docker-nginx.

Pulizia:

docker rm -f apparmor-test

Come eseguo un container Docker con filesystem in sola lettura?

Un filesystem root in sola lettura impedisce agli attaccanti di scrivere malware, backdoor o binari modificati dentro un container compromesso. Il container può comunque scrivere su volumi tmpfs montati esplicitamente per file temporanei e dati di runtime.

CLI:

docker run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=16m \
  --tmpfs /var/cache/nginx:rw,noexec,nosuid,size=128m \
  -p 8080:80 \
  --name readonly-nginx \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
      - /run:rw,noexec,nosuid,size=16m
      - /var/cache/nginx:rw,noexec,nosuid,size=128m

Nota il flag noexec sui mount tmpfs. Questo impedisce l'esecuzione di binari dalle directory temporanee, una tecnica comune usata dagli attaccanti dopo aver ottenuto accesso in scrittura.

Verificare che il filesystem sia in sola lettura

docker exec readonly-nginx touch /testfile

Output atteso:

touch: /testfile: Read-only file system

Conferma che i mount tmpfs funzionino:

docker exec readonly-nginx touch /tmp/testfile && echo "tmpfs works"

Verifica che il container stia servendo traffico:

curl -s -o /dev/null -w '%{http_code}' http://localhost:8080

Atteso: 200.

Pulizia:

docker rm -f readonly-nginx

Come deve essere un daemon.json Docker protetto?

Un daemon.json protetto applica impostazioni di sicurezza predefinite a ogni container sull'host. I singoli container possono comunque sovrascrivere alcune impostazioni, ma la configurazione del daemon stabilisce la baseline.

Crea o modifica /etc/docker/daemon.json:

{
  "no-new-privileges": true,
  "icc": false,
  "live-restore": true,
  "userland-proxy": false,
  "log-driver": "journald",
  "log-opts": {
    "tag": "{{.Name}}"
  },
  "default-ulimits": {
    "nproc": {
      "Name": "nproc",
      "Hard": 512,
      "Soft": 256
    },
    "nofile": {
      "Name": "nofile",
      "Hard": 65536,
      "Soft": 32768
    }
  },
  "storage-driver": "overlay2"
}

Cosa fa ogni impostazione:

  • no-new-privileges: Blocca l'escalation setuid/setgid in tutti i container per impostazione predefinita.
  • icc: false: Disabilita la comunicazione tra container sulla rete bridge predefinita. I container possono raggiungersi solo tramite porte esplicitamente pubblicate o reti definite dall'utente. Questo limita il movimento laterale se un container viene compromesso.
  • live-restore: Mantiene i container in esecuzione durante i riavvii del daemon. Previene i downtime durante gli aggiornamenti di Docker.
  • userland-proxy: false: Usa iptables per il port mapping invece di un processo proxy userland. Prestazioni migliori, meno file descriptor aperti.
  • log-driver: journald: Invia i log dei container al journal di sistema dove vengono gestiti centralmente e ruotati.
  • default-ulimits: Limita i processi e i file aperti per container. Previene fork bomb e esaurimento dei file descriptor.

Applica le modifiche:

sudo systemctl restart docker

Verifica che il daemon abbia recepito la configurazione:

docker info --format '{{.SecurityOptions}}'

Dovresti vedere no-new-privileges nella lista delle opzioni di sicurezza.

Controlla che ICC sia disabilitato:

docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'

Output atteso: false.

Nota su userns-remap: Se hai scelto user namespace remapping invece della modalità rootless, aggiungi "userns-remap": "default" a questa configurazione. Non combinare userns-remap con rootless Docker.

Nascondere la versione: Mentre modifichi daemon.json, considera anche di nascondere le informazioni sulla versione dell'API Docker. Docker non espone header di versione per impostazione predefinita come fa Nginx, ma se esponi l'API Docker su un socket TCP (non farlo, a meno che non sia strettamente necessario), proteggila con certificati TLS client. Un'API Docker esposta equivale a un accesso root shell.

Audit della configurazione: Esegui Docker Bench for Security per verificare la tua configurazione rispetto al CIS Docker Benchmark. Clona il repository ed esegui lo script direttamente, perché l'immagine container include un client Docker datato incompatibile con Docker Engine 29.x:

git clone https://github.com/docker/docker-bench-security.git /tmp/docker-bench
cd /tmp/docker-bench && sudo bash docker-bench-security.sh

Esamina l'output per le voci WARN. Il daemon.json protetto qui sopra ne risolve la maggior parte. Le voci segnate come INFO sono raccomandazioni, non errori.

Combinare più livelli di hardening in Docker Compose

Un file Compose di produzione deve sovrapporre più misure di hardening. Ecco un esempio per un container Nginx con tutti e sette i livelli applicati:

services:
  web:
    image: nginx:alpine
    read_only: true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - NET_BIND_SERVICE
      - SETUID
      - SETGID
    security_opt:
      - no-new-privileges:true
      - seccomp=/etc/docker/seccomp-nginx.json
      - apparmor=docker-nginx
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
      - /run:rw,noexec,nosuid,size=16m
      - /var/cache/nginx:rw,noexec,nosuid,size=128m
    ports:
      - "80:80"
    pids_limit: 100
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '1.0'

Nota che pids_limit previene le fork bomb e deploy.resources.limits limita memoria e CPU. Non sono flag security-opt ma prevengono il denial-of-service dall'interno del container. Per saperne di più sui limiti di risorse, vedi .

Verifica che tutte le opzioni di sicurezza siano attive sul container in esecuzione:

docker compose up -d
docker inspect web --format '{{json .HostConfig.SecurityOpt}}' | python3 -m json.tool

Dovresti vedere no-new-privileges, il percorso del tuo profilo seccomp e il nome del profilo AppArmor elencati.

Checklist di hardening per la produzione

Misura Flag CLI Chiave Compose daemon.json Minaccia prevenuta
Rootless Docker N/A (livello daemon) N/A N/A (daemon separato) Container escape dà root sull'host
userns-remap N/A N/A "userns-remap": "default" Root nel container = root sull'host
Rimuovere capabilities --cap-drop ALL --cap-add X cap_drop: [ALL] N/A Superficie d'attacco kernel
no-new-privileges --security-opt no-new-privileges:true security_opt: [no-new-privileges:true] "no-new-privileges": true Escalation setuid/setgid
Seccomp personalizzato --security-opt seccomp=profile.json security_opt: [seccomp:path] N/A Exploit kernel via syscall bloccate
Profilo AppArmor --security-opt apparmor=name security_opt: [apparmor:name] N/A Accesso a file/rete oltre le necessità dell'app
Rootfs in sola lettura --read-only read_only: true N/A Malware persistente, manomissione binari
Disabilitare ICC N/A N/A "icc": false Movimento laterale tra container
Limitare i processi --pids-limit 100 pids_limit: 100 "default-pids-limit": 100 Fork bomb
Disabilitare userland proxy N/A N/A "userland-proxy": false Spreco risorse, riduce la superficie d'attacco

Risoluzione dei problemi

Il container non parte dopo l'aggiunta del profilo seccomp: Al tuo profilo manca una syscall necessaria all'applicazione. Esegui temporaneamente con --security-opt seccomp=unconfined, fai strace del processo e aggiungi le syscall mancanti al profilo.

Controlla i log di audit del kernel per le syscall bloccate:

sudo journalctl -k | grep -i seccomp

AppArmor blocca operazioni legittime: Imposta il profilo in modalità complain per loggare i dinieghi senza applicarli:

sudo aa-complain /etc/apparmor.d/containers/docker-nginx

Osserva i log:

sudo journalctl -k | grep apparmor | tail -20

Dopo aver aggiunto i permessi necessari, torna alla modalità enforce:

sudo aa-enforce /etc/apparmor.d/containers/docker-nginx

Rootless Docker non riesce a scaricare le immagini: Controlla che DOCKER_HOST sia impostato correttamente:

echo $DOCKER_HOST

Deve puntare a /run/user/<UID>/docker.sock. Verifica anche che il daemon rootless sia in esecuzione:

systemctl --user status docker

Permesso negato sui bind mount con userns-remap: I file sull'host di proprietà di root (UID 0) sono inaccessibili perché l'UID 0 del container viene mappato a un UID alto sull'host. Risolvi cambiando la ownership:

# Find the remapped UID
grep dockremap /etc/subuid
# Then chown to that UID
sudo chown -R <remapped-uid>:<remapped-uid> /path/to/bind/mount

Sostituisci <remapped-uid> con il primo numero da /etc/subuid per l'utente dockremap (di solito 100000).

Log dei container: Per qualsiasi problema legato a Docker, parti dai log del container e dal journal del daemon Docker:

docker logs <container-name>
sudo journalctl -u docker -f

Per rootless Docker, controlla il journal a livello utente:

journalctl --user -u docker -f

Copyright 2026 Virtua.Cloud. Tutti i diritti riservati. Questo contenuto è un'opera originale del team Virtua.Cloud. La riproduzione, ripubblicazione o redistribuzione senza autorizzazione scritta è vietata.

Pronto a provare?

Distribuisci il tuo server in pochi secondi. Linux, Windows o FreeBSD.

Vedi piani VPS
Sicurezza Docker: Rootless, Seccomp, AppArmor su VPS