guest@6admin.io:~$

Docker rootless : le démon sans privilèges

Docker tourne en root par défaut. Le démon (dockerd) a accès à TOUT le filesystem, TOUT le réseau, TOUT les process. Un container qui s’échappe du démon obtient le root sur l’hôte. Le mode rootless déplace le démon vers un utilisateur normal: les containers tournent dans un namespace utilisateur et une évasion ne donne que les droits de cet utilisateur, pas le root.

Le problème

Un démon Docker en root, c’est un service qui a tous les droits sur la machine, accessible via un socket Unix (et parfois un port TCP). Chaque container qui monte un volume monte avec les droits root du démon. Chaque bug dans runc ou containerd est une évasion root.

L’installation

# Créer l'utilisateur
useradd -m -s /bin/bash dockerr

# Installer les dépendances rootless
apt-get install uidmap dbus-user-session

# Installer Docker rootless
curl -fsSL https://get.docker.com/rootless | sh

Le script installe le démon dans le home de l’utilisateur (~/bin/dockerd), configure les subuid/subgid (/etc/subuid, /etc/subgid) et démarre le démon en mode utilisateur.

La config Salt

Le déploiement automatisé via Salt:

{% set docker_user = salt["pillar.get"]("docker_rootless:user","docker") %}
{% set docker_version = "20.10.12" %}
{% set compose_version = "1.29.2" %}
{% set docker_home = "/home/" ~ docker_user %}
{% set docker_bin_dir = docker_home ~ "/bin" %}

apt_deps:
  pkg.installed:
    - names:
      - curl
      - dbus-user-session
      - uidmap
      - fuse-overlayfs
      - slirp4netns

net.ipv4.ip_unprivileged_port_start:
  sysctl.present:
    - name: net.ipv4.ip_unprivileged_port_start
    - value: 53

user.max_user_namespaces:
  sysctl.present:
    - name: user.max_user_namespaces
    - value: 15000

{% if grains.os_family == 'Debian' and grains.osmajorrelease <= 10 %}
kernel.unprivileged_userns_clone:
  sysctl.present:
    - name: kernel.unprivileged_userns_clone
    - value: 1
{% endif %}

{{ docker_user }}:
  user.present:
    - system:     true
    - fullname:   {{ docker_user }}
    - shell:      /bin/bash
    - home:       {{ docker_home }}
    - usergroup:  true
    - createhome: false

systemd_enable_lingering:
  cmd.run:
    - name: loginctl enable-linger {{ docker_user }}
    - require:
      - user: {{ docker_user }}
    - onchanges:
      - user: {{ docker_user }}

/etc/subuid:
  file.append:
    - text:
      - {{ docker_user}}:100000:65536
    - require:
      - pkg: apt_deps

/etc/subgid:
  file.append:
    - text:
      - {{ docker_user}}:100000:65536
    - require:
        - pkg: apt_deps

{{ docker_home }}:
  file.directory:
    - user:   {{ docker_user }}
    - group:  {{ docker_user }}
    - mode:   '0750'
    - require:
      - user: {{ docker_user }}

{{ docker_bin_dir }}:
  file.directory:
    - user:   {{ docker_user }}
    - group:  {{ docker_user }}
    - mode:   '0750'
    - require:
      - file: {{ docker_home }}

extract_docker:
  archive.extracted:
    - name:             {{ docker_bin_dir }}
    - user:             {{ docker_user }}
    - group:            {{ docker_user }}
    - source:           salt://docker/files/{{ docker_version }}-docker.tgz
    - options:          --strip-components=1
    - enforce_toplevel: false
    - require:
      - file : {{ docker_bin_dir }}

extract_docker_rootless:
  archive.extracted:
    - name:             {{ docker_bin_dir }}
    - user:             {{ docker_user }}
    - group:            {{ docker_user }}
    - source:           salt://docker/files/{{ docker_version }}-rootless.tgz
    - options:          --strip-components=1
    - enforce_toplevel: false
    - require:
      - file : {{ docker_bin_dir }}

{{ docker_bin_dir }}/docker-compose:
  file.managed:
    - user:   {{ docker_user }}
    - group:  {{ docker_user }}
    - mode:   '0750'
    - source: salt://docker/files/{{ compose_version }}-docker-compose.bin
    - require:
      - file : {{ docker_bin_dir }}

{{ docker_home }}/.profile:
  file.managed:
    - user:     nobody
    - group:    nogroup
    - mode:     '0644'
    - attrs:    i
    - replace:  true
    - template: jinja
    - source:   salt://docker/files/profile.j2
    - defaults:
        docker_bin_dir: {{ docker_bin_dir }}
    - require:
      - file: {{ docker_bin_dir }}

{{ docker_home }}/.config/systemd/user:
  file.directory:
    - user:     {{ docker_user }}
    - group:    {{ docker_user }}
    - mode:     '0750'
    - makedirs: true
    - require:
      - file: {{ docker_bin_dir }}

{{ docker_home }}/.config/systemd/user/docker.service.d:
  file.directory:
    - user:   {{ docker_user }}
    - group:  {{ docker_user }}
    - mode:   '0750'
    - require:
      - file: {{ docker_home }}/.config/systemd/user

{{ docker_home }}/.config/systemd/user/docker.service:
  file.managed:
    - user:     {{ docker_user }}
    - group:    {{ docker_user }}
    - mode:     '0640'
    - replace:  true
    - template: jinja
    - source:   salt://docker/files/systemd_rootless.j2
    - defaults:
        docker_bin_dir: {{ docker_bin_dir }}
    - require:
      - file :   {{ docker_home }}/.config/systemd/user
      - archive: extract_docker
      - archive: extract_docker_rootless

{{ docker_home }}/.config/systemd/user/docker.service.d/override.conf:
  file.managed:
    - user:    {{ docker_user }}
    - group:   {{ docker_user }}
    - mode:    '0640'
    - replace: true
    - source:  salt://docker/files/docker_service_override.conf
    - require:
      - file : {{ docker_home }}/.config/systemd/user/docker.service.d

systemd_reload_docker:
  cmd.run:
    - name: systemctl --user daemon-reload
    - runas: {{ docker_user }}
    - onchanges:
      - file: {{ docker_home }}/.config/systemd/user/docker.service
      - file: {{ docker_home }}/.config/systemd/user/docker.service.d/override.conf
    - require:
      - user: {{ docker_user }}
      - file: {{ docker_home }}/.config/systemd/user/docker.service
      - file: {{ docker_home }}/.config/systemd/user/docker.service.d/override.conf

{% if not salt["file.file_exists"](docker_home ~ "/.config/systemd/user/default.target.wants/docker.service") %}
systemd_enable_docker:
  cmd.run:
    - name: systemctl --user enable docker.service
    - runas: {{ docker_user }}
    - require:
      - cmd: systemd_reload_docker
{% endif %}

systemd_restart_docker:
  cmd.run:
    - name: systemctl --user restart docker.service
    - runas: {{ docker_user }}
    - onchanges:
      - file: /home/{{ docker_user }}/.config/systemd/user/docker.service
      - file: /home/{{ docker_user }}/.config/systemd/user/docker.service.d/override.conf

systemd_status_docker:
  cmd.run:
    - name: systemctl --user status docker.service
    - runas: {{ docker_user }}
    - onchanges:
      - file: /home/{{ docker_user }}/.config/systemd/user/docker.service
      - file: /home/{{ docker_user }}/.config/systemd/user/docker.service.d/override.conf
    - require:
      - cmd: systemd_restart_docker

docker_version:
  cmd.run:
    - name: docker --version
    - runas: {{ docker_user }}
    - onchanges:
      - archive: extract_docker
    - require:
      - archive: extract_docker
      - archive: extract_docker_rootless

docker-compose_version:
  cmd.run:
    - name: docker-compose --version
    - runas: {{ docker_user }}
    - onchanges:
      - file:  {{ docker_bin_dir }}/docker-compose
    - require:
      - file: {{ docker_bin_dir }}/docker-compose
      - cmd: docker_version

Les fichiers référencés

Le state dépose trois fichiers côté utilisateur, servis depuis le fileserver Salt (salt://docker/files/).

profile.j2, le ~/.profile du compte (l’environnement du client Docker):

[ -z "${XDG_RUNTIME_DIR}" ] && export XDG_RUNTIME_DIR="/run/user/$UID"
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"
export PATH="{{ docker_bin_dir }}:$PATH"

systemd_rootless.j2, l’unité systemd utilisateur (~/.config/systemd/user/docker.service):

[Unit]
Description=Docker Application Container Engine (Rootless)
Documentation=https://docs.docker.com/go/rootless/

[Service]
Environment=PATH={{ docker_bin_dir }}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart={{ docker_bin_dir }}/dockerd-rootless.sh
ExecReload=/bin/kill -s HUP $MAINPID
TimeoutSec=0
RestartSec=2
Restart=always
StartLimitBurst=3
StartLimitInterval=60s
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
Delegate=yes
Type=simple
KillMode=mixed

[Install]
WantedBy=default.target

docker_service_override.conf, force le driver de port slirp4netns pour récupérer les vraies adresses IP:

[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns"

Les binaires (20.10.12-docker.tgz, 20.10.12-rootless.tgz, 1.29.2-docker-compose.bin) sont servis depuis le fileserver, pas montrés ici.

Les points de douleur

  • cgroup v2: Docker rootless nécessite cgroup v2, pas cgroup v1. Sur Debian, GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1" dans /etc/default/grub, puis reboot.
  • slirp4netns: le réseau rootless passe par slirp4netns, un process userspace qui fait le NAT entre le container et le réseau hôte. Plus lent que le bridge Docker natif, mais fonctionnel.
  • Les volumes: les volumes Docker rootless sont dans le home de l’utilisateur, pas dans /var/lib/docker. Les chemins changent, les scripts qui supposent /var/lib/docker cassent.

Ce que ça change

Avant: dockerd en root, chaque container monte avec les droits root, une évasion = root sur l’hôte. Après: dockerd en utilisateur dockerr, chaque container monte avec les droits de dockerr, une évasion = les droits de dockerr (qui ne peut rien sur le système).

La surface d’attaque se réduit du root complet à un utilisateur confiné. C’est le principe non-root appliqué au démon lui-même.

Docker rootless est devenu standard dans les distributions modernes et Podman (l’alternative sans démon) l’a rendu encore plus naturel: pas de démon du tout. Le principe n’a pas bougé: une évasion de container ne donne que les droits de l’utilisateur, pas le root de l’hôte.

[edit: 2026] L’implémentation Ansible de tout ceci: les rôles docker et podman .