Contenedores Docker de solo lectura: Endurece sin romper tu app

¿Te fue útil?

Los contenedores de solo lectura parecen una ganancia clara en seguridad hasta que tu aplicación intenta escribir un archivo PID, cachear un resultado DNS, rotar un log o desempaquetar un paquete de certificados en /tmp como si fuera 2012. Entonces te encuentras viendo errores EROFS en producción y preguntándote por qué un “ajuste de endurecimiento simple” se convirtió en un incidente de servicio.

Esta es la forma práctica de hacerlo: asegurar el sistema de archivos del contenedor, mantener la aplicación contenta y conservar la cordura de los operadores. Nos centraremos en Docker, pero los patrones se mapean directamente a Kubernetes y cualquier runtime que pueda montar tmpfs y volúmenes.

Qué significa realmente “contenedor de solo lectura” (y qué no)

En términos de Docker, “contenedor de solo lectura” suele significar ejecutar un contenedor con --read-only. Eso convierte la capa escribible del contenedor en de solo lectura. Las capas de la imagen ya eran de solo lectura; el cambio es que la fina capa copy-on-write que normalmente absorbe las escrituras se vuelve inmutable.

Matiz importante: esto no significa que el contenedor no pueda escribir en ningún lugar. Significa que no puede escribir en el sistema de archivos raíz a menos que proporciones montajes escribibles. Los volúmenes, bind mounts y tmpfs son montajes separados y pueden permanecer escribibles. Si lo haces bien, el sistema de archivos raíz es efectivamente un dispositivo sellado, y las únicas superficies de escritura son las declaradas explícitamente.

El modelo de sistema de archivos con el que realmente estás tratando

  • Capas de la imagen: inmutables. Siempre de solo lectura.
  • Capa escribible del contenedor: normalmente copy-on-write escribible (OverlayFS/overlay2 en la mayoría de los casos). Con --read-only, se vuelve de solo lectura.
  • Montajes: volúmenes, bind mounts, tmpfs. Cada uno puede ser lectura/escritura o solo lectura independientemente del rootfs.
  • Sistemas de archivos virtuales provistos por el kernel: /proc, /sys, /dev. Estos son especiales y están controlados por banderas del runtime y capacidades de Linux, no solo por el “rootfs de solo lectura”.

Un rootfs de solo lectura no es un sandbox completo. Es un control: reduce la persistencia para un atacante y disminuye el radio de impacto de las “escrituras accidentales” de tu propio código. También te obliga a modelar explícitamente el estado: logs, cachés, archivos PID, subidas y configuración generada en tiempo de ejecución.

Una cita que se mantiene en operaciones (idea parafraseada): John Allspaw sostiene que la confiabilidad proviene de diseñar sistemas que hacen visibles y manejables los modos de fallo, no de asumir que nada fallará. El rootfs de solo lectura es exactamente ese tipo de restricción de diseño.

Por qué molestarse: modelo de amenazas y valor operativo

Seguridad: hacer que la persistencia sea costosa

Si un atacante obtiene ejecución de código dentro de un contenedor con rootfs escribible, puede dejar herramientas en /usr/local/bin, modificar código de la app o plantar persistencia tipo cron (sí, incluso en contenedores la gente intenta). Un rootfs de solo lectura no detiene la ejecución en tiempo de ejecución, pero bloquea un paso común: escribir nuevos binarios y editar los existentes.

¿Evita la exfiltración de datos? No. ¿Evita el malware residente en memoria? Tampoco. Pero eleva la barrera y reduce el estilo de persistencia “simplemente modificaré un archivo de configuración y esperaré”.

Operaciones: detener la deriva de configuración dentro del contenedor

Algunos equipos todavía hacen “hotfixes” con docker exec editando un archivo en un contenedor en ejecución. Es una tentación: hace desaparecer problemas hasta el próximo deploy, cuando reaparecen a las 2 a. m. Un rootfs de solo lectura hace que ese antipatrón falle ruidosamente. Bien. Arréglalo en la imagen o en la gestión de configuración.

Rendimiento y predictibilidad: menos escrituras en la capa COW

Los sistemas de archivos overlay pueden comportarse mal cuando una app escribe muchos archivos pequeños. Obtienes sobrecarga de copy-up, churn de inodos y momentos de “por qué se está disparando el uso de disco de mi contenedor”. Poner rutas de escritura conocidas en tmpfs o un volumen dedicado hace que el rendimiento sea más predecible y enfada menos al equipo de almacenamiento.

Broma #1: Un rootfs escribible es como una pizarra compartida de oficina: útil, pero tarde o temprano alguien dibuja un esquema de base de datos con marcador permanente.

Hechos interesantes y breve historia

  • Los sistemas de archivos union existen desde antes que Docker. AUFS y OverlayFS ya se usaban en sistemas en vivo y appliances embebidos; los contenedores los popularizaron a escala.
  • Los primeros drivers de almacenamiento de Docker eran desordenados. AUFS, Device Mapper, btrfs y OverlayFS tenían diferentes semánticas y casos límite; el rootfs de solo lectura era una forma de reducir “escrituras en lugares raros”.
  • El rootfs de solo lectura es anterior a los contenedores. Es un truco clásico de endurecimiento para chroot y sistemas tipo appliance donde solo /var es escribible.
  • Kubernetes lo convirtió en algo común. securityContext.readOnlyRootFilesystem lo transformó de “flag interesante de Docker” a una comprobación de política en muchas organizaciones.
  • El copy-up de OverlayFS es el costo oculto. Escribir en un archivo que existe en una capa inferior fuerza la copia del archivo entero a la capa superior antes de que ocurra la escritura.
  • Muchas imágenes base asumen un /tmp escribible. Gestores de paquetes, runtimes de lenguajes y herramientas TLS a menudo generan archivos temporales allí.
  • Algunas bibliotecas aún por defecto escriben cachés junto al código. Los caches de bytecode de Python (__pycache__) y el class data sharing de Java pueden sorprenderte.
  • El logging solía ser primero a ficheros. Hábitos de syslog y logrotate aún aparecen en imágenes que insisten en escribir en /var/log cuando stdout sería la respuesta correcta.
  • Las imágenes distroless ayudan, pero no resuelven las escrituras. Reducen herramientas y superficie, pero tu app aún necesita un lugar para el estado en tiempo de ejecución.

Patrones de diseño que no rompen aplicaciones

Patrón 1: Tratar la imagen como firmware

Construye tu imagen para que contenga todo lo necesario para correr: binarios, configs (o plantillas), bundles CA, datos de zona horaria y cualquier asset estático. Luego asume que no puede cambiar en tiempo de ejecución. Esto fuerza una separación limpia: código/config en la imagen (o inyectado), estado afuera.

Si actualmente “arreglas” contenedores editando archivos dentro de ellos, no tienes un problema de solo lectura. Tienes problemas de ingeniería de releases disfrazados de Docker.

Patrón 2: Rutas escribibles explícitas (el enfoque “/var es un contrato”)

La mayoría de las apps necesitan un pequeño conjunto de ubicaciones escribibles. Las más comunes:

  • /tmp para archivos temporales y sockets
  • /var/run (o /run) para archivos PID y sockets UNIX
  • /var/cache para cachés
  • /var/log solo si absolutamente debes registrar en ficheros (intenta evitarlo)
  • directorios de estado específicos de la app: /data, /uploads, /var/lib/app

Haz esas rutas escribibles usando tmpfs (para estado efímero) o volúmenes (para estado persistente). Todo lo demás permanece de solo lectura. Este es el movimiento central.

Patrón 3: Usar tmpfs para estado “que no debe persistir”

tmpfs está respaldado por RAM (y por swap si está habilitado). Es rápido, desaparece en reinicios y mantiene la basura fuera de la capa del contenedor. Ideal para:

  • Archivos PID
  • Sockets en tiempo de ejecución
  • Descompresión temporal
  • Caches de runtimes de lenguajes que no necesitas persistir

Sé disciplinado con el dimensionamiento de tmpfs. La actitud de “dale RAM y ya” es como acabas depurando kills por OOM que parecen fallos aleatorios.

Patrón 4: Los logs van a stdout/stderr, no a ficheros

Los contenedores no son mascotas. Los ficheros de log dentro de un contenedor son una trampa: llenan discos, necesitan rotación y se pierden si el contenedor muere. Prefiere stdout/stderr y deja que la plataforma maneje la agregación. Si debes escribir logs en disco (cumplimiento, agentes legacy), monta un volumen en /var/log y acepta el coste operativo.

Patrón 5: No olvides las bibliotecas que escriben “útilmente”

Ofensores comunes:

  • Nginx: quiere escribir en /var/cache/nginx y a veces en /var/run
  • OpenSSL tooling: puede escribir archivos temporales bajo /tmp
  • Java: escribe en /tmp y a veces necesita un $HOME escribible
  • Python: puede escribir caches de bytecode y puede esperar un $HOME escribible para algunos paquetes
  • Node: herramientas pueden escribir caches bajo /home/node/.npm si ejecutas pasos de build en tiempo de ejecución (no lo hagas)

Patrón 6: Preferir non-root + rootfs de solo lectura, pero mantenerlo depurable

El rootfs de solo lectura funciona bien junto a ejecutar como usuario no root. Reduces tanto “puede escribir” como “puede chmod/chown”. Pero no te pases quitando todas las herramientas y luego sorprenderte cuando on-call no pueda diagnosticar nada.

Si optas por distroless, planea una imagen de depuración separada o usa contenedores efímeros para debug. “Sin shell” está bien. “Sin plan” no lo está.

Patrón 7: Hacer el estado explícito en la app

El mejor endurecimiento es cuando la propia app es honesta sobre dónde escribe. Provee flags/vars de entorno para dirs de caché, dirs temporales y estado en tiempo de ejecución. Si tu app escribe en defaults aleatorios, jugarás a whack-a-mole con montajes.

Broma #2: Lo único más permanente que un archivo temporal es el archivo temporal que tu app escribe en cada petición.

Tareas prácticas: comandos, salidas, decisiones

Estas tareas están ordenadas como realmente harías un despliegue: inspeccionar, probar, restringir y luego verificar. Cada una incluye qué significa la salida y la decisión que tomas a partir de ella.

Task 1: Confirmar tu driver de almacenamiento (porque las semánticas importan)

cr0x@server:~$ docker info --format 'Storage Driver: {{.Driver}}'
Storage Driver: overlay2

Qué significa: Casi seguro estás en semánticas OverlayFS (copy-up, upperdir). El comportamiento alrededor de escrituras de archivos y agotamiento de inodos coincidirá con las expectativas de overlay2.

Decisión: Si no estás en overlay2 (por ejemplo devicemapper), valida el comportamiento de solo lectura y el rendimiento en staging. Los drivers antiguos tienen casos límite sorprendentes.

Task 2: Establecer línea base de escrituras del contenedor antes del endurecimiento

cr0x@server:~$ docker run --rm -d --name app-baseline myapp:latest
8b3c1d9d51a5a3a33bb3b4a2e7d0a9e5f3a7c1b0d8c9e2f1a6b7c8d9e0f1a2b3
cr0x@server:~$ docker exec app-baseline sh -c 'find / -xdev -type f -mmin -2 2>/dev/null | head'
/var/run/myapp.pid
/tmp/myapp.sock
/var/cache/myapp/index.bin

Qué significa: En los últimos dos minutos la app escribió en /var/run, /tmp y /var/cache. Esa es tu primera versión de “rutas escribibles”.

Decisión: Planea tmpfs para /tmp y /var/run; decide si /var/cache debe ser tmpfs (efímero) o un volumen (persistente entre reinicios).

Task 3: Comprobar crecimiento de uso de disco en la capa escribible

cr0x@server:~$ docker ps --format '{{.Names}} {{.ID}}'
app-baseline 8b3c1d9d51a5
cr0x@server:~$ docker inspect --format '{{.GraphDriver.Data.UpperDir}}' app-baseline
/var/lib/docker/overlay2/7c6f3e.../diff
cr0x@server:~$ sudo du -sh /var/lib/docker/overlay2/7c6f3e.../diff
84M	/var/lib/docker/overlay2/7c6f3e.../diff

Qué significa: El contenedor ya escribió 84 MB en su upper layer. Ese es “estado invisible” viviendo en el host, no en ningún volumen declarado.

Decisión: Si este número crece con tráfico, necesitas mover esas escrituras a tmpfs/volúmenes o eventualmente le reclamarán al equipo por “disco lleno en nodo worker”.

Task 4: Ejecutar el contenedor con rootfs de solo lectura y observar el modo de fallo

cr0x@server:~$ docker rm -f app-baseline
app-baseline
cr0x@server:~$ docker run --rm --name app-ro --read-only myapp:latest
myapp: error: open /var/run/myapp.pid: read-only file system

Qué significa: La primera escritura de la app es un archivo PID. Clásico. Ahora tienes una ruta precisa para hacer escribible.

Decisión: Añade tmpfs para /var/run (o cambia la app para que no escriba archivos PID si corre como PID 1 y no los necesita).

Task 5: Añadir tmpfs para rutas comunes de tiempo de ejecución

cr0x@server:~$ docker run --rm --name app-ro \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  myapp:latest
myapp: error: open /var/cache/myapp/index.bin: read-only file system

Qué significa: Arreglaste el primer fallo; la siguiente escritura se mostró. Esto es normal. El endurecimiento de solo lectura es una revelación iterativa de supuestos.

Decisión: Decide si /var/cache/myapp puede ser efímero. Si sí, ponlo en tmpfs. Si es caro reconstruirlo, usa un volumen.

Task 6: Montar un directorio de caché escribible dedicado

cr0x@server:~$ docker volume create myapp-cache
myapp-cache
cr0x@server:~$ docker run --rm --name app-ro \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
myapp: started on :8080

Qué significa: El contenedor ahora está operativo con áreas escribibles explícitas.

Decisión: Documenta el contrato: /tmp y /var/run son efímeros; /var/cache/myapp persiste entre reinicios del contenedor en el mismo host (o entre nodos si usas almacenamiento en red).

Task 7: Verificar que el sistema de archivos raíz está realmente montado como solo lectura

cr0x@server:~$ docker exec app-ro sh -c 'mount | head -n 6'
overlay on / type overlay (ro,relatime,lowerdir=/var/lib/docker/overlay2/l/...,
upperdir=/var/lib/docker/overlay2/u/...,
workdir=/var/lib/docker/overlay2/w/...)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)

Qué significa: El montaje overlay para / está en ro. Tus montajes tmpfs están en rw. Esta es la forma deseada.

Decisión: Si no ves ro en el montaje raíz, tu configuración del runtime no se está aplicando. Detente y corrige el despliegue; no “supongas que está bien”.

Task 8: Probar que las escrituras fallan donde deben

cr0x@server:~$ docker exec app-ro sh -c 'echo test > /etc/should-fail && echo wrote'
sh: can't create /etc/should-fail: Read-only file system

Qué significa: El contenedor no puede modificar rutas del sistema. Bien.

Decisión: Si esto tiene éxito, has dejado accidentalmente el root escribible y tu modelo de amenazas acaba de evaporarse.

Task 9: Confirmar que los montajes escribibles se comportan como se espera

cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /tmp/ok && cat /tmp/ok'
ok
cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /var/cache/myapp/ok && cat /var/cache/myapp/ok'
ok

Qué significa: El almacenamiento temporal y de caché está disponible. Tienes superficies de escritura controladas.

Decisión: Añade noexec en montajes tmpfs a menos que tengas una razón para no hacerlo. Bloquea una clase de comportamiento “descargar y ejecutar desde /tmp”.

Task 10: Buscar escrituras ocultas vía variables de entorno (HOME, XDG)

cr0x@server:~$ docker exec app-ro sh -c 'echo $HOME; ls -ld $HOME 2>/dev/null || true'
/home/app
drwxr-xr-x 2 app app 4096 Jan  1 00:00 /home/app
cr0x@server:~$ docker exec app-ro sh -c 'touch $HOME/.probe'
touch: cannot touch '/home/app/.probe': Read-only file system

Qué significa: El home del usuario de runtime existe pero no es escribible (porque está en el rootfs de solo lectura). Algunas bibliotecas intentan escribir configuración o caches bajo $HOME.

Decisión: O montas un tmpfs en /home/app (si es aceptable) o defines variables de entorno para redirigir caches a un montaje escribible (preferido cuando puedes controlarlo).

Task 11: Validar que el logging no escribe en disco

cr0x@server:~$ docker exec app-ro sh -c 'ls -l /var/log 2>/dev/null || true'
total 0
cr0x@server:~$ docker logs --tail=5 app-ro
2026-01-03T00:00:01Z INFO listening on :8080
2026-01-03T00:00:02Z INFO warmup complete

Qué significa: Los logs van a stdout/stderr (bien). /var/log no acumula ficheros.

Decisión: Si ves archivos de log aparecer, monta /var/log como volumen y gestiona la rotación externamente, o cambia la configuración del logger para stdout.

Task 12: Detectar problemas de permisos pronto con un usuario non-root

cr0x@server:~$ docker run --rm --name app-ro-nonroot \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
myapp: error: open /var/cache/myapp/index.bin: permission denied

Qué significa: Los permisos del directorio del volumen no permiten que UID 10001 escriba. Esto no es un fallo de solo lectura; es un desajuste de UID/GID en la propiedad.

Decisión: Arregla la propiedad del volumen (init one-time) o usa un runtime que permita fijar permisos de volumen. Evita ejecutar como root solo para esquivar pensar en propiedad de ficheros.

Task 13: Reparar la propiedad del volumen de forma segura (un enfoque)

cr0x@server:~$ docker run --rm -v myapp-cache:/var/cache/myapp alpine:3.20 \
  sh -c 'adduser -D -u 10001 app >/dev/null 2>&1; chown -R 10001:10001 /var/cache/myapp; ls -ld /var/cache/myapp'
drwxr-xr-x    2 app      app           4096 Jan  3 00:10 /var/cache/myapp

Qué significa: El directorio de caché ahora es propiedad del UID/GID non-root.

Decisión: Vuelve a ejecutar el contenedor endurecido como non-root. Si tu plataforma soporta init containers (Kubernetes), esto es un patrón más limpio a largo plazo que hacerlo manualmente.

Task 14: Volver a probar la ejecución endurecida como non-root

cr0x@server:~$ docker run --rm -d --name app-ro-nonroot \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
f2aa0e8d1bf2f7ad7f0c2b8b2b2a9a3d9a9e1e3c4b5d6e7f8a9b0c1d2e3f4a5b
cr0x@server:~$ docker exec app-ro-nonroot sh -c 'id'
uid=10001 gid=10001

Qué significa: Estás ejecutando endurecido y non-root. Eso es un avance significativo en contención.

Decisión: Ahora hazlo cumplir en CI/CD con una política: las imágenes deben ejecutarse como non-root y el rootfs debe ser de solo lectura a menos que exista una excepción documentada.

Task 15: Validar que tu app no esté fallando silenciosamente al persistir datos

cr0x@server:~$ docker exec app-ro-nonroot sh -c 'test -f /var/cache/myapp/index.bin && echo "cache exists" || echo "cache missing"'
cache exists

Qué significa: Tu caché se está creando y almacenando donde esperas.

Decisión: Si falta, la app podría estar silenciando errores de escritura y funcionando degradada. Añade health checks y métricas explícitas para el calentamiento de caché, subidas o cualquier estado que importe.

Task 16: Revisar denegaciones del kernel/LSM que parecen errores de sistema de archivos

cr0x@server:~$ dmesg --ctime | tail -n 5
[Fri Jan  3 00:12:10 2026] audit: type=1400 audit(1735863130.123:120): apparmor="DENIED" operation="open" profile="docker-default" name="/proc/kcore" pid=31245 comm="myapp"

Qué significa: No todos los “permission denied” son bits de modo del sistema de archivos. AppArmor (o SELinux) también puede bloquear accesos.

Decisión: Si ves denegaciones LSM, no desactives perfiles de seguridad al azar. Ajusta el perfil o arregla el comportamiento de la app que lo desencadena.

Guía rápida de diagnóstico

Cuando un despliegue de solo lectura rompe algo, no necesitas un debate filosófico. Necesitas un bucle de triage rápido que te diga qué intenta escribir dónde, y si la solución es un montaje, un cambio de configuración o un cambio de código.

Primero: identifica la ruta exacta que falla

  • Revisa los logs del contenedor buscando EROFS, Read-only file system, permission denied.
  • Encuentra la primera ruta que falla; suele ser la primera escritura y a menudo la más simple de abordar.
cr0x@server:~$ docker logs --tail=50 app-ro
myapp: error: open /var/run/myapp.pid: read-only file system

Segundo: determina si es rootfs read-only o permisos/propiedad del montaje

  • Si el error ocurre en una ruta que pretendías que fuera escribible, probablemente sea propiedad/permisos.
  • Si ocurre en una ruta que no montaste, es esperado; debes añadir un montaje escribible o cambiar la app para que no escriba allí.
cr0x@server:~$ docker exec app-ro sh -c 'mount | grep -E " /var/run | /var/cache | /tmp "'
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)

Tercero: inventario rápido de escrituras

  • Usa find en archivos modificados recientemente si el contenedor puede arrancar.
  • Si no puede arrancar, ejecuta el entrypoint en modo debug (o sobreescribe el comando) para obtener una shell y reproducirlo.
cr0x@server:~$ docker exec app-ro sh -c 'find / -xdev -type f -mmin -5 2>/dev/null | head -n 20'
/var/cache/myapp/index.bin
/tmp/myapp.sock
/var/run/myapp.pid

Cuarto: verifica cuellos de botella de recursos introducidos por tmpfs

  • Tmpfs consume memoria. Bajo carga, la presión de memoria se ve como “reinicios aleatorios”.
  • Observa uso de memoria y consumo de tmpfs.
cr0x@server:~$ docker exec app-ro sh -c 'df -h /tmp /var/run'
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M  2.1M   62M   4% /tmp
tmpfs            16M   44K   16M   1% /var/run

Quinto: confirma que no rompiste flujos de actualización o renovación de certificados

  • Si tu imagen previamente ejecutaba apt-get o descargaba assets en el arranque, el modo solo lectura lo bloqueará. Bien—muévelo a build time.
  • Si dependes de actualizaciones de bundle CA en tiempo de ejecución dentro del contenedor, para. Actualiza reconstruyendo y redeployando imágenes.

Tres mini-historias corporativas desde el terreno

Mini-historia 1: El incidente causado por una suposición equivocada

En una empresa SaaS mediana, un equipo de plataforma desplegó rootfs de solo lectura para “todos los servicios stateless”. Hicieron lo responsable: canary, monitorización, plan de rollback. Aun así el canary cayó en minutos.

El servicio era una API en Go. Parecía stateless. Hablaba con una base de datos y una cola. El equipo asumió que no escribía nada localmente. En realidad, tenía un cliente TLS que cacheaba respuestas OCSP e intermedios de certificados en un directorio bajo $HOME, heredado de un default de una biblioteca antigua. Bajo condiciones normales “estaba bien”, porque el directorio existía y era escribible en la capa superior del contenedor.

Con rootfs de solo lectura, las escrituras de caché fallaron. La biblioteca no falló cerrado; reintentó fetches de red agresivamente. La latencia se disparó, luego la dependencia descendente empezó a aplicar rate-limiting. La API estuvo arriba, pero lo suficientemente lenta como para causar timeouts en cadena en algunos servicios que la llamaban.

La solución fue aburrida: redirigir el directorio de caché a un tmpfs y limitar el comportamiento de reintentos. La lección fue más afilada: “stateless” no es una creencia. Es un contrato que aplicas por diseño y verificas por medición.

Mini-historia 2: La optimización que salió mal

Otra organización quería rootfs de solo lectura y además ser lista con el rendimiento. Movieron /tmp a tmpfs y pusieron un tamaño grande “para que nunca se llene”. En staging todo se veía genial. Builds más rápidas, manejo de peticiones más veloz para un servicio de procesamiento de imágenes. Todos se felicitaron.

Luego llegó el tráfico de producción con comportamiento real de usuarios: un pequeño porcentaje de peticiones subía imágenes enormes, y el servicio escribió múltiples archivos intermedios en /tmp por petición. El uso de memoria de tmpfs subió rápidamente. Linux hizo lo que hace bajo presión: empezó a reclamar, luego invocó el OOM killer.

El on-call vio contenedores reiniciando y asumió una regresión de código. Hicieron rollback del cambio de solo lectura y el problema “desapareció”, porque el servicio volvió a escribir intermedios en disco. Al día siguiente lo intentaron de nuevo y obtuvieron los mismos reinicios “misteriosos”.

La solución correcta fue mantener tmpfs para archivos temporales pequeños pero mover intermedios grandes a un volumen dedicado (o rediseñar para hacer streaming). También: fijar límites de tamaño en tmpfs que reflejen la realidad. “tmpfs ilimitado” es solo una forma creativa de convertir memoria en disco sin avisar a nadie.

Mini-historia 3: La práctica aburrida que salvó el día

Un equipo de servicios financieros tenía una política: cada contenedor declara sus rutas escribibles en un único lugar, revisado como cualquier otra interfaz. Mantenían un pequeño documento interno de “contrato del contenedor” junto al Dockerfile: qué rutas deben ser escribibles, cuáles son tmpfs, cuáles son volúmenes, cuáles son binds de solo lectura y por qué.

Durante una ola de seguridad, habilitaron rootfs de solo lectura en docenas de servicios. La mayoría de los cambios fueron rutinarios porque las rutas escribibles ya eran explícitas. Un puñado de apps legacy falló, pero las fallas fueron localizadas: el contrato les decía qué debía ser escribible y sus tests afirmaban que los montajes existían.

Un servicio aún falló en producción por una nueva versión de biblioteca que empezó a escribir una caché bajo /var/lib. Su canary lo detectó. El rollback fue limpio y la post-mortem fue directa: actualizar el contrato, añadir el montaje y añadir una prueba que busque Read-only file system en los logs durante el arranque.

No pasó nada heroico. Ese es el punto. La práctica aburrida evitó un outage entre servicios y convirtió un proyecto de endurecimiento arriesgado en una migración controlada.

Errores comunes: síntomas → causa raíz → solución

1) La app falla instantáneamente con “Read-only file system” en /var/run

Síntoma: Creación de archivo PID o socket falla en el arranque.

Causa raíz: Estado de runtime esperado bajo /var/run o /run pero el rootfs es de solo lectura.

Solución: Montar tmpfs en /var/run (y posiblemente en /run) o configurar la app para escribir PID/sockets en /tmp.

2) La app corre pero se vuelve lenta; downstream ve picos

Síntoma: No hay crash, pero la latencia aumenta tras habilitar solo lectura.

Causa raíz: Una escritura de caché falla y la biblioteca cambia silenciosamente a recomputar/reobtener en cada petición.

Solución: Identificar el directorio de caché, montar una ruta de caché escribible y añadir observabilidad para hit rate de caché o éxito de warmup.

3) “Permission denied” en un volumen montado

Síntoma: Las escrituras fallan aunque montaste un volumen en modo lectura/escritura.

Causa raíz: Desajuste UID/GID. El proceso non-root no puede escribir en la propiedad existente del volumen.

Solución: Fija la propiedad correcta mediante un paso init (init container, job de chown one-time) o usa un storage class que soporte fsGroup en Kubernetes.

4) Reinicios aleatorios tras cambiar /tmp a tmpfs

Síntoma: Contenedor recibe OOM-kill o eviction bajo carga.

Causa raíz: tmpfs consume memoria; archivos temporales grandes amplifican la presión de memoria.

Solución: Dimensionar tmpfs de forma conservadora, mover cargas temporales grandes a un volumen o rediseñar para streaming en lugar de volcar a disco.

5) Nginx falla con errores de cache o client body temp

Síntoma: Logs de Nginx: “open() … failed (30: Read-only file system)” o “client_body_temp”.

Causa raíz: Nginx por defecto escribe archivos temporales y caché bajo /var/cache/nginx.

Solución: Montar /var/cache/nginx escribible (tmpfs para temp, volumen para cache) y configurar client_body_temp_path a un directorio escribible.

6) Java o herramientas Python fallan porque esperan un home escribible

Síntoma: Errores escribiendo en /root o /home/app o rutas XDG.

Causa raíz: Las bibliotecas usan por defecto $HOME para caches/config incluso en procesos server.

Solución: Establece HOME a un tmpfs escribible (con cuidado) o configura rutas de caché específicas del lenguaje hacia montajes escribibles.

7) “Funciona en Docker, falla en Kubernetes” (o al revés)

Síntoma: El comportamiento difiere entre entornos.

Causa raíz: Montajes por defecto diferentes, contextos de seguridad o flags de solo lectura distintos. Kubernetes además inyecta mounts de cuenta de servicio y puede establecer filesystem groups.

Solución: Haz los montajes explícitos en ambos entornos. No dependas de “defaults de la plataforma” para rutas escribibles.

Listas de verificación / plan paso a paso

Plan de despliegue paso a paso (qué haría en un equipo de producción)

  1. Inventariar escrituras en el contenedor baseline. Ejecuta bajo carga normal y lista archivos modificados recientemente en / con -xdev. Captura las rutas.
  2. Clasificar cada ruta: efímera (tmpfs), persistente (volumen) o “no debería existir” (arreglar la app/imagen).
  3. Eliminar instaladores en tiempo de ejecución. Si el entrypoint ejecuta installs, descargas o compila, muévelos a build time. El modo solo lectura forzará el tema de todos modos.
  4. Añadir --read-only y los tmpfs mínimos. Empieza con /tmp y /var/run.
  5. Iterar las fallas. Añade la siguiente ruta escribible solo después de confirmar que es necesaria y está correctamente acotada (un directorio, no “montar / como rw”).
  6. Ejecutar como non-root. Arreglar problemas de propiedad de volúmenes correctamente. Aquí es donde los equipos intentan hacer trampa. No lo hagas.
  7. Endurecer más: usa noexec en tmpfs, quita capacidades de Linux y aplica un perfil seccomp restrictivo donde sea factible.
  8. Añadir tests: test de integración que la imagen arranca con rootfs de solo lectura y que solo puede escribir en rutas declaradas.
  9. Canary en producción. Vigila latencia, tasas de error, reinicios y memoria (tmpfs). Avanza o retrocede rápido.
  10. Documentar el contrato. Las rutas escribibles son ahora parte de la interfaz del servicio.

Checklist de endurecimiento (rápido pero estricto)

  • Root filesystem montado en solo lectura (--read-only / readOnlyRootFilesystem: true).
  • tmpfs para /tmp y /var/run con nosuid,nodev,noexec cuando sea posible.
  • Montajes de volúmenes dedicados para estado verdaderamente persistente.
  • Logs a stdout/stderr; evitar escribir en /var/log.
  • Usuario non-root, con propiedad de volúmenes gestionada explícitamente.
  • No a instalaciones en tiempo de ejecución, no a binarios que se auto-actualizan.
  • Health checks que detecten modo “degradado pero en ejecución” (fallos de caché, fallos de upload).
  • Monitorización de uso de tmpfs y reinicios de contenedores.

Guía de selección de montaje (decide como adulto)

  • tmpfs: secretos descifrados en tiempo de ejecución, archivos PID, archivos temporales pequeños, sockets. Genial para velocidad; peligroso si no está acotado.
  • volumen nombrado: caches que quieras persistir en un host, estado pequeño, colas usadas para buffering (idealmente no dentro del contenedor de la app, pero la realidad ocurre).
  • bind mount: config o certificados proporcionados por el host (a menudo en solo lectura). Ten cuidado: te acoplas a rutas del host.
  • no montar: cualquier cosa que puedas eliminar cambiando la app para stream, para loggear a stdout o para tratar la imagen como inmutable.

Preguntas frecuentes

1) ¿Hace --read-only que mi contenedor sea “seguro”?

No. Es un control que reduce la persistencia en el sistema de archivos y la mutación accidental. Aún necesitas non-root, quitar capacidades, controles de red y parches mediante rebuilds.

2) Si monto un volumen escribible, ¿no se contradeciría el propósito?

No, si eres intencional. El objetivo es reducir la superficie escribible y hacerla explícita. Un pequeño volumen escribible para /var/cache/myapp es mucho mejor que “todo puede escribir en cualquier parte”.

3) ¿Por qué no ejecutar todo sin estado y evitar volúmenes?

Porque muchas apps “stateless” aún necesitan espacio temporal, sockets y caches. El objetivo es mantener el estado mínimo y controlado, no fingir que no existe.

4) ¿Puedo montar / como solo lectura y luego remontar partes escribibles dentro del contenedor?

Dentro de un contenedor, remontar típicamente requiere privilegios elevados y capacidades que no deberías conceder. Haz los montajes desde el runtime (Docker/Kubernetes) para que el proceso del contenedor no pueda ampliar sus permisos de escritura.

5) ¿Cuáles son los montajes mínimos escribibles que la mayoría de apps necesita?

Generalmente /tmp y /var/run. A partir de ahí es específico de la app: caches, uploads, archivos SQLite, etc. La respuesta correcta es “lo que tu app demuestre necesitar bajo carga”.

6) ¿Cómo funciona esto en Kubernetes?

Configuras securityContext.readOnlyRootFilesystem: true y luego defines volúmenes emptyDir (opcionalmente medium: Memory) para comportamiento tipo tmpfs, además de volúmenes persistentes donde se requiera. El mismo contrato de rutas escribibles aplica.

7) ¿Por qué obtengo “permission denied” si el montaje es rw?

Porque la propiedad y permisos del sistema de ficheros siguen aplicando. Un montaje lectura/escritura no hace que UID 10001 tenga automáticamente permiso para escribir en un directorio propiedad de root. Arregla la propiedad o usa fsGroup/pasos init.

8) ¿Y las aplicaciones que generan archivos de configuración al arrancar?

Prefiere generar la config en un montaje escribible (como /tmp o /var/run) y apunta a la app hacia ahí. Mejor: genera configs en tiempo de build o mediante configuración inyectada (env vars, archivos montados).

9) ¿Romperá noexec en /tmp algo?

A veces. Herramientas que extraen y ejecutan binarios desde /tmp fallarán. A menudo eso es deseable en producción. Si tu app realmente lo necesita (raro en servicios bien construidos), documenta la excepción y constriéndela.

10) ¿Cómo pruebo esto en CI?

Ejecuta el contenedor con --read-only y los tmpfs/volúmenes requeridos, ejecuta un smoke test y falla el build ante cualquier línea de log con Read-only file system o salida con código distinto de cero.

Conclusión: siguientes pasos que realmente perduran

Los contenedores de solo lectura son una de esas medidas de hardening raras que además mejoran la claridad operativa. Te obligan a declarar dónde vive el estado, lo que facilita la depuración y hace más difícil convertir una compromisión en persistencia. El coste es que las suposiciones perezosas del sistema de archivos de tu app se vuelven tu problema. Eso no es un inconveniente; es la realidad sin la máscara puesta.

Haz esto a continuación

  1. Elige un servicio que controles de extremo a extremo y ejecútalo con --read-only en staging.
  2. Itera montajes escribibles hasta que funcione, luego redúcelos al mínimo de directorios.
  3. Mueve logs a stdout, detén instaladores en tiempo de ejecución y haz las cachés explícitas.
  4. Ejecuta como non-root y arregla la propiedad de volúmenes correctamente.
  5. Convierte la lista final de montajes en un contrato: versionado, revisado y testeado.

Si solo haces una cosa: deja de permitir que los contenedores escriban donde quieran. Tus futuros incidentes serán más aburridos, y eso es el mayor cumplido que producción puede ofrecer.

← Anterior
La historia del regreso de Ryzen: por qué pareció repentino (pero no lo fue)
Siguiente →
ZFS redundant_metadata: cuando más copias de metadatos sí importan

Deja un comentario