ZFS para Docker: diseño de datasets que evita la explosión de capas

¿Te fue útil?

Te despiertas con el sistema de archivos raíz lleno. Docker “solo usa 40GB” según docker system df,
pero df -h grita. Las compilaciones de CI son lentas. Los prune no ayudan. Alguien sugiere “simplemente añade disco”,
y puedes sentir a tu yo de guardia futuro preparando una queja.

En ZFS, la diferencia entre un host que ejecuta contenedores tranquilamente durante años y un host que estalla en
mil pequeñas capas, snapshots y clones es principalmente una cosa: el diseño de datasets. Hazlo bien y el uso de disco
se vuelve legible. Hazlo mal y estarás haciendo arqueología con zdb a las 2 a.m.

Cómo se ve la “explosión de capas” en ZFS

Las imágenes de Docker son pilas de capas. Con el driver ZFS, cada capa puede mapearse a un dataset ZFS
(o a un clone de un snapshot) dependiendo de la implementación y versión de Docker. Eso no es automáticamente malo.
Los clones de ZFS son baratos al crearse. La factura llega después, con intereses:

  • Tormentas de clones: cada arranque de contenedor produce un clone escribible, y tu pool empieza
    a parecer un árbol genealógico.
  • Proliferación de snapshots: las capas vienen con snapshots; los snapshots mantienen bloques vivos; los datos “borrados”
    siguen contando porque están referenciados.
  • Agitación de metadatos: muchos datasets pequeños significan mucha metadata de dataset, operaciones de montaje,
    y sorpresas por herencia de propiedades.
  • Contabilidad de espacio engañosa (para humanos): df ve un montaje, Docker ve capas lógicas,
    ZFS ve bytes referenciados, y tu cerebro no ve nada con claridad.

La explosión de capas no es solo “demasiadas imágenes”. Es demasiados objetos de sistema de archivos cuya vida útil no coincide
con tu ciclo operacional
. La solución no es “hacer más prune”; la solución es alinear los límites de datasets de ZFS con lo que
realmente gestionas: estado del motor, caché de imágenes, capas escribibles de contenedores y datos persistentes de aplicaciones.

Datos interesantes y contexto histórico

  1. Los clones de ZFS se diseñaron para aprovisionamiento instantáneo (piensa en entornos de desarrollo y plantillas de VM). El modelo de capas de Docker coincide con esa forma—a veces demasiado bien.
  2. Docker inicialmente promovió AUFS con fuerza porque los sistemas de fusión eran el modelo mental más simple para capas. ZFS llegó después como driver con semánticas distintas y bordes más afilados.
  3. OverlayFS ganó el lugar por defecto en la mayoría de distribuciones Linux en gran parte porque es “suficientemente bueno” y vive en el kernel sin la historia de módulos separada de ZFS.
  4. ZFS distingue entre “referenciado” y “lógico”. Por eso “lo borré” no es lo mismo que “el pool recuperó espacio” cuando hay snapshots y clones involucrados.
  5. Los valores por defecto de recordsize (128K) vienen de cargas orientadas al rendimiento. Bases de datos, cargas de archivos pequeños y capas de contenedores a veces quieren valores distintos, y “una talla para todos” es un mito.
  6. La compresión LZ4 se volvió la opción obvia en muchas instalaciones de ZFS porque es lo suficientemente rápida para ser aburrida y a menudo reduce significativamente escrituras—especialmente en capas llenas de texto y binarios.
  7. La deduplicación ha sido una advertencia recurrente en el mundo ZFS: atractiva en presentaciones, implacable en RAM y complejidad operativa. Las imágenes de contenedores tientan a la gente a probarla.
  8. “ZFS on Linux” maduró hacia OpenZFS como proyecto multiplataforma. Esa madurez es la razón por la que mucha gente ahora ejecuta ZFS en hosts de contenedores de producción con confianza.

Objetivos de diseño: qué te aporta un layout sensato

Si ejecutas Docker sobre ZFS en producción, quieres que el host se comporte como un aparato:
actualizaciones predecibles, retrocesos aburridos, planificación de capacidad sencilla y fallos que sean ruidosos temprano en lugar de sutiles tarde.

1) Separar ciclos de vida

El estado del motor Docker, la caché de imágenes, las capas escribibles y los volúmenes persistentes no comparten un ciclo de vida.
Tratar todo como un único árbol de directorios bajo un dataset es la forma más rápida de llegar a “no podemos limpiar esto sin arriesgar producción.”

2) Hacer la contabilidad de espacio legible

ZFS puede decirte exactamente dónde están los bytes—si le das límites que se correspondan con tu modelo mental.
Los datasets te dan used, usedbydataset, usedbysnapshots, usedbychildren, cuotas y reservas.
Un dataset monolítico te da un número grande y dolor de cabeza.

3) Evitar que la “basura sobreviva”

El churn de Docker crea basura que persiste debido a snapshots y clones. El layout debe permitir destruir subárboles enteros de forma segura (y rápida)
cuando decidas que la caché o las capas son desechables.

4) Mantener el rendimiento ajustable

Las capas escribibles de contenedores se comportan como escrituras aleatorias pequeñas. Las descargas de imágenes son escrituras secuenciales.
Las bases de datos en volúmenes pueden tener sus propias necesidades. Necesitas propiedades a nivel de dataset para afinar sin convertir todo el pool en una feria de ciencias.

Idea parafraseada de Werner Vogels: “Todo falla, todo el tiempo—diseña para que los fallos estén contenidos y sean recuperables.”
Esto es exactamente lo que hacen los límites de dataset ante fallos de almacenamiento: contienen el radio de desastre.

Por qué funciona: mecánica de ZFS que importa

Los clones mantienen los bloques vivos

El driver ZFS se apoya en snapshots y clones: las capas de imagen se convierten en snapshots, las capas escribibles en clones.
Eso es eficiente—hasta que intentas recuperar espacio. Una capa borrada puede seguir referenciando bloques vía una cadena de clones.
El pool ve “bytes referenciados”, y los bytes referenciados no desaparecen solo porque Docker los olvidó.

Los límites de dataset son límites operacionales

Si tu estado de Docker está en un solo dataset, puedes establecer propiedades para ese conjunto de comportamientos. También puedes
destruirlo como unidad. Si tus volúmenes persistentes viven dentro de ese dataset, has soldado tus joyas de la corona a tu montón de basura.

Quota y refquota no son la misma herramienta

quota limita un dataset más sus hijos. refquota limita solo el dataset en sí.
Para “cada app obtiene 200G pero puede crear datasets hijos dentro”, quota es útil.
Para “este dataset no debe crecer, independientemente de snapshots en otro lugar”, refquota te da control más directo.

El comportamiento de montaje importa para la fiabilidad de Docker

Docker espera que /var/lib/docker esté presente temprano y se mantenga estable. Los datasets ZFS montan vía zfs mount
(a menudo gestionados por servicios systemd). Si entierras Docker dentro de una jerarquía auto-montable con dependencias límite,
acabarás produciendo una carrera de arranque y un demonio Docker muy confundido.

La presión del ARC es real en hosts de contenedores

A ZFS le gusta la RAM. A los hosts de contenedores también. Si no limitas el ARC en un nodo ocupado, puedes dejar sin memoria a las cargas
de contenedores de maneras sutiles: reclaim elevado, picos de latencia y mucho “está lento pero nada está al 100%”.

Broma #2: La dedup parece almacenamiento gratis hasta que tu RAM aprende lo que significa “horas extra obligatorias”.

Tareas prácticas (comandos, salidas y decisiones)

Estas son tareas operacionales reales que puedes ejecutar en un host Docker usando ZFS. Cada una incluye: el comando, salida de ejemplo,
lo que significa la salida y la decisión que tomas a partir de ella. Ejecútalas en este orden cuando estés construyendo confianza, y
en un bucle más cerrado cuando estés extinguiendo fuegos.

Tarea 1: Confirmar que Docker usa realmente el driver de almacenamiento ZFS

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

Significado: La tienda de imágenes/capas de Docker es consciente de ZFS. Si dice overlay2, este artículo sigue siendo útil para volúmenes, pero no para la mecánica de capas.

Decisión: Si no es zfs, detente y decide si vas a migrar drivers o solo organizar volúmenes.

Tarea 2: Identificar el dataset que respalda /var/lib/docker

cr0x@server:~$ findmnt -no SOURCE,TARGET /var/lib/docker
tank/var/lib/docker /var/lib/docker

Significado: Tu raíz de Docker es un dataset, no solo un directorio. Bien—ahora puedes establecer propiedades y cuotas de forma limpia.

Decisión: Si no es un dataset (por ejemplo, muestra /dev/sda2), planifica una migración antes de tocar el tuning.

Tarea 3: Listar el dataset de Docker y los hijos inmediatos

cr0x@server:~$ zfs list -r -o name,used,refer,avail,mountpoint tank/var/lib/docker | head
NAME                       USED  REFER  AVAIL  MOUNTPOINT
tank/var/lib/docker        78.4G  1.20G   420G  /var/lib/docker
tank/var/lib/docker/zfs    77.1G  77.1G   420G  /var/lib/docker/zfs

Significado: El driver de Docker a menudo crea un dataset hijo (comúnmente llamado zfs) que contiene datasets de capas.

Decisión: Si ves miles de hijos bajo este árbol, la explosión de capas ya está ocurriendo; la gestionarás con cuotas y cadencia de limpieza.

Tarea 4: Contar cuántos datasets ha generado Docker

cr0x@server:~$ zfs list -r tank/var/lib/docker/zfs | wc -l
3427

Significado: Eso es número de datasets, no número de imágenes. Miles no es automáticamente fatal, pero correlaciona con montajes lentos, destrucciones lentas y secuencias de arranque lentas.

Decisión: Si esto crece sin control, necesitas retención de imágenes más estricta, limpieza de CI, o un nodo de compilación separado que puedas resetear.

Tarea 5: Ver dónde está el espacio: dataset vs snapshots vs hijos

cr0x@server:~$ zfs list -o name,used,usedbydataset,usedbysnapshots,usedbychildren -r tank/var/lib/docker | head
NAME                       USED  USEDDS  USEDSNAP  USEDCHILD
tank/var/lib/docker        78.4G   1.20G     9.30G     67.9G
tank/var/lib/docker/zfs    77.1G   2.80G     8.90G     65.4G

Significado: Si usedbysnapshots es grande, los datos “borrados” están siendo retenidos por snapshots. Si usedbychildren domina, los datasets de capas son los que consumen espacio.

Decisión: Uso alto de snapshots: reduce el snapshotting en datasets de Docker y limpia snapshots antiguos. Uso alto de hijos: prune de imágenes/contenedores y considera resetear el dataset de Docker si es seguro.

Tarea 6: Encontrar los snapshots más antiguos relacionados con Docker (si hay)

cr0x@server:~$ zfs list -t snapshot -o name,creation,used -s creation | grep '^tank/var/lib/docker' | head
tank/var/lib/docker@weekly-2024-11-01  Fri Nov  1 02:00  1.12G
tank/var/lib/docker@weekly-2024-11-08  Fri Nov  8 02:00  1.08G

Significado: Las políticas de snapshot a nivel de host a veces incluyen accidentalmente datasets de Docker. Eso suele ser contraproducente con el driver ZFS.

Decisión: Excluye los datasets de Docker de horarios de snapshot genéricos; haz snapshots de datasets persistentes de aplicaciones en su lugar.

Tarea 7: Comprobar propiedades críticas de ZFS en el dataset de Docker

cr0x@server:~$ zfs get -o name,property,value -s local,inherited compression,atime,xattr,recordsize,acltype tank/var/lib/docker
NAME                 PROPERTY    VALUE
tank/var/lib/docker  compression lz4
tank/var/lib/docker  atime       off
tank/var/lib/docker  xattr       sa
tank/var/lib/docker  recordsize  16K
tank/var/lib/docker  acltype     posixacl

Significado: Estas propiedades influyen mucho en el rendimiento de archivos pequeños y la sobrecarga de metadata.

Decisión: Si atime=on, apágalo para datasets de Docker. Si la compresión está deshabilitada, habilita lz4 salvo que tengas una razón específica para no hacerlo.

Tarea 8: Aplicar una cuota para acotar el radio de desastre de Docker

cr0x@server:~$ sudo zfs set quota=250G tank/var/lib/docker
cr0x@server:~$ zfs get -o name,property,value quota tank/var/lib/docker
NAME                 PROPERTY  VALUE
tank/var/lib/docker  quota     250G

Significado: Docker ya no puede consumir todo el pool y tumbar el host.

Decisión: Elige una cuota que soporte tu churn de imágenes esperado más margen. Si golpeas la cuota con regularidad, arregla la retención; no la aumentes inmediatamente.

Tarea 9: Confirmar salud del pool y si hay restricción de capacidad

cr0x@server:~$ zpool status -x
all pools are healthy
cr0x@server:~$ zpool list
NAME   SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP  HEALTH  ALTROOT
tank   928G   721G   207G        -         -    41%    77%  1.00x  ONLINE  -

Significado: Pool sano, 77% de capacidad, fragmentación moderada. Al acercarte al 85–90% de uso, el rendimiento y el comportamiento de asignación de ZFS se degradan.

Decisión: Si CAP está por encima de ~85%, prioriza liberar espacio o añadir vdevs antes de perseguir micro-optimizaciones.

Tarea 10: Identificar amplificación de escritura y latencia de un vistazo

cr0x@server:~$ iostat -x 1 3
Linux 6.8.0 (server)  12/25/2025  _x86_64_  (16 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          12.1    0.0     6.2     9.8     0.0    71.9

Device            r/s     w/s   rKB/s   wKB/s  avgrq-sz avgqu-sz   await  svctm  %util
nvme0n1         210.0   980.0  9800.0 42000.0     72.1     8.90    7.40   0.52   62.0

Significado: %iowait elevado y await sugieren que la latencia de almacenamiento está afectando al sistema. %util por debajo de 100% significa que podrías estar limitado por colas o comportamiento sync, no por throughput bruto.

Decisión: Si await es alto durante tormentas de build, considera separar nodos de build, afinar datasets sync-heavy y comprobar la efectividad del SLOG (si hay).

Tarea 11: Comprobar tamaño de ARC y señales de presión de memoria

cr0x@server:~$ cat /proc/spl/kstat/zfs/arcstats | egrep '^(size|c|c_min|c_max) '
size                            4    8589934592
c                               4    10737418240
c_min                           4    1073741824
c_max                           4    17179869184

Significado: ARC está actualmente en ~8G, puede crecer hasta ~16G. En un host de contenedores, ARC creciendo sin tope puede dejar sin memoria a las cargas.

Decisión: Si tu nodo está matando procesos por OOM mientras ARC crece, limita ARC mediante parámetros del módulo y reserva memoria para aplicaciones.

Tarea 12: Encontrar qué datasets tienen más snapshots (proxy de churn)

cr0x@server:~$ zfs list -H -t snapshot -o name | awk -F@ '{print $1}' | sort | uniq -c | sort -nr | head
   914 tank/var/lib/docker/zfs/graph/3f0c2b3d2a0e
   842 tank/var/lib/docker/zfs/graph/9a1d11c7e6f4

Significado: Si los datasets relacionados con Docker acumulan snapshots fuera de la gestión propia de Docker, algo está tomando snapshots con demasiada agresividad.

Decisión: Audita tus herramientas de snapshot; excluye los árboles de capas de Docker.

Tarea 13: Detectar espacio retenido por bloques borrados pero referenciados (snapshots/clones)

cr0x@server:~$ zfs get -o name,property,value used,referenced,logicalused,logicalreferenced tank/var/lib/docker
NAME                 PROPERTY           VALUE
tank/var/lib/docker  used               78.4G
tank/var/lib/docker  referenced         1.20G
tank/var/lib/docker  logicalused        144G
tank/var/lib/docker  logicalreferenced  3.10G

Significado: El espacio lógico es mayor que el físico usado: la compresión está funcionando, y/o existen bloques compartidos. La clave: used incluye hijos y snapshots; referenced es lo que este dataset liberaría si se destruyera.

Decisión: Si used es enorme pero referenced es pequeño, destruir el dataset podría liberar mucho (porque se lleva hijos y snapshots). Esa es una estrategia válida de reset para el estado de Docker—si los datos persistentes están en otro lugar.

Tarea 14: Crear un dataset de aplicación persistente con un límite estricto

cr0x@server:~$ sudo zfs create -o mountpoint=/containers tank/containers
cr0x@server:~$ sudo zfs create -o mountpoint=/containers/payments -o compression=lz4 -o atime=off tank/containers/payments
cr0x@server:~$ sudo zfs set refquota=200G tank/containers/payments
cr0x@server:~$ zfs get -o name,property,value mountpoint,refquota tank/containers/payments
NAME                     PROPERTY   VALUE
tank/containers/payments  mountpoint /containers/payments
tank/containers/payments  refquota   200G

Significado: Los datos persistentes tienen su propio mountpoint y un límite estricto de tamaño.

Decisión: Haz bind-mount de /containers/payments dentro de los contenedores. Si la app alcanza el refquota, fallará de forma contenida en lugar de consumir el host.

Tarea 15: Replicar datasets persistentes de forma segura (send/receive)

cr0x@server:~$ sudo zfs snapshot tank/containers/payments@replica-001
cr0x@server:~$ sudo zfs send -c tank/containers/payments@replica-001 | sudo zfs receive -u backup/containers/payments
cr0x@server:~$ zfs get -o name,property,value readonly backup/containers/payments
NAME                      PROPERTY  VALUE
backup/containers/payments readonly  off

Significado: Has transferido un snapshot consistente. El dataset recibido no es automáticamente de solo lectura a menos que lo pongas.

Decisión: Activa readonly=on en objetivos de backup para evitar escrituras accidentales.

cr0x@server:~$ sudo zfs set readonly=on backup/containers/payments
cr0x@server:~$ zfs get -o name,property,value readonly backup/containers/payments
NAME                      PROPERTY  VALUE
backup/containers/payments readonly  on

Tarea 16: Verificar uso de disco de Docker vs uso de ZFS (detectar desajustes)

cr0x@server:~$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          44        12        38.7GB    22.3GB (57%)
Containers      61        9         4.1GB     3.5GB (85%)
Local Volumes   16        10        9.8GB     1.2GB (12%)
Build Cache     93        0         21.4GB    21.4GB
cr0x@server:~$ zfs list -o name,used,avail tank/var/lib/docker
NAME                 USED  AVAIL
tank/var/lib/docker  78.4G   420G

Significado: Docker informa tamaños lógicos que cree controlar. ZFS informa uso real incluyendo snapshots y relaciones de clone. Si el usado por ZFS es mucho mayor que la vista de Docker, tienes bloques referenciados fuera de la contabilidad de Docker (a menudo snapshots).

Decisión: Busca snapshots y clones reteniendo espacio; considera excluir datasets de Docker de herramientas de snapshot y resetear el dataset de Docker si es necesario.

Guía rápida de diagnóstico

Cuando el host está lento o lleno, no empieces con prune aleatorio. Empieza con un bucle cerrado que te diga
qué subsistema es culpable: capacidad del pool, retención de snapshots de ZFS, churn de caché de Docker o pura latencia I/O.

Primero: capacidad y “espacio retenido como rehén”

  1. Capacidad del pool: zpool list. Si CAP > ~85%, espera problemas.
  2. Dónde está el espacio: zfs list -o used,usedbysnapshots,usedbychildren -r tank/var/lib/docker.
  3. Snapshots: zfs list -t snapshot | grep docker. Si hay snapshots en datasets de Docker, eso es sospechoso.

Interpretación: Si los snapshots dominan, recorta snapshots. Si los hijos dominan, recorta imágenes/contenedores o considera resetear el dataset de Docker.

Segundo: tipo de cuello de botella (latencia vs CPU vs presión de memoria)

  1. Latencia de disco: iostat -x 1 3 y vigila await, %util, %iowait.
  2. Crecimiento del ARC: comprueba /proc/spl/kstat/zfs/arcstats y la memoria del sistema.
  3. Steal de CPU / contención del planificador: si estás virtualizado, comprueba %steal en la salida de iostat.

Interpretación: La presión de ARC y la latencia I/O a menudo se hacen pasar por “Docker está lento.” No son la misma solución.

Tercero: fuente del churn de Docker

  1. Explosión de caché de build: docker system df y estrategia de docker builder prune.
  2. Retención de imágenes: lista imágenes y tags antiguos; aplica TTL en CI y registros.
  3. Tendencia de conteo de datasets: conteo de datasets en tank/var/lib/docker/zfs semana a semana.

Interpretación: Si el conteo de datasets crece sin cesar, tu carga de build/pull es efectivamente una fábrica de capas. Conténla con cuotas e aislamiento.

Tres micro-historias corporativas (cómo los equipos fallan)

Incidente: la suposición equivocada (“Docker prune libera espacio”)

Una empresa mediana ejecutaba su CI en un host Docker respaldado por ZFS robusto. Tenían un trabajo nocturno: prune de imágenes,
prune de contenedores, prune de build cache. Salía en verde, con logs que parecían responsables. Mientras tanto el pool fue
subiendo de 60% a 90% en un mes y luego se desplomó durante una semana de lanzamientos.

El de guardia hizo lo habitual: ejecutó los prune manualmente, reinició Docker e incluso reinició el host. Nada cambió.
docker system df afirmaba que había mucho espacio recuperable. ZFS no estaba de acuerdo. zpool list decía 94% lleno,
y la latencia I/O se disparó porque ZFS estaba asignando desde los peores segmentos restantes.

La suposición equivocada fue sutil: asumieron que la noción de “no usado” de Docker equivalía a la capacidad de ZFS para liberar bloques.
Pero el host también ejecutaba una política genérica de snapshots en tank/var, que incluía /var/lib/docker.
Cada noche tomaban snapshots de un dataset lleno de clones y churn. Eso significaba que las “capas borradas” seguían
referenciadas por snapshots, así que el espacio quedó atrapado.

La solución no fue heroica. Excluyeron el dataset de Docker de la política de snapshots, destruyeron los snapshots antiguos,
y movieron los datos persistentes fuera del dataset de Docker para tener la opción de limpiar el estado de Docker si hacía falta.
Después de eso, los prune volvieron a funcionar porque ZFS finalmente pudo liberar bloques.

Optimización que salió mal (“Activemos dedup para imágenes”)

Otro equipo tuvo una buena intuición: las imágenes de contenedores comparten muchos archivos idénticos. ¿Por qué no habilitar dedup en
el dataset de Docker y ahorrar mucho espacio? Lo pilotaron en un nodo y celebraron los números iniciales. El espacio usado cayó. Choca de manos en la sala de reuniones.

Luego el nodo empezó a tartamudear bajo carga. Las builds se volvieron erráticas. Aparecieron picos de latencia durante tráfico pico,
no solo en CI. El equipo añadió CPU. Añadieron discos más rápidos. Ejecutaron el ritual de depuración de rendimiento mientras
el verdadero problema estaba ahí, callado.

La dedup incrementa dramáticamente las búsquedas de metadata y requiere mucha RAM para la DDT (tabla de dedup). El nodo ahora hacía trabajo extra
en cada ruta de escritura y lectura, especialmente con el churn de creación y eliminación de capas. Peor aún, los fallos de rendimiento
eran intermitentes, porque dependían de tasas de acierto de caché y del conjunto de trabajo de la DDT.

El rollback fue doloroso porque desactivar dedup no “undedupea” bloques existentes; solo deja de deduplicar nuevas escrituras.
Migraron el estado de Docker a un dataset nuevo con dedup off, y mantuvieron compresión activada.
Recuperaron la mayor parte del ahorro de espacio necesario con lz4 y políticas de retención sensatas, sin la carga operativa.

Práctica aburrida pero correcta que salvó el día (datasets separados + cuotas)

Un equipo de plataforma de pagos ejecutaba Docker sobre ZFS con un layout que parecía casi demasiado ordenado: Docker vivía en un dataset
con una cuota firme. Cada servicio con estado tenía su propio dataset bajo /containers con refquota y un simple
horario de snapshots. El target de backup era receive-only. Nada sofisticado. Sin scripts elegantes. Sin “iniciativa de optimización de almacenamiento.”

Una tarde una mala configuración de CI provocó un bucle: un pipeline de build tiraba imágenes base repetidamente y creaba
nuevos tags en cada ejecución. En la mayoría de sistemas, esto habría masticado el disco hasta tumbar el host. Aquí, el dataset de Docker
alcanzó su cuota y Docker empezó a fallar pulls. Ruidosamente. El nodo se mantuvo vivo. Las bases de datos siguieron funcionando.

El de guardia recibió una alerta sobre builds fallidos, no sobre un host de producción muerto. Arreglaron la config de CI y limpiaron
el dataset de Docker. No hubo restauración de datos. No compra de capacidad de emergencia. La cuota no evitó el error; evitó que el error se convirtiera en un outage.

Este es el tipo de práctica que nunca recibe un post de celebración. Debería. Los límites de almacenamiento aburridos son lo que convierten
un “ups” en un “ticket”, en lugar de un “ups” en un “incidente.”

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

1) “Docker prune se ejecutó, pero el espacio en ZFS no volvió”

Síntoma: Docker informa espacio recuperable; used de ZFS se mantiene alto.

Causa raíz: Snapshots en datasets de Docker manteniendo bloques referenciados; o cadenas de clones manteniendo bloques vivos.

Solución: Deja de snapshotear datasets de capas de Docker; destruye snapshots; considera resetear tank/var/lib/docker después de mover datos persistentes fuera.

2) “El host tiene miles de montajes y el arranque es lento”

Síntoma: Tiempos de arranque largos, unidades de montaje systemd tardan, Docker arranca tarde o falla.

Causa raíz: El driver ZFS de Docker produjo un conteo enorme de datasets; el manejo de montajes se vuelve costoso.

Solución: Acota el dataset con una cuota; reduce el churn de imágenes; reconstruye el dataset de Docker periódicamente en nodos CI; separa nodos de build de nodos de producción de larga vida.

3) “Los contenedores se ralentizan aleatoriamente; la CPU no está al 100%”

Síntoma: Picos de latencia, timeouts, rendimiento de build inconsistente.

Causa raíz: Pool casi lleno, fragmentación y lentitud en la asignación; o ARC robando memoria a las aplicaciones.

Solución: Mantén el pool por debajo de ~80–85%; limita ARC; añade vdevs (no discos más grandes en el mismo arreglo, si quieres mejora real de rendimiento).

4) “ZFS usedbysnapshots es enorme bajo /var/lib/docker”

Síntoma: El espacio de snapshots domina los números de uso.

Causa raíz: Política genérica de snapshots aplicada al dataset de Docker; Docker ya tiene su propio modelo interno de snapshots/clones.

Solución: Excluye el dataset de Docker de horarios de snapshot del host; haz snapshots de /containers/<app> en su lugar.

5) “Afinamos recordsize para Docker y la base de datos empeoró”

Síntoma: Latencia de la BD aumentó después del “tuning de almacenamiento para contenedores”.

Causa raíz: Los datos de la BD están dentro del dataset de Docker o dentro de la ruta gestionada por el driver; recordsize elegido para churn de capas, no para patrones de BD.

Solución: Pon la BD en su propio dataset; afina recordsize y logbias allí; mantiene el dataset de Docker afinado para Docker.

6) “La replicación es un lío y las restauraciones dan miedo”

Síntoma: Los backups incluyen capas de Docker, cachés y estado, haciendo send/receive enorme y lento.

Causa raíz: Datos persistentes mezclados con el estado de Docker bajo un único árbol de datasets.

Solución: Separa datasets persistentes bajo /containers; replica esos. Trata el dataset de Docker como caché/estado, no como material de backup.

7) “Activamos dedup y ahora todo es impredecible”

Síntoma: Variación de rendimiento, presión de memoria, picos de latencia extraños.

Causa raíz: Conjunto de trabajo de la tabla de dedup demasiado grande; sobrecarga de metadata por capas con mucho churn.

Solución: No uses dedup para almacenes de capas Docker. Usa compresión y políticas de retención; si ya está habilitado, migra a un dataset nuevo.

Listas de verificación / plan paso a paso

Plan A: Host nuevo (instalación limpia)

  1. Crea el pool con ashift y diseño de vdev sensatos para tu hardware (mirror/RAIDZ según tu modelo de fallos).
  2. Crea datasets:
    • tank/var/lib/docker montado en /var/lib/docker
    • tank/containers montado en /containers
    • Datasets opcionales por app: tank/containers/<app>
  3. Configura propiedades del dataset de Docker: compression=lz4, atime=off, xattr=sa, y considera recordsize=16K o 32K.
  4. Fija una cuota en tank/var/lib/docker dimensionada para tu churn esperado.
  5. Para cada servicio con estado, crea un dataset bajo /containers y asigna refquota.
  6. Configura Docker para usar el driver ZFS y el zpool/dataset correcto (vía config del daemon), luego arranca Docker.
  7. Excluye datasets de Docker de cualquier automatización genérica de snapshots; haz snapshots solo de datasets persistentes.
  8. Define replicación desde el subtree tank/containers hacia un pool de backup receive-only.

Plan B: Host existente (migrar sin drama)

  1. Inventaria qué es persistente:
    • Lista stacks de compose y sus volúmenes.
    • Identifica qué volúmenes son bases de datos o servicios con estado.
  2. Crea datasets /containers por app y mueve datos allí (rsync o migración a nivel de aplicación).
  3. Actualiza manifests de compose/k8s para bind-mount de rutas del host desde /containers/<app>.
  4. Sólo después de mover datos persistentes: aplica una cuota al dataset de Docker.
  5. Audita snapshots: si tienes snapshots de datasets de Docker, elimínalos cuidadosamente tras verificar que no forman parte de un procedimiento de rollback requerido.
  6. Configura propiedades en el dataset de Docker y reinicia Docker en una ventana controlada.
  7. Si el árbol de datasets ya es patológico (decenas de miles de datasets), considera reconstruir el estado de Docker:
    • Detén Docker
    • Destruye y recrea tank/var/lib/docker
    • Arranca Docker y vuelve a tirar las imágenes

Plan C: Nodos CI (trátalos como ganado)

  1. Pon el estado Docker de CI en su propio dataset con una cuota estricta.
  2. No hagas snapshots de datasets Docker de CI.
  3. Programa limpieza agresiva de build cache.
  4. Reconstruye nodos CI periódicamente en vez de intentar “mantenerlos limpios para siempre.”
  5. Mantén los artefactos en un almacenamiento externo; usa Docker como caché.

Preguntas frecuentes

1) ¿Debo usar el driver ZFS de Docker o overlay2 sobre ZFS?

Si ya tienes ZFS y quieres snapshots/clones nativos de ZFS para capas, usa el driver ZFS. Si prefieres la ruta mainstream
y operaciones day-2 más simples, overlay2 sobre ZFS puede ser aceptable—pero pierdes algunas semánticas nativas de ZFS y puedes encontrar interacciones extrañas.
En cualquier caso, mantén los datos persistentes en sus propios datasets.

2) ¿Puedo snapshotear /var/lib/docker para backups?

Puedes. No deberías. El estado de Docker es reconstruible; tus bases de datos no lo son. Haz snapshot y replica /containers/<app>.
Trata las imágenes y capas de Docker como caché y material para reconstruir.

3) ¿Por qué importa tanto el conteo de datasets?

Cada dataset tiene metadata y puede implicar manejo de montajes. Miles pueden estar bien; decenas de miles se convierten en fricción operativa:
listados lentos, borrados lentos, montar/desmontar lento y un mayor radio de desastre para errores.

4) ¿Qué propiedades son más importantes para datasets Docker?

compression=lz4, atime=off, xattr=sa son las victorias habituales. recordsize depende de la carga; 16K–32K suele comportarse mejor para capas con churn que 128K.

5) ¿Debo poner volúmenes Docker dentro del dataset de Docker?

Para volúmenes efímeros está bien. Para cargas con estado, no. Usa bind mounts de rutas del host respaldadas por datasets bajo /containers.
Así es como haces backups y cuotas sin terror.

6) ¿Añadir un SLOG es útil para Docker?

Solo si tienes cargas sync-heavy en datasets con sync=standard y tus aplicaciones realmente hacen escrituras sync.
Muchas cargas de contenedores no están ligadas a sync. Prueba con métricas; no compres un SLOG por superstición.

7) ¿Por qué veo mucho usedbysnapshots incluso cuando no tomo snapshots manualmente?

Herramientas de snapshot del host suelen apuntar árboles enteros (como tank/var). O un producto de backup está snapshotando recursivamente.
Docker también usa snapshots ZFS internamente, pero normalmente se gestionan bajo el árbol del driver de Docker.
La solución es acotar tu automatización de snapshots con precisión.

8) ¿Puedo “desfragmentar” un pool ZFS para arreglar rendimiento?

No en el sentido clásico de sistemas de archivos. La solución práctica es disciplina de capacidad (no operar al límite), buen diseño de vdev,
y a veces reescribir datos migrando datasets (send/receive) a un pool fresco.

9) ¿Cuál es la forma más segura de resetear el estado de Docker en un host ZFS?

Detén Docker, asegúrate de que no hay datos persistentes dentro de /var/lib/docker, luego destruye y recrea el dataset de Docker.
Por eso separamos /containers—para que este movimiento sea seguro cuando lo necesites.

10) ¿Cómo evito que una aplicación descontrolada llene el pool?

Pon cada app con estado en su propio dataset y asigna refquota. Pon el estado de Docker bajo una cuota.
Luego alerta por utilización de cuotas antes de que golpeen el muro.

Conclusión: próximos pasos que puedes hacer hoy

Si recuerdas una cosa: el estado de Docker no es precioso, y tu diseño de ZFS debe reflejar eso. Dale a Docker su propio dataset,
limita su tamaño con una cuota y deja de snapshotearlo como si fuera un álbum de fotos familiar. Coloca los datos persistentes en sus propios datasets bajo
/containers, con refquotas y un plan de replicación que puedas explicar a un compañero cansado a las 3 a.m.

Pasos prácticos siguientes:

  1. Ejecuta findmnt y confirma que /var/lib/docker es un dataset dedicado.
  2. Ejecuta zfs list -o usedbydataset,usedbysnapshots,usedbychildren y aprende qué está reteniendo espacio realmente.
  3. Excluye datasets de Docker de la automatización de snapshots.
  4. Crea datasets /containers para servicios con estado y mueve los datos allí.
  5. Configura cuotas/refquotas para que los errores fallen de forma pequeña y audible.

La explosión de capas no se detiene porque se lo pidas amablemente. Se detiene porque dibujaste un límite y lo hiciste cumplir.

← Anterior
MySQL vs PostgreSQL: réplicas de lectura — cuándo ayudan y cuándo mienten
Siguiente →
Proxmox vs VMware ESXi: ¿qué hipervisor deberías usar en 2026?

Deja un comentario