Ansiedade de separação: um tutorial para isolar seu sistema com namespaces do Linux
Publicados: 2022-03-11Com o advento de ferramentas como Docker, Linux Containers e outras, ficou super fácil isolar processos Linux em seus próprios pequenos ambientes de sistema. Isso torna possível executar toda uma gama de aplicativos em uma única máquina Linux real e garantir que dois deles não interfiram um no outro, sem precisar recorrer ao uso de máquinas virtuais. Essas ferramentas têm sido um grande benefício para os provedores de PaaS. Mas o que exatamente acontece sob o capô?
Essas ferramentas contam com vários recursos e componentes do kernel do Linux. Alguns desses recursos foram introduzidos recentemente, enquanto outros ainda exigem que você corrija o próprio kernel. Mas um dos principais componentes, usando namespaces do Linux, tem sido um recurso do Linux desde que a versão 2.6.24 foi lançada em 2008.
Qualquer pessoa familiarizada com chroot
já tem uma ideia básica do que os namespaces do Linux podem fazer e como usar o namespace em geral. Assim como o chroot
permite que os processos vejam qualquer diretório arbitrário como a raiz do sistema (independente do resto dos processos), os namespaces do Linux permitem que outros aspectos do sistema operacional também sejam modificados independentemente. Isso inclui a árvore de processos, interfaces de rede, pontos de montagem, recursos de comunicação entre processos e muito mais.
Por que usar namespaces para isolamento de processos?
Em um computador de usuário único, um ambiente de sistema único pode funcionar. Mas em um servidor, onde você deseja executar vários serviços, é essencial para segurança e estabilidade que os serviços sejam o mais isolados possível uns dos outros. Imagine um servidor executando vários serviços, um dos quais é comprometido por um intruso. Nesse caso, o invasor pode explorar esse serviço e abrir caminho para os outros serviços, e pode até comprometer todo o servidor. O isolamento de namespace pode fornecer um ambiente seguro para eliminar esse risco.
Por exemplo, usando namespaces, é possível executar com segurança programas arbitrários ou desconhecidos em seu servidor. Recentemente, tem havido um número crescente de concursos de programação e plataformas de “hackathon”, como HackerRank, TopCoder, Codeforces e muito mais. Muitos deles utilizam pipelines automatizados para executar e validar programas enviados pelos concorrentes. Muitas vezes é impossível saber antecipadamente a verdadeira natureza dos programas dos concorrentes, e alguns podem até conter elementos maliciosos. Ao executar esses programas com namespaces completamente isolados do resto do sistema, o software pode ser testado e validado sem colocar o resto da máquina em risco. Da mesma forma, serviços de integração contínua online, como Drone.io, buscam automaticamente seu repositório de código e executam os scripts de teste em seus próprios servidores. Novamente, o isolamento de namespace é o que torna possível fornecer esses serviços com segurança.
Ferramentas de namespace como o Docker também permitem melhor controle sobre o uso de recursos do sistema pelos processos, tornando essas ferramentas extremamente populares para uso por provedores de PaaS. Serviços como Heroku e Google App Engine usam essas ferramentas para isolar e executar vários aplicativos de servidor da Web no mesmo hardware real. Essas ferramentas permitem que eles executem cada aplicativo (que pode ter sido implantado por vários usuários diferentes) sem se preocupar com o uso de muitos recursos do sistema ou interferência e/ou conflito com outros serviços implantados na mesma máquina. Com esse isolamento de processos, é até possível ter pilhas de softwares de dependência (e versões) totalmente diferentes para cada ambiente isolado!
Se você já utilizou ferramentas como o Docker, já sabe que essas ferramentas são capazes de isolar processos em pequenos “contêineres”. A execução de processos em contêineres do Docker é como executá-los em máquinas virtuais, apenas esses contêineres são significativamente mais leves que as máquinas virtuais. Uma máquina virtual normalmente emula uma camada de hardware em cima de seu sistema operacional e, em seguida, executa outro sistema operacional em cima disso. Isso permite que você execute processos dentro de uma máquina virtual, completamente isolado do seu sistema operacional real. Mas as máquinas virtuais são pesadas! Os contêineres do Docker, por outro lado, usam alguns recursos importantes do seu sistema operacional real, incluindo namespaces, e garantem um nível semelhante de isolamento, mas sem emular o hardware e executar outro sistema operacional na mesma máquina. Isso os torna muito leves.
Namespace do processo
Historicamente, o kernel do Linux manteve uma única árvore de processos. A árvore contém uma referência a cada processo atualmente em execução em uma hierarquia pai-filho. Um processo, desde que tenha privilégios suficientes e satisfaça certas condições, pode inspecionar outro processo anexando um rastreador a ele ou pode até matá-lo.
Com a introdução dos namespaces do Linux, tornou-se possível ter várias árvores de processos “aninhadas”. Cada árvore de processo pode ter um conjunto inteiramente isolado de processos. Isso pode garantir que os processos pertencentes a uma árvore de processos não possam inspecionar ou matar - na verdade, nem mesmo saber da existência de - processos em outras árvores de processos irmãos ou pais.
Toda vez que um computador com Linux é inicializado, ele inicia com apenas um processo, com identificador de processo (PID) 1. Esse processo é a raiz da árvore de processos e inicia o restante do sistema executando o trabalho de manutenção apropriado e iniciando os daemons/serviços corretos. Todos os outros processos começam abaixo desse processo na árvore. O namespace PID permite criar uma nova árvore, com seu próprio processo PID 1. O processo que faz isso permanece no namespace pai, na árvore original, mas torna o filho a raiz de sua própria árvore de processos.
Com o isolamento do namespace PID, os processos no namespace filho não têm como saber da existência do processo pai. No entanto, os processos no namespace pai têm uma visão completa dos processos no namespace filho, como se fossem qualquer outro processo no namespace pai.
É possível criar um conjunto aninhado de namespaces filho: um processo inicia um processo filho em um novo namespace PID e esse processo filho gera outro processo em um novo namespace PID e assim por diante.
Com a introdução de namespaces PID, um único processo agora pode ter vários PIDs associados a ele, um para cada namespace em que se enquadra. No código-fonte do Linux, podemos ver que um struct chamado pid
, que costumava acompanhar apenas um único PID, agora rastreia vários PIDs através do uso de um struct chamado 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 criar um novo namespace PID, deve-se chamar a chamada de sistema clone()
com um sinalizador especial CLONE_NEWPID
. (C fornece um wrapper para expor esta chamada de sistema, assim como muitas outras linguagens populares.) Enquanto os outros namespaces discutidos abaixo também podem ser criados usando a chamada de sistema unshare()
, um namespace PID só pode ser criado no momento em que um novo processo é gerado usando clone()
. Uma vez que clone()
é chamado com este sinalizador, o novo processo inicia imediatamente em um novo namespace PID, sob uma nova árvore de processos. Isso pode ser demonstrado com um programa simples em 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 e execute este programa com privilégios de root e você notará uma saída semelhante a esta:
clone() = 5304 PID: 1
O PID, conforme impresso de child_fn
, será 1
.
Mesmo que este código do tutorial de namespace acima não seja muito maior do que “Hello, world” em alguns idiomas, muita coisa aconteceu nos bastidores. A função clone()
, como seria de esperar, criou um novo processo clonando o atual e iniciou a execução no início da função child_fn()
. No entanto, ao fazê-lo, desvinculou o novo processo da árvore de processos original e criou uma árvore de processos separada para o novo processo.
Tente substituir a função static int child_fn()
pelo seguinte, para imprimir o PID pai da perspectiva do processo isolado:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
A execução do programa desta vez produz a seguinte saída:
clone() = 11449 Parent PID: 0
Observe como o PID pai da perspectiva do processo isolado é 0, indicando que não há pai. Tente executar o mesmo programa novamente, mas desta vez, remova o sinalizador CLONE_NEWPID
de dentro da chamada da função clone()
:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
Desta vez, você notará que o PID pai não é mais 0:
clone() = 11561 Parent PID: 11560
No entanto, este é apenas o primeiro passo em nosso tutorial. Esses processos ainda têm acesso irrestrito a outros recursos comuns ou compartilhados. Por exemplo, a interface de rede: se o processo filho criado acima fosse escutar na porta 80, ele impediria que todos os outros processos no sistema pudessem ouvi-lo.
Namespace de rede Linux
É aqui que um namespace de rede se torna útil. Um namespace de rede permite que cada um desses processos veja um conjunto totalmente diferente de interfaces de rede. Até mesmo a interface de loopback é diferente para cada namespace de rede.
Isolar um processo em seu próprio namespace de rede envolve a introdução de outro sinalizador na chamada da função 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; }
Saída:

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
O que está acontecendo aqui? O dispositivo ethernet físico enp4s0
pertence ao namespace de rede global, conforme indicado pela ferramenta “ip” executada a partir desse namespace. No entanto, a interface física não está disponível no novo namespace de rede. Além disso, o dispositivo de loopback está ativo no namespace de rede original, mas está "inativo" no namespace de rede filho.
Para fornecer uma interface de rede utilizável no namespace filho, é necessário configurar interfaces de rede “virtuais” adicionais que abrangem vários namespaces. Feito isso, é possível criar pontes Ethernet e até mesmo rotear pacotes entre os namespaces. Finalmente, para fazer tudo funcionar, um “processo de roteamento” deve estar em execução no namespace de rede global para receber tráfego da interface física e roteá-lo através das interfaces virtuais apropriadas para os namespaces de rede filho corretos. Talvez você possa ver por que ferramentas como o Docker, que fazem todo esse trabalho pesado para você, são tão populares!
Para fazer isso manualmente, você pode criar um par de conexões Ethernet virtuais entre um namespace pai e um filho executando um único comando do namespace pai:
ip link add name veth0 type veth peer name veth1 netns <pid>
Aqui, <pid>
deve ser substituído pelo ID do processo no namespace filho conforme observado pelo pai. A execução deste comando estabelece uma conexão tipo pipe entre esses dois namespaces. O namespace pai retém o dispositivo veth0
e passa o dispositivo veth1
para o namespace filho. Qualquer coisa que entre em uma das extremidades, sai pela outra extremidade, exatamente como você esperaria de uma conexão Ethernet real entre dois nós reais. Assim, ambos os lados desta conexão Ethernet virtual devem ter endereços IP atribuídos.
Espaço de nomes de montagem
O Linux também mantém uma estrutura de dados para todos os pontos de montagem do sistema. Inclui informações como quais partições de disco estão montadas, onde estão montadas, se são somente leitura, etc. Com os namespaces do Linux, pode-se ter essa estrutura de dados clonada, para que os processos em diferentes namespaces possam alterar os pontos de montagem sem afetar uns aos outros.
Criar um namespace de montagem separado tem um efeito semelhante a fazer um chroot()
. chroot()
é bom, mas não fornece isolamento completo e seus efeitos são restritos apenas ao ponto de montagem raiz. A criação de um namespace de montagem separado permite que cada um desses processos isolados tenha uma visão completamente diferente da estrutura de ponto de montagem de todo o sistema da original. Isso permite que você tenha uma raiz diferente para cada processo isolado, bem como outros pontos de montagem específicos para esses processos. Usado com cuidado de acordo com este tutorial, você pode evitar expor qualquer informação sobre o sistema subjacente.
O sinalizador clone()
necessário para conseguir isso é CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
Inicialmente, o processo filho vê exatamente os mesmos pontos de montagem que seu processo pai. No entanto, estando sob um novo namespace de montagem, o processo filho pode montar ou desmontar quaisquer endpoints que desejar, e a alteração não afetará o namespace de seu pai nem qualquer outro namespace de montagem em todo o sistema. Por exemplo, se o processo pai tiver uma partição de disco específica montada na raiz, o processo isolado verá exatamente a mesma partição de disco montada na raiz no início. Mas o benefício de isolar o namespace de montagem é aparente quando o processo isolado tenta alterar a partição raiz para outra coisa, pois a alteração afetará apenas o namespace de montagem isolado.
Curiosamente, isso realmente torna uma má ideia gerar o processo filho de destino diretamente com o sinalizador CLONE_NEWNS
. Uma abordagem melhor é iniciar um processo “init” especial com o sinalizador CLONE_NEWNS
, fazer com que o processo “init” altere o “/”, “/proc”, “/dev” ou outros pontos de montagem conforme desejado e, em seguida, inicie o processo de destino . Isso é discutido com um pouco mais de detalhes perto do final deste tutorial de namespace.
Outros namespaces
Existem outros namespaces nos quais esses processos podem ser isolados, como usuário, IPC e UTS. O namespace de usuário permite que um processo tenha privilégios de root dentro do namespace, sem dar a ele esse acesso a processos fora do namespace. Isolar um processo pelo namespace IPC fornece a ele seus próprios recursos de comunicação entre processos, por exemplo, mensagens System V IPC e POSIX. O namespace UTS isola dois identificadores específicos do sistema: nodename
e domainname
.
Um exemplo rápido para mostrar como o namespace UTS é isolado é mostrado abaixo:
#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 produz a seguinte saída:
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
Aqui, child_fn()
imprime o nodename
, o altera para outra coisa e o imprime novamente. Naturalmente, a mudança acontece apenas dentro do novo namespace UTS.
Mais informações sobre o que todos os namespaces fornecem e isolam podem ser encontradas no tutorial aqui
Comunicação entre namespaces
Muitas vezes é necessário estabelecer algum tipo de comunicação entre o namespace pai e filho. Isso pode ser para fazer o trabalho de configuração em um ambiente isolado ou pode ser simplesmente para manter a capacidade de espiar a condição desse ambiente de fora. Uma maneira de fazer isso é manter um daemon SSH em execução nesse ambiente. Você pode ter um daemon SSH separado dentro de cada namespace de rede. No entanto, ter vários daemons SSH em execução usa muitos recursos valiosos, como memória. É aqui que ter um processo especial de “inicialização” prova ser uma boa ideia novamente.
O processo “init” pode estabelecer um canal de comunicação entre o namespace pai e o namespace filho. Este canal pode ser baseado em sockets UNIX ou até mesmo usar TCP. Para criar um soquete UNIX que abrange dois namespaces de montagem diferentes, você precisa primeiro criar o processo filho, depois criar o soquete UNIX e isolar o filho em um namespace de montagem separado. Mas como podemos criar o processo primeiro e isolá-lo depois? Linux fornece unshare()
. Essa chamada de sistema especial permite que um processo se isole do namespace original, em vez de fazer com que o pai isole o filho em primeiro lugar. Por exemplo, o código a seguir tem exatamente o mesmo efeito que o código mencionado anteriormente na seção de namespace de rede:
#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; }
E como o processo “init” é algo que você criou, você pode fazer com que ele faça todo o trabalho necessário primeiro e depois se isolar do resto do sistema antes de executar o filho de destino.
Conclusão
Este tutorial é apenas uma visão geral de como usar namespaces no Linux. Deve dar uma ideia básica de como um desenvolvedor Linux pode começar a implementar o isolamento do sistema, parte integrante da arquitetura de ferramentas como Docker ou Linux Containers. Na maioria dos casos, seria melhor simplesmente usar uma dessas ferramentas existentes, que já são bem conhecidas e testadas. Mas, em alguns casos, pode fazer sentido ter seu próprio mecanismo de isolamento de processo personalizado e, nesse caso, este tutorial de namespace o ajudará tremendamente.
Há muito mais acontecendo sob o capô do que abordei neste artigo, e há mais maneiras de limitar seus processos de destino para maior segurança e isolamento. Mas, esperançosamente, isso pode servir como um ponto de partida útil para quem está interessado em saber mais sobre como o isolamento de namespace com Linux realmente funciona.