Skip to main content

2.2 Contenedores

Los contenedores han supuesto toda una revolución en la experiencia del desarrollo para millones de personas en todo el mundo. A la hora del despliegue tenemos que tener algunos factores en cuenta para mantener un buen aislamiento y control de los recursos que compartimos entre el host y los contenedores, debemos cerciorarnos que las imágenes que utilizamos son de suficiente calidad para nuestros objetivos.

2.2.1 Imágenes base

Para crear un contenedor, primero necesitamos una imagen de la que partir. No es necesario crear nosotros mismos las imágenes, ya que en muchas ocasiones encontraremos imágenes ya disponibles en los registros públicos más populares.

Sin ir más lejos Docker Hub cuenta ya con más de 8.3 millones de repositorios, esto hace que elegir una imagen con la que trabajar sea una tarea complicada. Veamos algunos criterios que pueden ayudarnos.

Origen confiable

Docker hub divide las imágenes en varias categorías:

Muchas de las imágenes más descargas son imágenes oficiales, por ejemplo Mongo, Nodejs, Python, Mysql, entre otras. Esto no quiere decir que las imágenes no oficiales sean peores o inseguras, simplemente intentaremos priorizar aquellas que sean oficiales. Las imágenes no oficiales incluyen una referencia al usuario por ejemplo ulisesgascon/check-my-headers en comparación con las oficiales que no incluyen el usuario, como busybox.

Popularidad

Otro criterio que puede ayudarnos a decidir entre imágenes similares, es su popularidad. Por regla general cuantas más estrellas tenga un repositorio, más influencia tendrá y más extendido será su uso. Lo que facilitará enormemente la tarea de buscar ayuda o documentación cuando lo necesitemos.

Mantenimiento

Tanto si estamos usando imágenes de Docker finales o como base para crear otras, es muy recomendable entender cómo de activo y mantenido está un repositorio. En ocasiones podemos toparnos con imágenes que aunque son populares no están mantenidas de forma activa, introduciendo así un vector de ataque especialmente importante cuando se reportan vulnerabilidades que requieren de un parcheo rápido.

consejo

En ocasiones así, es interesante contar con la opción de ser nosotros mismos quienes demos un paso al frente y decidamos mantener esa imagen de Docker.

Minimalismo

Cuanto más simple es una imagen de Docker menos superficie de ataque nos ofrece, siendo importante aprender a usar tags más allá de latest. Como podemos ver es muy notoria la diferencia:

Información

Es cierto que no todas las vulnerabilidades potenciales de la imágenes se convierten en vulnerabilidades explotables en nuestros proyectos, pero es importante entender que usar imágenes vulnerables, nos hace vulnerables

Docker images almost always bring known vulnerabilities alongside their great value. Liran Tal

Otra forma de alcanzar el minimalismo, es hacer uso de imágenes que hagan uso del sistema multi-stage (ver capítulo 3.1.6)

Tags

La inmutabilidad es uno de los conceptos más importantes del mundo DevOps, y esto se refleja en el uso de tags dentro de Docker. Si estás familiarizado con el versionamiento semántico, esto es llevar el proceso un paso más allá. Tomemos como ejemplo Node node:<version>-<os> o Python python:<version>-<os>:

FROM node:latest
FROM node:18
FROM node:18-slim
FROM node:18-alpine
FROM node:18.1.0-alpine3.15
FROM python:latest
FROM python:3
FROM python:3.10-slim
FROM python:3.10-alpine
FROM python:3.10.5-alpine3.16

Como vemos, muchos repositorios en Docker Hub han adoptado este enfoque. En términos de inmutabilidad sobretodo si estamos usando entornos productivos, es necesario definir una política de equipo/empresa en lo referente a la especificidad de las versiones, ya que node:18-alpine ahora puede ser [email protected] & [email protected] pero dentro de tres meses puede ser [email protected] & [email protected] haciendo que nuestra inmutabilidad no lo sea tanto y acabemos teniendo versiones de nuestro software dispares entre los diversos entornos que manejemos (local, staging, live...) siendo los bugs ocasionados por esta discrepancia bastante difíciles de detectar en muchas ocasiones.

2.2.2 Archivos y carpetas

Una de las funcionalidades más populares en el mundo de los contenedores es poder compartir información a nivel de ficheros y carpetas entre el host y los contenedores, siendo esta una actividad de riesgo si no tenemos claro los retos y fortalezas que presentan los volúmenes en Docker.

Tomemos como ejemplo el siguiente código:

sudo docker run -it -v /:/var/host-hd ubuntu /bin/bash

Al ejecutar el ejemplo anterior, estamos usando el usuario root, en principio si somos root en el contenedor también lo somos en host como vimos en el capítulo 2.1.3. Además de ello, hemos montado el directorio / como /var/host-hd en nuestro contenedor de Docker haciendo que sea fácil para el contenedor acceder a información sensible, borrar y modificar ficheros o carpetas.

Evidentemente este es un caso extremo y ayuda a darnos cuenta lo fácil que resulta exponer información de más con un contenedor y además con demasiados permisos heredados del propio host.

Docker ya introduce bastantes mecanismos para gestionar esto, además cada orquestador suele tener funcionalidades adicionales que nos ayudarán a compartir información de una forma más eficiente y segura.

Tipología

Por definición cualquier fichero o carpeta que se genere dentro de un contenedor estará disponible de forma efímera, cuando el contenedor muera esa información desaparecerá.

En caso de necesitar persistencia tendremos que apoyarnos necesariamente en el host, y esto puede hacerse de varias formas:

Relacion entre los tipos de volumen y el hostRelacion entre los tipos de volumen y el host

Imagen derivada de Docker para adaptar el formato

  • Volumes se guardan en el host como designe Docker y no debería de usarse fuera del contexto de los contenedores
  • Bind mounts son ficheros o carpetas (incluyendo información sensible) que se montan directamente en el contenedor. El contenedor puede modificar estos ficheros y carpetas en muchos casos
  • tmpfs son ficheros y carpetas que usan solamente la memoria del host y no hacen uso del sistema de ficheros.
Lectura recomendada

Modo lectura

Una forma efectiva de evitar la modificación de archivos en el host es hacer uso del modo solo-lectura cuando sea posible (ver :ro)

docker run -d -it -v /host_folder:/app:ro ubuntu

2.2.3 Gestión de secretos

Es muy frecuente que tengamos que compartir información sensible con nuestros contenedores, sobre todo en entornos productivos.

Esta información sensible puede ser desde pequeños artefactos como una llave privada o simples cadenas de texto que pueden contener contraseñas, tokens, etc.

Este tipo de información puede ser bastante sensible sobre todo si compartimos un token que permita el acceso a recursos clave fuera de nuestra red como un token de AWS con demasiados permisos o las credenciales para acceder nuestra base de datos de un entorno productivo.

Evidentemente, antes de compartir cualquier secreto deberemos de confiar que nuestra imagen es de confianza, siguiendo las recomendaciones del capítulo 2.1.1 y del resto del libro. Aunque no es muy frecuente, cada vez más podemos encontrar contenedores maliciosos en Docker Hub de los que podríamos ser víctimas potenciales.

Por supuesto, si almacenamos los secretos dentro de nuestras imágenes tendremos un problema ya que ese secreto será visible y extraíble con herramientas como Dive (capítulo 4.3), además dificultará enormemente su rotación, etc...

Una forma común de pasar secretos es mediante las variables de entorno haciendo posible que el secreto solo pase al contenedor que se ejecuta y no su imagen.

Podemos pasar las variables de entorno de forma individual con los argumentos -e o --env, pudiendo incluso hacer uso de variables ya presentes en la máquina host:

export MYVAR1=value1
docker run -e MYVAR1 --env MYVAR2=value2 ubuntu bash

O haciendo uso de un fichero específico tipo .env con el argumento --env-file.

cat .env
# This is a comment
MYVAR1=value1
MYVAR2=value2

$ docker run --env-file .env ubuntu bash

Este sistema tiene ciertas limitaciones que debemos conocer. Por un lado cualquier persona con acceso al contenedor podrá acceder a los secretos.

# El el host:
docker run -it --rm --name ubuntu -e MYSECRET=el_gran_secreto ubuntu /bin/bash
# Dentro del contenedor:
env | grep MYSECRET
#MYSECRET=el_gran_secreto

Por otro lado si hacemos uso de comandos como inspect de docker podremos acceder a la misma información

docker inspect ubuntu -f "{{json: .Config.Env}}"
# ["MYSECRET=el_gran_secreto", ...]

Además, este sistema también es proclive a generar la exposición accidental de información sensible en nuestros logs, CWE-532: Insertion of Sensitive Information into Log File. Aunque esto dependerá enormemente de cómo hagamos esa configuración/centralización de logs.

Las variables de entorno son un mecanismo de mucha utilidad, pero para cierta información sensible en ocasiones necesitaremos otra estrategia. Una forma de mitigar este problema sería hacer uso de volúmenes (capítulo 2.2.2) o de Buildkit (capítulo 3.1.8). En entornos productivos debemos de confiar en los mecanismos que nos ofrecen los sistemas de orquestación de contenedores que usemos, por ejemplo con Docker Swarm o Kubernetes.

Como opción adicional, Docker Compose (capítulo 4.1) nos ofrece mayor comodidad al lidiar con volúmenes, ficheros tipo .env y variables de entorno, especialmente para uso local.

2.2.4 Redes

Sin duda uno de las funcionalidades más revolucionarias de Docker ha sido la facilidad con la que podemos gestionar la comunicación entre contenedores y el propio host mediante el uso de network.

La securización, aprovisionamiento y bastionado de redes es un tema en sí muy complejo que no está incluido en los objetivos de este libro, pero es importante remarcar algunos conceptos importantes a la hora de entender la seguridad de nuestros contenedores.

Cuando usamos orquestadores en entornos productivos, estos ya suelen incluir diversas formas de conectar y securizar los contenedores entre sí, y las formas de establecer subredes y mecanismos de comunicación seguros y eficaces.

Pero si usamos el demonio de docker en nuestro entorno local debemos tener varias cosas en consideración.

La configuración por defecto

Algo que no se hace tan obvio en un primer vistazo en docker es que los contenedores pueden comunicarse entre sí aunque los puertos no hayan sido específicamente publicados o expuestos ya que usan bridge network por defecto.

Veamos un ejemplo ilustrativo con Nginx y busybox:

# Arrancamos Nginx
docker run --rm --name nginx-server --detach nginx

# Sacar la IP del Nginx (p.j: 172.17.0.2)
docker inspect nginx-server| grep IPAddress

# Arrancamos busybox y entramos en su terminal
docker run -it busybox sh

# hacemos un GET al Nginx
wget -q -O - 172.17.0.2:80
#<!DOCTYPE html>
#...

Una forma de evitar esto, es modificando el comportamiento del demonio de Docker usando el argumento --icc=false

dockerd --icc=false 

Para asegurarnos que el cambio surtió efecto podemos buscar com.docker.network.bridge.enable_icc:false en las redes de docker disponibles

docker network ls --quiet | xargs docker network inspect --format '{{ .Name }}: {{ .Options }}'

Abusando de host

Una posibilidad que nos ofrece Docker es hacer uso de la red de la propia máquina host --network=host compartiendo así la red.

Al hacer esto, es fácil acabar exponiendo la máquina host a través de los contenedores si estos tienen alguna vulnerabilidad explotable.

Información

Como ejemplo ilustrado de este tipo de ataques podemos leer este didáctico writeups como How to contact Google SRE: Dropping a shell in cloud SQL o Metadata service MITM allows root privilege escalation (EKS / GKE)

Es común hoy en día encontrar referencias sobre el uso del argumento link cuando queramos que los contenedores puedan comunicar entre sí de forma explícita. A día de hoy, la idea sería portarnos al uso de network.

Establecimiento de redes

Docker hace uso del Container Network Model (CNM) que nos permite crear pequeñas redes segmentadas donde podemos hacer mejor control de los recursos, dejando así abierta la posibilidad de ir aislando y conectando elementos entre sí de una forma más segura.

Este trabajo es infinitamente más simple si hacemos uso de Docker Compose (capítulo 4.1) cuando tengamos que montar comunicaciones entre varios contenedores en el entorno local.

2.2.5 Limitación de recursos

Como en cualquier escenario de virtualización debemos preocuparnos de una forma proactiva y consciente del uso y sobre todo del mal uso de los recursos del sistema.

Los contenedores consumen los recursos que el host ofrece a través del demonio de Docker, permitiendo así también limitar esos recursos para evitar ataques tipo DOS (ver CWE-400: Uncontrolled Resource Consumption) o que se comprometa la integridad del host por un memory leak en uno o varios contenedores.

Los orquestadores de contenedores en sistemas productivos suelen tener también mecanismos que podemos utilizar para poner límites a nuestros contenedores, algunos incluso proveen interfaces más avanzadas que las del propio demonio de Docker.

Limitar los reinicios

Docker ofrece la posibilidad de definir las políticas de reinicio con el argumento --restart evitando así la interrupción de servicio. Esto es especialmente útil si contamos con un sistema de escalado horizontal como en el caso de Kubernetes

La política más relevante a la hora de limitar recursos sería on-failure que nos permite limitar el número de reinicios máximos que permitimos a una imagen. En este ejemplo especificamos que 10 sería el máximo permitido.

docker run -d --restart on-failure:10 ubuntu

Evidentemente si una imagen ha terminado su tarea y decide cerrarse según se espera (código de salida 0) esta no será reiniciada.

Información

Existen varias convenciones adicionales que deberemos tener presentes a la hora de entender como Docker aplica las políticas de reinicio. A su vez es interesante entender el papel que juegan los health checks en Docker, de lo que hablaremos en el capítulo 3.1.9.

También es importante remarcar que para poner límites a nuestros contenedores previamente tendremos que saber cuántos recursos necesita para funcionar de una forma correcta, siendo una primera opción utilizar herramientas como Dockprom (capítulo 4.9). Podemos hacer pruebas sin parar los contenedores para probar límites nuevos de CPU/Memoria ya que el comando update de Docker nos permite esa flexibilidad

# A un contenedor específico
docker update --cpuset-cpu ".5" --memory "500m" <ContainerNameOrID>

# A todos:
docker update --cpuset-cpus "1.5" --memory "350m" $(docker ps | awk 'NR>1 {print $1}')

Una forma interactiva de familiarizarnos con los límites en Docker es hacer uso de la imagen progrium/stress que ya nos provee una versión dockerizada de Stress.

Limitar CPU

La limitación de uso de CPU se puede hacer con diversos argumentos --cpus, --cpu-period, --cpu-quota, --cpuset-cpus, --cpu-shares lo que permite mucha flexibilidad.

En este ejemplo veremos cómo permitimos el uso de 1 CPU de dos formas equivalentes:

docker run -it --cpus="1" ubuntu /bin/bash
docker run -it --cpu-period="100000" --cpu-quota="100000" ubuntu /bin/bash

Limitar memoria

En sistemas Linux es fácil que acabemos con una excepción Out of Memory Exception (OOME) si nuestros contenedores llegan a comprometer la memoría mínima necesaria para que el host sobreviva. Esto hace que Docker suba su prioridad (OOM Priority) haciendo que sea más fácil terminar con un contenedor que con el propio demonio de Docker.

Ram

Podemos limitar el uso de memoria RAM siendo lo mínimo 6m pudiendo llegar a varios Gigas sin problema

docker run -it --memory="1g" ubuntu /bin/bash

Swap

La memoria Swap nos permite usar espacio del disco duro como memoria en lugar de usar RAM directamente. Esto es muy útil si no tenemos mucha memoria RAM, pero el tener que acceder al disco hace que la aplicación sea mucho más lenta.

Imagen que representa la memoria y la memoria swap como conjuntoImagen que representa la memoria y la memoria swap como conjunto

Imagen derivada de Thorsten Hans para adaptar el formato

Solo podemos configurar Swap si tenemos el argumento de --memory ya habilitado. Por defecto Docker igualará la memoria que especifiquemos en Swap. Haciendo así que la suma de ram+swap sea el doble que la ram que definimos.

Al combinar --memory y --memory-swap podemos llegara varios escenarios interesantes, veamos algunos ejemplos:

# Swap: 256m. Ram: 256mb. Total: 256mb + 256mb
docker run --memory="256m" ubuntu /bin/bash

# Swap: 1G. Ram: 256mb. Total: 1G + 256mb
docker run --memory="256m" --memory-swap="1g" ubuntu /bin/bash

# Swap: ilimitado. Ram: 256mb. Total: ? + 256mb
docker run --memory="256m" --memory-swap -1 ubuntu /bin/bash

# Swap: 0. Ram: 256mb. Total: 0 + 256mb
docker run --memory="256m" --memory-swap="256m" ubuntu /bin/bash

Otras limitaciones

Información

En escenarios un poco más específicos contamos con la posibilidad de crear limitaciones para uso de GPUs y de la memoria del Kernel.

Cuando contamos con orquestadores ciertas tareas de limitación son más sencillas como la del propio demonio de Docker o del consumo de red especialmente útil en escenarios P2P o IPFS, pero en red local entrañan ciertas dificultades técnicas y deberemos de investigar formas de mitigarlo de una forma efectiva.