Este artigo explora os conceitos fundamentais por trás da implementação do Docker, incluindo Namespaces, Cgroups e Rootfs - as três tecnologias essenciais que tornam os contêineres possíveis.
Se você está curioso sobre tecnologias nativas da nuvem e deseja aprofundar seus conhecimentos, continue lendo para compreender como o Docker funciona sob o capô.
Primeiro, vamos abordar uma questão fundamental: qual a diferença entre um processo e um contêiner?
Processo: é a soma de todos os elementos do ambiente de execução de um programa em execução.
Ou seja: um conjunto de dados na memória do computador, valores nos registradores, instruções na pilha, arquivos abertos e informações de estado de diversos dispositivos.
Contêiner: basicamente funciona criando "fronteiras" para um processo, restringindo e modificando seu comportamento dinâmico.
Para a maioria dos contêineres Linux, como o Docker, a tecnologia Cgroups é o principal mecanismo para criar essas restrições, enquanto a tecnologia Namespace modifica a visão do processo.
- Isolamento de Visualização com Namespaces
Ao iniciar um contêiner com docker run -it e acessá-lo, notamos que processos, rede e sistema de arquivos parecem isolados:
[root@docker cpu]# docker run -it busybox
/ #
/ # ps
PID USER TIME COMMAND
1 root 0:00 sh
7 root 0:00 ps
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
120: eth0@if121: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
/ # ls
bin dev etc home lib lib64 proc root sys tmp usr var
- O comando ps não mostra processos do hospedeiro
- O comando ip exibe apenas interfaces de rede internas ao contêiner
- O comando ls mostra um sistema de arquivos diferente do hospedeiro
Este é um dos pilares do Docker: o isolamento de visualização através da tecnologia Linux Namespace.
Visualmente, o contêiner parece completamente separado do hospedeiro
No Linux, os Namespaces são classificados por tipo de isolamento:
-
- Namespace PID
-
- Namespace Mount
-
- Namespace UTS
-
- Namespace IPC
-
- Namespace de Rede
-
- Namespace de Usuário
Cada tipo implementa isolamento para diferentes recursos. Por exemplo, a visualização limitada das interfaces de rede é conseguida através do Namespace de Rede.
A tecnologia Linux Namespace, na verdade, modifica a "visão" que um processo tem do sistema inteiro, restringindo-o a ver apenas conteúdos específicos.
Podemos contornar esse isolamento entrando no namespace correspondente:
Primeiro, inicie um contêiner busybox e verifique suas interfaces de rede:
[root@docker ~]# docker run --rm -it busybox /bin/sh
/ #
/ # ip a show eth0
116: eth0@if117: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
O IP do contêiner é 172.17.0.2
Agora, em um novo terminal, use o nsenter para acessar o namespace de rede do contêiner:
Encontre o PID do contêiner usando docker inspect:
[root@docker ~]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
75e821d24261 busybox "/bin/sh" 32 seconds ago Up 31 seconds great_heisenberg
[root@docker ~]# docker inspect -f '{{.State.Pid}}' 75e821d24261
3533
Use o comando nsenter --net para acessar o namespace de rede do processo com esse PID:
[root@docker ~]# nsenter --target 3533 --net
[root@docker ~]# ip a show eth0
116: eth0@if117: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
As informações obtidas são idênticas às vistas dentro do contêiner.
Isso comprova que o Docker realmente utiliza namespaces para isolamento.
Vale mencionar uma limitação dos Namespaces: o isolamento não é completo.
- Como os contêineres são apenas processos especiais no hospedeiro, múltiplos contêineres compartilham o mesmo kernel do sistema operacional.
Por isso surgem soluções como Firecracker, gVisor e Kata Containers, que não compartilham o kernel para melhorar a segurança.
- Recursos como tempo não podem ser "namespaceados". Modificar a hora dentro de um contêiner afeta o tempo do hospedeiro e, consequentemente, todos os outros contêineres.
- Limitação de Recursos com Cgroups
Ao iniciar um contêiner com docker run, podemos usar flags como --cpus ou --memory para especificar limites de CPU e memória.
Por exemplo: use --cpus=0.5 para limitar a metade de um núcleo de CPU, execute um loop infinito e verifique o uso da CPU:
[root@docker ~]# docker run -d --cpus 0.5 busybox sh -c "while true; do :; done"
d281fb2dc96cff371e9607197502c6ea3e04f4d0f3fd2ad38991c2321271736b
Verifique o uso da CPU:
[root@docker ~]# top
top - 15:38:20 up 286 days, 4:02, 3 users, load average: 0.72, 0.46, 0.36
Tasks: 96 total, 2 running, 48 sleeping, 0 stopped, 0 zombie
%Cpu(s): 52.0 us, 0.0 sy, 0.0 ni, 47.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.3 st
KiB Mem : 1006956 total, 157876 free, 201008 used, 648072 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 538972 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3763 root 20 0 4392 400 336 R 50.0 0.0 0:49.43 sh
3790 root 20 0 162112 4416 3724 R 0.3 0.4 0:00.01 top
1 root 20 0 43884 4280 2552 S 0.0 0.4 1:56.18 systemd
Como a CPU foi limitada para 0.5, o uso é de 50% (meio núcleo).
Esta é outra função essencial do Docker: a limitação de recursos baseada na tecnologia Linux Cgroups.
Linux Cgroups é um recurso do kernel Linux projetado para definir limites de recursos para grupos de processos.
O nome completo é Linux Control Groups.
Sua principal função é limitar o uso máximo de recursos por um grupo de processos, incluindo CPU, memória, disco, largura de banda de rede, etc.
No Linux, a interface do Cgroups é exposta como um sistema de arquivos, organizado em diretórios sob /sys/fs/cgroup. Podemos verificar com o comando:
mount -t cgroup
A estrutura parece com isto:
[root@docker ~]# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
Em /sys/fs/cgroup, existem vários subdiretórios chamados "subsistemas", como cpuset, cpu, memory, etc.
Eles representam os tipos de recursos que podem ser limitados pelo Cgroups.
Por exemplo, para o subsistema CPU, podemos ver os seguintes arquivos de configuração:
[root@docker ~]# ls /sys/fs/cgroup/cpu
cgroup.clone_children cgroup.sane_behavior cpuacct.stat cpuacct.usage_all cpuacct.usage_percpu_sys cpuacct.usage_sys cpu.cfs_period_us cpu.rt_period_us cpu.shares docker release_agent tasks
cgroup.procs cpuacct.usage cpuacct.usage_percpu cpuacct.usage_percpu_user cpuacct.usage_user cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat notify_on_release system.slice user.slice
Esses arquivos definem como limitar a CPU e quais processos serão limitados.
Vamos demonstrar como usar esses arquivos de configuração:
Exemplo: Limitando o uso de CPU
Primeiro, crie um diretório no subsistema correspondente. Vamos entrar no diretório /sys/fs/cgroup/cpu e criar um diretório chamado "contenedor":
[root@docker ~]# cd /sys/fs/cgroup/cpu
[root@docker cpu]# mkdir contenedor
Este diretório é um "grupo de controle". Quando criado, o sistema operacional automaticamente gera os arquivos de limitação de recursos para este subsistema:
[root@docker cpu]# ls contenedor
cgroup.clone_children cpuacct.stat cpuacct.usage_all cpuacct.usage_percpu_sys cpuacct.usage_sys cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpuacct.usage cpuacct.usage_percpu cpuacct.usage_percpu_user cpuacct.usage_user cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
Agora, vamos usar este grupo de controle para limitar o uso de CPU através de três arquivos principais:
- cpu.cfs_quota_us: Tempo de CPU que o processo pode usar em cada ciclo de controle, padrão é -1 (sem limite).
- cpu.cfs_period_us: Período de controle, padrão é 100 ms
- tasks: Lista de PIDs dos processos a serem limitados
O Cgroups limita todos os processos listados no arquivo tasks, garantindo que eles usem no máximo cpu.cfs_quota_us de CPU durante cada ciclo de cpu.cfs_period_us.
Por exemplo, 100ms permitindo apenas 20ms de uso de CPU significa 0.2 núcleo.
Primeiro, execute um script em segundo plano:
[root@docker cpu]# while : ; do : ; done &
[1] 3892
Este script executa um loop infinito, consumindo 100% da CPU. O número do processo (PID) é 3892.
Verifique o uso da CPU com o comando top:
[root@docker cpu]# top
top - 16:07:06 up 286 days, 4:31, 3 users, load average: 0.59, 0.50, 0.50
Tasks: 97 total, 2 running, 48 sleeping, 0 stopped, 0 zombie
%Cpu(s):100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 1006956 total, 166520 free, 190840 used, 649596 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 549216 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3892 root 20 0 115684 532 0 R 92.0 0.1 0:48.64 bash
O processo 3892 está consumindo quase 100% de um núcleo de CPU.
Agora, vamos limitar o uso de CPU deste processo através do Cgroups. Por padrão, o grupo de controle "contenedor" não tem limite de quota (valor -1) e o período é 100ms (100000 us):
[root@docker cpu]# cat /sys/fs/cgroup/cpu/contenedor/cpu.cfs_quota_us
-1
[root@docker cpu]# cat /sys/fs/cgroup/cpu/contenedor/cpu.cfs_period_us
100000
Vamos definir um limite de 20ms (20000 us) para a quota de CPU:
[root@docker cpu]# echo 20000 > /sys/fs/cgroup/cpu/contenedor/cpu.cfs_quota_us
[root@docker cpu]# cat /sys/fs/cgroup/cpu/contenedor/cpu.cfs_quota_us
20000
Finalmente, adicione o PID do processo ao arquivo tasks para aplicar a limitação:
[root@docker cpu]# echo 3892 > /sys/fs/cgroup/cpu/contenedor/tasks
[root@docker cpu]# cat /sys/fs/cgroup/cpu/contenedor/tasks
3892
Verifique se a limitação está funcionando:
[root@docker cpu]# top
top - 16:13:56 up 286 days, 4:38, 3 users, load average: 0.20, 0.61, 0.59
Tasks: 94 total, 2 running, 48 sleeping, 0 stopped, 0 zombie
%Cpu(s): 21.6 us, 0.0 sy, 0.0 ni, 78.1 id, 0.0 wa, 0.0 hi, 0.0 si, 0.3 st
KiB Mem : 1006956 total, 166552 free, 190808 used, 649596 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 549248 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3892 root 20 0 115684 532 0 R 19.9 0.1 5:56.94 bash
O processo 3892 agora está limitado a 20% de uso de CPU.
Cada subsistema do Cgroups tem suas próprias capacidades de limitação de recursos:
- blkio: Define limites de I/O para dispositivos de bloco, como discos;
- cpuset: Atribui núcleos de CPU e nós de memória específicos para processos;
- memory: Define limites de uso de memória para processos.
O design do Linux Cgroups é bastante prático: basicamente, é um diretório de subsistema combinado com um conjunto de arquivos de limitação de recursos.
Para projetos como Docker, basta criar um grupo de controle (um novo diretório) em cada subsistema para cada contêiner, e então adicionar o PID do processo do contêiner ao arquivo tasks correspondente.
Os valores para os arquivos de limitação são definidos pelos parâmetros do comando docker run, como:
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
Após iniciar o contêiner, podemos verificar os valores no sistema de arquivos Cgroups:
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
20000
- Segredos das Imagens de Contêiner
Esta seção explica três questões importantes:
-
- Por que modificar arquivos dentro de um contêiner não afeta o hospedeiro?
-
- Onde o sistema de arquivos do contêiner vem?
-
- Como as imagens Docker são implementadas?
Esta é a terceira função essencial do Docker: as imagens de contêiner (rootfs), que empacotam o ambiente de execução para evitar problemas de ambiente que impeçam a aplicação de rodar.
1. Sistema de Arquivos
Como é o sistema de arquivos dentro de um contêiner?
Devido ao isolamento do Mount Namespace, o sistema de arquivos de um contêiner é independente.
O Mount Namespace modifica o que o processo do contêiner "enxerga" como pontos de montagem do sistema de arquivos. Apenas após a operação de "montagem" a visão do processo é alterada. Antes disso, novos contêineres herdam diretamente os pontos de montagem do hospedeiro.
Podemos remontar todo o diretório raiz "/" antes de iniciar o processo do contêiner. Devido ao Mount Namespace, essa montagem é invisível para o hospedeiro, permitindo que o processo do contêiner opere livremente.
O comando chroot (change root file system) do Linux facilita essa operação.
O Mount Namespace foi desenvolvido a partir de melhorias contínuas no chroot e foi o primeiro Namespace do sistema operacional Linux.
Assim, a primeira questão por que modificar arquivos dentro de um contêiner não afeta o hospedeiro é respondida: devido ao isolamento via Mount Namespace.
2. rootfs
Como mencionado, o Mount Namespace modifica a visão do processo do contêiner sobre os pontos de montagem. O sistema de arquivos montado no diretório raiz do contêiner, que fornece o ambiente de execução isolado, é chamado de "imagem de contêiner". Tem um nome mais técnico: rootfs (sistema de arquivos raiz).
O rootfs contém apenas os arquivos, configurações e diretórios de um sistema operacional, mas não inclui o kernel do sistema operacional. Em sistemas Linux, essas duas partes são armazenadas separadamente. O sistema operacional carrega apenas a imagem do kernel especificada durante a inicialização.
Portanto, o rootfs inclui apenas o "corpo" do sistema operacional, sem sua "alma". Na verdade, todos os contêineres na mesma máquina compartilham o kernel do sistema operacional do hospedeiro.
Esta é uma das principais limitações dos contêineres em comparação com máquinas virtuais: estas não têm apenas uma máquina de hardware simulada como sandbox, mas também executam um sistema operacional Guest completo para a aplicação manipular.
No entanto, graças ao rootfs, os contêineres têm uma importante característica frequentemente destacada: consistência. Como o rootfs não contém apenas a aplicação, mas todo o sistema operacional com seus arquivos e diretórios, a aplicação e todas as suas dependências são encapsuladas juntas.
A segunda questão: onde o sistema de arquivos do contêiner vem? Na verdade, é empacotado quando construímos a imagem e, então, montado no diretório raiz durante a inicialização do contêiner.
3. Camadas da Imagem (Layer)
O Docker introduziu o conceito de camadas (layer) em seu design de imagens. Cada etapa na criação de uma imagem gera uma camada, ou seja, um rootfs incremental.
Através do conceito de camadas, a reutilização do rootfs é possível. Não é necessário criar um novo rootfs a cada vez; podemos modificar a partir de uma camada existente.
As camadas das imagens Docker usam uma capacidade chamada sistema de arquivos unido (Union File System). O Union File System, também chamado UnionFS, tem como função principal combinar (unir) múltiplos diretórios de diferentes locais em um único diretório de montagem.
Por exemplo, montar os diretórios A e B no diretório C fará com que C contenha todos os arquivos de A e B.
Como os diretórios A e B não são visíveis, parece que o diretório C contém todos esses arquivos por si mesmo
As imagens Docker são divididas em múltiplas camadas, e o UFS monta essas camadas em um único diretório, resultando em um sistema de arquivos completo.
Existem diferentes implementações do UnionFS em diferentes sistemas, e as distribuições do Docker usam implementações diferentes. Podemos verificar com
docker info. Implementações comuns incluem aufs (comum em Ubuntu) e overlay2 (comum em CentOS).
A estrutura se parece com isto: a montagem union está na camada superior, fornecendo uma visão unificada. Para o usuário, parece que todo o sistema tem apenas uma camada, quando na verdade há muitas camadas abaixo.
A imagem contém apenas arquivos estáticos, mas os contêineres geram dados em tempo real. Portanto, o rootfs do contêiner inclui camada de leitura/escrita e camada Init além da imagem.
Ou seja, o rootfs do contêiner inclui: camada de apenas leitura (rootfs da imagem) + camada Init (modificações de inicialização) + camada de leitura/escrita (dados gerados em tempo real).
Camada de apenas leitura (rootfs da imagem)
Esta é a base do rootfs do contêiner, ou seja, a soma de todas as camadas da imagem. Elas são montadas no modo apenas leitura (ro+wh, readonly+whiteout).
Camada de leitura/escrita (dados gerados em tempo real)
Esta é a camada superior do rootfs do contêiner, montada no modo rw (read write). Antes de qualquer operação de escrita, este diretório está vazio.
Assim que uma operação de escrita é feita no contêiner, o conteúdo modificado aparece incrementalmente nesta camada. As operações de exclusão são implementadas de forma especial (similarmente à exclusão por marcação).
A implementação do whiteout do AUFS cria arquivos ocultos correspondentes na camada gravável para realizar a exclusão.
Para implementar a exclusão, o AUFS (uma implementação do UnionFS) cria um arquivo whiteout na camada gravável para "ocultar" o arquivo da camada de apenas leitura.
Por exemplo, para excluir um arquivo chamado foo na camada de apenas leitura, a operação de exclusão cria na verdade um arquivo chamado .wh.foo na camada gravável. Quando essas camadas são montadas juntas, o arquivo foo é "ocultado" pelo arquivo .wh.foo e "desaparece".
Camada Init
É uma camada que termina com "-init", localizada entre a camada de apenas leitura e a camada de leitura/escrita. A camada Init é gerada internamente pelo Docker e é usada especificamente para armazenar informações como /etc/hosts e /etc/resolv.conf.
Por que precisamos da camada Init?
Dados como hostname originalmente fazem parte da camada da imagem. Para modificá-los, poderíamos fazer na camada de leitura/escrita, mas não gostaríamos de incluí-los ao fazer um docker commit. Por isso, usamos a camada Init para armazenar essas modificações.
Pode ser entendido como geralmente não enviarmos informações de configuração ao fazer commit de código.
O docker commit apenas envia as camadas de apenas leitura e de leitura/escrita.
A última questão: como as imagens Docker são implementadas? Através do conceito de camadas e do sistema de arquivos unido (Union File System), formando a imagem completa.
Aqui apenas abordamos o conteúdo principal das imagens. O formato específico para empacotar esses conteúdos em formato de imagem é definido pela especificação OCI.
- Conclusão
Agora entendemos que a implementação do Docker depende principalmente de três funções:
-
- Capacidade de isolamento dos Linux Namespaces
-
- Capacidade de limitação dos Linux Cgroups
-
- Sistema de arquivos baseado em rootfs
- Referências
https://draveness.me/docker/
https://en.wikipedia.org/wiki/Linux_namespaces
https://0xax.gitbooks.io/linux-insides/content/Cgroups/linux-cgroups-1.html
https://coolshell.cn/articles/17061.html
深入剖析Kubernetes