Saltar al contenido principal
intermedioPart 2

Root por defecto: endurecimiento del privilegio del contenedor en una pila de IA autoalojada

· 9 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
+Langfuse
0/3
🎯 Skill path0/3 earned
Hardening self-hosted AI infra

Parte 1 de esta serie auditó una caja multi-agente en vivo y encontró cada pregunta sobre exposición de red que valía la pena hacer. Una línea de esa auditoría no se siguió: "el Postgres, ClickHouse y Redis detrás de Langfuse estaban publicados en 127.0.0.1 en lugar del mundo — alguien tomó una buena decisión allí. Mantén ese pensamiento."

Aquí está la otra mitad de ese pensamiento: conseguir la red correcta no dice nada sobre lo que sucede después de que alguien ya esté dentro de un contenedor. Si el proceso que se ejecuta allí es root, un compromiso comienza con las llaves de todo el sistema de archivos. Así que verificamos — en la misma caja, la misma pila de Langfuse que la Parte 1 ya alabó — si "red correcta" también significaba "privilegio correcto." No lo era, para dos de los seis contenedores. Aquí está cómo se veía realmente la solución, incluyendo la parte que se rompió.

Verificando quién es realmente root

Docker no te dice esto por defecto — un contenedor puede estar publicado correctamente, saludable y ejecutándose como root todo el tiempo, sin que nada en docker ps lo sugiera. La única forma de saberlo es preguntar directamente al contenedor:

docker exec langfuse-postgres-1 id -u
docker exec langfuse-redis-1 id -u
Salida
0
0

Ambas verificaciones de id -u devolviendo 0 — ambos contenedores ejecutándose como root Figura 1. La verificación que la mayoría de las configuraciones nunca ejecutan: ambas llamadas a id -u regresan 0. Saludables, publicados correctamente, y root todo el tiempo.

0 es root. Ambos estaban ejecutándose como root, y al mirar el archivo de composición se explica por qué — ninguno de los servicios tiene una directiva user: en absoluto:

docker-compose.yml (antes)
redis:
image: docker.io/redis:7
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
--maxmemory-policy noeviction
...

postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
...

Nadie decidió que estos debían ejecutarse como root. Es simplemente lo que sucede cuando no configuras nada — la misma forma que el hallazgo de la Parte 1 de que una contraseña de Redis no configurada predetermina la falta de autenticación. El servicio clickhouse en el mismo archivo muestra la solución ya medio hecha, sentada justo allí para comparación:

docker-compose.yml (ya correcto)
clickhouse:
image: docker.io/clickhouse/clickhouse-server
restart: always
user: "101:101"

Una línea. Alguien lo configuró para ClickHouse y nunca llegó a los otros dos.

Encontrando el usuario correcto, no una suposición

No inventes un UID — ambas imágenes oficiales ya incluyen un usuario no root construido para exactamente esto, y adivinar mal significa un error de permisos o, peor aún, un UID que silenciosamente no coincide con el que los propios archivos de la imagen son propiedad:

docker exec langfuse-postgres-1 id postgres
docker exec langfuse-redis-1 id redis
Salida
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)
uid=999(redis) gid=999(redis) groups=999(redis)

Ambas imágenes utilizan 999:999 para su usuario no root incorporado. Agrégalo al archivo de composición:

docker-compose.yml (después)
redis:
image: docker.io/redis:7
user: "999:999"
restart: always
...

postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-17}
user: "999:999"
restart: always
...

La conversión en sí fue un no evento

docker compose up -d --force-recreate postgres redis
Salida
✔ Contenedor langfuse-postgres-1 recreado
✔ Contenedor langfuse-redis-1 recreado
sleep 5
docker compose ps
docker exec langfuse-postgres-1 id -u
docker exec langfuse-redis-1 id -u
Salida
langfuse-postgres-1 Up 34 seconds (healthy) 127.0.0.1:5433->5432/tcp
langfuse-redis-1 Up 34 seconds (healthy) 127.0.0.1:6379->6379/tcp
999
999

docker compose ps mostrando ambos contenedores saludables, y id -u ahora devolviendo 999 Figura 2. Los mismos dos contenedores, segundos después de --force-recreate: ambos healthy, ambos id -u ahora 999 en lugar de 0.

Sin errores de permisos, sin dramas de propiedad, ambos saludables en segundos. Eso vale la pena decirlo claramente porque no siempre es cierto — si estos volúmenes se hubieran inicializado como root en algún lugar upstream y nunca se les hubiera hecho chown, este comando exacto puede fallar con permission denied en el directorio de datos. No falló aquí, pero revisa tus propios registros después de recrear, no asumas que "sin salida" significa "sin problema."

Habilidad desbloqueada 🏅

Puedes verificar si un contenedor en ejecución es realmente root (docker exec <container> id -u), encontrar el usuario no root previsto de su imagen en lugar de adivinar un UID (docker exec <container> id <username>), y convertirlo con una línea user: en la composición.

La parte que realmente se rompió

Todo lo anterior fue limpio. Luego, los registros de un contenedor completamente diferente contaron una historia diferente:

docker logs langfuse-langfuse-worker-1 --tail 20
Salida
Error: getaddrinfo ENOTFOUND redis
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Error de Redis getaddrinfo ENOTFOUND redis
Error de Redis connect ECONNREFUSED 172.19.0.5:6379
El trabajo de cola mixpanel-integration-processing-queue falló: Error: Tiempo de espera de socket.
Esperando datos, pero no se recibió ninguno en 30000ms.
El trabajo de cola trace-delete falló: Error: Tiempo de espera de socket. Esperando datos, pero no se recibió ninguno en 30000ms.

registros de langfuse-worker mostrando repetidos tiempos de espera de socket de 30 segundos contra Redis, uno por cola Figura 3. Un contenedor que nadie tocó, fallando de todos modos — cada trabajo de cola con el mismo tiempo de espera de socket de 30 segundos contra Redis, mucho después de la recreación que lo causó.

langfuse-worker nunca tocó la propiedad de Postgres o Redis — solo mantiene una conexión activa a Redis, y --force-recreate derribó el contenedor al que apuntaba esa conexión. La propia verificación de salud de Docker decía que Redis estaba healthy de nuevo mucho antes de que el trabajador se rindiera al reintentar; ioredis simplemente no se reconecta limpiamente por sí mismo aquí, y siguió fallando con tiempos de espera de socket de 30 segundos mucho después de que la dependencia de la que dependía volvió.

La solución es el reinicio del propio servicio dependiente, no otra mirada a Redis:

docker compose restart langfuse-worker langfuse-web
Salida
2026-08-10T19:40:22.505Z info La conexión a Redis se ha cerrado.
2026-08-10T19:40:22.769Z info Apagado completo, saliendo del proceso...
sleep 10
docker logs langfuse-langfuse-worker-1 --tail 15
Salida
experiment-create-queue executor started: true
posthog-integration-queue executor started: true
data-retention-queue executor started: true
webhook-queue executor started: true
Listening: http://21caf9a28775:3030

registros de langfuse-worker después del reinicio, mostrando cada ejecutor de cola comenzando limpiamente Figura 4. El mismo contenedor, un reinicio después — cada ejecutor de cola comienza limpio, sin errores de Redis en ninguna parte del registro.

Inicio limpio de la cola, sin errores de Redis. Esa es toda la lección: endurecer un contenedor no se limita a ese contenedor. Cualquier cosa que mantenga una conexión activa con la cosa que acabas de recrear necesita su propio reinicio, a propósito, no como un pensamiento posterior que descubres a partir de un registro de errores veinte minutos después.

Esto es lo que debes recordar

--force-recreate en una dependencia compartida (una base de datos, una cola, un caché) puede romper silenciosamente cada servicio que ya tenía una conexión abierta a ella — incluso después de que la dependencia misma informe que está saludable de nuevo. Reinicia explícitamente a los dependientes; no asumas que lo notarán por sí mismos.

Lo que encontramos y lo que costó arreglar

ContenedorAntesSolución¿Rompe algo?
langfuse-postgres-1root (uid 0)user: "999:999"No — recreado limpio
langfuse-redis-1root (uid 0)user: "999:999"No — recreado limpio
langfuse-clickhouse-1ya 101:101ninguna necesaria
langfuse-langfuse-worker-1intocableninguna — solo necesitaba un reinicioSí — perdió su conexión a Redis después de que los otros dos contenedores fueron recreados

Dos soluciones de una línea. El costo real no fue la solución — fue notar que el tercer contenedor que nadie tocó fue el que se rompió.

Solución de problemas

docker compose restart dijo "no se proporcionó archivo de configuración: no encontrado"

No estás en el directorio del proyecto que Compose espera — solo encuentra docker-compose.yml relativo a tu directorio de trabajo actual (o a través de -f). Primero, cd en la carpeta de la pila; este error exacto significa que Compose nunca miró siquiera a tus contenedores.

El trabajador sigue dando errores después de reiniciar Redis

Reiniciar la dependencia no ayuda — la conexión que se rompió pertenece al servicio dependiente. Reinicia la cosa que mantiene la conexión (aquí, langfuse-worker), no la cosa a la que se conecta.

docker exec <container> id <username> devuelve "no existe tal usuario"

La imagen no incluye un usuario no root dedicado bajo ese nombre — consulta la documentación de la imagen, o docker exec <container> cat /etc/passwd para ver qué está realmente disponible antes de elegir un UID.

Finished this tutorial?
Mark it complete to earn Run containers as non-root on your skill path.

¿Qué sigue?

El privilegio es la segunda capa: el endurecimiento de la red evita que un compromiso se propague entre pilas, el no root evita que un compromiso posea todo el contenedor. La última capa en esta serie es la identidad — poner autenticación real frente a los puertos que deben permanecer expuestos, que es donde NetBird y Caddy entran en juego.

Lectura adicional

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.