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:
- Imágenes Oficiales (Docker Official Image): Son un conjunto de imágenes que ayudan a la comunidad bien por ser la base de otras imágenes o por seguir las mejores prácticas, etc... .
- Publicadores verificados (verified publisher): Suelen ser imágenes de mucha calidad y cuyos autores forman parte del programa de verificación de publicadores de Docker, normalmente entidades comerciales detras de productos relevantes
- Programa Open Source (Open Source Program): Imágenes publicadas y mantenidas por miembros del Programa Open Source de Docker.
- Las demás: Aquellas cuyos autores no forman parte de ningún programa y están normalmente sujetas a límites de uso.
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:
node:18
usa Debian 11 y cuenta con al menos 35 vulnerabilidades críticasnode:18-slim
usa Debian 11 y cuenta con solamente 40 vulnerabilidades levesnode:18-alpine
usa Alpine 3.16.2 y no cuenta con vulnerabilidades conocidas
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:
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)
Uso de Link
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.
Lectura recomendada
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.
Lectura recomendada
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 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
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.