Creando un contenedor en Go desde cero

Juan Carlos
Problem Solvers’ Tech Tales
8 min readJul 19, 2017

Este artículo está basado en el post de Julian Friedman y la excelente plática de Liz Rice sobre contenedores. Muchos de los conceptos que se mencionan en este post tuvieron sentido para mí después de escuchar sus pláticas así que recomiendo ampliamente seguirlos

En estos tiempos se escucha hablar por todos lados de “contenedores” y sus muchas ventajas. Que si el Docker, que si el Kubernetes, que si más tecnologías. Pero, realmente ¿qué es un contenedor?

Esto es un contenedor… pero no de los que hablaremos en este post.

Lo lógico sería partir de algo como la definición de Docker, según la cual:

A container image is a lightweight, stand-alone, executable package of a piece of software that includes everything needed to run it: code, runtime, system tools, system libraries, settings. Available for both Linux and Windows based apps, containerized software will always run the same, regardless of the environment.

Entonces, parafraseando, es “un paquete ejecutable de una pieza de software que incluye todo lo necesario para ejecutarse”. Pero, ¿esta definición realmente nos explica lo que son los contenedores? En lugar de clavarnos con analogías, quizás sea más sencillo explicar qué es un contenedor si entendemos por qué surgieron y qué se necesita para hacerlos, mientras construimos uno.

Ahora toca un poco de historia. Me gusta la ballenita de Docker :)

¿Por qué hay contenedores?

Digamos que tenemos un programa llamado “hola_mundo.sh” que queremos ejecutar en un servidor. Lo más sencillo es copiarlo vía algo como scp al servidor y listo. Sin embargo, ¿y si nosotros no programamos eso? ¿Y si ese programa afecta secciones del servidor que no debería? ¿Quién querría ejecutar código de terceros sin protección? Además de la seguridad, ¿qué pasa si más gente necesita correr sus propios scripts en el mismo servidor? ¿Cómo se garantiza que los programas de uno no interfieran con los de otra persona? El simple copiar y pegar archivos al servidor no es ni seguro ni escalable. Afortunadamente, aquí fue cuando se inventaron los servidores privados virtuales (VPS) y durante un tiempo todo estuvo bien.

Supongamos ahora que nuestro programa “hola_mundo.sh” requiere dependencias y bibliotecas externas. Y, como siempre pasa, las cosas nunca funcionan igual al trabajar localmente que en un ambiente remoto. La solución que surgió para esto fueron máquinas virtuales para “estandarizar” el ambiente en donde “hola_mundo.sh” se fuera a ejecutar. Aquí es cuando surgieron Vagrant, las Amazon Machine Images o las Virtual Box Images. Así, uno podía tener la misma imagen en el ambiente local y en el remoto y garantizar que el script corriera. Y todo funcionaba bien, salvo que estas imágenes de máquinas virtuales eran grandes, difíciles de mover de un lado a otro por su gran tamaño y no tenían un estándar en común.

Por lo tanto, se inventó el “caching” de las imágenes para poder partir de una imagen en común y modificarla con “deltas” pequeños, en lugar de mover toda la máquina virtual para poder hacer un cambio. Docker hace de manera excelente este trabajo; en ello radica su aportación y trascendencia: poder empaquetar bibliotecas y dependencias para entregar código en forma segura, simple y repetible.

Ahora que sabemos qué necesidades satisfacen, ¿qué son los contenedores y cómo se crean?. Para poder hacerlo necesitamos conocer algunos conceptos de Linux a bajo nivel.

Este es un “mapa” del Kernel de Linux. Hasta este nivel tenemos que bajar para poder hacer nuestro propio contenedor. Afortunadamente no es tan complejo como parece

Entonces… ¿Qué se necesita para hacer un contenedor?

Para poder crear un contenedor necesitamos conocer tres conceptos base del Kernel de Linux: namespaces, cgroups y layered filesystems.

Concepto 1: namespaces

Un namespace es la forma en que el Kernel de Linux aísla recursos entre procesos tal que cada proceso tenga su propio ambiente. En el mundo de los contenedores esto es importante ya que un proceso A podría modificar el ambiente de un proceso B (podría, por ejemplo, quitar una interfaz de red, o desmontar un sistema de archivos que A necesita). Mediante los namespaces aseguramos que el proceso A ni siquiera se dé cuenta de que los procesos B, C, D o E existen.

Ojo que los namespaces no restringen el uso de dispositivos físicos como el CPU, memoria o el disco. Esta tarea le corresponde a los cgroups (el cual también es un namespace), de los cuales hablaré más adelante.

Existen 7 namespaces a la fecha y cada uno de ellos puede ser requerido por algún procreso para acceder a distintos recursos de la computadora:

  • PID: Aísla el número de proceso (pid). De cierta forma crea una sub-tabla dentro de la tabla de procesos del kernel. Así, al primer proceso que se crea dentro de este namespace, se le asigna el pid 1, lo cual le da la apariencia de estar aislado y de ser el primer proceso en el sistema. Esto significa también que al eliminar el pid 1 de esta sub-tabla de procesos, todos sus hijos se eliminarán también
  • NET: Permite a los procesos crear su propia capa de red. Técnicamente sólo existe una capa de red física, pero los procesos pueden crear dispositivos de red virtuales que acceden a la red física. Dentro de este namespace, las redes se encuentran aisladas y cada proceso accede a la red mediante su propia interfaz virtual (incluye dirección IP, lista de sockets, tabla de conexiones, etc.).
  • MNT: Es uno de los más importantes ya que le permite a los procesos controlar los puntos de montaje sin afectar a los demás namespaces. Combinado con otras estrategias, esto permite a los procesos montar sistemas de archivos completos tal que se ejecute como si estuviera sobre Ubuntu, Alpine, BusyBox, etc.
  • IPC: Este namespace aísla la comunicación entre procesos.
  • UTS: Le permite al namespace tener un hostname distinto al del sistema
  • UserID: Es importantes a nivel seguridad ya que permite que los procesos se ejecuten con un set de usuarios distintos al del sistema. Esto significa que podemos ejecutar un proceso dentro del namespace con permisos de super usuario, sin que éste afecte al sistema base.
“unshare” es el comando para acceder a los namespaces. Este comando permite ejecutar un proceso dentro de los namespaces especificados.

Para más información sobre lo que hacen cada uno de estos namespaces, se puede consultar la documentación de linux.

Concepto 2: cgroups

Cgroup es también un namespace. Fue creado con el objetivo de controlar el acceso a los recursos del sistema. Mientras que los namespaces aíslan procesos, los cgroups garantizan que los procesos utilicen los recursos dentro de los límites establecidos.

Concepto 3: layered filesystems

Ok, es tiempo de crear un contenedor!

Suponiendo que tienen la última versión de Go, necesitamos el siguiente esqueleto de código para poder empezar a crear un contenedor:

Para ejecutarlo podemos hacerlo de la siguiente manera:

go run container.go run echo "hola mundo"

Lo que hace este código es simple: el programa recibe dos argumentos run y child seguidos de un comando a ejecutar. Si toca el caso de run, el programa se ejecuta a sí mismo llamando a /proc/self/exe (un archivo especial que contiene una copia del mismo proceso) y ejecuta child con el comando a ejecutar. En pocas palabras, ejecuta un comando en otro proceso distinto al principal.

El primer pid (12619) corresponde al proceso principal, el segundo (12622) al generado por child.

El siguiente paso consiste en agregar namespaces. Y es muy sencillo, lo que necesitamos hacer es pasarle al nuevo proceso los flags de los namespaces que queremos:

cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS,
}

Y es todo. El proceso cmd (en el ejemplo: echo “hola mundo”) ya se encuentra dentro de un “contenedor” y con el código anterior se especifica que el proceso cmd debe ejecutarse dentro de los namespaces UTS, PID y MNT. Esto significa que los procesos que se ejecuten con nuestro programa pueden tener un hostname distinto al de nuestro sistema, tienen un pid distinto y pueden montar sistemas de archivos independientes.

Podemos ver que el proceso ya se ejecuta con un pid distinto (1), dentro del contenedor.

En la imagen podemos ver que se requiere ejecutarcontainer.go con permisos de super usuario. Esto es debido a que root es el único usuario que puede acceder a ellos. En otras entradas escribiré cómo podemos acceder a ellos con un usuario normal.

Pero… ¿De verdad estamos ejecutando echo “hola mundo” en un contenedor? Podemos comprobarlo de forma sencilla, ejecutando un comando que pueda alterar el sistema, por ejemplo, un shell que nos dé acceso a otros comandos.

Intentemos ejecutar un shell como bash y cambiar el hostname del sistema: go run container.go run /bin/bash

A simple vista se puede ver que el proceso se ejecuta con el pid 1 y como usuario root. Al cambiar el hostname a nuevo-hostname se puede ver que efectivamente cambia el nombre. Sin embargo, al salir de bash y comprobar el hostname, podemos ver que sigue siendo el original. Felicidades, ya creamos un contenedor! El cambio de hostname que hicimos dentro de él no se propagó hacia afuera :D

En este punto ya podemos asegurar que los procesos que container.go ejecute estarán aislados de otros procesos. Sin embargo, aún comparten el mismo sistema de archivos base. Y el que un proceso tenga su propio sistema de archivos es una de las características por la que Docker y en general todo el tema de contenedores es tan famoso.

Aí que el siguiente paso consiste en hacerle pensar a nuestro proceso que se está ejecutando en otro sistema que puede ser un Ubuntu, CentOS, Alpine, etc. Para poder lograrlo necesitamos un sistema de archivos de la plataforma que queramos utilizar y montarlo. Para el ejemplo, utilizaré BusyBox. Lo único que hay q hacer es agregar dos líneas a la función child:

must(syscall.Chroot("busyboxfs"))
must(os.Chdir("/"))

(Nota: necesitamos tener un sistema de archivos. Podríamos construir uno o buscar uno en internet)

Ya estamos dentro de un busybox!

Y eso es todo. Tenemos un contenedor en muy pocas líneas en Go.

Obviamente este código no es productivo ni nada; hacen falta muchas cosas (usuarios, red, etc). Sin embargo, es bastante útil para entender cómo funcionan los contenedores.

Y entonces ¿qué es un contenedor? Un contenedor es una forma muy sencilla de mover código de un lado a otro, sin preocuparnos por dependencias, asegurando cierto grado de aislamiento. No sirve para mejorar la experiencia a un usuario final pero sí para hacer más sencilla la vida de los desarrolladores.

Este es el código final:

Personalmente, estos ejemplos son más atractivos viendo live-coding o jugando con los ejemplos. En artículos posteriores escribiré sobre cómo podemos agregar usuarios a los contenedores, cómo podemos hacer que nuestro contenedor tenga acceso a la red y salga a internet y cómo controlar los recursos del contenedor mediante cgroups.

--

--

Juan Carlos
Problem Solvers’ Tech Tales

Full stack software developer, Artificial Intelligence, rubyist, science, astronomy, photography, videogames and a spanglish timeline http://t.co/CQSZzkI6yl