Capítulo 4. Aislamiento de contenedores

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Este es el capítulo en el que descubrirás cómo funcionan realmente los contenedores. Será esencial para comprender hasta qué punto los contenedores están aislados entre sí y del host. Podrás evaluar por ti mismo la solidez de la frontera de seguridad que rodea a un contenedor.

Como sabrás si alguna vez has ejecutado docker exec <image> bash, un contenedor se parece mucho a una máquina virtual desde dentro. Si tienes acceso shell a un contenedor y ejecutas ps, sólo podrás ver los procesos que se están ejecutando en su interior. El contenedor tiene su propia pila de red, y parece tener su propio sistema de archivos con un directorio raíz que no tiene ninguna relación con el raíz del host. Puedes ejecutar contenedores con recursos limitados, como una cantidad restringida de memoria o una fracción de las CPU disponibles. Todo esto ocurre utilizando las funciones de Linux en las que vamos a profundizar en este capítulo.

Por mucho que se parezcan superficialmente, es importante darse cuenta de que los contenedores no son máquinas virtuales, y en el Capítulo 5 examinaremos las diferencias entre estos dos tipos de aislamiento. En mi experiencia, comprender realmente y ser capaz de contrastar los dos es absolutamente clave para comprender hasta qué punto las medidas de seguridad tradicionales pueden ser eficaces en los contenedores, y para identificar dónde se necesitan herramientas específicas para contenedores.

Verás cómo los contenedores se construyen a partir de elementos de Linux como namespaces y chroot, junto con cgroups, que se trataron en el Capítulo 3. Una vez que conozcas estas construcciones, tendrás una idea de lo bien protegidas que están tus aplicaciones cuando se ejecutan dentro de contenedores.

Aunque los conceptos generales de estas construcciones son bastante sencillos, la forma en que funcionan junto con otras características del núcleo Linux puede ser compleja. Las vulnerabilidades de escape de contenedores (por ejemplo, CVE-2019-5736, una grave vulnerabilidad descubierta tanto en runc como en LXC) se han basado en sutilezas de la forma en que interactúan los espacios de nombres, las capacidades y los sistemas de archivos.

Espacios de nombres Linux

Si los cgrupos controlan los recursos que puede utilizar un proceso, los espacios de nombres controlan lo que puede ver. Al poner un proceso en un espacio de nombres, puedes restringir los recursos que son visibles para ese proceso.

Los orígenes de los espacios de nombres se remontan al sistema operativo Plan 9. En aquella época, la mayoría de los sistemas operativos tenían un único "espacio de nombres" de archivos. Los sistemas Unix permitían montar sistemas de archivos, pero todos se montaban en la misma vista de todo el sistema de todos los nombres de archivo. En Plan 9, cada proceso formaba parte de un grupo de procesos que tenía su propia abstracción de "espacio de nombres", la jerarquía de archivos (y objetos similares a archivos) que este grupo de procesos podía ver. Cada grupo de procesos podía montar su propio conjunto de sistemas de archivos sin verse entre sí.

El primer espacio de nombres se introdujo en el núcleo Linux en la versión 2.4.19, allá por 2002. Se trataba del espacio de nombres mount, y seguía una funcionalidad similar a la del Plan 9. Hoy en día, Linux admite varios tipos diferentes de espacios de nombres:

  • Sistema de Tiempo Compartido Unix (UTS): parece complicado, pero a todos los efectos este espacio de nombres sólo se refiere al nombre de host y a los nombres de dominio del sistema que conoce un proceso.

  • ID de proceso

  • Puntos de montaje

  • Red

  • ID de usuario y de grupo

  • Comunicaciones entre procesos (IPC)

  • Grupos de control (cgrupos)

Es posible que se asignen nombres a más recursos en futuras revisiones del núcleo Linux. Por ejemplo, se ha hablado de crear un espacio de nombres para el tiempo.

Un proceso siempre está exactamente en un espacio de nombres de cada tipo. Cuando inicias un sistema Linux, éste tiene un único espacio de nombres de cada tipo, pero como verás, puedes crear espacios de nombres adicionales y asignar procesos a ellos. Puedes ver fácilmente los espacios de nombres de tu máquina utilizando el comando lsns:

vagrant@myhost:~$ lsns
        NS TYPE   NPROCS   PID USER    COMMAND
4026531835 cgroup      3 28459 vagrant /lib/systemd/systemd --user
4026531836 pid         3 28459 vagrant /lib/systemd/systemd --user
4026531837 user        3 28459 vagrant /lib/systemd/systemd --user
4026531838 uts         3 28459 vagrant /lib/systemd/systemd --user
4026531839 ipc         3 28459 vagrant /lib/systemd/systemd --user
4026531840 mnt         3 28459 vagrant /lib/systemd/systemd --user
4026531992 net         3 28459 vagrant /lib/systemd/systemd --user

Esto parece bonito y ordenado, y hay un espacio de nombres para cada uno de los tipos que he mencionado anteriormente. Lamentablemente, ¡es una imagen incompleta! La página de manual de lsns nos dice que "lee información directamente del sistema de archivos /proc y para los usuarios no root puede devolver información incompleta". Veamos qué obtiene cuando se ejecuta como root:

vagrant@myhost:~$ sudo lsns
        NS TYPE   NPROCS   PID USER            COMMAND
4026531835 cgroup     93     1 root            /sbin/init
4026531836 pid        93     1 root            /sbin/init
4026531837 user       93     1 root            /sbin/init
4026531838 uts        93     1 root            /sbin/init
4026531839 ipc        93     1 root            /sbin/init
4026531840 mnt        89     1 root            /sbin/init
4026531860 mnt         1    15 root            kdevtmpfs
4026531992 net        93     1 root            /sbin/init
4026532170 mnt         1 14040 root            /lib/systemd/systemd-udevd
4026532171 mnt         1   451 systemd-network /lib/systemd/systemd-networkd
4026532190 mnt         1   617 systemd-resolve /lib/systemd/systemd-resolved

El usuario root puede ver algunos espacios de nombres de montaje adicionales, y hay muchos más procesos visibles para root de los que eran visibles para el usuario no root. El motivo de mostrarte esto es señalar que, cuando utilicemos lsns, debemos ejecutarlo como root (o utilizar sudo) para obtener la imagen completa.

Exploremos cómo puedes utilizar los espacios de nombres para crear algo que se comporte como lo que llamamos un "contenedor".

Nota

Los ejemplos de este capítulo utilizan comandos del shell de Linux para crear un contenedor. Si quieres probar a crear un contenedor utilizando el lenguaje de programación Go, encontrarás instrucciones en https://github.com/lizrice/containers-from-scratch.

Aislar el nombre de host

Empecemos por el espacio de nombres del Sistema de Tiempo Compartido Unix (UTS). Como ya se ha dicho, abarca el nombre de host y los nombres de dominio. Al poner un proceso en su propio espacio de nombres UTS, puedes cambiar el nombre de host de este proceso independientemente del nombre de host de la máquina o máquina virtual en la que se esté ejecutando.

Si abres un terminal en Linux, podrás ver el nombre del host:

vagrant@myhost:~$ hostname
myhost

La mayoría de los sistemas de contenedores (¿quizás todos?) dan a cada contenedor un ID aleatorio. Por defecto, este ID se utiliza como nombre de host. Puedes comprobarlo ejecutando un contenedor y obteniendo acceso al shell. Por ejemplo, en Docker podrías hacer lo siguiente:

vagrant@myhost:~$ docker run --rm -it --name hello ubuntu bash
root@cdf75e7a6c50:/$ hostname
cdf75e7a6c50

Por cierto, en este ejemplo puedes ver que aunque le des un nombre al contenedor en Docker (aquí he especificado --name hello), ese nombre no se utiliza para el nombre de host del contenedor.

El contenedor puede tener su propio nombre de host porque Docker lo ha creado con su propio espacio de nombres UTS. Puedes explorar lo mismo utilizando el comando unshare para crear un proceso que tenga un espacio de nombres UTS propio.

Tal y como se describe en la página man (que se ve ejecutando man unshare), unshare te permite "ejecutar un programa con algunos espacios de nombres no compartidos con el padre". Profundicemos un poco más en esa descripción. Cuando "ejecutas un programa", el núcleo crea un nuevo proceso y ejecuta el programa en él. Esto se hace desde el contexto de un proceso en ejecución -el padre- yel nuevo proceso se denominará hijo. La palabra "des-compartir" significa que, en lugar de compartir los espacios de nombres de su padre, el hijo va a tener los suyos propios.

Vamos a intentarlo. Necesitas tener privilegios de root para hacerlo, de ahí el sudo al principio de la línea:

vagrant@myhost:~$ sudo unshare --uts sh
$ hostname
myhost
$ hostname experiment
$ hostname
experiment
$ exit
vagrant@myhost:~$ hostname
myhost

Esto ejecuta un shell sh en un nuevo proceso que tiene un nuevo espacio de nombres UTS. Cualquier programa que ejecutes dentro del shell heredará sus espacios de nombres. Cuando ejecutes el comando hostname, se ejecutará en el nuevo espacio de nombres UTS que se ha aislado del de la máquina anfitriona.

Si abrieras otra ventana de terminal al mismo host antes de la exit, podrías confirmar que el nombre de host no ha cambiado para toda la máquina (virtual). Puedes cambiar el nombre de host en el host sin que ello afecte al nombre de host que conoce el proceso namespaced, y viceversa.

Éste es un componente clave del funcionamiento de los contenedores. Los espacios de nombres les proporcionan un conjunto de recursos (en este caso, el nombre de host) que son independientes de la máquina anfitriona y de otros contenedores. Pero seguimos hablando de un proceso que está siendo ejecutado por el mismo núcleo Linux. Esto tiene implicaciones de seguridad que trataré más adelante en el capítulo. Por ahora, veamos otro ejemplo de espacio de nombres viendo cómo puedes dar a un contenedor su propia vista de los procesos en ejecución.

Aislar ID de procesos

Si ejecutas el comando ps dentro de un contenedor Docker, sólo podrás ver los procesos que se ejecutan dentro de ese contenedor y ninguno de los procesos que se ejecutan en el host:

vagrant@myhost:~$ docker run --rm -it --name hello ubuntu bash
root@cdf75e7a6c50:/$ ps -eaf
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 18:41 pts/0    00:00:00 bash
root        10     1  0 18:42 pts/0    00:00:00 ps -eaf
root@cdf75e7a6c50:/$ exit
vagrant@myhost:~$

Esto se consigue con el espacio de nombres ID de proceso, que restringe el conjunto de ID de proceso que son visibles. Prueba a ejecutar de nuevo unshare, pero esta vez especificando que quieres un nuevo espacio de nombres PID con la bandera --pid:

vagrant@myhost:~$ sudo unshare --pid sh
$ whoami
root
$ whoami
sh: 2: Cannot fork
$ whoami
sh: 3: Cannot fork
$ ls
sh: 4: Cannot fork
$ exit
vagrant@myhost:~$

Esto no parece tener mucho éxito: ¡no es posible ejecutar ningún comando después del primer whoami! Pero hay algunos artefactos interesantes en esta salida.

El primer proceso en sh parece haber funcionado bien, pero todos los comandos posteriores fallan debido a la imposibilidad de bifurcarse. El error se muestra en la forma <command>: <process ID>: <message>, y puedes ver que los ID de proceso se incrementan cada vez. Dada la secuencia, sería razonable suponer que el primer whoami se ejecutó como ID de proceso 1. Eso es una pista de que el espacio de nombres PID está funcionando de alguna manera, en el sentido de que la numeración de los ID de proceso se ha reiniciado. Pero es prácticamente inútil si no puedes ejecutar más de un proceso.

Hay pistas sobre cuál es el problema en la descripción de la bandera --fork en la página man de unshare: "Bifurca el programa especificado como proceso hijo de unshare en lugar de ejecutarlo directamente. Esto es útil cuando se crea un nuevo espacio de nombres pid".

Puedes explorar esto ejecutando ps para ver la jerarquía de procesos desde una segunda ventana de terminal:

vagrant@myhost:~$ ps fa
  PID TTY      STAT   TIME COMMAND
...
30345 pts/0    Ss     0:00 -bash
30475 pts/0    S      0:00  \_ sudo unshare --pid sh
30476 pts/0    S      0:00      \_ sh

El proceso sh no es hijo de unshare; es hijo del proceso sudo.

Ahora intenta lo mismo con el parámetro --fork:

vagrant@myhost:~$ sudo unshare --pid --fork sh
$ whoami
root
$ whoami
root

Esto es un progreso, en el sentido de que ahora puedes ejecutar más de un comando antes de encontrarte con el error "No se puede bifurcar". Si vuelves a mirar la jerarquía de procesos desde un segundo terminal, verás una diferencia importante:

vagrant@myhost:~$ ps fa
  PID TTY      STAT   TIME COMMAND
...
30345 pts/0    Ss     0:00 -bash
30470 pts/0    S      0:00  \_ sudo unshare --pid --fork sh
30471 pts/0    S      0:00      \_ unshare --pid --fork sh
30472 pts/0    S      0:00          \_ sh
...

Con el parámetro --fork, el intérprete de órdenes sh se ejecuta como hijo del proceso unshare, y puedes ejecutar con éxito tantos comandos hijos diferentes como elijas dentro de este intérprete de órdenes.

Dado que el intérprete de comandos está dentro de su propio espacio de nombres de ID de proceso, los resultados de ejecutar ps dentro de él pueden ser sorprendentes:

vagrant@myhost:~$ sudo unshare --pid --fork sh
$ ps
  PID TTY          TIME CMD
14511 pts/0    00:00:00 sudo
14512 pts/0    00:00:00 unshare
14513 pts/0    00:00:00 sh
14515 pts/0    00:00:00 ps
$ ps -eaf
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 Mar27 ?        00:00:02 /sbin/init
root         2     0  0 Mar27 ?        00:00:00 [kthreadd]
root         3     2  0 Mar27 ?        00:00:00 [ksoftirqd/0]
root         5     2  0 Mar27 ?        00:00:00 [kworker/0:0H]
...many more lines of output about processes...
$ exit
vagrant@myhost:~$

Como puedes ver, ps sigue mostrando todos los procesos de todo el host, a pesar de ejecutarse dentro de un nuevo espacio de nombres de ID de proceso. Si quieres el comportamiento de ps que verías en un contenedor Docker, no basta con utilizar un nuevo espacio de nombres de ID de proceso, y la razón de ello se incluye en la página man de ps: "Este ps funciona leyendo los archivos virtuales en /proc".

Echemos un vistazo al directorio /proc para ver a qué archivos virtuales se refiere. Tu sistema tendrá un aspecto similar, pero no exactamente igual, ya que ejecutará un conjunto diferente de procesos:

vagrant@myhost:~$ ls /proc
1      14553  292    467        cmdline      modules
10     14585  3      5          consoles     mounts
1009   14586  30087  53         cpuinfo      mpt
1010   14664  30108  538        crypto       mtrr
1015   14725  30120  54         devices      net
1016   14749  30221  55         diskstats    pagetypeinfo
1017   15     30224  56         dma          partitions
1030   156    30256  57         driver       sched_debug
1034   157    30257  58         execdomains  schedstat
1037   158    30283  59         fb           scsi
1044   159    313    60         filesystems  self
1053   16     314    61         fs           slabinfo
1063   160    315    62         interrupts   softirqs
1076   161    34     63         iomem        stat
1082   17     35     64         ioports      swaps
11     18     3509   65         irq          sys
1104   19     3512   66         kallsyms     sysrq-trigger
1111   2      36     7          kcore        sysvipc
1175   20     37     72         keys         thread-self
1194   21     378    8          key-users    timer_list
12     22     385    85         kmsg         timer_stats
1207   23     392    86         kpagecgroup  tty
1211   24     399    894        kpagecount   uptime
1215   25     401    9          kpageflags   version
12426  26     403    966        loadavg      version_signature
125    263    407    acpi       locks        vmallocinfo
13     27     409    buddyinfo  mdstat       vmstat
14046  28     412    bus        meminfo      zoneinfo
14087  29     427    cgroups    misc

Cada directorio numerado en /proc corresponde a un ID de proceso, y hay mucha información interesante sobre un proceso dentro de su directorio. Por ejemplo, /proc/<pid>/exe es un enlace simbólico al ejecutable que se está ejecutando dentro de este proceso concreto, como puedes ver en el siguiente ejemplo:

vagrant@myhost:~$ ps
  PID TTY          TIME CMD
28441 pts/1    00:00:00 bash
28558 pts/1    00:00:00 ps
vagrant@myhost:~$ ls /proc/28441
attr             fdinfo      numa_maps      smaps
autogroup        gid_map     oom_adj        smaps_rollup
auxv             io          oom_score      stack
cgroup           limits      oom_score_adj  stat
clear_refs       loginuid    pagemap        statm
cmdline          map_files   patch_state    status
comm             maps        personality    syscall
coredump_filter  mem         projid_map     task
cpuset           mountinfo   root           timers
cwd              mounts      sched          timerslack_ns
environ          mountstats  schedstat      uid_map
exe              net         sessionid      wchan
fd               ns          setgroups
vagrant@myhost:~$ ls -l /proc/28441/exe
lrwxrwxrwx 1 vagrant vagrant 0 Oct 10 13:32 /proc/28441/exe -> /bin/bash

Independientemente del espacio de nombres de ID de proceso en el que se esté ejecutando, ps va a buscar en /proc información sobre los procesos en ejecución. Para que ps devuelva sólo la información sobre los procesos dentro del nuevo espacio de nombres, tiene que haber una copia separada del directorio /proc, donde el núcleo pueda escribir la información sobre los procesos con espacio de nombres. Dado que /proc es un directorio directamente bajo root, esto significa cambiar el directorio raíz.

Cambiar el directorio raíz

Desde dentro de un contenedor, no ves todo el sistema de archivos del host; en su lugar, ves un subconjunto, porque el directorio raíz se modifica a medida que se crea el contenedor.

Puedes cambiar el directorio raíz en Linux con el comando chroot. Esto efectivamente mueve el directorio raíz del proceso actual para que apunte a alguna otra ubicación dentro del sistema de archivos. Una vez que has ejecutado el comando chroot, pierdes el acceso a cualquier cosa que estuviera más arriba en la jerarquía de archivos que tu directorio raíz actual, ya que no hay forma de ir más arriba que root dentro del sistema de archivos, como se ilustra en la Figura 4-1.

La descripción en la página man de chrootdice lo siguiente: "Ejecuta COMANDO con el directorio raíz establecido en NEWROOT. [...] Si no se indica ningún comando, ejecuta ${SHELL} -i (por defecto: /bin/sh -i)".

Changing root so a process sees only a subset of the filesystem
Figura 4-1. Cambiar la raíz para que un proceso sólo vea un subconjunto del sistema de archivos

De esto puedes ver que chroot no sólo cambia el directorio, sino que también ejecuta un comando, volviendo a ejecutar un intérprete de comandos si no especificas un comando diferente.

Crea un directorio nuevo e intenta entrar en él en chroot:

vagrant@myhost:~$ mkdir new_root
vagrant@myhost:~$ sudo chroot new_root
chroot: failed to run command ‘/bin/bash’: No such file or directory
vagrant@myhost:~$ sudo chroot new_root ls
chroot: failed to run command ‘ls’: No such file or directory

Esto no funciona. El problema es que una vez que estás dentro del nuevo directorio raíz, no hay ningún directorio bin dentro de esta raíz, por lo que es imposible ejecutar el shell /bin/bash. Del mismo modo, si intentas ejecutar el comando ls, no está ahí. Necesitarás que los archivos de los comandos que quieras ejecutar estén disponibles dentro de la nueva raíz. Esto es exactamente lo que ocurre en un contenedor "real": el contenedor se instancia a partir de una imagen de contenedor, que encapsula el sistema de archivos que ve el contenedor. Si un ejecutable no está presente en ese sistema de archivos, el contenedor no podrá encontrarlo y ejecutarlo.

¿Por qué no pruebas a ejecutar Alpine Linux dentro de tu contenedor? Alpine es una distribución Linux bastante mínima diseñada para contenedores. Tendrás que empezar descargando el sistema de archivos:

vagrant@myhost:~$ mkdir alpine
vagrant@myhost:~$ cd alpine
vagrant@myhost:~/alpine$ curl -o alpine.tar.gz http://dl-cdn.alpinelinux.org/
alpine/v3.10/releases/x86_64/alpine-minirootfs-3.10.0-x86_64.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 2647k  100 2647k    0     0  16.6M      0 --:--:-- --:--:-- --:--:-- 16.6M
vagrant@myhost:~/alpine$ tar xvf alpine.tar.gz

En este punto tienes una copia del sistema de archivos Alpine dentro del directorio alpine que has creado. Elimina la versión comprimida y vuelve al directorio principal:

vagrant@myhost:~/alpine$ rm alpine.tar.gz
vagrant@myhost:~/alpine$ cd ..

Puedes explorar el contenido del sistema de archivos con ls alpine para ver que se parece a la raíz de un sistema de archivos Linux con directorios como bin, lib, var, tmp, etc.

Ahora que ya has desempaquetado la distribución Alpine, puedes utilizar chroot para moverte al directorio alpine, siempre que proporciones un comando que exista dentro de la jerarquía de ese directorio.

Es algo más sutil que eso, porque el ejecutable tiene que estar en la ruta del nuevo proceso. Este proceso hereda el entorno del proceso padre, incluida la variable de entorno PATH. El directorio bin dentro de alpine se ha convertido en /bin para el nuevo proceso, y suponiendo que su ruta habitual incluya /bin, puede recoger el ejecutable ls de ese directorio sin especificar su ruta explícitamente:

vagrant@myhost:~$ sudo chroot alpine ls
bin    etc    lib    mnt    proc   run    srv    tmp    var
dev    home   media  opt    root   sbin   sys    usr
vagrant@myhost:~$

Observa que sólo el proceso hijo (en este ejemplo, el proceso que ejecutó ls) obtiene el nuevo directorio raíz. Cuando ese proceso finaliza, el control vuelve al proceso padre. Si ejecutas un intérprete de comandos como proceso hijo, no se completará inmediatamente, por lo que resulta más fácil ver los efectos de cambiar el directorio raíz:

vagrant@myhost:~$ sudo chroot alpine sh
/ $ ls
bin    etc    lib    mnt    proc   run    srv    tmp    var
dev    home   media  opt    root   sbin   sys    usr
/ $ whoami
root
/ $ exit
vagrant@myhost:~$

Si intentas ejecutar el intérprete de comandos bash, no funcionará. Esto se debe a que la distribución Alpine no lo incluye, por lo que no está presente dentro del nuevo directorio raíz. Si intentaras lo mismo con el sistema de archivos de una distribución como Ubuntu, que sí incluye bash, funcionaría.

En resumen, chroot literalmente "cambia la raíz" de un proceso. Tras cambiar la raíz, el proceso (y sus hijos) sólo podrán acceder a los archivos y directorios que estén más abajo en la jerarquía que el nuevo directorio raíz.

Nota

Además de chroot, existe una llamada al sistema llamada pivot_root. A efectos de este capítulo, que se utilice chroot o pivot_root es un detalle de implementación; el punto clave es que un contenedor necesita tener su propio directorio raíz. He utilizado chroot en estos ejemplos porque es ligeramente más sencillo y más familiar para mucha gente.

Existen ventajas de seguridad al utilizar pivot_root frente a chroot, por lo que en la práctica deberías encontrar el primero si miras el código fuente de una implementación en tiempo de ejecución de un contenedor. La principal diferencia es que pivot_root aprovecha el espacio de nombres mount; la antigua raíz ya no está montada y, por tanto, ya no es accesible dentro de ese espacio de nombres mount. La llamada al sistema chroot no adopta este enfoque, dejando la antigua raíz accesible a través de puntos de montaje.

Ya has visto cómo se puede dotar a un contenedor de su propio sistema de archivos raíz. Hablaré de ello con más detalle en el Capítulo 6, pero ahora vamos a ver cómo tener su propio sistema de archivos raíz permite al núcleo mostrar a un contenedor sólo una vista restringida de los recursos con espacio de nombres.

Combinar el espaciado de nombres y cambiar la raíz

Hasta ahora has visto el espaciado de nombres y el cambio de raíz como dos cosas separadas, pero puedes combinarlas ejecutando chroot en un nuevo espacio de nombres:

me@myhost:~$ sudo unshare --pid --fork chroot alpine sh
/ $ ls
bin    etc    lib    mnt    proc   run    srv    tmp    var
dev    home   media  opt    root   sbin   sys    usr

Si recuerdas lo dicho anteriormente en este capítulo (ver "Aislar IDs de procesos"), dar al contenedor su propio directorio raíz le permite crear un directorio /proc para el contenedor que es independiente de /proc en el host. Para que éste se rellene con información de procesos, tendrás que montarlo como un pseudo-sistema de archivos de tipo proc. Con la combinación de un espacio de nombres de ID de proceso y un directorio /proc independiente, ps mostrará ahora sólo los procesos que estén dentro del espacio de nombres de ID de proceso:

/ $ mount -t proc proc proc
/ $ ps
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    6 root      0:00 ps
/ $ exit
vagrant@myhost:~$

¡Éxito! Ha sido más complejo que aislar el nombre de host del contenedor, pero mediante la combinación de crear un espacio de nombres de ID de proceso, cambiar el directorio raíz y montar un pseudofilesistema para manejar la información de los procesos, puedes limitar un contenedor para que sólo tenga vista de sus propios procesos.

Quedan más espacios de nombres por explorar. Veamos ahora el espacio de nombres montaje.

Montar espacio de nombres

Normalmente no quieres que un contenedor tenga todos los mismos montajes del sistema de archivos que su anfitrión. Dando al contenedor su propio espacio de nombres de montaje se consigue esta separación.

Aquí tienes un ejemplo que crea un montaje bind sencillo para un proceso con su propio espacio de nombres de montaje:

vagrant@myhost:~$ sudo unshare --mount sh
$ mkdir source
$ touch source/HELLO
$ ls source
HELLO
$ mkdir target
$ ls target
$ mount --bind source target
$ ls target
HELLO

Una vez realizado el montaje bind, el contenido del directorio source también estará disponible en target. Si miras todos los montajes desde este proceso, probablemente habrá muchos, pero el siguiente comando encuentra el objetivo que creaste si seguiste el ejemplo anterior:

$ findmnt target
TARGET    SOURCE                FSTYPE OPTIONS
/home/vagrant/target
          /dev/mapper/vagrant--vg-root[/home/vagrant/source]
                                ext4   rw,relatime,errors=remount-ro,data=ordered

Desde la perspectiva del anfitrión, esto no es visible, lo que puedes comprobar ejecutando el mismo comando desde otra ventana del terminal y confirmando que no devuelve nada.

Intenta ejecutar de nuevo findmnt desde el espacio de nombres del montaje, pero esta vez sin ningún parámetro, y obtendrás una larga lista. Puede que estés pensando que parece incorrecto que un contenedor pueda ver todos los montajes del host. Se trata de una situación muy similar a la que viste con el espacio de nombres ID de proceso: el núcleo utiliza el directorio /proc/<PID>/mounts para comunicar información sobre los puntos de montaje de cada proceso. Si creas un proceso con su propio espacio de nombres de montaje, pero está utilizando el directorio /proc del host, verás que su archivo /proc/<PID>/mounts incluye todos los montajes preexistentes del host. (Puedes simplemente cat este archivo para obtener una lista de montajes).

Para conseguir un conjunto totalmente aislado de montajes para el proceso en contenedor, tendrás que combinar la creación de un nuevo espacio de nombres de montaje con un nuevo sistema de archivos raíz y un nuevo montaje proc, de esta manera:

vagrant@myhost:~$ sudo unshare --mount chroot alpine sh
/ $ mount -t proc proc proc
/ $ mount
proc on /proc type proc (rw,relatime)
/ $ mkdir source
/ $ touch source/HELLO
/ $ mkdir target
/ $ mount --bind source target
/ $ mount
proc on /proc type proc (rw,relatime)
/dev/sda1 on /target type ext4 (rw,relatime,data=ordered)

Alpine Linux no viene con el comando findmnt, así que este ejemplo utiliza mount sin parámetros para generar la lista de montajes. (Si eres cínico con este cambio, prueba el ejemplo anterior con mount en lugar de findmnt para comprobar que obtienes los mismos resultados).

Puede que estés familiarizado con el concepto de montar directorios de host en un contenedor utilizando docker run -v <host directory>:<container directory> .... Para ello, una vez creado el sistema de archivos raíz del contenedor, se crea el directorio del contenedor de destino y, a continuación, se monta el directorio del host de origen en ese destino. Como cada contenedor tiene su propio espacio de nombres de montaje, los directorios de host montados de este modo no son visibles desde otros contenedores.

Nota

Si creas un montaje visible para el anfitrión, no se limpiará automáticamente cuando termine tu proceso "contenedor". Tendrás que destruirlo utilizando umount. Esto también se aplica a los pseudoarchivos de /proc. No harán ningún daño en particular, pero si te gusta mantener las cosas ordenadas, puedes eliminarlos con umount proc. El sistema no te permitirá desmontar el /proc final utilizado por el host.

Espacio de nombres de red

El espacio de nombres de red permite que un contenedor tenga su propia vista de las interfaces de red y las tablas de enrutamiento. Cuando creas un proceso con su propio espacio de nombres de red, puedes verlo con lsns:

vagrant@myhost:~$ sudo lsns -t net
        NS TYPE NPROCS PID USER    NETNSID NSFS COMMAND
4026531992 net      93   1 root unassigned      /sbin/init
vagrant@myhost:~$ sudo unshare --net bash
root@myhost:~$ lsns -t net
        NS TYPE NPROCS   PID USER    NETNSID NSFS COMMAND
4026531992 net      92     1 root unassigned      /sbin/init
4026532192 net       2 28586 root unassigned      bash
Nota

Puede que te encuentres con el comando ip netns, pero no nos sirve de mucho aquí. Utilizar unshare --net crea un espacio de nombres de red anónimo, y los espacios de nombres anónimos no aparecen en la salida de ip netns list.

Cuando pones un proceso en su propio espacio de nombres de red, comienza sólo con la interfaz de loopback:

vagrant@myhost:~$ sudo unshare --net bash
root@myhost:~$ ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Con nada más que una interfaz loopback, tu contenedor no podrá comunicarse. Para proporcionarle un camino hacia el mundo exterior, crea una interfaz Ethernet virtual o, más estrictamente, un par de interfaces Ethernet virtuales. Éstas actúan como si fueran los dos extremos de un cable metafórico que conecta el espacio de nombres de tu contenedor con el espacio de nombres de la red por defecto.

En una segunda ventana de terminal, como root, puedes crear un par Ethernet virtual especificando los espacios de nombres anónimos asociados a sus ID de proceso, así

root@myhost:~$ ip link add ve1 netns 28586 type veth peer name ve2 netns 1
  • ip link add indica que quieres añadir un enlace.

  • ve1 es el nombre de un "extremo" del "cable" Ethernet virtual.

  • netns 28586 dice que este extremo está "conectado" al espacio de nombres de red asociado al ID de proceso 28586 (que se muestra en la salida de lsns -t net en el ejemplo del principio de esta sección).

  • type veth muestra que se trata de un par Ethernet virtual.

  • peer name ve2 da el nombre del otro extremo del "cable".

  • netns 1 especifica que este segundo extremo está "conectado" al espacio de nombres de red asociado al ID de proceso 1.

La interfaz Ethernet virtual ve1 es ahora visible desde el interior del proceso "contenedor":

root@myhost:~$ ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ve1@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group ...
    link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0

El enlace está en estado "ABAJO" y necesita ser levantado antes de que sea útil. Ambos extremos de la conexión deben ser activados.

Sube el extremo ve2 en el host:

root@myhost:~$ ip link set ve2 up

Y una vez que subas el extremo ve1 en el contenedor, el enlace debería pasar al estado "ARRIBA":

root@myhost:~$ ip link set ve1 up
root@myhost:~$ ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ve1@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP ...
    link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::788a:3fff:feba:612c/64 scope link
       valid_lft forever preferred_lft forever

Para enviar tráfico IP, es necesario que haya una dirección IP asociada a su interfaz. En el contenedor:

root@myhost:~$ ip addr add 192.168.1.100/24 dev ve1

Y sobre el anfitrión:

root@myhost:~$ ip addr add 192.168.1.200/24 dev ve1

Esto también tendrá el efecto de añadir una ruta IP a la tabla de enrutamiento del contenedor:

root@myhost:~$ ip route
192.168.1.0/24 dev ve1 proto kernel scope link src 192.168.1.100

Como se ha mencionado al principio de esta sección, el espacio de nombres de red aísla tanto las interfaces como la tabla de enrutamiento, de modo que esta información de enrutamiento es independiente de la tabla de enrutamiento IP del host. En este punto, el contenedor sólo puede enviar tráfico a las direcciones 192.168.1.0/24. Puedes comprobarlo haciendo un ping desde el contenedor al extremo remoto:

root@myhost:~$ ping 192.168.1.100
PING 192.168.1.100 (192.168.1.100) 56(84) bytes of data.
64 bytes from 192.168.1.100: icmp_seq=1 ttl=64 time=0.355 ms
64 bytes from 192.168.1.100: icmp_seq=2 ttl=64 time=0.035 ms
^C

Profundizaremos más en las redes y en la seguridad de las redes de contenedores en el Capítulo 10.

Espacio de nombres de usuario

El espacio de nombres de usuario permite a los procesos tener su propia vista de los ID de usuario y grupo. Al igual que los IDs de proceso, los usuarios y grupos siguen existiendo en el host, pero pueden tener IDs diferentes. La principal ventaja de esto es que puedes asignar el ID raíz de 0 dentro de un contenedor a alguna otra identidad no raíz en el host. Esto supone una gran ventaja desde el punto de vista de la seguridad, ya que permite que el software se ejecute como root dentro de un contenedor, pero un atacante que escape del contenedor al host tendrá una identidad no root y sin privilegios. Como verás en el Capítulo 9, no es difícil configurar mal un contenedor para facilitar el escape al host. Con los espacios de nombres de usuario, no estás sólo a un paso en falso de la toma de control del host.

Nota

En el momento de escribir esto, los espacios de nombres de usuario aún no son de uso especialmente común. Esta función no está activada por defecto en Docker (consulta "Restricciones del espacio de nombres de usuario en Docker"), y no está soportada en absoluto en Kubernetes, aunque se ha estado debatiendo.

En general, necesitas ser root para crear nuevos espacios de nombres, por eso el demonio Docker se ejecuta como root, pero el espacio de nombres de usuario es una excepción:

vagrant@myhost:~$ unshare --user bash
nobody@myhost:~$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
nobody@myhost:~$ echo $$
31196

Dentro del nuevo espacio de nombres de usuario, el usuario tiene el ID nobody. Tienes que establecer una correspondencia entre los ID de usuario dentro y fuera del espacio de nombres, como se muestra en la Figura 4-2.

Mapping a non-root user on the host to root in a container
Figura 4-2. Asignar un usuario no root en el host a root en un contenedor

Esta asignación existe en /proc/<pid>/uid_map, que puedes editar como root (en el host). Hay tres campos en este archivo:

  • El ID más bajo a mapear desde la perspectiva del proceso hijo

  • El ID correspondiente más bajo al que debe asignarse en el host

  • El número de ID que hay que asignar

Por ejemplo, en mi máquina, el usuario vagrant tiene el ID 1000. Para que a vagrant se le asigne el ID raíz de 0 dentro del proceso hijo, los dos primeros campos son 0 y 1000. El último campo puede ser 1 si quieres asignar sólo un ID (que puede ser el caso si sólo quieres un usuario dentro del contenedor). Éste es el comando que utilicé para configurar ese mapeo:

vagrant@myhost:~$ sudo echo '0 1000 1' > /proc/31196/uid_map

Inmediatamente, dentro de su espacio de nombres de usuario, el proceso ha adoptado la identidad de root. No te desanimes por el hecho de que el prompt de bash siga diciendo "nadie"; esto no se actualiza a menos que vuelvas a ejecutar los scripts que se ejecutan cuando inicias un nuevo shell (por ejemplo, ~/.bash_profile):

nobody@myhost:~$ id
uid=0(root) gid=65534(nogroup) groups=65534(nogroup)

Se utiliza un proceso de asignación similar para asignar el grupo o grupos utilizados dentro del proceso hijo.

Este proceso funciona ahora con un gran conjunto de capacidades:

nobody@myhost:~$ capsh --print | grep Current
Current: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,
cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,
cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,
cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,
cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,
cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,
cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,
cap_wake_alarm,cap_block_suspend,cap_audit_read+ep

Como viste en el Capítulo 2, las capacidades conceden al proceso diversos permisos. Cuando creas un nuevo espacio de nombres de usuario, el núcleo otorga al proceso todas estas capacidades para que el pseudousuario raíz dentro del espacio de nombres pueda crear otros espacios de nombres, configurar la red, etc., cumpliendo todo lo necesario para convertirlo en un contenedor real.

De hecho, si creas simultáneamente un proceso con varios espacios de nombres nuevos, el espacio de nombres de usuario se creará primero para que dispongas del conjunto de capacidades completo que te permita crear otros espacios de nombres:

vagrant@myhost:~$ unshare --uts bash
unshare: unshare failed: Operation not permitted
vagrant@myhost:~$ unshare --uts --user bash
nobody@myhost:~$

Los espacios de nombres de usuario permiten que un usuario sin privilegios se convierta efectivamente en root dentro del proceso en contenedor. Esto permite a un usuario normal ejecutar contenedores utilizando un concepto llamado contenedores sin raíz, que trataremos en el Capítulo 9.

El consenso general es que los espacios de nombres de usuario son una ventaja para la seguridad porque menos contenedores necesitan ejecutarse como root "real" (es decir, root desde la erspectiva). Sin embargo, ha habido algunas vulnerabilidades (por ejemplo, CVE-2018-18955) directamente relacionadas con privilegios que se transforman incorrectamente al pasar a o desde un espacio de nombres de usuario. El núcleo de Linux es una pieza de software compleja, y debes esperar que la gente encuentre problemas en él de vez en cuando.

Restricciones del espacio de nombres de usuario en Docker

Puedes activar el uso de espacios de nombres de usuario en Docker, pero no está activado por defecto porque es incompatible con algunas cosas que los usuarios de Docker podrían querer hacer.

Lo siguiente también te afectará si utilizas espacios de nombres de usuario con otros tiempos de ejecución de contenedores:

  • Los espacios de nombres de usuario son incompatibles con compartir un ID de proceso o un espacio de nombres de red con el host.

  • Aunque el proceso se ejecute como root dentro del contenedor, en realidad no tiene todos los privilegios de root. Por ejemplo, no tiene CAP_NET_BIND_SERVICE, por lo que no puede enlazarse a un puerto con un número bajo. (Consulta el Capítulo 2 para obtener más información sobre las capacidades de Linux).

  • Cuando el proceso en contenedor interactúe con un archivo, necesitará los permisos adecuados (por ejemplo, acceso de escritura para modificar el archivo). Si el archivo se monta desde el host, lo que importa es el ID de usuario efectivo en el host.

    Esto es bueno para proteger los archivos host de accesos no autorizados desde dentro de un contenedor, pero puede resultar confuso si, por ejemplo, lo que parece ser root dentro del contenedor no tiene permiso para modificar un archivo.

Espacio de nombres de comunicaciones entre procesos

En Linux es posible comunicarse entre distintos procesos dándoles acceso a un rango compartido de memoria, o utilizando una cola de mensajes compartida. Los dos procesos tienen que ser miembros del mismo espacio de nombres de comunicaciones entre procesos (IPC) para que tengan acceso al mismo conjunto de identificadores para estos mecanismos.

En general, no quieres que tus contenedores puedan acceder a la memoria compartida de los demás, por lo que se les asignan sus propios espacios de nombres IPC.

Puedes ver esto en acción creando un bloque de memoria compartida y viendo después el estado actual de la IPC con ipcs:

$ ipcmk -M 1000
Shared memory id: 98307
$ ipcs

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x00000000 0          root       644        80         2
0x00000000 32769      root       644        16384      2
0x00000000 65538      root       644        280        2
0xad291bee 98307      ubuntu     644        1000       0

------ Semaphore Arrays --------
key        semid      owner      perms      nsems
0x000000a7 0          root       600        1

En este ejemplo, el bloque de memoria compartida recién creado (con su ID en la columna shmid ) aparece como el último elemento del bloque "Segmentos de memoria compartida". También hay algunos objetos IPC preexistentes que habían sido creados previamente por root.

Un proceso con su propio espacio de nombres IPC no ve ninguno de estos objetos IPC:

$ sudo unshare --ipc sh
$ ipcs

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status

------ Semaphore Arrays --------
key        semid      owner      perms      nsems

Espacio de nombres Cgroup

El último de los espacios de nombres (al menos, en el momento de escribir este libro) es el espacio de nombres cgroup. Es un poco como un chroot para el sistema de archivos cgroup; impide que un proceso vea la configuración cgroup más arriba en la jerarquía de directorios cgroup que su propio cgroup.

Nota

La mayoría de los espacios de nombres se añadieron en la versión 3.8 del núcleo de Linux, pero el espacio de nombres cgroup se añadió más tarde, en la versión 4.6. Si utilizas una distribución relativamente antigua de Linux (como Ubuntu 16.04), no tendrás soporte para esta función. Puedes comprobar la versión del núcleo en tu host Linux ejecutando uname -r.

Puedes ver el espacio de nombres cgroup en acción comparando el contenido de /proc/self/cgroup fuera y luego dentro de un espacio de nombres cgroup:

vagrant@myhost:~$ cat /proc/self/cgroup
12:cpu,cpuacct:/
11:cpuset:/
10:hugetlb:/
9:blkio:/
8:memory:/user.slice/user-1000.slice/session-51.scope
7:pids:/user.slice/user-1000.slice/session-51.scope
6:freezer:/
5:devices:/user.slice
4:net_cls,net_prio:/
3:rdma:/
2:perf_event:/
1:name=systemd:/user.slice/user-1000.slice/session-51.scope
0::/user.slice/user-1000.slice/session-51.scope
vagrant@myhost:~$
vagrant@myhost:~$ sudo unshare --cgroup bash
root@myhost:~# cat /proc/self/cgroup
12:cpu,cpuacct:/
11:cpuset:/
10:hugetlb:/
9:blkio:/
8:memory:/
7:pids:/
6:freezer:/
5:devices:/
4:net_cls,net_prio:/
3:rdma:/
2:perf_event:/
1:name=systemd:/
0::/

Ya has explorado los distintos tipos de espacios de nombres y has visto cómo se utilizan junto con chroot para aislar la vista de un proceso de su entorno. Combina esto con lo que aprendiste sobre los cgroups en el capítulo anterior, y deberías tener una buena comprensión de todo lo que se necesita para hacer lo que llamamos un"contenedor".

Antes de pasar al siguiente capítulo, merece la pena echar un vistazo a un contenedor desde la perspectiva del host en el que se ejecuta.

Procesos de Contenedores desde la Perspectiva del Anfitrión

Aunque se les llama contenedores, sería más exacto utilizar el término "procesos en contenedores". Un contenedor sigue siendo un proceso Linux que se ejecuta en la máquina anfitriona, pero tiene una visión limitada de esa máquina anfitriona, y sólo tiene acceso a un subárbol del sistema de archivos y quizá a un conjunto limitado de recursos restringidos por cgroups. Como en realidad es sólo un proceso, existe en el contexto del sistema operativo anfitrión, y comparte el núcleo del anfitrión, como se muestra en la Figura 4-3.

Containers share the host's kernel
Figura 4-3. Los contenedores comparten el núcleo del host

Verás cómo se compara esto con las máquinas virtuales en el próximo capítulo, pero antes de eso, vamos a examinar con más detalle hasta qué punto un proceso en contenedor está aislado del host, y de otros procesos en contenedor en ese host, probando algunos experimentos en un contenedor Docker. Inicia un proceso contenedor basado en Ubuntu (o en tu distribución de Linux favorita) y ejecuta un intérprete de comandos en él, y luego ejecuta un sleep largo en él, como se indica a continuación:

$ docker run --rm -it ubuntu bash
root@1551d24a $ sleep 1000

Este ejemplo ejecuta el comando sleep durante 1.000 segundos, pero ten en cuenta que el comando sleep se ejecuta como un proceso dentro del contenedor. Cuando pulsas Intro al final del comando sleep, esto hace que Linux clone un nuevo proceso con un nuevo ID de proceso y ejecute el ejecutable sleep dentro de ese proceso.

Puedes poner el proceso de suspensión en segundo plano (Ctrl-Z para pausar el proceso, y bg %1 para ponerlo en segundo plano). Ahora ejecuta ps dentro del contenedor para ver el mismo proceso desde la perspectiva del contenedor:

me@myhost:~$ docker run --rm -it ubuntu bash
root@ab6ea36fce8e:/$ sleep 1000
^Z
[1]+  Stopped                 sleep 1000
root@ab6ea36fce8e:/$ bg %1
[1]+ sleep 1000 &
root@ab6ea36fce8e:/$ ps
  PID TTY          TIME CMD
    1 pts/0    00:00:00 bash
   10 pts/0    00:00:00 sleep
   11 pts/0    00:00:00 ps
root@ab6ea36fce8e:/$

Mientras se ejecuta ese comando sleep, abre un segundo terminal en el mismo host y observa el mismo proceso de suspensión desde la perspectiva del host:

me@myhost:~$ ps -C sleep
  PID TTY          TIME CMD
30591 pts/0    00:00:00 sleep

El parámetro -C sleep especifica que sólo nos interesan los procesos que ejecutan el ejecutable sleep.

El contenedor tiene su propio espacio de nombres de ID de proceso, por lo que tiene sentido que sus procesos tengan números bajos, y eso es de hecho lo que ves al ejecutar ps en el contenedor. Sin embargo, desde la perspectiva del anfitrión, el proceso de suspensión tiene un ID de proceso diferente, con un número alto. En el ejemplo anterior, sólo hay un proceso, y tiene el ID 30591 en el host y 10 en el contenedor. (El número real variará en función de qué más se esté ejecutando y se haya ejecutado en la misma máquina, pero es probable que sea un número mucho mayor).

Para entender bien los contenedores y el nivel de aislamiento que proporcionan, es fundamental comprender el hecho de que, aunque haya dos ID de proceso diferentes, ambos se refieren al mismo proceso. Sólo que, desde la perspectiva del anfitrión, tiene un número de ID de proceso superior.

El hecho de que los procesos de los contenedores sean visibles desde el host es una de las diferencias fundamentales entre los contenedores y las máquinas virtuales. Un atacante que consiga acceder al host puede observar y afectar a todos los contenedores que se ejecuten en ese host, especialmente si tiene acceso de root. Y como verás en el Capítulo 9, hay algunas formas notablemente fáciles de hacer posible que un atacante pase inadvertidamente de un contenedor comprometido al host.

Máquinas huésped de contenedores

Como has visto, los contenedores y su anfitrión comparten un núcleo, y esto tiene algunas consecuencias para lo que se consideran buenas prácticas relativas a las máquinas anfitrionas de los contenedores. Si un host se ve comprometido, todos los contenedores de ese host son víctimas potenciales, especialmente si el atacante obtiene privilegios de root o elevados de otro modo (como ser miembro del grupo docker que puede administrar contenedores en los que se utiliza Docker como tiempo de ejecución).

Se recomienda encarecidamente ejecutar aplicaciones en contenedores en máquinas anfitrionas dedicadas (ya sean máquinas virtuales o de metal desnudo), y las razones están relacionadas principalmente con la seguridad:

  • Utilizar un orquestador para ejecutar contenedores significa que los humanos necesitan poco o ningún acceso a los hosts. Si no ejecutas ninguna otra aplicación, necesitarás un conjunto muy pequeño de identidades de usuario en las máquinas anfitrionas. Éstas serán más fáciles de gestionar, y los intentos de acceder como un usuario no autorizado serán más fáciles de detectar.

  • Puedes utilizar cualquier distribución de Linux como SO anfitrión para ejecutar contenedores Linux, pero hay varias distribuciones de "SO delgado" diseñadas específicamente para ejecutar contenedores. Éstas reducen la superficie de ataque del anfitrión al incluir sólo los componentes necesarios para ejecutar contenedores. Algunos ejemplos son RancherOS, Fedora CoreOS de Red Hat y Photon OS de VMware. Con menos componentes incluidos en la máquina anfitriona, hay menos posibilidades de vulnerabilidades (ver Capítulo 7) en esos componentes.

  • Todas las máquinas anfitrionas de un clúster pueden compartir la misma configuración, sin requisitos específicos de la aplicación. Esto facilita la automatización del aprovisionamiento de máquinas anfitrionas, y significa que puedes tratarlas como inmutables. Si una máquina anfitriona necesita una actualización, no la parcheas, sino que la retiras del clúster y la sustituyes por una máquina recién instalada. Tratar las máquinas anfitrionas como inmutables facilita la detección de intrusiones.

Volveré sobre las ventajas de la inmutabilidad en el capítulo 6.

Utilizar un SO Thin reduce el conjunto de opciones de configuración, pero no las elimina por completo. Por ejemplo, tendrás un tiempo de ejecución de contenedores (quizás Docker) más código orquestador (quizás el kubelet de Kubernetes) ejecutándose en cada host. Estos componentes tienen numerosas configuraciones, algunas de las cuales afectan a la seguridad. El Centro para la Seguridad en Internet (CIS) publica puntos de referencia sobre buenas prácticas para configurar y ejecutar diversos componentes de software, incluidos Docker, Kubernetes y Linux.

En un entorno empresarial, busca una solución de seguridad para contenedores que también proteja los hosts informando sobre vulnerabilidades y ajustes de configuración preocupantes. También querrás registros y alertas de inicios e intentos de inicio de sesión a nivel de host.

Resumen

¡Enhorabuena! Como has llegado al final de este capítulo, ahora deberías saber lo que es realmente un contenedor. Has visto los tres mecanismos esenciales del núcleo Linux que se utilizan para limitar el acceso de un proceso a los recursos del host:

  • Los espacios de nombres limitan lo que el proceso contenedor puede ver, por ejemplo, dando al contenedor un conjunto aislado de IDs de proceso.

  • Cambiar la raíz limita el conjunto de archivos y directorios que el contenedor puede ver.

  • Los Cgroups controlan los recursos a los que puede acceder el contenedor.

Como viste en el Capítulo 1, aislar una carga de trabajo de otra es un aspecto importante de la seguridad de los contenedores. Ahora debes ser plenamente consciente de que todos los contenedores de un determinado host (ya sea una máquina virtual o un servidor bare-metal) comparten el mismo kernel. Por supuesto, lo mismo ocurre en un sistema multiusuario en el que distintos usuarios pueden acceder a la misma máquina y ejecutar aplicaciones directamente. Sin embargo, en un sistema multiusuario, es probable que los administradores limiten los permisos concedidos a cada usuario; desde luego, no les darán a todos privilegios de root. Con los contenedores -al menos en el momento de escribir esto- todos se ejecutan como root por defecto y confían en la frontera que proporcionan los espacios de nombres, los directorios raíz modificados y los cgroups para evitar que un contenedor interfiera con otro.

Nota

Ahora que ya sabes cómo funcionan los contenedores, quizá quieras explorar el sitio contained.af de Jess Frazelle para ver lo eficaces que son. ¿Serás tú la persona que rompa la contención?

En el Capítulo 8 exploraremos las opciones para reforzar el límite de seguridad alrededor de cada contenedor, pero a continuación vamos a profundizar en cómo funcionan las máquinas virtuales. Esto te permitirá considerar los puntos fuertes relativos del aislamiento entre contenedores y entre máquinas virtuales, especialmente desde el punto de vista de la seguridad.

Get Seguridad de los contenedores now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.