Introducción
Al comienzo de la revolución de TI, la mayoría de las aplicaciones se desplegaban directamente en hardware físico, sobre el sistema operativo del host. Debido a ese único espacio de usuario, el tiempo de ejecución se compartía entre las aplicaciones. El despliegue era estable, centrado en el hardware y tenía un ciclo de mantenimiento largo. Era gestionado principalmente por un departamento de TI y ofrecía mucha menos flexibilidad a los desarrolladores. En tales casos, los recursos de hardware estaban infrautilizados la mayor parte del tiempo. El siguiente diagrama muestra una configuración de este tipo:

Despliegue tradicional de aplicaciones
Para despliegues flexibles y para utilizar mejor los recursos del sistema host, se inventó la virtualización. Con hipervisores, como KVM, XEN, ESX, Hyper-V, etc., emulamos el hardware para máquinas virtuales (VMs) y desplegamos un sistema operativo invitado en cada máquina virtual. Las VMs pueden tener un sistema operativo diferente al de su host; esto significa que somos responsables de gestionar los parches, la seguridad y el rendimiento de esa VM. Con la virtualización, las aplicaciones están aisladas a nivel de VM y se definen por el ciclo de vida de las VMs. Esto nos proporciona un mejor retorno de la inversión y mayor flexibilidad a costa de una mayor complejidad y redundancia. El siguiente diagrama muestra un entorno virtualizado típico:

Despliegue de aplicaciones en un entorno virtualizado
Desde que se desarrolló la virtualización, nos hemos estado moviendo hacia una TI más centrada en las aplicaciones. Hemos eliminado la capa del hipervisor para reducir la emulación de hardware y la complejidad. Las aplicaciones se empaquetan con su entorno de ejecución y se despliegan utilizando containers. OpenVZ, Solaris Zones y LXC son algunos ejemplos de tecnología de containers. Los containers son menos flexibles en comparación con las VMs; por ejemplo, no podemos ejecutar Microsoft Windows en un sistema operativo Linux al momento de escribir esto. Los containers también se consideran menos seguros que las VMs, porque con los containers, todo se ejecuta en el sistema operativo del host. Si un container se ve comprometido, entonces podría ser posible obtener acceso completo al sistema operativo del host. Puede ser un poco demasiado complejo de configurar, gestionar y automatizar. Estas son algunas de las razones por las que no hemos visto la adopción masiva de containers en los últimos años, a pesar de que teníamos la tecnología. El siguiente diagrama muestra cómo se despliega una aplicación utilizando containers:

Despliegue de aplicaciones con containers
Con Docker, los containers se convirtieron de repente en ciudadanos de primera clase. Todas las grandes corporaciones, como Google, Microsoft, Red Hat, IBM y otras, están trabajando ahora para que los containers sean de uso generalizado.
Docker comenzó como un proyecto interno del fundador de dotCloud, Solomon Hykes. Fue lanzado como código abierto en marzo de 2013 bajo la licencia Apache 2.0. Con la experiencia de plataforma como servicio de dotCloud, los fundadores e ingenieros de Docker eran conscientes de los desafíos de ejecutar containers. Así, con Docker, desarrollaron una forma estándar de gestionar containers.
Docker utiliza las características subyacentes del núcleo del sistema operativo, que permiten la contenerización. El siguiente diagrama muestra la plataforma Docker y las características del núcleo utilizadas por Docker. Veamos algunas de las principales características del núcleo que utiliza Docker:

Plataforma Docker y las características del núcleo utilizadas por Docker
Espacios de nombres
Los espacios de nombres son los bloques de construcción de un container. Existen diferentes tipos de espacios de nombres, y cada uno de ellos aísla las aplicaciones de las demás. Se crean utilizando la llamada al sistema clone. También puedes adjuntarte a espacios de nombres existentes. Algunos de los espacios de nombres utilizados por Docker se explicarán en las siguientes secciones.
El espacio de nombres PID
El espacio de nombres PID permite que cada container tenga su propia numeración de procesos. Cada PID forma su propia jerarquía de procesos. Un espacio de nombres padre puede ver los espacios de nombres hijos y afectarlos, pero un hijo no puede ver el espacio de nombres padre ni afectarlo.
Si hay dos niveles de jerarquía, entonces en el nivel superior, veríamos el proceso ejecutándose dentro del espacio de nombres hijo con un PID diferente. Así, un proceso ejecutándose en un espacio de nombres hijo tendría dos PIDs: uno en el espacio de nombres hijo y el otro en el espacio de nombres padre. Por ejemplo, si ejecutamos un programa en el container container.sh, entonces también podemos ver el programa correspondiente en el host.
En el container, el proceso sh container.sh tiene un PID de 8:

En el host, el mismo proceso tiene un PID de 29778:

El espacio de nombres de red
Con el espacio de nombres PID, podemos ejecutar el mismo programa varias veces en diferentes entornos aislados; por ejemplo, podemos ejecutar diferentes instancias de Apache en diferentes containers. Pero sin el espacio de nombres de red, no podríamos escuchar en el puerto 80 en cada uno de ellos. El espacio de nombres de red nos permite tener diferentes interfaces de red en cada container, lo que resuelve el problema que mencioné anteriormente. Las interfaces de loopback también serían diferentes en cada container.
Para habilitar el networking en containers, creamos pares de interfaces especiales en dos espacios de nombres de red diferentes y permitimos que se comuniquen entre sí. Un extremo de la interfaz especial reside dentro del container y el otro reside en el sistema host. Generalmente, la interfaz dentro del container se llama eth0, y en el sistema host, se le da un nombre aleatorio, como veth516cc56. Estas interfaces especiales se enlazan luego a través de un puente (docker0) en el host para permitir la comunicación entre los containers y enrutar los paquetes.
Dentro del container, verás algo como lo siguiente:
$ docker container run -it alpine ash# ip a

En el host, se vería algo como lo siguiente:
$ ip a

Además, cada espacio de nombres de red tiene su propia tabla de enrutamiento y reglas de firewall.
El espacio de nombres IPC
El espacio de nombres de comunicación entre procesos (IPC) proporciona semáforos, colas de mensajes y segmentos de memoria compartida. No es muy utilizado hoy en día, pero algunos programas todavía dependen de él.
Si el recurso IPC creado por un container es consumido por otro container, entonces la aplicación que se ejecuta en el primer container podría fallar. Con el espacio de nombres IPC, los procesos que se ejecutan en un espacio de nombres no pueden acceder a los recursos de otro espacio de nombres.
El espacio de nombres mnt
Utilizando solo un chroot, puedes inspeccionar las rutas relativas del sistema desde un directorio/espacio de nombres chroot. El espacio de nombres mnt lleva la idea de los chroots al siguiente nivel. Con el espacio de nombres mnt, un container puede tener su propio conjunto de sistemas de archivos montados y directorios raíz. Los procesos en un espacio de nombres mnt no pueden ver los sistemas de archivos montados de otro espacio de nombres mnt.
El espacio de nombres UTS
Con el espacio de nombres UTS, podemos tener diferentes nombres de host para cada container.
El espacio de nombres de usuario
Con el soporte de espacios de nombres de usuario, podemos tener usuarios con un ID distinto de cero en el host, pero que pueden tener un ID cero dentro del container. Esto se debe a que el espacio de nombres de usuario permite mapeos de IDs de usuarios y grupos por espacio de nombres.
Hay formas de compartir espacios de nombres entre el host y el container, y también con otros containers. Veremos cómo hacerlo en capítulos posteriores.
Cgroups
Los grupos de control (cgroups) proporcionan limitaciones de recursos y contabilidad para los containers. La siguiente cita es de la documentación del núcleo de Linux:
“Control Groups provide a mechanism for aggregating/partitioning sets of tasks, and all their future children, into hierarchical groups with specialized behaviour.”
En términos sencillos, se pueden comparar con el comando de shell ulimit o la llamada al sistema setrlimit. En lugar de establecer el límite de recursos para un solo proceso, los cgroups te permiten limitar los recursos a un grupo de procesos.
Los grupos de control se dividen en diferentes subsistemas, como CPU, conjuntos de CPU, E/S de bloques de memoria, etc. Cada subsistema puede utilizarse de forma independiente o agruparse con otros. Las características que proporcionan los cgroups son las siguientes:
-
Limitación de recursos: Por ejemplo, un cgroup puede vincularse a CPUs específicas, de modo que todos los procesos de ese grupo se ejecutarán solo en las CPUs dadas.
-
Priorización: Algunos grupos pueden obtener una mayor parte de las CPUs.
-
Contabilidad: Puedes medir el uso de recursos de diferentes subsistemas para la facturación.
-
Control: Puedes congelar y reiniciar grupos.
Algunos de los subsistemas que pueden ser gestionados por cgroups son los siguientes:
-
blkio: Establece el acceso de E/S hacia y desde dispositivos de bloque, como discos, SSDs, etc.
-
Cpu: Limita el acceso a la CPU.
-
Cpuacct: Genera la utilización de recursos de la CPU.
-
Cpuset: Asigna CPUs en un sistema multinúcleo a tareas en un cgroup.
-
Devices: Concede acceso a dispositivos a un conjunto de tareas en un grupo.
-
Freezer: Suspende o reanuda tareas en un cgroup.
-
Memory: Establece límites en el uso de memoria por tareas en un cgroup.
Hay múltiples formas de controlar el trabajo con cgroups. Dos de las más populares son acceder al sistema de archivos virtual de cgroup manualmente y acceder a él con la biblioteca libcgroup. Para usar libcgroup en Linux, ejecuta el siguiente comando para instalar los paquetes requeridos en Ubuntu o Debian:
$ sudo apt-get install cgroup-tools
Para instalar los paquetes requeridos en CentOS, Fedora o Red Hat, usa el siguiente código:
$ sudo yum install libcgroup libcgroup-tools
NOTA
Estos pasos no son posibles en Docker para Mac y Windows, porque no puedes instalar los paquetes requeridos en esas versiones de Docker.
Una vez instalado, puedes obtener la lista de subsistemas y su punto de montaje en el pseudo sistema de archivos con el siguiente comando:
$ lssubsys -M

Aunque todavía no hemos visto los comandos reales, asumamos que estamos ejecutando algunos containers y queremos obtener las entradas de cgroup para un container. Para obtenerlas, primero necesitamos obtener el ID del container y luego usar el comando lscgroup para obtener las entradas de cgroup de un container, lo cual podemos hacer usando el siguiente comando:

NOTA
Para más detalles, visita https://docs.docker.com/config/containers/runmetrics/.
El sistema de archivos union
El sistema de archivos union permite que los archivos y directorios de sistemas de archivos separados, conocidos como capas, se superpongan de forma transparente para crear un nuevo sistema de archivos virtual. Al iniciar un container, Docker superpone todas las capas adjuntas a una image y crea un sistema de archivos de solo lectura. Además, Docker crea una capa de lectura/escritura que es utilizada por el entorno de ejecución del container. Puedes leer la receta Pulling an image and running a container de este capítulo para más detalles. Docker puede usar varias variantes de sistemas de archivos union, incluyendo AUFS, Btrfs, zfs, overlay, overlay2 y DeviceMapper.
Docker también tiene un controlador de almacenamiento de sistema de archivos virtual (VFS). Un VFS no soporta copy-on-write (COW) y no es un sistema de archivos union. Esto significa que cada capa es un directorio en el disco, y cada vez que se crea una nueva capa, requiere una copia profunda de su capa padre. Por estas razones, tiene un rendimiento inferior y requiere más espacio en disco, pero es una opción robusta y estable que funciona en cualquier entorno.
El formato de container
Docker Engine combina los espacios de nombres, los grupos de control y UnionFS en un envoltorio llamado formato de container. En 2015, Docker donó su formato de container y tiempo de ejecución a una organización llamada Open Container Initiative (OCI). La OCI es una estructura ligera de gobernanza abierta formada bajo la Linux Foundation por Docker y otros líderes de la industria. El propósito de la OCI es crear estándares industriales abiertos en torno a los formatos y tiempos de ejecución de containers. Actualmente existen dos especificaciones: la Especificación de Tiempo de Ejecución y la Especificación de Image.
La Especificación de Tiempo de Ejecución describe cómo ejecutar un paquete de sistema de archivos de tiempo de ejecución OCI. Docker donó runC, (https://github.com/opencontainers/runc) su tiempo de ejecución compatible con OCI a la OCI, para servir como implementación de referencia.
El formato de image OCI contiene la información necesaria para lanzar la aplicación en la plataforma de destino. La especificación define cómo crear la image OCI y cómo sería la salida deseada. La salida consistiría en un manifiesto de image, una serialización de sistema de archivos (capa) y la configuración de la image. Docker donó su formato de image Docker V2 a la OCI para formar la base de la especificación de image OCI.
Actualmente existen dos motores de containers que soportan las Especificaciones de Tiempo de Ejecución y de Image de OCI: Docker y rkt.