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.
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 chroot
dice 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)".
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 delsns -t net
en el ejemplo del principio de esta sección). -
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
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.
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.
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.