Práticas recomendadas para trabalhar com contêineres

Last reviewed 2023-02-28 UTC

Neste artigo, há um conjunto de práticas recomendadas para facilitar o trabalho com contêineres. Essas práticas abrangem vários tópicos, incluindo segurança, monitoramento e geração de registros. O objetivo é facilitar a execução de aplicativos no Google Kubernetes Engine e em contêineres em geral. Muitas das práticas discutidas aqui foram inspiradas na metodologia 12 fatores, que é um ótimo recurso para criar aplicativos nativos da nuvem.

Essas práticas recomendadas não são de igual importância. Por exemplo, você pode executar com sucesso uma carga de trabalho de produção sem algumas delas, mas outras são fundamentais. Em especial, a importância das práticas recomendadas em relação à segurança é subjetiva. A implementação delas depende do ambiente e das restrições.

Para aproveitar ao máximo este artigo, você precisa ter conhecimento sobre o Docker e o Kubernetes. Algumas práticas recomendadas discutidas aqui também se aplicam a contêineres do Windows, mas a maioria presume que você esteja trabalhando com contêineres do Linux. Para orientações sobre como criar contêineres, consulte Práticas recomendadas para criar contêineres.

Usar mecanismos de geração de registro nativos de contêineres

Importância: ALTA

Como parte do gerenciamento de aplicativos, os registros têm informações valiosas sobre os eventos que ocorrem no aplicativo. O Docker e o Kubernetes se empenham para facilitar o gerenciamento de registros.

Em um servidor clássico, provavelmente é necessário gravar seus registros em um arquivo específico e processar a rotação de registros para evitar que os discos fiquem cheios. Se tiver um sistema de geração de registros avançado, você poderá encaminhar esses registros a um servidor remoto para centralizá-los.

Os contêineres oferecem uma maneira fácil e padronizada de lidar com registros, porque é possível gravá-los em stdout e em stderr. O Docker captura essas linhas de registro e permite acessá-las usando o comando docker logs. Como desenvolvedor de aplicativos, você não precisa implantar mecanismos avançados de geração de registros. Em vez disso, use os mecanismos nativos.

O operador da plataforma precisa fornecer um sistema para centralizar os registros e torná-los pesquisáveis. No GKE, esse serviço é fornecido pelo Fluent Bit e pelo Cloud Logging. Dependendo da versão do mestre do cluster do GKE, fluentd ou fluentbit são usados para coletar registros. A partir do GKE 1.17, os registros são coletados usando um agente baseado em fluidos. Os clusters do GKE que usam versões anteriores ao GKE 1.17 usam um agente baseado em fluentd. Em outras distribuições do Kubernetes, métodos comuns incluem o uso de uma pilha EFK (Elasticsearch, Fluentd, Kibana).

Diagrama de um sistema clássico de gerenciamento de registro no Kubernetes.
Figura 1. Diagrama de um sistema típico de gerenciamento de registros no Kubernetes

Registros JSON

Na verdade, a maioria dos sistemas de gerenciamento de registros são bancos de dados de séries temporais que armazenam documentos indexados por tempo. Em geral, esses documentos podem ser fornecidos no formato JSON. No Cloud Logging e no EFK, uma única linha de registro é armazenada como um documento com alguns metadados (informações sobre pod, contêiner, nó e assim por diante).

Você pode aproveitar esse comportamento gerando o registro diretamente no formato JSON com diferentes campos. Pesquise seus registros com mais eficiência com base nesses campos.

Por exemplo, pense em transformar o seguinte registro em formato JSON:

[2018-01-01 01:01:01] foo - WARNING - foo.bar - There is something wrong.

Veja aqui o registro transformado:

{
  "date": "2018-01-01 01:01:01",
  "component": "foo",
  "subcomponent": "foo.bar",
  "level": "WARNING",
  "message": "There is something wrong."
}

Essa transformação permite pesquisar com facilidade seus registros para todos registros em nível WARNING ou todos os registros do subcomponente foo.bar.

Se decidir gravar registros formatados em JSON, esteja ciente de que é necessário gravar cada evento em uma única linha para que sejam analisados corretamente. Na realidade, ele se parece com o seguinte:

{"date":"2018-01-01 01:01:01","component":"foo","subcomponent":"foo.bar","level": "WARNING","message": "There is something wrong."}

Como você pode ver, o resultado é muito menos legível do que a linha normal de um registro. Se decidir usar esse método, verifique se suas equipes não dependem muito da inspeção manual de registros.

Padrão de arquivo secundário do agregador de registro

Não é fácil configurar alguns aplicativos, como o Tomcat, para gravar registros em stdout e stderr. Como esses aplicativos gravam em diferentes arquivos de registro no disco, a maneira ideal de manipulá-los no Kubernetes é usar o padrão de arquivo secundário para criação de registro. Os arquivos secundários são pequenos contêineres executados no mesmo pod que seu aplicativo. Para mais detalhes sobre arquivos secundários, consulte a documentação oficial do Kubernetes.

Nesta solução, adicione um agente de geração de registros em um contêiner de arquivo secundário ao seu aplicativo (no mesmo pod) e compartilhe um volume emptyDir entre os dois contêineres, conforme mostrado neste exemplo YAML no GitHub. Em seguida, configure o aplicativo para gravar seus registros no volume compartilhado e configure o agente de geração de registro para lê-los e encaminhá-los onde necessário.

Nesse padrão, como você não está usando os mecanismos de geração de registro nativos do Docker e do Kubernetes, é necessário lidar com a rotação de registros. Se o agente de geração de registros não suportar a rotação de registros, outro contêiner de arquivo secundário no mesmo pod poderá operar a rotação.

Padrão do arquivo secundário para gerenciamento de registros.
Figura 2. Padrão do arquivo secundário para gerenciamento de registros

Certifique-se de que seus contêineres estejam sem estado e imutáveis

Importância: ALTA

Se você estiver testando contêineres pela primeira vez, não os trate como servidores tradicionais. Por exemplo, você pode querer atualizar seu aplicativo dentro de um contêiner em execução ou aplicar patch em algum quando surgirem vulnerabilidades.

Os contêineres não são projetados para funcionar dessa maneira. Eles foram criados para serem sem estado e imutáveis.

Sem estado

Sem estado significa que qualquer estado (dados persistentes de qualquer tipo) é armazenado fora de um contêiner. Esse armazenamento externo pode assumir várias formas, dependendo do que você precisa:

  • Para armazenar arquivos, recomendamos o uso de um armazenamento de objetos, como o Cloud Storage.
  • Para armazenar informações como sessões de usuários, recomendamos o uso de um armazenamento de valor-chave externo de baixa latência, como Redis ou Memcached.
  • Para o armazenamento no nível de bloco, como para bancos de dados, use um disco externo conectado ao contêiner. No caso do GKE, recomendamos o uso de discos permanentes.

Com essas opções, é possível remover os dados do próprio contêiner, o que significa que ele pode ser encerrado e destruído de maneira correta e a qualquer momento sem receio de perder dados. Se um novo contêiner for criado para substituir o antigo, basta conectar o novo contêiner ao mesmo armazenamento de dados ou vinculá-lo ao mesmo disco.

Imutabilidade

Imutabilidade significa que um contêiner não será modificado durante sua vida útil: sem atualizações, sem aplicação de patches e sem alterações de configuração. Se precisar atualizar o código do aplicativo ou aplicar um patch, crie uma nova imagem e implante-a novamente. Essa imutabilidade torna as implantações mais seguras e mais repetíveis. Se precisar fazer alguma reversão, basta implantar a imagem antiga novamente. Essa abordagem permite que você implante a mesma imagem de contêiner em cada um de seus ambientes, tornando-os o mais idênticos possível.

Para usar a mesma imagem de contêiner em diferentes ambientes, recomendamos que você externalize a configuração do contêiner (porta de escuta, opções de tempo de execução e outros). Em geral, os contêineres são configurados com variáveis de ambiente ou arquivos de configuração ativados em um caminho específico. No Kubernetes, use Secrets e ConfigMaps para injetar configurações em contêineres, como variáveis de ambiente ou arquivos.

Para atualizar uma configuração, implante um novo contêiner, com base na mesma imagem, usando a configuração atualizada.

Exemplo de como atualizar a configuração de uma implantação usando um ConfigMap ativado como um arquivo de configuração nos pods.
Figura 3. Exemplo de como atualizar a configuração de uma implantação usando um ConfigMap ativado como um arquivo de configuração nos pods

A combinação de ser sem estado e imutável é um dos argumentos de venda de infraestruturas baseadas em contêineres. Essa combinação permite automatizar implantações e aumentar a frequência e a confiabilidade delas.

Evitar contêineres privilegiados

Importância: ALTA

Em uma máquina virtual ou em um servidor bare-metal, evite executar seus aplicativos com o usuário root por um motivo simples: se o aplicativo for comprometido, o invasor terá acesso total ao servidor. Pelo mesmo motivo, evite usar contêineres privilegiados. Contêiner privilegiado é um que tem acesso a todos os dispositivos da máquina host, ignorando quase todos os recursos de segurança dos contêineres.

Se acreditar que precisa usar contêineres privilegiados, pense nestas alternativas:

  • Forneça recursos específicos ao contêiner pela opção securityContext do Kubernetes ou da sinalização --cap-add do Docker. A documentação do Docker lista os recursos habilitados por padrão e os que precisam ser habilitados de maneira explícita.
  • Se o aplicativo precisar modificar as configurações do host para ser executado, faça isso em um contêiner de arquivo secundário ou em um contêiner init. Ao contrário do seu aplicativo, esses contêineres não precisam ser expostos a tráfego interno ou externo, tornando-os mais isolados.
  • Se precisar modificar o sysctls no Kubernetes, use a anotação dedicada.

É possível proibir contêineres privilegiados no Kubernetes usando o Controlador de políticas. No cluster do Kubernetes, não é possível criar pods que violem as políticas configuradas usando o Policy Controller.

Facilitar o monitoramento do aplicativo

Importância: ALTA

Assim como a geração de registros, o monitoramento é uma parte importante do gerenciamento de aplicativos. De muitas maneiras, o monitoramento de aplicativos em contêineres segue os mesmos princípios dos que não estão em contêineres. No entanto, como as infraestruturas em contêiner tendem a ser altamente dinâmicas, com contêineres que são criados ou excluídos com frequência, não é possível reconfigurar seu sistema de monitoramento sempre que isso acontece.

É possível distinguir duas classes principais de monitoramento: de caixa preta e de caixa branca. O monitoramento de caixa preta refere-se à análise de seu aplicativo pelo lado de fora, como se você fosse um usuário final. Ele será útil se o serviço final que você quer oferecer estiver disponível e funcionando. Por ser externo à infraestrutura, o monitoramento de caixa preta não é diferente entre infraestruturas tradicionais e em contêineres.

O monitoramento de caixa branca refere-se à análise de seu aplicativo com algum tipo de acesso privilegiado e à coleta de métricas sobre seu comportamento que um usuário final não pode ver. Como esse monitoramento precisa examinar as camadas mais profundas de sua infraestrutura, ele é muito diferente para infraestruturas tradicionais e em contêiner.

Uma opção bem conhecida na comunidade do Kubernetes para monitoramento de caixa branca é o Prometheus, um sistema que pode descobrir automaticamente os pods a serem monitorados. O Prometheus copia os pods de métricas. Ele espera um formato específico para eles. O Google Cloud oferece o Google Cloud Managed Service para Prometheus, um serviço que permite monitorar e criar alertas globais sobre suas cargas de trabalho sem precisar gerenciar e operar manualmente o Prometheus em escala. Por padrão, o Google Cloud Managed Service para Prometheus é configurado para coletar métricas do sistema de clusters do GKE e enviá-las ao Cloud Monitoring. Para mais informações, consulte Observabilidade do GKE.

Para usar o Prometheus ou o Monitoring, seus aplicativos precisam expor métricas. Os dois métodos a seguir mostram isso.

Endpoint HTTP de métricas

O endpoint HTTP de métricas funciona de maneira semelhante aos que serão mencionados posteriormente em expor a integridade de seu aplicativo. Ele expõe as métricas internas do aplicativo, geralmente em URI /metrics. Uma resposta tem esta aparência:

http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"}    3
http_requests_total{method="get",code="200"} 10892
http_requests_total{method="get",code="400"}    97

Neste exemplo, http_requests_total é a métrica, method e code são os rótulos, e o número mais à direita é o valor dessa métrica para esses rótulos. Desde a inicialização, o aplicativo respondeu a uma solicitação HTTP GET 97 vezes com o código de erro 400.

A geração desse ponto de extremidade HTTP é facilitada pelas bibliotecas de cliente do Prometheus que existem em várias linguagens. O OpenCensus também pode exportar métricas usando esse formato, entre muitos outros recursos. Não exponha esse endpoint à Internet pública.

Esse assunto é abordado em detalhes na documentação oficial do Prometheus (conteúdo em inglês). Leia também o Capítulo 6 da Engenharia de confiabilidade do site para saber mais sobre o monitoramento de caixa branca (e caixa preta).

Padrão de arquivo secundário para monitoramento

Nem todos os aplicativos podem ser instrumentados com um endpoint HTTP /metrics. Para manter o monitoramento padronizado, recomendamos usar o padrão de arquivo secundário para exportar as métricas no formato correto.

A seção Padrão de arquivo secundário do agregador de registro explicou como usar um contêiner de arquivo secundário para gerenciar os registros do aplicativo. É possível usar o mesmo padrão de monitoramento. O contêiner do arquivo secundário hospeda um agente de monitoramento que traduz as métricas à medida que são expostas pelo aplicativo a um formato e protocolo que o sistema de monitoramento global entende.

Pense em um exemplo concreto: aplicativos Java e JMX (Java Management Extensions). Muitos em Java expõem métricas usando JMX. Em vez de reescrever um aplicativo para expor métricas no formato do Prometheus, aproveite o jmx_exporter. O jmx_exporter reúne métricas de um aplicativo pelo JMX e as expõe por meio de um endpoint /metrics que o Prometheus consiga ler. Essa abordagem também tem a vantagem de limitar a exposição do endpoint JMX, que pode ser usado para modificar as configurações do aplicativo.

Padrão de arquivo secundário para monitoramento
Figura 4. Padrão de arquivo secundário para monitoramento

Expor a integridade de seu aplicativo

Importância: MÉDIA

Para facilitar seu gerenciamento na produção, o aplicativo precisa comunicar seu estado ao sistema em geral. O aplicativo está em execução? É íntegro? Está pronto para receber tráfego? Como ele está se comportando?

O Kubernetes tem dois tipos de verificações de integridade: sondagens de atividade e de prontidão. Conforme descrito nesta seção, cada um tem um uso específico. Você pode implementá-los de várias maneiras (incluindo a execução de um comando dentro do contêiner ou a verificação de uma porta TCP), mas o método preferido é usar os pontos de extremidade HTTP descritos nesta prática recomendada. Para mais informações sobre este tópico, consulte a documentação do Kubernetes.

Sondagem de ativação

A maneira recomendada de implantar a sondagem de atividade é para seu aplicativo expor um endpoint HTTP /healthz. Ao receber uma solicitação nesse endpoint, o aplicativo precisará enviar a resposta "200 OK" se for considerado íntegro. No Kubernetes, "íntegro" significa que o contêiner não precisa ser eliminado ou reiniciado. A integridade varia de um aplicativo para outro, mas geralmente significa o seguinte:

  • O aplicativo está em execução.
  • Suas principais dependências são atendidas (por exemplo, é possível acessar seu banco de dados).

Sondagem de preparo

A maneira recomendada de implantar a sonda de atividade é para que seu aplicativo exponha um endpoint HTTP /ready. Ao receber uma solicitação nesse endpoint, o aplicativo precisará enviar a resposta "200 OK" se estiver pronto para receber tráfego. Isso quer dizer o seguinte:

  • O aplicativo é íntegro.
  • Quaisquer etapas potenciais de inicialização foram concluídas.
  • Qualquer solicitação válida enviada ao aplicativo não resulta em erro.

O Kubernetes usa a sondagem de prontidão para orquestrar a implantação de seu aplicativo. Se você atualizar uma implantação, o Kubernetes fará uma atualização contínua dos pods pertencentes a essa implantação. A política padrão é atualizar um pod por vez. O Kubernetes espera o novo pod ficar pronto, conforme indicado pela sonda de atividade, antes de atualizar o próximo.

Evitar a execução com acesso root

Importância: MÉDIA

Os contêineres oferecem isolamento. Com configurações padrão, um processo dentro de um contêiner Docker não pode acessar informações da máquina host ou de outros contêineres colocados. No entanto, como os contêineres compartilham o kernel da máquina host, o isolamento não é tão completo como é com máquinas virtuais, conforme explica o post do blog. Um invasor pode encontrar vulnerabilidades ainda desconhecidas (no Docker ou no próprio kernel do Linux) que permitiriam a ele escapar do contêiner. Se o invasor encontrar uma vulnerabilidade e seu processo estiver sendo executado com acesso root dentro do contêiner, ele terá acesso root à máquina host.

À esquerda: máquinas virtuais usam hardware virtualizado.
À direita: aplicativos em contêineres usam o kernel do host.
Figura 5. À esquerda, máquinas virtuais usam hardware virtualizado. À direita: os aplicativos em contêineres usam o kernel do host.

Para evitar essa possibilidade, é uma prática recomendada não executar processos como raiz dentro de contêineres. É possível aplicar esse comportamento no Kubernetes usando o Policy Controller. Ao criar um pod no Kubernetes, use a opção runAsUser para especificar o usuário do Linux que está executando o processo. Essa abordagem modifica a instrução USER do Dockerfile.

Na realidade, existem desafios. Muitos pacotes de software conhecidos têm o processo principal deles executado como raiz. Se quiser evitar a execução como raiz, crie seu contêiner para que ele possa ser executado com um usuário desconhecido e sem privilégios. Em geral, essa prática significa que você precisa ajustar as permissões em várias pastas. Quando você segue a prática recomendada de um único aplicativo por contêiner e executa um único aplicativo com um único usuário, preferencialmente sem acesso root, é possível conceder a todos os usuários permissões de gravação para as pastas e arquivos que precisam ser gravados e tornar todas as outras pastas e arquivos graváveis apenas por acesso root.

Uma maneira simples de verificar se o contêiner está em conformidade com essa prática recomendada é executá-lo no local com um usuário aleatório e testar se ele funciona corretamente. Substitua [YOUR_CONTAINER] pelo nome do contêiner.

docker run --user $((RANDOM+1)) [YOUR_CONTAINER]

Se o contêiner precisar de um volume externo, configure a opção fsGroup Kubernetes para conceder a propriedade desse volume a um grupo específico do Linux. Essa configuração soluciona o problema da propriedade de arquivos externos.

Se o processo for executado por um usuário sem privilégios, ele não poderá se vincular a portas abaixo da 1024. Geralmente isso não é problema, porque você pode configurar os Serviços do Kubernetes para rotear o tráfego de uma porta para outra. Por exemplo, configure um servidor HTTP para ligar-se à porta 8080 e redirecionar o tráfego da porta 80 com um Serviço Kubernetes.

Escolher com cuidado a versão da imagem

Importância: MÉDIA

Quando você usa uma imagem do Docker, seja como uma imagem de base em um Dockerfile ou como uma imagem implantada no Kubernetes, escolha a tag da imagem que está sendo usada.

A maioria das imagens públicas e privadas segue um sistema de marcação semelhante ao descrito em Práticas recomendadas para criar contêineres. Se a imagem usar um sistema próximo ao Controle de versões semântico, pense em alguns detalhes de marcação.

Mais importante, a tag "mais recente" pode ser movida com frequência de imagem para imagem. A consequência é que não é possível confiar nessa tag para versões previsíveis ou reproduzíveis. Por exemplo, veja o Dockerfile a seguir:

FROM debian:latest
RUN apt-get -y update && \ apt-get -y install nginx

Se você criar uma imagem a partir desse Dockerfile duas vezes, em diferentes momentos, é possível ficar com duas versões diferentes do Debian e do NGINX. Em vez disso, examine esta versão revisada:

FROM debian:11.6
RUN apt-get -y update && \ apt-get -y install nginx

Ao usar uma tag mais precisa, você garante que a imagem resultante sempre será baseada em uma versão secundária específica do Debian. Como a versão específica do Debian também é fornecida com uma versão específica do NGINX, você tem muito mais controle sobre a imagem que está sendo criada.

Esse resultado ocorre não só no momento da criação, mas também no tempo de execução. Se referenciar a tag "latest" em um manifesto do Kubernetes, você não terá garantia da versão que o Kubernetes usará. Diferentes nós do cluster podem usar a mesma tag "latest" em momentos diferentes. Se a tag foi atualizada em um ponto entre os pulls, será possível ter nós diferentes executando imagens diferentes (todas marcadas como "latest" em um ponto).

O ideal é que você sempre use uma tag imutável na sua linha FROM. Essa tag permite que você tenha versões que possam ser reproduzidas. No entanto, existem algumas vantagens e desvantagens de segurança: quanto mais você fixar a versão que quer usar, menos automatizados serão os patches de segurança em suas imagens. Se a imagem usada estiver com o devido controle de versões semântico, a versão do patch (ou seja, o "Z" em "X.Y.Z") não poderá ter alterações incompatíveis com versões anteriores. É possível usar a tag "X.Y" e receber correções de bugs automaticamente.

Imagine um software chamado "SuperSoft". Suponha que o processo de segurança para o SuperSoft corrija as vulnerabilidades por meio de uma nova versão do patch. Você quer personalizar o SuperSoft e escreveu o seguinte Dockerfile:

FROM supersoft:1.2.3
RUN a-command

Após um tempo, o fornecedor descobre uma vulnerabilidade e lança a versão 1.2.4 do SuperSoft para corrigir o problema. Nesse caso, é sua responsabilidade manter-se informado sobre os patches da SuperSoft e atualizar seu Dockerfile de acordo. Em vez disso, se você usar FROM supersoft:1.2 em seu Dockerfile, a nova versão será extraída automaticamente.

No final, você precisa examinar cuidadosamente o sistema de tag de cada imagem externa que está usando, decidir o quanto você confia nas pessoas que criam essas imagens e decidir qual tag será utilizada.

Próximas etapas

Confira arquiteturas de referência, diagramas e práticas recomendadas do Google Cloud. Confira o Centro de arquitetura do Cloud.