Containers - entre historia y runtimes
Estudiando kubernetes gasté un tiempo considerable intentando entender muchos conceptos, por ejemplo, por todo lado se habla de OCI compliant, buscas OCI y te lleva a runtime-spec, buscas runtimes y te lleva a containerd, runc, image-spec, cgroups, namespaces, etc; puedes pasar días buscando, y mucho más cuando eres del tipo de persona que quiere entender a fondo cómo funcionan las cosas.
Motivado por lo anterior, me decidí a escribir este post con la idea de compartir los conceptos que logré adquirir y que me han servido para entender varias cosas del gran mundo de los containers, en algunas cosas no voy a tan bajo nivel ya que hay muchos conceptos que todavía desconozco y puedo decir cosas equiviocadas.
Lo básico
Iniciemos entendiendo un poco la idea detrás de los containers.
Containers tienen como objetivo crear un ambiente virtual aislado el cual se pueda distribuir y desplegar fácilmente. Dentro del container pueden correr diferentes procesos los cuales deben estar aislados de otros corriendo en el host. El kernel de linux ofrece distintas funcionalidades que permiten la creación de estos ambientes. Hay dos componentes principales que quizás son el core de todos los containers.
Linux namespaces
Linux namespaces nos permite crear ambientes virtuales y aislados, estos particionan recursos del kernel y hacen que sean visibles solo para los procesos que corren dentro del namespace, pero no para procesos externos. En otras palabras, namespaces nos facilitan el aislamiento entre procesos.
¿Qué recursos se pueden particionar?, bueno esto va a depender del tipo de namespace que se este usando, por ejemplo, network namespaces nos permite encapsular los recursos relacionados con networking, como interfaces, tablas de rutas, etc. De esta forma podemos crear una red virtual dentro de nuestro namespace.
Este post explica un poco más en detalle los namespaces.
cgroups
Recordemos que el Kernel de Linux es la interfaz principal entre el hardware y los procesos, permitiendo la comunicación entre estos dos y ayudando a la gestión de recursos, por ejemplo, puede terminar procesos que consuman demasiada memoria para evitar afectar el sistema operativo. Adicionalmente pueden controlar qué procesos pueden consumir cierta cantidad de recursos.
cgroups es una funcionalidad del Kernel de Linux que permite organizar jerárquicamente procesos y distribuir recursos(cpu, memoria, networking, storage) dentro de dicha jerarquía.
Configurar cgroups puede ser un poco complejo, en mi caso estuve leyendo varios post acerca del tema y requiere cierto tiempo para entender por completo su funcionamiento. En esta serie de posts creados por RedHat se habla sobe cgroups y su configuración a través de systemd, pero si se desea entrar en detalle la documentación de Linux puede ser de ayuda.
cgroups y namespaces se convierten en los ingredientes secretos en la creación de containers, namespaces permiten aislamiento a nivel de recursos y cgroups permiten controlar los limites para dichos recursos.
Por suerte hoy en día con una sola linea podemos crear un container, no tenemos que entrar a configurar namespaces ni cgroups.
Veamos un poco de la evolución de los containers y así vamos aclarando ciertas cosas.
Un poco de historia
Docker fue el primero que popularizó los containers, era(o es) común asociar containers directamente con Docker, pero antes ya existía algo llamado LXC(Linux containers), el cual puede entenderse como un proveedor de ambientes virtuales en Linux que usa ciertos componentes del Kernel de Linux para crear ambientes aislados(containers).
LXC se encuentra dentro del user-space, es decir, nosotros interactuamos con LXC y este se encarga de interactuar con los componentes del kernel para permitir la creación de containers. Aqui un video en donde se puede ver LXC en acción.
Nota: Antes de LXC ya se habían desarrollado otros alternativas para la creación de containers como OpenVZ y Linux Vserver. LXC es mencionado inicialmente ya que es lo más cercano a Docker que es el software con el que muchos iniciamos interactuando con containers.
La llegada de Docker
Docker empaquetó LXC en una herramienta que facilitaba más la creación de containers. Al ganar popularidad se crearon mejoras y unos meses después Docker lanzó libcontainer el cual está escrito en Golang y básicamente reemplazaba LXC.
Docker se enfocó más en la creación de containers optimizados para el despliegue de aplicaciones mejorando la portabilidad. Este post explica más detalladamente las diferencias entre LXC y Docker.
Definiendo un estándar para containers
Como alternativa a Docker, empezaron a surgir otras opciones,CoreOS por su parte lanzó rkt(2014) proponiendo mejores de seguridad, CoreOS argumentaba que Docker había sido construido como un monolito el cual corría como root en el host, abriendo posibilidades a comprometer todo el host en el caso de un ataque.
rkt usa appc(open source container) con el fin de mejorar la operabilidad, appc tiene como propósito crear un estándar general para crear containers buscando ser vendor-independent y OS-independent.
Otras iniciativas empezaron a surgir debido a la alta popularidad de los containers y debido a esto, en 2015 se crea OCI(Open Container Initiative) para definir un estandar para containers(runtimes e imagenes).
OCI Runtime spec
Runtime spec define la configuración(archivo JSON), ambiente y ciclo de vida de un container. Las configuraciones son definidas en un archivo llamado config.json, el cual contiene la metadata necesaria para la ejecución del container, este archivo es definido de acuerdo a plataforma a usar(windows, linux, solaris, etc).
otro concepto a destacar es el filesystem bundle, este es un grupo de archivos con la data y metadata para correr un container. Los principales archivos que deben contener son, el config.json mencionado anteriormente y el rootfs(linux file system), este filesystem bundle se genera a través del container image.
Todas las especificaciones para el container runtime son descritas aqui.
OCI Image spec
Docker en sus inicios ya había definido las especificaciones para la creación de imágenesImage Manifest 2 Schema Version 2, al ser el más popular, OCI partió de este para crear un estándar más general, que no estuviera asociado a un vendor en específico. Image spec define como construir y empaquetar container images, personalmente no he entendido del todo el funcionamiento pero aquí está la url del repo y un blog-post que contienen mayor información.
Haciendo uso del Image spec, se puede crear un container image que puede ser ejecutada por cualquier OCI Runtime, esto quiere decir que a través del Image spec se puede generar el filesystem bundle, el cual es usado por el runtime para la creación y ejecución del container.
The Runtime Specification outlines how to run a "filesystem bundle" that is unpacked on disk. At a high-level an OCI implementation would download an OCI Image then unpack that image into an OCI Runtime filesystem bundle. At this point the OCI Runtime Bundle would be run by an OCI Runtime.
Container runtimes y Kubernetes
En el 2015 se lanza el primer release de kubernetes, el cual usaba Docker como runtime.
Docker decide dividir el monolito creado. libcontainer es donado a OCI y Docker empieza a trabajar en un proyecto llamado runC, este se puede ver como una herramienta que lee OCI specifications e interactúa con libcontainer para la creación de containers. runC es independiente del Docker Engine y es donado a OCI.
runC es una low-level runtime por lo que también se desarrolla containerd el cual es como una interfaz entre el cliente y runC.
Hasta el momento solo se ha cubierto parte del origen de los container y el origen de algunas herramientas que seguimos viendo hoy en día como runC y conteinerd. En lo que sigue del post trataré de exponer un poco más a fondo las container images al igual que algunas containers runtimes.
Container Images
Antes de entrar a ver las containers runtimes, es importante entender qué es lo que contienen las containers images, para ello vamos a usar Skopeo.
Skopeo permite manipular e inspeccionar container images ya sea para Windows, Linux o MacOs. En este caso vamos a usar Skopeo para obtener "el contenido" de una imagen que se encuentra en DockerHub, esto es muy similar al comando docker export,pero en este caso no vamos a instalar Docker.
copiando images con skopeo
Para instalar skopeo se puede usar snap en ubuntu
sudo snap install skopeo --edge
una vez que finalice la instalación podemos copiar una imagen que se encuentra en DockerHub a nuestro local. En este caso se va a usar la imagen de golang.
sudo skopeo --insecure-policy copy docker://golang:latest oci://home/ubuntu/example-dev-to/golang-image-v2
Skopeo copia el contenido de la imagen en el destino especificado, en este caso oci:/home/ubuntu/example-dev-to/golang-image-v2
. En la imagen se puede ver que se tiene un archivo index.json, oci-layout y un directorio llamado blobs. Esto corresponde a la estructura de archivos definidos por OCI
el index.json se puede entender como un resumen de la imagen, en donde se ve el sistema operativo y la arquitectura, además se especifica la ubicación del image manifest.
El image manifest contiene metadata de la imagen al igual que las especificaciones de cada layer creada.
Revisando el index.json vamos a encontrar lo siguiente:
Se puede ver información acerca del sistema operativo y arquitectura soportados por la imagen. El digest(linea 6) nos indica en que archivo se encuentra el manifest.json.
En el manifest(imagen anterior) se puede ver el digest para el config file y para cada una de las layers que se tienen. El mediaType puede entenderse como el formato de cada archivo, por ejemplo la linea 4 nos dice que el archivo config de formato json se puede identificar con el digest bdba673e96d6e9707e2a724103e8835dbdd11dc81ad0c76c4453066ed8db29fd
. Este se puede encontrar en la carpeta blobs y va a lucir como la siguiente imagen.
Este archivo ya contiene más información de la imagen, por ejemplo podemos ver el workdir y algunas variables de entorno.
pasemos ahora a las layers, en el manifest podemos identificar los digest para cada layers, si vemos el media type nos indica que es v1.tar+gzip
, en este caso tenemos que descomprimir el contenido de dicho digest, para ello vamos a usar tar
Una vez termine el proceso podemos analizar el resultado, en este caso vamos a tener una serie de directorios que representan el rootfs de la imagen, estos archivos van a hacer parte de un layer en específico. Si observamos la siguente imagen podemos ver que tenemos /home, /etc y /bin, etc, los cuales representan el sistema de archivos de linux(rootfs).
Con esto vemos a alto nivel el contenido de un container image, al final el container runtime es el que se encarga de descomprimir y leer todos estos archivos, el cual va a ser usado para correr el container.
Hasta aquí va la primera parte de este post, en la siguiente veremos un poco m'as los container runtimes.