Ansiedad de separación: un tutorial para aislar su sistema con espacios de nombres de Linux
Publicado: 2022-03-11Con la llegada de herramientas como Docker, Linux Containers y otras, se ha vuelto muy fácil aislar los procesos de Linux en sus propios pequeños entornos de sistema. Esto hace posible ejecutar una amplia gama de aplicaciones en una sola máquina Linux real y garantizar que ninguna de ellas interfiera entre sí, sin tener que recurrir al uso de máquinas virtuales. Estas herramientas han sido de gran ayuda para los proveedores de PaaS. Pero, ¿qué sucede exactamente debajo del capó?
Estas herramientas se basan en una serie de características y componentes del kernel de Linux. Algunas de estas características se introdujeron recientemente, mientras que otras aún requieren que parchee el kernel. Pero uno de los componentes clave, el uso de espacios de nombres de Linux, ha sido una característica de Linux desde que se lanzó la versión 2.6.24 en 2008.
Cualquiera que esté familiarizado con chroot
ya tiene una idea básica de lo que pueden hacer los espacios de nombres de Linux y cómo usarlos en general. Así como chroot
permite que los procesos vean cualquier directorio arbitrario como la raíz del sistema (independientemente del resto de los procesos), los espacios de nombres de Linux también permiten que otros aspectos del sistema operativo se modifiquen de forma independiente. Esto incluye el árbol de procesos, las interfaces de red, los puntos de montaje, los recursos de comunicación entre procesos y más.
¿Por qué usar espacios de nombres para el aislamiento de procesos?
En una computadora de un solo usuario, un entorno de un solo sistema puede estar bien. Pero en un servidor, donde desea ejecutar múltiples servicios, es esencial para la seguridad y la estabilidad que los servicios estén tan aislados entre sí como sea posible. Imagine un servidor que ejecuta varios servicios, uno de los cuales se ve comprometido por un intruso. En tal caso, el intruso puede explotar ese servicio y llegar a los otros servicios, e incluso puede comprometer todo el servidor. El aislamiento del espacio de nombres puede proporcionar un entorno seguro para eliminar este riesgo.
Por ejemplo, utilizando el espacio de nombres, es posible ejecutar con seguridad programas arbitrarios o desconocidos en su servidor. Recientemente, ha habido un número creciente de concursos de programación y plataformas de "hackathon", como HackerRank, TopCoder, Codeforces y muchas más. Muchos de ellos utilizan canalizaciones automatizadas para ejecutar y validar los programas que envían los concursantes. A menudo es imposible saber de antemano la verdadera naturaleza de los programas de los concursantes, y algunos incluso pueden contener elementos maliciosos. Al ejecutar estos programas con espacios de nombres completamente aislados del resto del sistema, el software se puede probar y validar sin poner en riesgo el resto de la máquina. De manera similar, los servicios de integración continua en línea, como Drone.io, obtienen automáticamente su repositorio de código y ejecutan los scripts de prueba en sus propios servidores. Nuevamente, el aislamiento del espacio de nombres es lo que hace posible brindar estos servicios de manera segura.
Las herramientas de espacio de nombres como Docker también permiten un mejor control sobre el uso de los recursos del sistema por parte de los procesos, lo que hace que estas herramientas sean extremadamente populares para su uso por parte de los proveedores de PaaS. Servicios como Heroku y Google App Engine utilizan estas herramientas para aislar y ejecutar varias aplicaciones de servidor web en el mismo hardware real. Estas herramientas les permiten ejecutar cada aplicación (que puede haber sido implementada por varios usuarios diferentes) sin preocuparse de que una de ellas use demasiados recursos del sistema o interfiera o entre en conflicto con otros servicios implementados en la misma máquina. Con tal aislamiento de procesos, ¡incluso es posible tener pilas completamente diferentes de software de dependencia (y versiones) para cada entorno aislado!
Si ha utilizado herramientas como Docker, ya sabe que estas herramientas son capaces de aislar procesos en pequeños "contenedores". Ejecutar procesos en contenedores Docker es como ejecutarlos en máquinas virtuales, solo que estos contenedores son significativamente más livianos que las máquinas virtuales. Una máquina virtual normalmente emula una capa de hardware sobre su sistema operativo y luego ejecuta otro sistema operativo encima de eso. Esto le permite ejecutar procesos dentro de una máquina virtual, en completo aislamiento de su sistema operativo real. ¡Pero las máquinas virtuales son pesadas! Los contenedores Docker, por otro lado, usan algunas características clave de su sistema operativo real, incluidos los espacios de nombres, y garantizan un nivel similar de aislamiento, pero sin emular el hardware y ejecutar otro sistema operativo en la misma máquina. Esto los hace muy ligeros.
Espacio de nombres del proceso
Históricamente, el kernel de Linux ha mantenido un único árbol de procesos. El árbol contiene una referencia a cada proceso que se está ejecutando actualmente en una jerarquía padre-hijo. Un proceso, dado que tiene suficientes privilegios y cumple ciertas condiciones, puede inspeccionar otro proceso al adjuntarle un rastreador o incluso puede eliminarlo.
Con la introducción de los espacios de nombres de Linux, se hizo posible tener múltiples árboles de procesos "anidados". Cada árbol de procesos puede tener un conjunto de procesos completamente aislado. Esto puede garantizar que los procesos que pertenecen a un árbol de procesos no puedan inspeccionar o matar (de hecho, ni siquiera pueden saber de la existencia de) procesos en otros árboles de procesos hermanos o padres.
Cada vez que un equipo con Linux arranca, arranca con un solo proceso, con identificador de proceso (PID) 1. Este proceso es la raíz del árbol de procesos, e inicia el resto del sistema realizando el trabajo de mantenimiento adecuado y arrancando los demonios/servicios correctos. Todos los demás procesos comienzan debajo de este proceso en el árbol. El espacio de nombres PID permite derivar un nuevo árbol, con su propio proceso PID 1. El proceso que hace esto permanece en el espacio de nombres padre, en el árbol original, pero convierte al hijo en la raíz de su propio árbol de procesos.
Con el aislamiento del espacio de nombres PID, los procesos en el espacio de nombres secundario no tienen forma de conocer la existencia del proceso principal. Sin embargo, los procesos en el espacio de nombres principal tienen una vista completa de los procesos en el espacio de nombres secundario, como si fueran cualquier otro proceso en el espacio de nombres principal.
Es posible crear un conjunto anidado de espacios de nombres secundarios: un proceso inicia un proceso secundario en un nuevo espacio de nombres PID, y ese proceso secundario genera otro proceso en un nuevo espacio de nombres PID, y así sucesivamente.
Con la introducción de los espacios de nombres PID, un solo proceso ahora puede tener múltiples PID asociados, uno para cada espacio de nombres en el que se encuentra. En el código fuente de Linux, podemos ver que una estructura llamada pid
, que solía realizar un seguimiento de un solo PID, ahora realiza un seguimiento de varios PID mediante el uso de una estructura llamada upid
:
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
Para crear un nuevo espacio de nombres PID, se debe llamar al sistema clone()
con un indicador especial CLONE_NEWPID
. (C proporciona un contenedor para exponer esta llamada al sistema, al igual que muchos otros lenguajes populares). Mientras que los otros espacios de nombres que se analizan a continuación también se pueden crear mediante la llamada al sistema unshare()
, un espacio de nombres PID solo se puede crear en el momento en que se crea un nuevo espacio de nombres. El proceso se genera usando clone()
. Una vez que se llama a clone()
con este indicador, el nuevo proceso comienza inmediatamente en un nuevo espacio de nombres PID, bajo un nuevo árbol de procesos. Esto se puede demostrar con un simple programa en C:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
Compile y ejecute este programa con privilegios de root y notará un resultado similar a este:
clone() = 5304 PID: 1
El PID, tal como se imprime desde child_fn
, será 1
.
A pesar de que este código de tutorial de espacio de nombres anterior no es mucho más largo que "Hola, mundo" en algunos idiomas, han sucedido muchas cosas detrás de escena. La función clone()
, como era de esperar, creó un nuevo proceso al clonar el actual y comenzó la ejecución al comienzo de la función child_fn()
. Sin embargo, al hacerlo, separó el nuevo proceso del árbol de procesos original y creó un árbol de procesos separado para el nuevo proceso.
Intente reemplazar la función static int child_fn()
con lo siguiente, para imprimir el PID principal desde la perspectiva del proceso aislado:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
Ejecutar el programa esta vez produce el siguiente resultado:
clone() = 11449 Parent PID: 0
Observe cómo el PID padre desde la perspectiva del proceso aislado es 0, lo que indica que no tiene padre. Intente ejecutar el mismo programa nuevamente, pero esta vez, elimine el indicador CLONE_NEWPID
dentro de la llamada a la función clone()
:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
Esta vez, notará que el PID principal ya no es 0:
clone() = 11561 Parent PID: 11560
Sin embargo, este es solo el primer paso en nuestro tutorial. Estos procesos todavía tienen acceso sin restricciones a otros recursos comunes o compartidos. Por ejemplo, la interfaz de red: si el proceso secundario creado anteriormente escuchara en el puerto 80, impediría que todos los demás procesos del sistema pudieran escucharlo.
Espacio de nombres de red de Linux
Aquí es donde un espacio de nombres de red se vuelve útil. Un espacio de nombres de red permite que cada uno de estos procesos vea un conjunto completamente diferente de interfaces de red. Incluso la interfaz de loopback es diferente para cada espacio de nombres de red.
Aislar un proceso en su propio espacio de nombres de red implica introducir otro indicador en la llamada a la función clone()
: CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
Producción:

Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
¿Que está pasando aqui? El dispositivo ethernet físico enp4s0
pertenece al espacio de nombres de la red global, como lo indica la herramienta "ip" que se ejecuta desde este espacio de nombres. Sin embargo, la interfaz física no está disponible en el nuevo espacio de nombres de red. Además, el dispositivo de loopback está activo en el espacio de nombres de la red original, pero está "inactivo" en el espacio de nombres de la red secundaria.
Para proporcionar una interfaz de red utilizable en el espacio de nombres secundario, es necesario configurar interfaces de red "virtuales" adicionales que abarquen varios espacios de nombres. Una vez hecho esto, es posible crear puentes Ethernet e incluso enrutar paquetes entre los espacios de nombres. Finalmente, para que todo funcione, se debe ejecutar un "proceso de enrutamiento" en el espacio de nombres de la red global para recibir el tráfico de la interfaz física y enrutarlo a través de las interfaces virtuales apropiadas a los espacios de nombres de la red secundaria correctos. ¡Tal vez puedas ver por qué las herramientas como Docker, que hacen todo este trabajo pesado por ti, son tan populares!
Para hacer esto a mano, puede crear un par de conexiones Ethernet virtuales entre un espacio de nombres principal y uno secundario ejecutando un solo comando desde el espacio de nombres principal:
ip link add name veth0 type veth peer name veth1 netns <pid>
Aquí, <pid>
debe reemplazarse por el ID del proceso en el espacio de nombres secundario según lo observado por el principal. Ejecutar este comando establece una conexión similar a una canalización entre estos dos espacios de nombres. El espacio de nombres principal conserva el dispositivo veth0
y pasa el dispositivo veth1
al espacio de nombres secundario. Todo lo que entra por uno de los extremos, sale por el otro extremo, tal y como cabría esperar de una conexión Ethernet real entre dos nodos reales. En consecuencia, ambos lados de esta conexión Ethernet virtual deben tener direcciones IP asignadas.
Montar espacio de nombres
Linux también mantiene una estructura de datos para todos los puntos de montaje del sistema. Incluye información como qué particiones de disco están montadas, dónde están montadas, si son de solo lectura, etcétera. Con los espacios de nombres de Linux, se puede clonar esta estructura de datos, de modo que los procesos en diferentes espacios de nombres puedan cambiar los puntos de montaje sin afectarse entre sí.
Crear un espacio de nombres de montaje separado tiene un efecto similar a hacer un chroot()
. chroot()
es bueno, pero no proporciona un aislamiento completo y sus efectos están restringidos solo al punto de montaje raíz. La creación de un espacio de nombres de montaje separado permite que cada uno de estos procesos aislados tenga una vista completamente diferente de la estructura de punto de montaje de todo el sistema de la original. Esto le permite tener una raíz diferente para cada proceso aislado, así como otros puntos de montaje que son específicos para esos procesos. Usado con cuidado según este tutorial, puede evitar exponer cualquier información sobre el sistema subyacente.
El indicador de clone()
requerido para lograr esto es CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
Inicialmente, el proceso secundario ve exactamente los mismos puntos de montaje que vería su proceso principal. Sin embargo, al estar bajo un nuevo espacio de nombres de montaje, el proceso secundario puede montar o desmontar cualquier punto final que desee, y el cambio no afectará ni al espacio de nombres de su padre ni a ningún otro espacio de nombres de montaje en todo el sistema. Por ejemplo, si el proceso principal tiene una partición de disco particular montada en la raíz, el proceso aislado verá exactamente la misma partición de disco montada en la raíz al principio. Pero el beneficio de aislar el espacio de nombres de montaje es evidente cuando el proceso aislado intenta cambiar la partición raíz a otra cosa, ya que el cambio solo afectará el espacio de nombres de montaje aislado.
Curiosamente, esto hace que sea una mala idea generar el proceso secundario de destino directamente con el indicador CLONE_NEWNS
. Un mejor enfoque es iniciar un proceso "init" especial con el indicador CLONE_NEWNS
, hacer que ese proceso "init" cambie "/", "/proc", "/dev" u otros puntos de montaje como se desee, y luego iniciar el proceso de destino . Esto se analiza con un poco más de detalle cerca del final de este tutorial de espacio de nombres.
Otros espacios de nombres
Hay otros espacios de nombres en los que se pueden aislar estos procesos, a saber, usuario, IPC y UTS. El espacio de nombres de usuario permite que un proceso tenga privilegios de root dentro del espacio de nombres, sin otorgarle ese acceso a los procesos fuera del espacio de nombres. Aislar un proceso por el espacio de nombres IPC le otorga sus propios recursos de comunicación entre procesos, por ejemplo, mensajes System V IPC y POSIX. El espacio de nombres UTS aísla dos identificadores específicos del sistema: nombre de nodename
y nombre de domainname
.
A continuación, se muestra un ejemplo rápido para mostrar cómo se aísla el espacio de nombres UTS:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
Este programa produce el siguiente resultado:
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
Aquí, child_fn()
imprime el nombre de nodename
, lo cambia a otra cosa y lo vuelve a imprimir. Naturalmente, el cambio ocurre solo dentro del nuevo espacio de nombres UTS.
Puede encontrar más información sobre lo que proporcionan y aíslan todos los espacios de nombres en el tutorial aquí
Comunicación entre espacios de nombres
A menudo, es necesario establecer algún tipo de comunicación entre el espacio de nombres principal y secundario. Esto podría ser para realizar el trabajo de configuración dentro de un entorno aislado, o simplemente para retener la capacidad de observar las condiciones de ese entorno desde el exterior. Una forma de hacerlo es mantener un demonio SSH ejecutándose dentro de ese entorno. Puede tener un demonio SSH separado dentro de cada espacio de nombres de red. Sin embargo, tener varios demonios SSH en ejecución utiliza muchos recursos valiosos como la memoria. Aquí es donde tener un proceso especial de "init" vuelve a ser una buena idea.
El proceso "init" puede establecer un canal de comunicación entre el espacio de nombres principal y el espacio de nombres secundario. Este canal puede estar basado en sockets UNIX o incluso puede usar TCP. Para crear un socket UNIX que abarque dos espacios de nombres de montaje diferentes, primero debe crear el proceso secundario, luego crear el socket UNIX y luego aislar el hijo en un espacio de nombres de montaje separado. Pero, ¿cómo podemos crear el proceso primero y aislarlo después? Linux proporciona unshare()
. Esta llamada al sistema especial permite que un proceso se aísle del espacio de nombres original, en lugar de que el padre aísle al hijo en primer lugar. Por ejemplo, el siguiente código tiene exactamente el mismo efecto que el código mencionado anteriormente en la sección del espacio de nombres de la red:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
Y dado que el proceso "init" es algo que ha ideado, puede hacer que haga todo el trabajo necesario primero y luego aislarse del resto del sistema antes de ejecutar el objetivo secundario.
Conclusión
Este tutorial es solo una descripción general de cómo usar los espacios de nombres en Linux. Debería darle una idea básica de cómo un desarrollador de Linux podría comenzar a implementar el aislamiento del sistema, una parte integral de la arquitectura de herramientas como Docker o Linux Containers. En la mayoría de los casos, lo mejor sería simplemente utilizar una de estas herramientas existentes, que ya son conocidas y probadas. Pero en algunos casos, podría tener sentido tener su propio mecanismo de aislamiento de procesos personalizado y, en ese caso, este tutorial de espacio de nombres lo ayudará enormemente.
Ocurren muchas más cosas debajo del capó de las que he cubierto en este artículo, y hay más formas en las que puede querer limitar sus procesos de destino para mayor seguridad y aislamiento. Pero, con suerte, esto puede servir como un punto de partida útil para alguien que esté interesado en saber más sobre cómo funciona realmente el aislamiento del espacio de nombres con Linux.