Alô galera! Estamos de volta trazendo a segunda parte da série sobre Docker.
No primeiro post apresentamos o docker e seu funcionamento, abrangendo a arquitetura (cliente, daemon, socket, etc.), os recursos principais que compõem o docker (imagem, container e registro), além de outros componentes utilizados. No final do post apresentamos o fluxo básico das ações tomadas para rodar uma aplicação através do docker. Nos próximos posts mostraremos esse fluxo na prática, estudando os principais comandos e a utilização de cada um deles. Antes de prosseguirmos no entanto, é necessário entendermos o modo de gerenciamento de redes do docker.
Na prática um container pode ser definido como uma instância, que possui sistema operacional, processos, área de armazenamento e ambiente de rede (endereço IP, gateway, máscara de rede, etc). No entanto, sabemos que um único host pode executar diversos containers, logo, cada container deve fornecer um ambiente isolado, tanto de outros containers quanto do próprio host.
Vimos no primeiro post que o docker usa o recurso do kernel linux namespace para isolar os containers em nível de processos. Esse isolamento garante que os processos de um determinado container não interfiram nos processos de outros, ou nos processos do host. Agora veremos como o docker trata o isolamento de containers em nível de rede.
O gerenciamento de redes do docker é muito semelhante aos métodos de virtualização tradicionais como virtualbox, kvm, etc. Ele fornece tipos de rede para cada finalidade.
Assim como o gerenciamento de armazenamento, o gerenciamento de redes é controlado por driver. O docker fornece três drivers (bridge, host, e overlay), mas permite a utilização de drivers de terceiros, e a possibilidade de criarmos nossos próprios drivers de rede.
O docker possui três modos básicos de rede que são criados no momento da instalação do docker engine: bridge, host e none. É possível ainda criar redes personalizadas. No entanto, nenhuma rede padrão pode ser removida. O tipo de rede é definido pelo driver usado na sua criação. Redes personalizadas podem ser criadas usando os drivers bridge, overlay (usado na construção de redes para cluster), ou drivers de terceiros.
Como o próprio nome sugere, a rede do tipo none ignora qualquer configuração de rede no container. Isso significa que containers criados nessa rede não possuem IP, máscara ou gateway, isolando-os completamente. O que define esse comportamento é justamente a ausência de um driver de rede.
A rede do tipo host usa o driver de mesmo nome (exclusivo para essa rede). Nesse modo, o daemon configura a rede do container como uma cópia da rede do host docker. Sendo assim o container possui as mesmas configurações do host (IP, gateway, rotas, hostname, etc.).
A rede bridge é utilizada por padrão pelo docker no momento da criação de um container. Isso significa que, a menos que uma rede distinta seja especificada, um container será criado usando a rede bridge. Esse tipo de rede usa o driver bridge e interliga duas ou mais redes distintas, o que no caso do docker interliga as redes dos containers, criando uma única rede (rede docker).
No host, a instalação do docker engine cria um adaptador de rede virtual do tipo bridge chamado docker0.
Para cada container o docker cria um par de interfaces virtuais, uma no container (normalmente chamada eth0) e outra no host, que recebe um identificador único iniciado por veth (exemplo veth2afbed0). As interfaces nos container são configuradas com endereços IP e MAC, enquanto que as interfaces veth* são adicionadas a ponte docker0. O exemplo a seguir mostra o status da rede bridge após a criação de dois containers:
bridge name bridge id STP enabled interfaces docker0 8000.02429ba102ab no veth304e078 veth7264bee
O docker escolhe automaticamente um IP e uma sub-rede do range privado, definido pela RFC1918 (http://tools.ietf.org/html/rfc1918#section-3), para atribuir a interface docker0, desde que não esteja em uso por nenhuma interface do host docker. Apesar disso, é possível especificar manualmente um endereço IP e uma máscara de rede para a interface virtual, o que nos permite utilizar qualquer sub-rede.
Na maioria dos casos o docker seleciona a sub-rede 172.17.0.0/16, atribuindo o primeiro endereço (172.17.0.1) à interface docker0, que é o gateway da rede bridge. Os demais IPs do range serão distribuídos de forma dinâmica (dhcp) e sequencial, ou seja, o primeiro container criado recebe o IP 172.17.0.2, o segundo o IP 172.17.0.3, e assim sucessivamente, até que todos os endereços do range tenham sido utilizados. É possível limitar a distribuição de IPs a uma porção do range da sub-rede da interface docker0, porém, é necessário especificar manualmente a rede que será utilizada pelo daemon. Poderíamos por exemplo, especificar a rede 10.10.0.0/24 no daemon (254 endereços) e então limitar a distribuição a apenas metade desse range (10.10.0.0/25).
A figura abaixo ilustra a estrutura de rede criada pelo docker no host:
Apesar da distribuição dinâmica, os endereços IPs dos containers não são atribuídos sob concessão de tempo (default-lease-time ou max-lease-time), isso significa que o endereço de um container não precisa ser renovado a cada intervalo de tempo. Ao invés disso, ele permanece atribuído enquanto o container estiver em execução. Quando este é deletado ou interrompido (stop) seu endereço IP retorna para o range, deixando-o livre para ser atribuído a outro container. Por exemplo, ao interrompemos (ou deletamos) o container B (imagem acima), o IP 172.17.0.3 retorna para o range. Se criarmos o container E, o docker fará a atribuição desse endereço ao container. Quando recriarmos o container B, o próximo IP do range (172.17.0.6) será atribuído a ele.
Nesse caso devemos ter em mente que: se excluirmos acidentalmente um container e precisarmos recriá-lo, garantindo que ele receba o mesmo endereço IP, devemos nos certificar que nenhum outro container seja criado até que isso seja feito.
O docker utiliza um range específico de endereços MAC para atribuição sequencial nas interfaces dos containers. A capacidade desse range é igual a quantidade de endereços disponíveis no range de IPs (65mil por causa da máscara de 16 bits), e vai de 02:42:ac:11:00:00 até 02:42:ac:11:ff:ff (4^16). Cada MAC é gerado usando o endereço alocado para o container, ou seja, a interface do container com IP 172.17.0.5 recebe o MAC address 02:42:ac:11:00:05. Quanto as interfaces agregadas a ponte (veth*), o docker gera um endereço MAC aleatório para cada uma delas.
Para que haja comunicação entre a rede docker e a rede do host, o docker utiliza dois recursos do sistema linux, o ip forward e o iptables.
O ip forward é um parâmetro do kernel usado para permitir ou não o encaminhamento de pacotes entre redes. Em sistemas linux ele é controlado pelo comando sysctl (usado para modificar parâmetros do kernel linux), que define o valor 1 para habilitar o encaminhamento ou 0 para desabilitá-lo. Já o docker controla esse parâmetro através do seu daemon, que trás por padrão a função habilitada (ip_forward=1).
Veja a figura abaixo:
Com o encaminhamento habilitado, os containers terão acesso aos hosts e vice-versa. Se o encaminhamento for desabilitado (ip_forward=0), a comunicação não será feita:
Observe que o encaminhamento não afeta a comunicação entre containers pois estes fazem parte da mesma rede (rede docker).
Apesar de o daemon habilitar por padrão o parâmetro, ainda poderemos desabilitá-lo com o comando sysctl. Caso o daemon seja reiniciado o parâmetro será habilitado novamente (a menos que seja desabilitado no próprio daemon).
Outro recurso usado pelo docker é o iptables, firewall nativo do kernel linux.
Não vamos entrar nos detalhes do seu funcionamento aqui. Vamos apresentar apenas os conceitos básicos, necessários para entendermos como o docker usa esse recurso no gerenciamento de rede.
De modo geral, o iptables controla o tráfego de pacotes em um host. Esse controle é feito através de regras que definem, dentre outros parâmetros, qual ação tomar (accept, drop, reject, etc.) quando um pacote cumprir os requisitos da regra. Por exemplo, uma regra pode definir que todo pacote chegando no firewall na porta 22 (ssh) seja bloqueado (drop).
Regras são criadas em cadeias (chains), que por sua vez são organizadas em tabelas. As tabelas organizam as regras por tipo (liberação, bloqueio ou alteração de endereços e portas), enquanto as cadeias organizam as regras de acordo com a direção do pacote (entrada, saída ou encaminhamento de pacotes). Por exemplo, a regra para permitir que os hosts de uma rede efetuem ping para o host (firewall), seria criada na cadeia input da tabela filter (liberação/bloqueio de portas e endereços IPs), liberando a entrada de pacotes (accept) cujo protocolo corresponda ao icmp. Para que o firewall consiga responder essa requisição para os hosts, uma regra semelhante seria criada na mesma tabela (filter), porém na cadeia output.
Tal qual o encaminhamento de pacotes, é possível determinar se o daemon docker fará a interação com o iptables ou não. No entanto, independente do comportamento do daemon, ainda podemos manipular a aplicação através do comando iptables.
Por padrão o docker manipula suas próprias regras no iptables automaticamente (iptables habilitado no daemon). Sendo assim, não precisamos interagir diretamente com a aplicação para criar, modificar ou excluir qualquer regra referente ao docker. Além disso, a instalação do docker engine cria uma cadeia chamada DOCKER nas tabelas filter e nat, que será usada para armazenar as regras criadas pelo daemon. Ele [o daemon] também pode criar regras em outras cadeias do iptables, mas não pode modificar ou excluir qualquer regra criada manualmente nelas. Devemos considerar apenas que regras criadas manualmente na chain DOCKER serão removidas caso o daemon seja reiniciado. Esse comportamento permite uma melhor organização das regras do docker, separando-as das demais.
A comunicação da rede docker para a rede do host é feita através de NAT – técnica que reescreve o endereço IP (origem ou destino) de um pacote, modificando-o para um endereço válido na rede alvo. Essa comunicação é controlada por uma regra criada na cadeia POSTROUTING – usada no tratamento de pacotes roteados (de uma rede para outra) que deixam o host – da tabela nat.
Na figura abaixo, o container A (rede docker) está enviando uma requisição para o host A (rede host).
O pacote construído no container possui seu IP (172.17.0.2) como origem. Quando o pacote chega ao docker host, o iptables aplica a regra abaixo:
Chain POSTROUTING (policy ACCEPT) target prot opt source destination MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
Essa regra informa que pacotes com origem na rede docker (172.17.0.0/16) e qualquer destino (0.0.0.0/0) devem ser modificados, alterando o endereço de origem para o endereço IP da interface do host docker que o conecta a rede alvo. Sendo assim, o host A vai receber o pacote como se ele tivesse sido enviado do host docker.
O docker permite que containers possam se comunicar com redes externas de modo fácil e eficiente. O caminho de volta no entanto, não é tão simples.
Para que um host externo possa acessar diretamente um container, seria necessário configurarmos uma rota para esse host, definindo o endereço do host docker como gateway para a rede dos containers, visto que as redes são distintas. Apesar de ser perfeitamente possível, essa técnica não é recomendável, pois além do trabalho de adicionarmos rotas em todos os hosts da rede externa, algumas aplicações não funcionariam adequadamente atrás do NAT. A solução adotada pelo docker é o mapeamento de portas.
O mapeamento de portas consiste em definir, no momento da criação dos containers, uma porta do host docker que será mapeada para a porta usada pela aplicação no container. Dessa forma, toda requisição recebida pelo host docker na porta definida, será redirecionada para o container na porta da aplicação. Por exemplo, uma aplicação web rodando em um container expõe a porta 80. Um host externo no entanto, não consegue “enxergar” a aplicação diretamente por causa do isolamento da rede docker. Nesse caso, podemos mapear a porta do host docker 9090 para a porta 80 do container, permitindo que os hosts externos acessem a aplicação através do IP do host docker na porta 9090. Vale ressaltar que o mapeamento de portas não funciona nos modos none e host. Isso porque, como dissemos, o modo none não configura rede no container, enquanto o modo host não usa NAT, permitindo que containers sejam acessados diretamente por outros hosts da rede.
Usando a imagem anterior como base, imagine que nosso container esteja rodando a aplicação wordpress na porta 80. Definiríamos o mapeamento da porta 9090 (do host docker) para a porta 80 do container. O host A (externo) faria a requisição de acesso a aplicação no endereço 192.168.122.13:9090 (IP do host). O host docker, por sua vez, encaminharia a requisição para o container (172.17.0.2:80), permitindo assim que a aplicação possa ser acessada.
O encaminhamento feito pelo host docker consiste em alterar o endereço IP (nat) e a porta de origem do pacote, para o IP/porta do container que hospeda a aplicação. Essa alteração é feita no iptables através da regra abaixo:
Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:9090 to:172.17.0.2:80
Veja que a regra (criada automaticamente pelo docker ao iniciar o container), determina que pacotes destinados a qualquer endereço (0.0.0.0/0) na porta 9090, sofrerão DNAT (alteração do endereço de destino) para o IP do container na porta 80 (172.17.0.2:80). Isso permite que todos os host externos possam acessar a aplicação no container. No entanto, caso o seu servidor docker tenha mais de uma interface conectando várias redes, é possível limitar o acesso à redes específicas. Para isso devemos especificar, no momento da criação do container, o IP da interface do host docker que se conecta a rede desejada.
Imagine que nosso host docker está ligado a rede A (192.168.122.0/24) e a rede B (10.10.122.0/24). Os IPs das interfaces que conectam esse host as redes são 192.168.122.13 e 10.10.122.117 respectivamente. A regra anterior liberava o acesso ao wordpress para as duas redes. Se recriarmos o container especificando o endereço da segunda interface (10.10.122.117), estaremos limitando o acesso à aplicação apenas aos hosts da rede B. A regra a seguir seria criada pelo docker:
Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 10.10.122.117 tcp dpt:9090 to:172.17.0.2:80
Apesar de termos definido a porta do host docker manualmente nos exemplos anteriores (9090), é possível deixarmos o docker selecionar automaticamente uma porta alta para mapear. Esse método faz o mapeamento de todas as portas definidas na criação da imagem com o parâmetro EXPOSE (veremos construção de imagens nos próximos posts), selecionando automaticamente uma porta disponível em um range específico (normalmente de 32769 até 65534) do host docker. Por exemplo, ao criar um container a partir de uma imagem configurada para exportar as portas 80 e 443 usando essa opção, o docker fará o mapeamento das portas 32769 e 32770 respectivamente.
Fechando esse post, vamos entender como o docker implementa o serviço de resolução de nomes.
A resolução de nomes no docker pode ser feita de duas formas, dependendo do tipo de rede usado (rede padrão ou rede personalizada). Em redes default (bridge e host) a resolução é feita a partir da manipulação dos arquivos hosts e resolv.conf nos containers. Esses arquivos são exportados e mapeados em arquivos virtuais no diretório dos containers no host docker (/var/lib/docker/containers). Esse mapeamento permite que alterações feitas nos arquivos virtuais sejam imediatamente consolidadas nos containers.
O arquivo resolv.conf define o endereço IP de um servidor DNS responsável pela resolução de nomes na rede. O docker clona automaticamente o arquivo do host docker para os containers quando eles são criados (a menos que seja especificado um DNS na criação do container). Isso permite que containers possam encaminhar requisições de resolução de nomes para o servidor DNS da rede do host docker.
O conteúdo desse arquivo nos containers só pode ser alterado de duas formas: especificando um servidor de nomes ao criar um container, ou alterando o arquivo original no host docker. A alteração feita no resolv.conf do host será repassada imediatamente aos containers assim que eles forem reiniciados. Já os containers cujo DNS foi especificado na criação não sofrem com a alteração do arquivo no host. A figura a seguir ilustra esse cenário.
O container B foi criado especificando-se o dns 10.10.0.1, enquanto o container A foi criado de modo padrão. Repare que apenas o container A refletirá alterações feitas no nameserver do arquivo no host.
O arquivo hosts armazena localmente uma lista de hostnames e seus respectivos endereços IP. O docker usa esse arquivo para mapear os hostnames dos containers.
Não podemos confundir nome do container com hostname do container. O nome do container é criado automaticamente pelo docker (escolhendo-se um nome aleatório) e serve apenas para identificar de forma mais amigável um container pelo cliente (veremos esse conceito na prática). Já o hostname do container (usado para identificar o container na rede) é definido por padrão usando o identificador do container (container id), que é formado pelos doze primeiros dígitos do identificador hash da camada desse container (ex.: 4a4ce8292e34).
Apesar da atribuição padrão, o hostname do container pode ser alterado no momento de sua criação.
Ao criar um container o docker adiciona automaticamente o hostname do container e seu IP ao arquivo hosts. Esse arquivo, diferente do resolv.conf, não é um clone do original no host docker, permitindo que cada container tenha sua própria versão do arquivo. Além disso, alterações devem ser feitas diretamente nas versões virtuais mapeadas no host docker, e serão imediatamente percebidas pelos containers (não é necessário reiniciá-o). Veja a imagem abaixo:
Nesse exemplo criamos dois containers (App e Database). O docker definiu o hostname dos containers baseado em seus identificadores (4a4ce8292e34 e fc14daa8dafe), e adicionou a entrada correspondente nos seus respectivos arquivos hosts.
Imagine que estamos configurando uma aplicação no container App, que irá se conectar ao banco de dados do container Database através do seu hostname. O mapeamento do arquivo dos containers no host (/var/lib/docker/containers…) nos permite editá-los individualmente. Nesse caso, poderíamos editar o arquivo do container App no host, adicionando uma entrada para o hostname do container de banco de dados (172.17.0.10 fc14daa8dafe). Essa é apenas uma alternativa, visto que o docker possibilita efetuar essa alteração de forma mais dinâmica, passando ao daemon uma opção que faz o link entre containers.
Um link pode ser criado entre containers para permitir que a comunicação entre eles seja feita através de nomes. Ele é definido na criação do container recipiente, informando como alvo o id (ou nome) do container de origem e um alias (opcional). O docker adiciona uma entrada no arquivo hosts do container recipiente para o endereço IP, identificador (ou nome) e o alias do container de origem. Voltando ao exemplo anterior, criamos o container App (recipiente) adicionando o link para o container Database (origem). Além do identificador do container Database, definimos um alias chamado mysql. O daemon criou o arquivo hosts no container recipiente como informado abaixo:
Dessa forma podemos configurar nossa aplicação para se conectar ao banco através do hostname do container (fc14daa8dafe) ou do alias (mysql). Lembre-se que o hostname dos containers aqui apresentados retratam a criação de um container de modo simples, sem definir um hostname específico. Se for especificado, a entrada no arquivo hosts (além dos arquivos hostname) do container refletirá o nome definido na criação.
Diferente das redes default, o docker implementa um DNS incorporado para redes personalizadas. Containers criados nesse tipo de rede tem como servidor DNS o IP 127.0.0.11, o que significa que a resolução de nomes será encaminhada para o docker. Dessa forma não precisamos mais manipular os arquivos hosts e resolv.conf diretamente. Ao invés disso podemos passar opções específicas ao daemon, que farão a interação com o DNS incorporado.
Uma das principais vantagens no uso dessa técnica é que o docker inclui no DNS o mapeamento dos nomes personalizados dos containers para os seus respectivos IPs. Por exemplo, ao criar o container A em uma rede personalizada chamada my-net, definimos seu nome como myapp (não confundir com hostname do container). O docker então, adiciona o registro no DNS incorporado. Dessa forma, qualquer container na rede my-net poderá se comunicar com o container A através do seu nome (my-app).
Bom pessoal, por enquanto é só. No próximo post vamos ao hands on. Colocaremos em prática o conteúdo até aqui mencionado, estudando o fluxo de trabalho ao efetuar o deploy de uma aplicação. Até a próxima e não esqueça de deixar seu comentário e/ou sugestão.