Como uma continuação do artigo anterior sobre KVM, publicamos uma nova tradução e entendemos como os contêineres funcionam usando o exemplo de execução de uma imagem Docker do busybox.
Este artigo sobre contêineres é uma continuação do artigo anterior sobre KVM. Gostaria de mostrar exatamente como os contêineres funcionam, executando uma imagem do Busybox Docker em nosso pequeno contêiner.
Ao contrário da máquina virtual, o termo contêiner é muito vago e vago. O que normalmente chamamos de contêiner é um pacote autônomo de código com todas as dependências necessárias que podem ser enviadas juntas e executadas em um ambiente isolado dentro do sistema operacional host. Se você acha que esta é a descrição de uma máquina virtual, vamos nos aprofundar no tópico e ver como os contêineres são implementados.
BusyBox Docker
Nosso principal objetivo será executar uma imagem regular do busybox para Docker, mas sem Docker. Docker usa btrfs como sistema de arquivos para suas imagens. Vamos tentar baixar a imagem e descompactá-la em um diretório:
mkdir rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -
Agora temos o sistema de arquivos de imagem do busybox descompactado na pasta rootfs . Claro, você pode executar ./rootfs/bin/sh e obter um shell funcional, mas se olharmos a lista de processos, arquivos ou interfaces de rede, podemos ver que temos acesso a todo o sistema operacional.
Então, vamos tentar criar um ambiente isolado.
Clone
Como queremos controlar a que o processo filho tem acesso, usaremos clone (2) em vez de fork (2) . Clone faz quase a mesma coisa, mas permite que sinalizadores sejam passados, indicando quais recursos você deseja compartilhar (com o host).
Os seguintes sinalizadores são permitidos:
- CLONE_NEWNET - dispositivos de rede isolados
- CLONE_NEWUTS - host e nome de domínio (sistema UNIX timesharing)
- CLONE_NEWIPC - objetos IPC
- CLONE_NEWPID - identificadores de processo (PID)
- CLONE_NEWNS - pontos de montagem (sistemas de arquivos)
- CLONE_NEWUSER - usuários e grupos.
Em nosso experimento, tentaremos isolar processos, IPC, rede e sistemas de arquivos. Então vamos começar:
static char child_stack[1024 * 1024];
int child_main(void *arg) {
printf("Hello from child! PID=%d\n", getpid());
return 0;
}
int main(int argc, char *argv[]) {
int flags =
CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNET;
int pid = clone(child_main, child_stack + sizeof(child_stack),
flags | SIGCHLD, argv + 1);
if (pid < 0) {
fprintf(stderr, "clone failed: %d\n", errno);
return 1;
}
waitpid(pid, NULL, 0);
return 0;
}
O código deve ser executado com privilégios de superusuário ou a clonagem falhará.
O experimento fornece um resultado interessante: o PID filho é 1. Estamos bem cientes de que o processo init geralmente tem PID 1. Mas, neste caso, o processo filho obtém sua própria lista de processos isolados, onde se tornou o primeiro processo.
Shell de trabalho
Para tornar mais fácil aprender um novo ambiente, vamos iniciar um shell no processo filho. Vamos executar comandos arbitrários como docker run :
int child_main(void *arg) {
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Agora, lançar nosso aplicativo com o argumento / bin / sh abre um shell real no qual podemos inserir comandos. Este resultado prova o quão errados estávamos quando falamos sobre isolamento:
# echo $$
1
# ps
PID TTY TIME CMD
5998 pts/31 00:00:00 sudo
5999 pts/31 00:00:00 main
6001 pts/31 00:00:00 sh
6004 pts/31 00:00:00 ps
Como podemos ver, o próprio processo shell possui um PID de 1, mas, na verdade, pode ver e acessar todos os outros processos do SO principal. A razão é que a lista de processos é lida do procfs , que ainda é herdada.
Então, desmonte procfs :
umount2("/proc", MNT_DETACH);
Agora, o ps , mount e outros comandos quebram ao iniciar o shell porque procfs não está montado. No entanto, isso ainda é melhor do que o vazamento procfs pai.
Chroot
Normalmente chroot é usado para criar o diretório raiz , mas usaremos a alternativa pivot_root . Esta chamada de sistema move a raiz do sistema atual para um subdiretório e atribui um diretório diferente à raiz:
int child_main(void *arg) {
/* Unmount procfs */
umount2("/proc", MNT_DETACH);
/* Pivot root */
mount("./rootfs", "./rootfs", "bind", MS_BIND | MS_REC, "");
mkdir("./rootfs/oldrootfs", 0755);
syscall(SYS_pivot_root, "./rootfs", "./rootfs/oldrootfs");
chdir("/");
umount2("/oldrootfs", MNT_DETACH);
rmdir("/oldrootfs");
/* Re-mount procfs */
mount("proc", "/proc", "proc", 0, NULL);
/* Run the process */
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Faz sentido montar tmpfs em / tmp , sysfs em / sys e criar um sistema de arquivos / dev válido , mas vou pular esta etapa para abreviar.
Agora, vemos apenas os arquivos da imagem do busybox, como se estivéssemos usando um chroot :
/ # ls
bin dev etc home proc root sys tmp usr var
/ # mount
/dev/sda2 on / type ext4 (rw,relatime,data=ordered)
proc on /proc type proc (rw,relatime)
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
4 root 0:00 ps
/ # ps ax
PID USER TIME COMMAND
1 root 0:00 /bin/sh
5 root 0:00 ps ax
No momento, o contêiner parece bastante isolado, talvez até demais. Não conseguimos pingar nada e a rede parece não funcionar.
Rede
Criar um novo namespace de rede foi apenas o começo! Você precisa atribuir interfaces de rede e configurá-las para encaminhar pacotes adequadamente.
Se você não tem a interface br0, você precisa criá-la manualmente (brctl faz parte do pacote bridge-utils no Ubuntu):
brctl addbr br0
ip addr add dev br0 172.16.0.100/24
ip link set br0 up
sudo iptables -A FORWARD -i wlp3s0 -o br0 -j ACCEPT
sudo iptables -A FORWARD -o wlp3s0 -i br0 -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 172.16.0.0/16 -j MASQUERADE
No meu caso, wlp3s0 era a interface de rede WiFi principal e 172.16.xx era a rede do contêiner.
Nosso lançador de contêiner precisa criar um par de interfaces, veth0 e veth1, associá-las a br0 e configurar o roteamento dentro do contêiner.
Na função main () , executaremos estes comandos antes da clonagem:
system("ip link add veth0 type veth peer name veth1");
system("ip link set veth0 up");
system("brctl addif br0 veth0");
Quando a chamada para clone () termina, adicionamos veth1 ao novo namespace filho:
char ip_link_set[4096];
snprintf(ip_link_set, sizeof(ip_link_set) - 1, "ip link set veth1 netns %d",
pid);
system(ip_link_set);
Agora, se executarmos o ip link em um shell de contêiner, veremos uma interface de loopback e alguma interface veth1 @ xxxx. Mas a rede ainda não funciona. Vamos definir um nome de host exclusivo no contêiner e configurar as rotas:
int child_main(void *arg) {
....
sethostname("example", 7);
system("ip link set veth1 up");
char ip_addr_add[4096];
snprintf(ip_addr_add, sizeof(ip_addr_add),
"ip addr add 172.16.0.101/24 dev veth1");
system(ip_addr_add);
system("route add default gw 172.16.0.100 veth1");
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Vamos ver como fica:
/ # ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
47: veth1@if48: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue qlen 1000
link/ether 72:0a:f0:91:d5:11 brd ff:ff:ff:ff:ff:ff
/ # hostname
example
/ # ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: seq=0 ttl=57 time=27.161 ms
64 bytes from 1.1.1.1: seq=1 ttl=57 time=26.048 ms
64 bytes from 1.1.1.1: seq=2 ttl=57 time=26.980 ms
...
Trabalho!
Conclusão
O código-fonte completo está disponível aqui . Se você encontrar um bug ou tiver uma sugestão, deixe um comentário!
Claro, o Docker pode fazer muito mais! Mas é incrível quantas APIs adequadas o kernel do Linux possui e como é fácil usá-las para obter virtualização no nível do sistema operacional.
Espero que você tenha gostado do artigo. Você encontra os projetos do autor no Github e segue o Twitter para acompanhar as novidades, assim como via rss .