Encerrar mis servicios en Python con systemd, sin Docker
Cómo aíslo los servicios en Python de mi VM de 836 MiB con DynamicUser, ProtectSystem y MemoryMax, y por qué dos servicios casi iguales sacan un 6,1 y un 1,7 en systemd-analyze.

Mi VM de Azure tiene 836 MiB de RAM. Lo digo de entrada porque condiciona casi todo lo que viene después. Ahí dentro conviven nginx, CrowdSec y una colección de servicios pequeños en Python que he ido escribiendo yo: el colector de Web Vitals que recibe los sendBeacon de la web, el backend del chat de “pregúntale a mi CV” y el panel de administración con passkeys. Ninguno es gran cosa. Todos están escritos por un estudiante a horas poco recomendables, que es justo el tipo de código que no quiero corriendo como root ni con permiso para tocar /etc.
Meter Docker para esto me parecía matar moscas a cañonazos, en una máquina que ya tiene más de 700 MiB metidos en swap. Así que tiré de lo que ya estaba ahí, que es systemd. Empecé por el colector de Web Vitals, porque es el más expuesto (acepta peticiones POST de cualquiera que cargue la web) y a la vez el que menos hace.
Una línea que activa media docena
Lo primero que puse fue DynamicUser=yes. Según la documentación de systemd.exec, systemd crea un usuario y un grupo al arrancar la unidad y los libera al pararla. No aparecen en /etc/passwd, porque los resuelve el módulo NSS nss-systemd mientras el servicio existe. Los UID salen del rango 61184–65519 y se reciclan entre unidades.
Lo interesante está en la letra pequeña. Activar DynamicUser= implica, sin escribirlas, ProtectSystem=strict, ProtectHome=read-only, PrivateTmp=, NoNewPrivileges=, RestrictSUIDSGID= y RemoveIPC=. Tiene sentido cuando entiendes el motivo, que es el reciclaje de UID. Si el servicio dejara ficheros sueltos por el disco, la siguiente unidad que recibiera ese mismo UID podría leerlos. Poettering lo cuenta con más calma en el post donde presentó la idea. Tiene sus años, pero para entender el porqué sigue siendo lo mejor que he leído.
Hay un detalle que conviene saber. Lo que va implícito es ProtectHome=read-only, no yes, así que el servicio puede seguir leyendo cosas de /home si los permisos se lo dejan. Ninguno de mis servicios pinta nada ahí (el código vive en /usr/local/bin y /opt), de modo que en todos lo puse explícito a yes.
Dónde escribe un servicio que no puede escribir en ningún sitio
ProtectSystem=strict monta todo el árbol de ficheros en solo lectura para el proceso, salvo /dev, /proc y /sys. La forma más rápida de verlo es con systemd-run, sin escribir ninguna unidad:
$ sudo systemd-run --wait --pipe -q -p DynamicUser=yes touch /var/lib/prueba
/usr/bin/touch: cannot touch '/var/lib/prueba': Read-only file system
Hasta aquí bien, pero los servicios tienen que guardar algo en algún sitio, y hay dos caminos. ReadWritePaths= abre un directorio concreto, y el propio manual avisa de que con usuarios dinámicos hay que vigilar lo que queda escrito ahí cuando el UID cambie de manos. El otro son las opciones StateDirectory=, LogsDirectory= y compañía: systemd crea el directorio, se lo asigna al UID de turno y le pasa la ruta al proceso en una variable ($STATE_DIRECTORY, $LOGS_DIRECTORY).
Con DynamicUser= el directorio real no está donde crees. El colector escribe en /var/log/vitals, pero eso es un enlace simbólico:
$ ls -l /var/log | grep vitals
lrwxrwxrwx 1 root root 14 May 12 vitals -> private/vitals
$ sudo ls -l /var/log/private
drwxr-x--- 2 vitals-collector vitals-collector 4096 May 12 vitals
A /var/log/private (y a /var/lib/private, donde el chat guarda su estado) solo entra root, y por eso otro servicio que herede el UID no puede tocar los datos.
Dos unidades, dos notas
Esta es la unidad del colector tal cual la tengo:
[Service]
Type=simple
ExecStart=/usr/local/bin/vitals-collector.py
Restart=on-failure
RestartSec=5
DynamicUser=yes
LogsDirectory=vitals
LogsDirectoryMode=0750
ReadWritePaths=/var/log/vitals
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
NoNewPrivileges=yes
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=yes
SystemCallArchitectures=native
MemoryMax=64M
CPUQuota=30%
Releyéndola para este artículo me sobran cosas. ProtectSystem=strict, PrivateTmp y NoNewPrivileges ya los trae DynamicUser, y el ReadWritePaths= es redundante con LogsDirectory=. No hacen daño, pero dicen que cuando la escribí no tenía claro qué hacía cada línea.
El backend del chat lo escribí meses después, ya sabiendo algo más, y lleva bastantes opciones extra:
[Service]
EnvironmentFile=/etc/claude-ai/api.env
ExecStart=/opt/claude-ai/cv_chat.py
DynamicUser=yes
StateDirectory=cv-chat
StateDirectoryMode=0750
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
MemoryMax=160M
CPUQuota=50%
Las diferencias gordas son CapabilityBoundingSet= vacío (el proceso no puede tener ninguna capability, ni aunque algo intente dársela), SystemCallFilter=@system-service, que limita las llamadas al sistema al grupo que necesita un servicio normal, y RestrictNamespaces=.
Para ver cuánto se nota, está systemd-analyze security. Según su manual, revisa las opciones de sandbox de cada unidad y calcula un “exposure level” entre 0.0 y 10.0, donde más alto es peor. Lynis también lo sugiere en su aviso BOOT-5264.
$ systemd-analyze security vitals-collector.service | tail -1
→ Overall exposure level for vitals-collector.service: 6.1 MEDIUM
$ systemd-analyze security cv-chat.service | tail -1
→ Overall exposure level for cv-chat.service: 1.7 OK
$ systemd-analyze security nginx.service | tail -1
→ Overall exposure level for nginx.service: 9.6 UNSAFE
Del 6,1 al 1,7 con una docena de líneas. Y nginx, que es lo único expuesto de verdad a Internet, en 9,6, porque es la unidad que trae el paquete sin tocar.
El propio manual avisa de que solo mide lo que aplica systemd. No sabe nada de lo que haga el código del servicio, y una nota alta tampoco significa que el servicio sea vulnerable. Yo lo uso como lista de comprobación. Intentar bajarlo a cero me parece excesivo, porque a partir de cierto punto empiezas a añadir filtros que no entiendes del todo y que te romperán algo en la próxima actualización.
MemoryMax en una máquina que ya vive en swap
La parte de recursos está en otra página, systemd.resource-control. Me sorprendió que MemoryMax= no sea lo que el manual recomienda como mecanismo principal. MemoryHigh= es un límite de estrangulamiento: si el proceso lo pasa, el kernel lo frena y le quita memoria agresivamente. MemoryMax= es el techo duro, y si el uso no se puede contener por debajo, salta el OOM killer dentro de la unidad. La recomendación es usar el primero para el día a día y dejar el segundo como última línea de defensa. Por debajo son los ficheros memory.high y memory.max del cgroup v2, que vienen descritos en la documentación del kernel.
Antes de esto yo tenía en la cabeza ulimit y LimitRSS=, y me equivocaba. La tabla de systemd.exec dice que LimitRSS= no tiene efecto en Linux, y LimitAS= lo que limita es el espacio de direcciones. Si lo hubiera puesto, me habría quedado tan tranquilo con un límite que no hace nada.
Los valores los saqué de lo que gastan de verdad. systemd lleva la cuenta de cada unidad:
$ systemctl show vitals-collector -p MemoryCurrent -p MemoryPeak
MemoryCurrent=4456448
MemoryPeak=19959808
$ systemctl show cv-chat -p MemoryCurrent -p MemoryPeak
MemoryCurrent=3129344
MemoryPeak=87011328
El colector vive en unos 4 MB y ha llegado a 20, así que 64 le deja margen de sobra. El chat descansa en 3 MB pero ha tocado los 87 en algún momento, y le di 160.
Comprobarlo con un proceso que se pasa
No me fío de un límite que no he visto saltar. Con systemd-run se crea una unidad transitoria con los mismos límites y se le mete un Python que reserve 200 MB de golpe:
$ sudo systemd-run --wait --pipe -p MemoryMax=50M -p MemorySwapMax=0 \
python3 -c 'b = b"x" * 200_000_000'
Running as unit: run-u319.service
Finished with result: oom-kill
Main processes terminated with: code=killed/status=KILL
Service runtime: 112ms
Memory peak: 50.0M
Memory swap peak: 0B
Y en el log del kernel:
Memory cgroup out of memory: Killed process 1105614 (python3) total-vm:213660kB, anon-rss:50968kB, ...
112 milisegundos y fuera, sin que el resto de la máquina se enterase.
Ahí puse MemorySwapMax=0 a propósito, y eso me hizo pensar en el swap. Cuando un cgroup llega a su límite, el kernel intenta primero recuperar memoria de ese cgroup, y eso incluye mandar páginas a swap. En una VM con tanto swap ocupado, un servicio con una fuga no muere rápido: va cada vez más lento y arrastra a los demás con él. Mis unidades reales no tienen MemorySwapMax, y de hecho el colector tiene ahora mismo unos 11 MB en swap. Para un servicio que recoge métricas prefiero que caiga y que lo levante Restart=on-failure, así que es lo siguiente que voy a añadir.
Lo que queda
Ojo con las versiones al copiar del manual. La documentación “latest” de freedesktop.org va bastante por delante de la systemd 255 que trae Ubuntu 24.04, y cada opción indica en qué versión apareció. Conviene mirarlo antes de pegar nada, o te encuentras con un aviso de opción desconocida en el journal y un servicio que arranca menos protegido de lo que crees.
Lo siguiente es nginx, que es lo único expuesto de verdad y también lo más difícil de encerrar bien: arranca como root para abrir el 443, tiene que leer los certificados que renueva certbot y escribe sus logs. Un drop-in con ProtectSystem=full, PrivateTmp=yes y NoNewPrivileges=yes parece un punto de partida con poco riesgo, y con ese 9,6 tengo con qué comparar.