Cristhian Villegas
DevOps11 min read0 views

Curso Docker #8: Seguridad en Docker — Mejores Practicas

Curso Docker #8: Seguridad en Docker — Mejores Practicas

Bienvenido al Curso de Docker - Parte 8 de 10. Docker simplifica enormemente el despliegue de aplicaciones, pero si no aplicas buenas practicas de seguridad, puedes estar exponiendo tu infraestructura a ataques. Un contenedor mal configurado puede ser la puerta de entrada para comprometer todo tu sistema.

Logo de Docker

Fuente: Wikimedia Commons

En este articulo aprenderemos las mejores practicas de seguridad para Docker: desde ejecutar contenedores como usuario non-root hasta escanear vulnerabilidades con Trivy.

Por que la seguridad en Docker es critica

Los contenedores Docker comparten el kernel del sistema operativo host. A diferencia de las maquinas virtuales, no hay un hipervisor que aisle completamente el contenedor del host. Esto significa que:

  • Un contenedor comprometido puede potencialmente afectar al host
  • Ejecutar como root dentro del contenedor puede ser equivalente a root en el host si hay una vulnerabilidad de escape
  • Imagenes con vulnerabilidades conocidas son un vector de ataque comun
  • Secretos expuestos en capas de la imagen son visibles para cualquiera que tenga acceso a la imagen
Dato alarmante: Segun estudios de seguridad, mas del 50% de las imagenes Docker en Docker Hub contienen al menos una vulnerabilidad critica. Nunca asumas que una imagen publica es segura sin verificarla.

Ejecutar como usuario non-root

Por defecto, los procesos dentro de un contenedor Docker se ejecutan como root. Esta es la regla numero uno de seguridad: nunca ejecutes tu aplicacion como root.

dockerfile
1# MAL: ejecuta como root (por defecto)
2FROM node:20-alpine
3WORKDIR /app
4COPY . .
5CMD ["node", "index.js"]
6
7# BIEN: crea y usa un usuario non-root
8FROM node:20-alpine
9WORKDIR /app
10
11# Crear usuario y grupo
12RUN addgroup -S appgroup && adduser -S appuser -G appgroup
13
14# Copiar archivos y cambiar propietario
15COPY --chown=appuser:appgroup . .
16
17# Instalar dependencias como root (necesario para escribir en node_modules)
18RUN npm ci --omit=dev
19
20# Cambiar a usuario non-root ANTES del CMD
21USER appuser
22
23EXPOSE 3000
24CMD ["node", "index.js"]

Algunas imagenes oficiales ya incluyen usuarios non-root:

dockerfile
1# Node.js tiene el usuario 'node'
2FROM node:20-alpine
3USER node
4
5# PostgreSQL tiene el usuario 'postgres'
6FROM postgres:16
7# Ya ejecuta como 'postgres' por defecto
8
9# Nginx tiene www-data pero necesita root para el puerto 80
10# Usa un puerto alto como alternativa
11FROM nginx:alpine
12# Configurar nginx para usar puerto 8080 y usuario nginx
Tip: Puedes verificar con que usuario se ejecuta un contenedor con: docker exec mi-contenedor whoami. Si dice "root", necesitas corregirlo.

Usar imagenes base minimas

Cada paquete instalado en tu imagen es una potencial vulnerabilidad. Usa siempre la imagen mas pequena posible:

En lugar de... Usa... Razon
node:20 node:20-alpine De 1.1 GB a 140 MB, sin herramientas innecesarias
python:3.12 python:3.12-slim De 1 GB a 150 MB
ubuntu:22.04 alpine:3.19 De 77 MB a 7 MB
eclipse-temurin:21-jdk eclipse-temurin:21-jre-alpine Solo el runtime, no el compilador
bash
1# Comparar vulnerabilidades entre imagenes base
2docker run --rm aquasec/trivy image node:20 | tail -5
3docker run --rm aquasec/trivy image node:20-alpine | tail -5
4
5# Resultado tipico:
6# node:20         -> 245 vulnerabilidades (15 criticas)
7# node:20-alpine  -> 12 vulnerabilidades (0 criticas)

Escaneo de vulnerabilidades con Trivy

Trivy es una herramienta open-source de Aqua Security que escanea imagenes Docker, repositorios y archivos de configuracion en busca de vulnerabilidades conocidas (CVEs).

bash
1# Instalar Trivy (en macOS)
2brew install trivy
3
4# Instalar con Docker (sin instalacion local)
5docker run --rm aquasec/trivy --version
6
7# Escanear una imagen local
8trivy image mi-app:latest
9
10# Escanear y fallar si hay vulnerabilidades criticas
11trivy image --severity CRITICAL --exit-code 1 mi-app:latest
12
13# Escanear un Dockerfile
14trivy config ./Dockerfile
15
16# Escanear el sistema de archivos del proyecto
17trivy fs .
18
19# Generar reporte en formato JSON
20trivy image --format json --output report.json mi-app:latest
21
22# Escanear ignorando vulnerabilidades especificas
23trivy image --ignorefile .trivyignore mi-app:latest

Ejemplo de salida de Trivy:

bash
1mi-app:latest (alpine 3.19.1)
2Total: 3 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
3
4+-----------+------------------+----------+-------------------+
5| LIBRARY   | VULNERABILITY ID | SEVERITY | INSTALLED VERSION |
6+-----------+------------------+----------+-------------------+
7| libcrypto | CVE-2024-1234    | HIGH     | 3.1.4-r1          |
8| libssl    | CVE-2024-5678    | MEDIUM   | 3.1.4-r1          |
9| zlib      | CVE-2024-9012    | LOW      | 1.3.1-r0          |
10+-----------+------------------+----------+-------------------+
Importante: Integra Trivy en tu pipeline de CI/CD para escanear cada imagen antes de publicarla. Si hay vulnerabilidades criticas, el build debe fallar. Veremos como hacerlo en el articulo 9 sobre CI/CD.

No guardar secretos en las capas de la imagen

Cada instruccion en un Dockerfile crea una capa. Aunque borres un archivo en una capa posterior, sigue existiendo en las capas anteriores y cualquiera puede extraerlo.

dockerfile
1# MAL: el secreto queda en las capas
2FROM node:20-alpine
3WORKDIR /app
4COPY . .
5# Aunque borremos .env despues, queda en la capa anterior
6COPY .env .env
7RUN npm ci
8RUN rm .env  # ESTO NO SIRVE - .env sigue en la capa de COPY
9
10# MAL: credenciales en variables de build
11FROM node:20-alpine
12ARG DB_PASSWORD=supersecret
13ENV DB_PASSWORD=${DB_PASSWORD}
14# El ARG y ENV quedan visibles con docker inspect
15
16# BIEN: usar Docker secrets en build (BuildKit)
17FROM node:20-alpine
18WORKDIR /app
19COPY . .
20RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
21
22# BIEN: inyectar secretos en runtime, no en build
23FROM node:20-alpine
24WORKDIR /app
25COPY . .
26RUN npm ci --omit=dev
27# Los secretos se pasan al ejecutar el contenedor:
28# docker run -e DB_PASSWORD=xxx mi-app
29CMD ["node", "index.js"]

Puedes verificar que no hay secretos en tu imagen:

bash
1# Ver todas las capas de una imagen
2docker history mi-app:latest
3
4# Inspeccionar variables de entorno
5docker inspect mi-app:latest | jq '.[0].Config.Env'
6
7# Analizar capas con dive
8docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive mi-app:latest
Regla de oro: Nunca uses COPY para archivos .env, claves SSH, tokens de API o certificados privados. Siempre inyecta secretos en runtime con -e, --env-file o Docker Secrets.

Filesystem de solo lectura y no-new-privileges

Puedes ejecutar contenedores con el sistema de archivos en modo solo lectura para prevenir que un atacante modifique archivos dentro del contenedor:

bash
1# Ejecutar con filesystem de solo lectura
2docker run --read-only --tmpfs /tmp mi-app:latest
3
4# Agregar no-new-privileges para prevenir escalacion
5docker run --read-only --tmpfs /tmp --security-opt no-new-privileges mi-app:latest

En Docker Compose:

yaml
1services:
2  api:
3    image: mi-app:latest
4    read_only: true
5    tmpfs:
6      - /tmp
7      - /var/run
8    security_opt:
9      - no-new-privileges:true
10    environment:
11      - NODE_ENV=production

La opcion no-new-privileges previene que procesos dentro del contenedor obtengan privilegios adicionales a traves de setuid, setgid u otros mecanismos de escalacion.

Capabilities: cap_drop y cap_add

Linux capabilities permiten otorgar permisos granulares en lugar de dar acceso root completo. Docker por defecto da al contenedor un subconjunto de capabilities, pero puedes restringirlas aun mas:

bash
1# Eliminar TODAS las capabilities y agregar solo las necesarias
2docker run --cap-drop ALL --cap-add NET_BIND_SERVICE mi-app:latest
3
4# Ver las capabilities de un contenedor en ejecucion
5docker inspect mi-contenedor | jq '.[0].HostConfig.CapAdd'
6docker inspect mi-contenedor | jq '.[0].HostConfig.CapDrop'

En Docker Compose:

yaml
1services:
2  api:
3    image: mi-app:latest
4    cap_drop:
5      - ALL
6    cap_add:
7      - NET_BIND_SERVICE  # solo si necesita puertos < 1024
8    security_opt:
9      - no-new-privileges:true
Capability Descripcion Necesaria?
NET_BIND_SERVICE Bind a puertos menores a 1024 Solo si usas puerto 80/443
CHOWN Cambiar propietario de archivos Raramente
SYS_ADMIN Operaciones de administracion del sistema Casi nunca (peligrosa)
NET_RAW Sockets raw (ping, etc.) No para apps web

Aislamiento de red y Docker Content Trust

La seguridad de red es fundamental. Nunca expongas servicios internos al exterior:

yaml
1# docker-compose.yml seguro
2services:
3  frontend:
4    image: nginx:alpine
5    ports:
6      - "443:443"  # solo el frontend expone puertos
7    networks:
8      - public
9      - internal
10
11  api:
12    image: mi-api:latest
13    # NO exponer puertos al host
14    networks:
15      - internal
16
17  db:
18    image: postgres:16
19    # NO exponer puertos al host
20    networks:
21      - internal
22    volumes:
23      - db_data:/var/lib/postgresql/data
24
25networks:
26  public:
27    driver: bridge
28  internal:
29    driver: bridge
30    internal: true  # sin acceso a internet

Docker Content Trust (DCT) permite verificar la integridad y el publicador de las imagenes:

bash
1# Activar Docker Content Trust
2export DOCKER_CONTENT_TRUST=1
3
4# Ahora solo podras hacer pull de imagenes firmadas
5docker pull nginx:alpine  # OK si esta firmada
6docker pull imagen-random:latest  # FALLA si no esta firmada
7
8# Firmar tu propia imagen al hacer push
9docker trust sign mi-usuario/mi-app:latest
Nota: Docker Content Trust funciona con Notary. Las imagenes oficiales de Docker Hub estan firmadas, pero imagenes de terceros generalmente no. Activar DCT en produccion previene la ejecucion de imagenes no verificadas.

Dockerfile hardening completo

Aqui un ejemplo que aplica todas las practicas de seguridad que hemos visto:

dockerfile
1# ---- Etapa 1: Build ----
2FROM node:20-alpine AS builder
3WORKDIR /app
4
5# Instalar dependencias primero (cache)
6COPY package.json package-lock.json ./
7RUN npm ci
8
9# Copiar y compilar
10COPY tsconfig.json ./
11COPY src/ ./src/
12RUN npm run build
13
14# Instalar solo dependencias de produccion
15RUN npm ci --omit=dev
16
17# ---- Etapa 2: Produccion (hardened) ----
18FROM node:20-alpine AS production
19
20# Actualizar paquetes del SO para parchar CVEs
21RUN apk update && apk upgrade --no-cache
22
23# Eliminar herramientas innecesarias
24RUN apk del --purge apk-tools && \
25    rm -rf /var/cache/apk/* /tmp/* /root/.npm
26
27WORKDIR /app
28
29# Crear usuario non-root
30RUN addgroup -S appgroup && adduser -S appuser -G appgroup
31
32# Copiar solo artefactos necesarios
33COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
34COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
35COPY --from=builder --chown=appuser:appgroup /app/package.json ./
36
37# Cambiar a usuario non-root
38USER appuser
39
40# Puerto no privilegiado
41EXPOSE 3000
42
43# Healthcheck
44HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
45  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
46
47# Metadata
48LABEL maintainer="[email protected]"
49LABEL version="1.0.0"
50LABEL description="API segura con Node.js"
51
52CMD ["node", "dist/index.js"]

Y el correspondiente docker-compose.yml con hardening:

yaml
1services:
2  api:
3    build:
4      context: .
5      dockerfile: Dockerfile
6    read_only: true
7    tmpfs:
8      - /tmp
9    security_opt:
10      - no-new-privileges:true
11    cap_drop:
12      - ALL
13    deploy:
14      resources:
15        limits:
16          memory: 256M
17          cpus: "0.5"
18    healthcheck:
19      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
20      interval: 30s
21      timeout: 5s
22      retries: 3
23    networks:
24      - internal

Resumen y siguiente articulo

En este articulo aprendimos las mejores practicas de seguridad para Docker:

  • Usuario non-root: nunca ejecutes tu aplicacion como root
  • Imagenes minimas: Alpine, slim o distroless para reducir la superficie de ataque
  • Escaneo con Trivy: detecta vulnerabilidades antes de desplegar
  • Secretos seguros: nunca en las capas de la imagen, siempre en runtime
  • Filesystem read-only: previene modificaciones maliciosas
  • Capabilities: cap_drop ALL y agrega solo las necesarias
  • Aislamiento de red: redes internas para servicios que no necesitan acceso externo
  • Docker Content Trust: verifica la integridad de las imagenes
Siguiente articulo: En la parte 9 aprenderemos sobre Docker en CI/CD — como automatizar builds, tests y despliegues con GitHub Actions. Referencia oficial: Docker Security Documentation.
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles