Trabalhando com Containers, Pt. 1
Quando se começa a trabalhar com containers e ler as documentações como a de docker, há uma série de vantagens anunciadas: portabilidade (buildar localmente, fazer o deploy em qualquer lugar), segurança (divisão de namespaces, isolamento de processos e recursos), deploys determinísticos, acoplamento fraco e várias outras coisas.
Essas vantagens são anunciadas como “out-of-the-box”, ou seja, não requerendo nenhuma configuração ou trabalho extra da parte do usuário, mas os operadores da plataforma continuam sendo humanos, o que torna essas vantagens não realmente “out-of-the-box”, por que seres humanos erram e fazem besteira.
Com o passar do tempo (a primeira versão pública de docker saiu em 2013), foi sendo publicado tanto falhas como jeitos melhores de trabalhar com containers, orquestrados ou não, para evitar erros humanos no processo. Essa série de posts tem como objetivo compartilhar alguns conceitos que eu acho importante ao trabalhar com containers (docker, mais especificamente), tanto na parte do desenvolvimento, segurança e operação.
Deploys Determinísticos
Uma das maiores vantagens (na minha visão) de usar containers é a de ter deploys determinísticos, ou seja, dado uma versão de uma certa imagem, se tem a certeza de que não importa onde vai ser feito o deploy, containers idênticos vão estar rodando: tanto as aplicações quanto o sistema do mesmo. Porém, isso SE elas apontarem para a mesma versão, ou no caso de docker para a mesma tag da imagem, que é o método mais comum de se referir a uma versão de uma determinada imagem.
Acontece que uma tag não é mais que um ponteiro para apontar para o que realmente representa aquela versão da imagem, uma hash sha:256, chamada de digest, que é a representação imutável daquela versão da imagem PARA SEMPRE. Isso por que essa hash é gerada a partir de um cálculo baseado no conteúdo da imagem no momento que o docker build
foi executado, ou seja, se tiver qualquer alteração em um documento, isso irá gerar uma imagem com um digest diferente, já a tag é especificada pelo usuário, sendo o objetivo dela ser uma representação mais human-friendly daquela versão da imagem.
Logo, o push dessa tag pro registry de imagens docker (docker push lucastercas/node:13.14.0
) vai sobrescrever a tag antiga com uma nova versão da imagem. Mas note que para alguém de fora a imagem ainda vai estar com a mesma tag. Para saber que a imagem mudou, ele teria que comparar o digest antigo com o novo, que é feito com o comando docker inspect lucastercas/node:13.14.0
, mas vendo somente pela tag, a imagem ainda vai ser igual.
Para se ter certeza que a versão em produção nunca vai mudar, pode fazer duas coisas: ou nunca dar pull do registry, o que não é recomendado por que assim ele também vai perder as atualizações, ou referenciar a imagem pelo digest dela, por exemplo a node:13
no momento da escrita desse post: docker pull node@sha256:70d4fffcab39a1f9f7161d58e674ddcc56c7f0724196b68d52a87bab15cb4a04
Porém, como o digest é uma hash, como pode ser visto acima, não é muito comum usar ele para fazer o deploy, uma das razões sendo que a tag pode conter informações importantes sobre a imagem, que é útil para quem está a utilizando: node:14
, node:14.12.0
, node:14.12.0-buster
, node:14.12.0-alpine3.12
. Contendo a versão do node, o sistema operacional da imagem, a versão do sistema operacional da image.
Por isso que o recomendado quando for usar docker para rodar aplicações em produção, é ser o mais específico possível: usar node:14.12.0-buster
, que especifica a versão major, minor e patch e o OS da imagem, invés de somente node:14
, ou pior ainda: node:latest
ou node
, nesses casos não se sabe nem a versão do node que está sendo usada.
Se algum dia os responsáveis por manter essa imagem atualizarem para uma versão que pode quebrar sua aplicação por problemas de retrocompatibilidade, esse não é um problema tão fácil de debugar, por que no caso de usar node:latest
por exemplo, não se sabe nem a versão do node que estava sendo usada, nem a nova versão que está sendo usada, que foi a que quebrou a aplicação. O problema piora quando se introduz orquestradores como kubernetes na arquitetura, daí pode acontecer de certos nós estarem com a versão da imagem que funciona, e outros nós estarem com a versão da imagem quebrada, mas a tag ser a mesma, tornando ainda mais difícil identificar o problema.
Usuários e Permissões
É sempre uma boa prática em qualquer ambiente não executar os programas com usuário root, o aconselhável é usar um usuário com as mínimas permissões possíveis para conseguir fazer as tarefas que esse usuário necessita.
Ao rodar aplicações com docker não seria diferente. Por mais que a plataforma provenha uma isolação de recursos, processos, etc, tem algumas coisas que ela não isola: a rede em que o container está rodando (possibilitando descobrir serviços que deveriam estar escondidos), os arquivos ou variáveis de configuração do container, o código da aplicação, se o usuário tiver permissões: instalar pacotes, baixar arquivos da internet, etc.
Por isso é sempre bom criar um usuário com permissões minimas e com acesso somente a comandos e arquivos necessários. O Dockerfile
abaixo exemplifica como fazer isso:
FROM debian:buster
USER root
RUN apt-get update && apt-get install -y cowsay
USER 1001:1001
ENTRYPOINT [ "cowsay" ]
CMD [ "Hello World!" ]
E ao fazer uma nova imagem baseada nessa e quiser instalar mais pacotes, é necessário somente mudar para o usuário root, fazer as alterações necessárias, e mudar de volta para o usuário com menos permissões.