Problema

Al ejecutar varios servicios basados en Docker (por ejemplo, Komga, Calibre‑Web, LazyLibrarian, Suwayomi) dentro de un contenedor LXC sin privilegios, es frecuente encontrarse con dos patrones de fallo:

  1. Bloqueos de SQLite (SQLITE_BUSY o OperationalError: database is locked) que impiden que la aplicación arranque o que realice operaciones de escritura.
  2. Errores al crear symlinks (ln: failed to create symbolic link … Operation not supported) que aparecen en aplicaciones que dependen de KCEF, Chromium Embedded Framework u otras bibliotecas que usan enlaces simbólicos.

Ambos síntomas aparecen cuando los directorios de datos y de medios están montados desde el host mediante bind mount o SMB/CIFS y el contenedor LXC está configurado como unprivileged (UID/GID mapeados). La combinación de AppArmor, la falta de soporte de O_SYMLINK en sistemas de archivos remotos y el modo de bloqueo de SQLite (WAL) genera un entorno donde las llamadas POSIX esperadas son denegadas o se comportan de forma no atómica.

Causa

1. Mapeo de IDs y permisos en LXC unprivileged

En un LXC sin privilegios, el UID 0 del contenedor se traduce a un UID arbitrario en el host (por ejemplo, 100107). Cuando el bind mount apunta a un recurso SMB, el servidor de archivos interpreta esos IDs como usuarios remotos. Si el servidor no reconoce el UID/GID mapeado, los archivos aparecen como propiedad de nobody y, lo que es peor, el kernel marca el punto de montaje con la opción nosuid y nodev. Estas restricciones impiden que procesos dentro del contenedor creen enlaces simbólicos o cambien atributos de archivo.

SMB 2/3 sí permite symlinks, pero la mayoría de los clientes Linux los tratan como reparse points y requieren la opción mfsymlinks. Sin ella, ln -s devuelve Operation not supported. Incluso con mfsymlinks, el servidor debe estar configurado para aceptarlos; de lo contrario el kernel devuelve EOPNOTSUPP.

3. SQLite en modo WAL sobre red

SQLite usa archivos de registro WAL (-wal y -shm) que requieren bloqueos de fcntl a nivel de archivo. En sistemas de archivos remotos que no implementan correctamente los bloqueos POSIX (SMB sin noperm o noserverino), los procesos pueden recibir SQLITE_BUSY incluso cuando no hay otra conexión activa. Además, la latencia de la red amplifica la ventana de bloqueo, provocando colisiones al iniciar varios contenedores simultáneamente.

4. AppArmor y perfil LXC

El perfil predeterminado de LXC restringe operaciones de mknod, mount y link. Cuando una aplicación intenta crear un symlink dentro de un directorio que está bajo un bind mount, AppArmor puede denegar la llamada antes de que el kernel evalúe la capacidad del sistema de archivos remoto.

Solución

Una solución robusta combina tres capas:

  1. Re‑ubicación de los directorios críticos a un storage local

    • Mantener los archivos de configuración y bases SQLite en un disco local del nodo Proxmox (por ejemplo, /var/lib/lxc/107/rootfs/opt/data).
    • Usar bind mounts solo para los volúmenes de medios (imágenes, PDFs, mangas) que no requieren escritura concurrente de bases.
  2. Ajuste de la capa de montaje SMB

    • Añadir mfsymlinks,nobrl,vers=3.0,uid=100107,gid=100110,forceuid,forcegid a /etc/fstab o al comando mount.
    • Verificar que el servidor Samba tenga unix extensions = no y wide links = yes.
    • Si el servidor no permite symlinks, crear un link farm local que apunte a la ruta SMB mediante mount --bind y usar ln -s dentro del contenedor solo sobre el punto de montaje local.
  3. Configuración de SQLite en modo journal (no WAL)

    • Añadir la variable de entorno SQLITE_JOURNAL_MODE=DELETE o montar los volúmenes con la opción noatime,nodiratime.
    • Alternativamente, habilitar PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; en la inicialización de la base, pero solo si el backend SMB garantiza bloqueos POSIX (poco fiable).
  4. Modificación del perfil AppArmor del LXC

    • Crear un archivo de perfil local /etc/apparmor.d/lxc/107 con:
      #include <tunables/global>
      profile lxc-107 flags=(attach_disconnected,mediate_deleted) {
          #allow symlink creation in /config
          /config/** wl,
          #allow fcntl locking on SQLite files
          /config/**/*.db rw,
          /config/**/*.db-wal rw,
          /config/**/*.db-shm rw,
      }
      
    • Recargar con apparmor_parser -r /etc/apparmor.d/lxc/107 y reiniciar el contenedor.
  5. Ajuste de Docker Compose

    • Declarar user: "100107:100110" para que los procesos dentro del contenedor usen los IDs mapeados.
    • Añadir tmpfs: /tmp:exec para evitar que los contenedores intenten crear symlinks en rutas montadas remotamente.
    • Usar environment: para forzar el modo de journal de SQLite.

Cuándo aplicar esta solución

  • Síntomas: contenedores que reinician constantemente con mensajes de Operation not supported al crear symlinks, o logs que muestran SQLITE_BUSY al iniciar.
  • Entorno: LXC sin privilegios, bind mounts a SMB/CIFS, múltiples servicios Docker que comparten la misma carpeta de datos.
  • No aplicar: si todas las rutas están en un disco local ext4 o ZFS y el LXC está en modo privilegiado; en esos casos el problema suele estar en la configuración de la propia aplicación, no en la capa de almacenamiento.

Código

# 1. Añadir opciones al fstab (ejemplo para /mnt/media)
UUID=xxxx-xxxx /mnt/media cifs credentials=/etc/samba/creds,iocharset=utf8,vers=3.0,mfsymlinks,nobrl,uid=100107,gid=100110,forceuid,forcegid 0 0

# 2. Montar inmediatamente
mount -a

# 3. Crear perfil AppArmor para LXC 107
cat > /etc/apparmor.d/lxc/107 <<'EOF'
#include <tunables/global>
profile lxc-107 flags=(attach_disconnected,mediate_deleted) {
    /config/** wl,
    /config/**/*.db rw,
    /config/**/*.db-wal rw,
    /config/**/*.db-shm rw,
}
EOF
apparmor_parser -r /etc/apparmor.d/lxc/107

# 4. Docker‑compose snippet (relevante)
cat > docker-compose.yml <<'EOF'
services:
  suwayomi:
    image: ghcr.io/suwayomi/tachidesk:latest
    user: "100107:100110"
    environment:
      - SQLITE_JOURNAL_MODE=DELETE
    volumes:
      - /mnt/configs/suwayomi:/home/suwayomi/.local/share/Tachidesk
      - /mnt/manga:/mnt/mangas
    tmpfs:
      - /tmp:exec
    restart: unless-stopped
EOF
docker compose up -d

Verificación

  1. Symlink

    • Dentro del contenedor ejecuta ln -s /tmp /home/suwayomi/.local/share/Tachidesk/testlink.
    • El comando debe completarse sin error.
  2. SQLite

    • Inicia el contenedor y revisa docker logs <container>.
    • No debe aparecer SQLITE_BUSY.
    • Opcional: abrir la base con sqlite3 /config/app.db y ejecutar PRAGMA journal_mode; para confirmar que el modo es DELETE.
  3. AppArmor

    • Ejecuta aa-status | grep lxc-107 y verifica que el perfil está cargado y en modo enforced.
  4. Rendimiento

    • Copia 1 GB de datos al directorio de medios y mide I/O con dd if=/dev/zero of=/mnt/media/testfile bs=1M count=1024 oflag=direct.
    • La velocidad debe estar dentro del rango esperado para el disco local; si sigue siendo muy baja, reconsidera mover los volúmenes de datos a almacenamiento local.

Notas adicionales

  • Si el servidor Samba no permite mfsymlinks, la única alternativa fiable es no usar SMB para directorios que requieran symlinks.
  • En entornos donde la latencia de red es alta, considera usar NFS con nolock y no_root_squash como alternativa a SMB; NFS maneja mejor los bloqueos de fcntl.
  • Cuando se usan varios contenedores que comparten la misma base SQLite, es recomendable aislar cada aplicación en su propio archivo de base o usar una base de datos cliente‑servidor (PostgreSQL, MariaDB) para evitar colisiones.
  • Siempre revisa los logs de AppArmor (/var/log/syslog o journalctl -k) después de aplicar un nuevo perfil; una regla faltante suele aparecer como apparmor="DENIED" en los mensajes.