Fundamentos do Docker#
Assume-se que quem ler essa documentação é familiarizado com os conceitos básicos de Docker[1].
Antes de começar a executar comandos é preciso ter Docker instalado no sistema. Há documentação oficial[2] sobre instalação.
Note
Esse guia/tutorial é um tipo de ‘comece a mexer com docker rápido’. Ele não substitui e nem tenta substituir pesquisa mais aprofundada sobre Docker para quem tem interesse em aprender essa tecnologia.
A motivação para criar esse guia foi o fato de alguns projetos do C3SL
utilizarem Docker mas quem realmente tinha algum conhecimento era uma única
pessoa, e por conta disso já ocorreram algumas instâncias de precisar mexer
no Dockerfile
‘pra ontem’ mas ninguém ter o conhecimento básico para fazer
isso, pois quem sabia saiu do projeto (por exemplo). Esse guia visa no mínimo
atenuar esse problema.
Pré-requisitos#
A ideia para esse guia era ter o mínimo de pré-requisitos possível, para que quem precisar aprender a mexer com Docker no próprio projeto pudesse começar rapidamente. Entretanto é impossível negar que alguns conhecimentos específicos facilitariam o acompanhamento desse guia.
Segue a lista de conhecimentos que são interessantes ter:
Sistemas Operacionais: Em particular virtualização e sistemas de arquivos, facilitam a compreensão do que é Docker e volumes, respectivamente. Entender processos pode ajudar também.
Redes de Computadores: O guia assume que o leitor sabe o que é ‘porta’, e tem uma ideia básica de comunicação em rede. Compreensão do modelo TCP/IP é ideal.
Shell: Em certos momentos variáveis de ambiente são mencionadas, além disso o
Dockerfile
se assemelha muito a um script.sh
.NodeJS: Esse não é necessário, mas não faz mal. Como é comum utilizarem NodeJS nos projetos do C3SL um exemplo abaixo utiliza essa tecnologia com Docker. Não é necessário saber Node, mas saber exatamente o que o código faz apenas olhando para ele pode facilitar a compreensão do que está acontecendo.
Git: Em alguns momentos é feita analogia entre GitHub e DockerHub, não é necessário conhecer Git, apenas a analogia que não será compreendida.
Terminologia básica#
Algumas palavras vão se repetir frequentemente nesse guia:
‘host’: É a máquina que vai executar o contêiner Docker.
‘porta’: Definição de porta TCP/UDP.
‘output’: Saída padrão da execução de algum programa (neste caso de contêineres Docker).
Trabalhando com contêineres#
Utilizando imagens pré-construídas#
Vamos iniciar com prática: pegar uma imagem que já foi construída por alguém e executar um contêiner baseado nela. Para este guia foi escolhida a imagem do NodeJS[3].
Execute o comando docker run node
. A saída deve ser similar à seguinte:
root@devops:~# docker run node
Unable to find image 'node:latest' locally
latest: Pulling from library/node
7bb465c29149: Pull complete
2b9b41aaa3c5: Pull complete
49b40be4436e: Pull complete
c558fac597f8: Pull complete
449619e06fe3: Pull complete
91b364bb66eb: Pull complete
4974a440c623: Pull complete
43ff5adaa0a2: Pull complete
Digest: sha256:104b26b5d34f9907f1f1e5e51fd9e557845f1a354f07ee9f28814dd9574a6154
Status: Downloaded newer image for node:latest
root@devops:~#
Analisando o comando e o output:
Foi requisitado um contêiner em execução utilizando a imagem do Node.
O sistema não tinha nenhuma imagem do Node, então ele procurou no registry padrão: o DockerHub[4].
Cada linha que termina com ‘Pull complete’ é uma camada da imagem baixada. Veremos mais sobre camadas de imagens depois.
Após isso um novo contêiner foi criado com base nessa imagem e colocado em execução.
Há mais um detalhe: a imagem que foi baixada é node:latest
. Esse ‘latest’ é
uma tag, um identificador de versão da imagem. No caso da imagem do NodeJS as
tags da imagem são as próprias versões do Node, como 18.19
ou 21.7
.
Sempre que a tag não for especificada a versão mais recente será escolhida.
Como um exemplo vamos rodar docker run node:18.19
, especificando que queremos
a versão 18.19
do NodeJS:
root@devops:~# docker run node:18.19
Unable to find image 'node:18.19' locally
18.19: Pulling from library/node
7bb465c29149: Already exists
2b9b41aaa3c5: Already exists
49b40be4436e: Already exists
c558fac597f8: Already exists
449619e06fe3: Already exists
f52e55fee245: Pull complete
b51b5379e841: Pull complete
806ff1e3aade: Pull complete
Digest: sha256:aa329c613f0067755c0787d2a3a9802c7d95eecdb927d62b910ec1d28689882f
Status: Downloaded newer image for node:18.19
root@devops:~#
Note que as primeiras camadas da imagem aparecem como ‘Already exists’, isso
pois node:18.19
é uma versão mais antiga de node:latest
, logo a base para
ambos é a mesma, mas a versão nova muda algumas coisas.
Veremos essa questão de tags e ‘camadas’ de imagens mais à frente.
Verificando contêineres no sistema#
O comando docker ps
lista os contêineres em execução no sistema.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Mas onde estão os contêineres que executamos?
Simples, eles executaram. Uma vez que o processo principal de um contêiner finaliza ele também encerra a sua execução.
Para ver contêineres parados no sistema precisamos passar uma flag extra para
o comando docker ps
: --all
.
Em sua forma reduzida: docker ps -a
.
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
db1fae1ac24f node:18.19 "docker-entrypoint.s…" 4 seconds ago Exited (0) 2 seconds ago objective_williamson
f0c5f39f1c91 node "docker-entrypoint.s…" 9 seconds ago Exited (0) 8 seconds ago keen_shannon
root@devops:~#
Vamos analisar esse output:
Cada contêiner tem um id único, assim como um nome único.
Como não fornecemos um nome para os contêineres o Docker escolheu um nome para cada um deles.
O comando executado foi um tal
docker-entrypoint.s...
. Esse é um arquivo de entrypoint, que veremos mais à frente. Por enquanto veja esse comando apenas como um script que incia tudo o que o contêiner precisa.
Hint
Para mais opções do comando docker ps
basta executar docker ps --help
.
Executando um contêiner em modo interativo#
Legal, mas e se quisermos interagir com o contêiner? Para rodar um contêiner
interativo basta passar as flags --interactive
e --tty
para o comando.
Na forma reduzida, nosso comando vira: docker run -it node
.
Basicamente, sempre que o usuário for interagir com o contêiner as flags são
-it
, e se for apenas a shell que interage com o contêiner usa-se -i
.
root@devops:~# docker run -it node
Welcome to Node.js v21.7.0.
Type ".help" for more information.
> console.log('Hello, World!');
Hello, World!
undefined
>
Para sair da shell interativa do Node basta teclar Ctrl+D
.
Hint
Para mais opções do comando docker run
basta executar docker run --help
.
Removendo contêineres#
Se você rodar docker ps -a
de novo perceberá que o número de contêineres
parados aumentou. Para não ficar com esse lixo parado basta executar o
comando docker rm <identificador-do-container>
.
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5452a05b6ab7 node:18.19 "docker-entrypoint.s…" 6 seconds ago Exited (0) 2 seconds ago pedantic_varahamihira
00b6d2a0de3d node "docker-entrypoint.s…" About a minute ago Exited (0) About a minute ago objective_edison
root@devops:~# docker rm pedantic_varahamihira
pedantic_varahamihira
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
00b6d2a0de3d node "docker-entrypoint.s…" About a minute ago Exited (0) About a minute ago objective_edison
root@devops:~# docker rm 00b6d2a0de3d
00b6d2a0de3d
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
root@devops:~#
Note que tanto o identificador do nome quando do id do contêiner servem.
Tip
Além de que tanto faz se o comando recebe o ID ou o NOME do contêiner, se o comando receber apenas o prefixo do identificador, supondo que esse prefixo não se repete o efeito é o mesmo.
Veja um exemplo em que se faz isso utilizando o ID:
root@devops:~/node-test# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
80d9d7b2dab1 node "docker-entrypoint.s…" 2 seconds ago Exited (0) 2 seconds ago awesome_roentgen
ec38d6715864 node "docker-entrypoint.s…" 32 seconds ago Exited (0) 32 seconds ago jolly_gates
root@devops:~/node-test# docker rm 8
8
root@devops:~/node-test# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ec38d6715864 node "docker-entrypoint.s…" 41 seconds ago Exited (0) 40 seconds ago jolly_gates
root@devops:~/node-test#
Removendo contêineres automaticamente#
Para remover automaticamente um contêiner que você executou é só passar a
flag --rm
para o comando docker run
.
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
root@devops:~# docker run --rm node
root@devops:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
root@devops:~#
Lidando com imagens#
Como comentado anteriormente, imagens Docker são construídas por camadas.
Essencialmente ‘cada camada é uma linha de um script’. O que ocorre sempre que um contêiner é criado a partir de uma imagem é que ele pega o contêiner original, aplica as configurações definidas nas camadas da imagem em ordem, e
De certa forma o efeito seria o mesmo se você
Note
Como curiosidade: execute o comando docker image inspect node
.
Não veremos isso em detalhes, mas se você pesquisar pelos termos docker inspect
você encontrará diversos recursos sobre inspeção de imagens e
contêineres. Isso é extremamente útil para análise do que está realmente
sendo executado no sistema, e talvez ajude a compreender Docker em baixo
nível.
É possível ver quais imagens estão no seu sistema com o comando docker image ls
. E do mesmo modo que contêineres: para remover uma imagem basta executar
docker image rm <imagem>
, com a tag caso necessário.
root@devops:~# docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
node latest ba29744b7cd0 3 days ago 1.1GB
node 18.19 39e94893115b 3 weeks ago 1.09GB
root@devops:~# docker image rm node:18.19
Untagged: node:18.19
Untagged: node@sha256:aa329c613f0067755c0787d2a3a9802c7d95eecdb927d62b910ec1d28689882f
Deleted: sha256:39e94893115b118c1ada01fbdd42e954bb70f847933e91c3bcda278c96ad4ca2
Deleted: sha256:f4617b988ec85d56956f9c94329121c6a0435ca8a7ab9da5881704cd3da19c83
Deleted: sha256:f9a979819cd5b9ea2ee0acc379a6ec61813326bd33901ca53fccf3e15db8efbd
Deleted: sha256:82f9d9a7995035f61d6044546e6e28a5acb688b6028a2c071d3c06816b399fe1
root@devops:~# docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
node latest ba29744b7cd0 3 days ago 1.1GB
root@devops:~#
Hint
Caso você queira baixar uma imagem sem necessariamente criar um contêiner para
ela naquele momento basta executar docker pull
(versão curta de docker image pull
).
Criando imagens: o arquivo Dockerfile#
Colocando em termos extremamente simples: É possível descrever o Dockerfile
como um script que configura a máquina que vai executar a aplicação para
você do zero, o que garante um ambiente consistente, pois ela terá sempre o
mesmo estado inicial e passará pelos mesmos comandos de configuração.
Pré-requisitos#
Para acompanhar os próximos passos utilizaremos um código simples em NodeJS. Não é necessário conhecer Node, mas deixa a experiência mais tranquila.
Crie um diretório, nele colocaremos dois arquivos. O primeiro:
const express = require('express');
const fs = require('fs');
const app = express();
const FILENAME = 'texto.txt';
let counter = 0;
// Retorna o contador
app.use('/ler', (req, res) => {
console.log(`[LEITURA] contador eh ${counter}`);
const text = fs.readFileSync(FILENAME);
res.status(200).json({ texto: text, contador: counter });
});
// Escreve um texto no arquivo
app.use('/escrever', (req, res) => {
console.log(`[ESCRITA] contador foi de ${counter} para ${++counter}`);
const text = `O contador eh ${counter}`;
fs.writeFileSync(FILENAME, text);
res.status(200).json({ texto: text, contador: counter });
});
// Servidor escuta na porta 3000
app.listen(3000, () => {
fs.writeFileSync(FILENAME, 'O arquivo nao foi modificado!');
console.log('Escutando na porta 3000')
});
Em suma, o código acima cria um servidor que escuta na porta 3000, e possui duas operações: ler e escrever um arquivo específico no sistema de arquivos.
Esse código deve ser salvo em um arquivo chamado app.js
.
Além do código, o seguinte texto deve estar em outro arquivo chamado
package.json
:
{
"name": "docker-tutorial",
"version": "1.0.0",
"description": "Backend simples para brincar com docker.",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"nodejs",
"express",
"docker"
],
"author": "mvrp21@inf.ufpr.br",
"license": "GPL-3.0"
}
Pronto! Já podemos iniciar essa seção.
Executando o programa localmente#
Caso exista algum interesse em executar a aplicação localmente para comparar com a execução ‘dockarizada’ basta primeiro instalar o Node na máquina em que se quer fazer o teste. Após isso, no diretório com ambos os arquivos criados basta executar:
npm install
npm start
Após isso, em outro terminal se você executar a seguinte sequência de comandos o output deve ser o mesmo:
root@devops:~/node-test# curl -w "\n" localhost:3000/ler
{"texto":"O arquivo nao foi modificado!","contador":0}
root@devops:~/node-test# curl -w "\n" localhost:3000/escrever
{"texto":"O contador eh 1","contador":1}
root@devops:~/node-test# curl -w "\n" localhost:3000/ler
{"texto":"O contador eh 1","contador":1}
root@devops:~/node-test# curl -w "\n" localhost:3000/escrever
{"texto":"O contador eh 2","contador":2}
root@devops:~/node-test# curl -w "\n" localhost:3000/escrever
{"texto":"O contador eh 3","contador":3}
root@devops:~/node-test# curl -w "\n" localhost:3000/escrever
{"texto":"O contador eh 4","contador":4}
root@devops:~/node-test# curl -w "\n" localhost:3000/ler
{"texto":"O contador eh 4","contador":4}
root@devops:~/node-test#
O terminal acima é o que chamamos de ‘cliente’.
Note
Você pode acessar essas URLs no navegador ao invés de usar curl
no terminal.
Mas não tem tanta graça ;)
Voltando ao primeiro terminal, o output deve ser similar a esse:
root@devops:~/node-test# npm start
> docker-tutorial@1.0.0 start
> node app.js
Escutando na porta 3000
[LEITURA] contador eh 0
[ESCRITA] contador foi de 0 para 1
[LEITURA] contador eh 1
[ESCRITA] contador foi de 1 para 2
[ESCRITA] contador foi de 2 para 3
[ESCRITA] contador foi de 3 para 4
[LEITURA] contador eh 4
Esse é o terminal que chamamos de ‘servidor’.
Agora vamos ver como fazer isso executar com Docker.
Criando um Dockerfile#
Quem já conhece Node sabe que para executar o código será preciso primeiro
instalar as dependências do projeto com npm install
e depois basta
executar o servidor com npm start
.
Para isso é necessário que o npm
esteja instalado no sistema. Mas com
Docker isso não é necessário. Vamos ver como isso é configurado em um
chamado Dockerfile
:
FROM node:lts
WORKDIR /app/
COPY package.json /app/package.json
EXPOSE 3000
COPY . /app/
RUN npm install
CMD [ "npm", "start" ]
Sumarizando (veremos cada comando em detalhes à frente):
FROM node:lts
: Estamos criando nossa imagem baseada em uma que já existe, a imagemnode
com taglts
.WORKDIR /app
: Define que o diretório onde a aplicação vai ficar dentro do contêiner é o/app
.COPY package.json /app/package.json
: Copia o arquivopackage.json
do host para o contêiner, e o coloca em/app/package.json
.EXPOSE 3000
: Informa o leitor doDockerfile
que essa aplicação utiliza a porta 3000, e que ela provavelmente deve ser mapeada para o host.COPY . /app/
: Copia os demais arquivos do host para o contêiner, e os coloca em/app/
.RUN npm install
: Instala as dependências do projeto (esse comando roda dentro do contêiner).CMD [ "npm", "start" ]
: Informa que quando um contêiner for criado com base na imagem ele deve executarnpm start
para iniciar a aplicação.
Caution
A imagem escolhida é node:lts
. Para usos além de testes locais é sempre bom
mudar a tag lts
para uma versão específica da imagem.
Isso pois lts
é uma tag dinâmica. Hoje essa tag aponta para uma versão
específica (20.11.1 no momento da escrita desse parágrafo), mas conforme o
tempo passa a versão LTS (Long Term Support[5]) mudará; e não é
impossível que essa mudança quebre a aplicação.
Isso é particularmente ruim para aplicações que ficaram paradas por um tempo,
pois quando o contêiner for executado de novo ele procurará a versão lts
da
imagem, que pode não ser mais compatível com a versão da época em que o projeto
era ativo.
(Claro, o ideal é manter um projeto atualizado, sempre que a versão LTS de suas dependências mudar ele deveria acompanhar essa mudança. Infelizmente o mundo da teoria é muito mais bonito e previsível que o da prática.)
Criando e usando essa imagem#
Lembre-se: um Dockerfile define uma imagem, então usamos ele para criar uma imagem e depois criamos um contêiner com base nessa imagem.
Como criar uma imagem? Simples! Só executar o comando docker build . --tag <nome-da-imagem>
.
Vejamos um exemplo:
root@devops:/node-test# docker build . -t teste-node
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
Install the buildx component to build images with BuildKit:
https://docs.docker.com/go/buildx/
Sending build context to Docker daemon 2.65MB
Step 1/7 : FROM node:lts
---> 2e805f601f2b
Step 2/7 : WORKDIR /app/
---> Running in cd1fea97d9bd
---> Removed intermediate container cd1fea97d9bd
---> 433bbc33acff
Step 3/7 : EXPOSE 3000
---> Running in 7f753b00ccec
---> Removed intermediate container 7f753b00ccec
---> b85feba0a0d5
Step 4/7 : COPY package.json /app/package.json
---> 5b3d5e55a62e
Step 5/7 : RUN npm install
---> Running in 4323f8d14bbd
added 64 packages, and audited 65 packages in 2s
12 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
npm notice
npm notice New minor version of npm available! 10.2.4 -> 10.5.0
npm notice Changelog: <https://github.com/npm/cli/releases/tag/v10.5.0>
npm notice Run `npm install -g npm@10.5.0` to update!
npm notice
---> Removed intermediate container 4323f8d14bbd
---> d8c047dda876
Step 6/7 : COPY . /app/
---> 0d9bdde073a1
Step 7/7 : CMD [ "npm", "start" ]
---> Running in 0c796bc223a3
---> Removed intermediate container 0c796bc223a3
---> 094c7152f58d
Successfully built 094c7152f58d
Successfully tagged teste-node:latest
Note
Perceba que há um aviso ‘DEPRECATED’. No momento da escrita desse guia docker ainda suporta usar build ao invés do buildx. Eventualmente esse guia será atualizado para incluir ambas as opções (caso ambas ainda sejam válidas).
Um pouco sobre o buildx
será comentado na seção de plugins.
Vamos analisar esse output:
A mensagem
DEPRECATED
: No momento da escrita desse guia Docker está em processo de migrar seu builder padrão, não há problema com a mensagem.Sending build context to Docker daemon
:Os
STEP
s: São as camadas doDockerfile
sendo executadas, e gravadas para a imagem.Successfully built
etagged
: Build finalizou, a imagem foi criada e seu nome/tag foi atribuído normalmente.
Com isso devemos ter uma imagem local chamada teste-node
. Podemos verificar isso
com docker image ls
:
root@devops:~/node-test# docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
teste-node latest 094c7152f58d 15 minutes ago 1.11GB
root@devops:~/node-test#
Criando e usando o contêiner#
Para criar um contêiner com base em uma imagem sem executá-lo imediatamente
existe o comando docker container create <imagem>
, e sua versão curta:
docker create <imagem>
.
root@devops:~/node-test# docker create --name nome-legal teste-node
No caso acima também escolhemos um nome para o contêiner com --name
, mas
esse argumento não é necessário para o comando. Podemos ver esse contêiner
com docker ps -a
:
root@devops:~/node-test# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fa97dfc3c078 teste-node "docker-entrypoint.s…" 10 minutes ago Created nome-legal
root@devops:~/node-test#
Como colocá-lo em execução?
Qualquer contêiner que estiver parado pode ser iniciado com o comando docker start <identificador>
:
root@devops:~/node-test# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fa97dfc3c078 teste-node "docker-entrypoint.s…" 26 minutes ago Created nome-legal
root@devops:~/node-test# docker start nome-legal
nome-legal
root@devops:~/node-test# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fa97dfc3c078 teste-node "docker-entrypoint.s…" 26 minutes ago Up 1 second 3000/tcp nome-legal
root@devops:~/node-test#
Socorro! Não consigo dar Ctrl + C
!!!
Sim, por padrão seu terminal ficará preso aos logs do contêiner. Veremos logo à frente como mudar esse comportamento.
Em outro terminal você deve executar docker stop <identificador>
. Pode levar
uns segundos mas o contêiner vai parar e seu terminal será devolvido.
Fazendo como antes#
Também é possível executar contêineres do mesmo jeito de antes. Dessa vez passaremos mais argumentos para o comando pois eles ainda não foram demonstrados:
root@devops:~/node-test# docker run --rm --name meu-container teste-node
> docker-tutorial@1.0.0 start
> node app.js
Escutando na porta 3000
Note
Os argumentos que passamos a mais foram --rm
para remover automaticamente o
contêiner e --name
para que o nome do contêiner seja um criado por nós, não
um escolhido pelo Docker.
Para parar esse contêiner é só rodar docker stop <identificador>
em outro
terminal.
Mapeando portas#
Note
É esperado que o leitor tenha algum conhecimento de redes, em particular do conceito de ‘portas’, TCP e UDP idealmente.
Ainda, é interessante ter conhecimento dos comandos netstat
e ss
.
Se você é curioso e já sabe como o programa deveria funcionar, deve ter
percebido que acessar localhost:3000/ler
ou localhost:3000/escrever
não
funciona. Isso pois a porta está aberta no contêiner, mas não no host.
Na verdade, podemos ver quais portas estão sendo utilizadas no sistema com
netstat -tnlp
. A menos que outro programa esteja utilizando a porta 3000
não deve haver nenhuma linha do output com a porta 3000.
Mas e a instrução EXPOSE
que vimos antes?
A instrução EXPOSE <PORTA>
[6] não expõe de verdade as portas,
isso pois por mais que uma porta seja utilizada no contêiner o host pode querer
mapeá-la para outra.
A verdadeira razão para usar essa instrução é para informar quem estiver utilizando a imagem quais portas devem ser ‘publicadas’. Ela serve como uma espécie de documentação.
O que precisamos é passar um argumento ou flag para mapear a porta do contêiner
para o host. Essa flag é --publish <PORTA_HOST>:<PORTA_CONTAINER>
. O --publish
pode ser abreviado para -p
.
Vejamos um exemplo:
root@devops:~/node-test# docker run --rm -p 3000:3000 --name meu-container teste-node
> docker-tutorial@1.0.0 start
> node app.js
Escutando na porta 3000
Nada de diferente no output, mas se você verificar com netstat -tnlp
, em
algum lugar do output linhas similares às seguintes devem estar presentes:
tcp 0 0 0.0.0.0:3000 0.0.0.0:* LISTEN -
tcp6 0 0 :::3000 :::* LISTEN -
Por fim, basta fazer operações como anteriormente, e verifica-se que o resultado é o mesmo.
root@devops: ~/node-test# curl -w '\n' localhost:3000/ler
{"texto":"O arquivo nao foi modificado!","contador":0}
root@devops: ~/node-test#
E ou output no terminal do servidor deve ser:
Escutando na porta 3000
[LEITURA] contador eh 0
Pronto! Conseguimos criar uma imagem Docker para uma aplicação NodeJS!
Entretanto, antes de vermos como melhorar a interação com os contêineres vamos dar uma olhada em alguns detalhes que deixamos passar.
Ordenando as camadas#
Você deve ter percebido já que as camadas de um Dockerfile
são sequenciais,
executadas ‘de cima para baixo’.
Não só isso, se você já tem certa experiência com Docker ou brincou bastante
com o exemplo fornecido anteriormente deve ter percebido que nem todas as
camadas da imagem são refeitas durante um build
.
Isso é uma questão de otimização. Não é necessário refazer uma camada se as dependências dela não foram modificadas.
Mas há um porém: Toda camada depende das anteriores, portanto se você colocar
uma camada pesada, como um npm install
depois de outra que será modificada
bastante mas não fizer diferença, você pode deixar o npm install
antes.
Um exemplo disso é a ordenação COPY . /app/
e RUN npm install
. Quando se trabalha
com código os arquivos que serão copiados vão sempre mudar, pelo menos alguns, mas
nem sempre as dependências que serão instaladas foram modificadas. Portanto o ideal
para um projeto NodeJS é que COPY. /app/
seja feito depois de RUN npm install
.
Docker build cache
Para ver em detalhes como é feito o cache de camadas do Docker na hora do
build
dê uma olhada na documentação oficial[7].
ARG e ENV#
NODE_ENV
for production
no Dockerfile então haverá uma variável de ambiente
no contêner chamada NODE_ENV
que terá o valor production
.
Note
Mais à frente veremos o comando docker exec
, que permitirá interagir com um
contêiner em execução, assim poderemos verificar as variáveis de ambiente nela
e interagir com todo o sistema de arquivos utilizando uma shell como bash.
Isso nem sempre é o comportamento desejado. É possível que alguma variável de ambiente seja apenas necessária no momento da criação da imagem, para comandos executados dentro dela.
Uma solução para isso pode ser incluir a variável de ambiente na linha de comando executada:
RUN DEBIAN_FRONTEND=noninteractive apt update
...
RUN DEBIAN_FRONTEND=noninteractive apt upgrade
Entretanto isso gera duplicação caso a mesma variável precise ser utilizada
mais de uma vez. Para evitar isso existe a instrução ARG
, que essencialmente
faz a mesma coisa que a ENV
, mas a variável de ambiente não é passada para
o contêiner. O exemplo dado acima seria reescrito como:
ARG DEBIAN_FRONTEND=noninteractive
RUN apt update
...
RUN apt upgrade
Entretanto ARG
é ainda mais poderoso
See also
TODO: Passando argumentos e variáveis de ambiente por linha de comando.
COPY e RUN#
O comando COPY
copia arquivos do host para o contêiner. Seu uso avançado
permite copiar arquivos entre contêineres, que é feito geralmente em
multi-stage builds.
O comando RUN
executa algum comando na shell do contêiner. Geralmente a
shell do contêiner será Bash.
Tip
Vamos supor que você só precisa de um pequeno conjunto de seus arquivos
para executar o contêiner corretamente. Por exemplo, em um programa em C
isso poderia ser o Makefile
e alguns dos *.c
.
Não é necessário (nem recomendado) copiar mais arquivos do que o necessário,
afinal isso não é eficiente. Uma solução para isso é explicitar com o COPY
cada um dos arquivos necessários, ou diretórios se tudo neles for necessário.
Se você já tem familiaridade com o Git provavelmente conhece o arquivo
.gitignore
. Com docker existe o arquivo .dockerignore
para arquivos
não serem copiados com a instrução COPY
.
Veja mais sobre .dockerignore
na documentação oficial[8].
CMD vs ENTRYPOINT#
TODO: explicar isso aq
See also
A documentação oficial de Docker tem todas as instruções aceitas pelo
Dockerfile[9]. Uma instrução particularmente interessante é a
HEALTHCHECK
.
Vale a pena estudar a fundo como são feitas imagens para seus fins específicos.
Interagindo com contêineres#
Antes de vermos ‘volumes’ é ideal saber interagir com contêineres de forma mais eficiente e conveniente. A seguir estão algumas considerações sobre isso.
Attached vs detached#
Você já deve ter se incomodado com o fato de que após rodar docker run
o
terminal fica preso ao output do contêiner. Isso acontece pois por padrão os
contêineres são executados em modo attached
.
Note
Isso é particularmente inconveniente se o contêiner deve ser executado em um servidor, a partir de uma shell ssh.
O que fazer com o terminal aberto? Se ele for fechado o processo do contêiner será morto também!
No modo attached
o terminal ficará preso, logo precisamos apenas executar o
contêiner em modo detached
. Para isso basta passar a flag --detach
/-d
para o comando docker run
. Veja um exemplo:
root@devops: ~/node-test# docker run --detach --rm --name teste teste-node
07f74b3feb9ed7025179139bd62422970c21f127ea0a62791de0c78e716330d8
root@devops: ~/node-test# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
07f74b3feb9e teste-node "docker-entrypoint.s…" 45 seconds ago Up 11 seconds 3000/tcp teste
root@devops: ~/node-test#
Perceba que não vimos o output do contêiner, e no mesmo terminal pudemos
executar docker ps
para ver que o contêiner estava sim em execução.
See also
Para mais informações sobre a CLI do docker attach
veja a documentação
oficial.
Vendo Logs#
Mas e se quisermos ver o output do contêiner? Para isso existe o comando
docker logs <identificador>
, cujo funcionamento é similar ao comando tail
,
muito utilizado em scripts shell de Linux. Vejamos um exemplo:
root@devops: ~/node-test# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
07f74b3feb9e teste-node "docker-entrypoint.s…" 9 minutes ago Up 8 minutes 3000/tcp teste
root@devops: ~/node-test# docker logs teste
> docker-tutorial@1.0.0 start
> node app.js
Escutando na porta 3000
root@devops: ~/node-test#
E da mesma maneira que o tail
, se quisermos que o terminal fique preso ao
output basta passar a flag --follow
/-f
. É possível liberar o terminal com
Ctrl+C
sem que o contêiner pare, como pode ser visto abaixo:
root@devops ~/node-test# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
07f74b3feb9e teste-node "docker-entrypoint.s…" 11 minutes ago Up 10 minutes 3000/tcp teste
root@devops ~/node-test# docker logs -f 07
> docker-tutorial@1.0.0 start
> node app.js
Escutando na porta 3000
^CInterrupt
root@devops ~/node-test# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
07f74b3feb9e teste-node "docker-entrypoint.s…" 11 minutes ago Up 11 minutes 3000/tcp teste
root@devops ~/node-test#
Note
O ^CInterrupt
do output é o Ctrl+C
que liberou o terminal.
Utilizando docker exec
#
Um último comando interessante é o docker exec
com o qual, como o nome
indica, é possível executar comandos diretamente no contêiner. Três flags
em particular são bem relevantes para o uso com esse comando:
--detach
/-d
: Executa o comando ‘no background’, similar ao-d
dedocker run
.--interactive
/-i
: Mantém a entrada padrão aberta mesmo que o comando não seja executado em modoattached
.--tty
/-t
: Aloca um pseudo-TTY.
Note
TODO: explicar TTY?
See also
Para mais informações sobre a CLI do docker exec
veja a documentação
oficial
Disco persistente com volumes#
Caso ainda não tenha reparado, sempre que executamos um contêiner ele tem seus próprios arquivos, muito similar a uma máquina virtual. Entretanto, toda vez que o contêiner é parado todas as alterações que foram feitas no sistema de arquivos dele são perdidas.
Note
No exemplo utilizado até agora, com o projeto em NodeJS, é possível ver o
sistema de arquivos do contêiner com docker exec -it <id> bash
, que te dará
uma shell no contêiner.
Essa seção apenas introduz os conceitos de volumes e de bind mounts. Para ver seu uso detalhado recomenda-se dar uma olhada na documentação oficial para volumes[10] e para bind mounts[11].
Note
Tanto volumes quanto bind mounts podem ser utilizados em modo read-only ou read-write.
Volumes#
O jeito recomendado de lidar com dados persistentes utilizando docker é por meio de volumes, isso pois volumes são completamente gerenciados pelo Docker, enquanto bind mounts que são descritos mais à frente são dependentes do host executando o contêiner.
Para ver os volumes do sistema basta executar docker volume ls
.
Note
Você já deve ter percebido o padrão da CLI de docker. Antes vimos várias vezes
docker container ls
, depois docker image ls
, docker volume ls
e à frente
veremos também docker network ls
.
Volumes podem ser criados manualmente pela CLI com docker volume create
, e
podem ser utilizados no comando docker run
por meio da flag --volume
/-v
.
Uma grande utilidade de volumes é que eles podem ser pré-populados por um
contêiner. Em um projeto em NodeJS por exemplo, é possível utilizar essa
feature para acelerar o processo de build na parte de instalação de
dependências com npm install
ao ter o volume com os node_modules
pré-feitos
por um contêiner intermediário de build (isso se torna particularmente
importante para projetos que possuem muitas dependências, pois o build é
acelerado consideravelmente).
See also
Se o parágrafo acima interessou o leitor então pesquisar as seguintes palavras-chave é essencial para aprofundar o conhecimento nesse tópico:
docker multi-stage build;
docker volume caching;
docker node modules cache;
docker reduce build time;
Um exemplo de multi-stage build está no Dockerfile
oficial do
NextJS[12].
Bind Mounts#
Bind mounts podem ser compreendidos como ‘volumes mais simples’. Comparados a volumes bind mounts têm funcionalidade bem mais limitada, mas por sua simplicidade são frequentemente utilizados também.
Os bind mounts nada mais fazem do que montar uma árvore de diretórios do host diretamente no contêiner, enquanto os volumes são gerenciados diretamente pelo Docker.
Uma implicação do fato acima é que em sistemas Mac ou Windows o desempenho de bind mounts é um pouco pior do que o de volumes (lembre-se que Docker utiliza o kernel do host, baseado em Linux, e que em Mac e Windows uma Máquina Virtual Linux é executada para prover esse kernel), um dos fatores da preferência de volumes a bind mounts para gerenciar dados persistentes.
Note
Não é possível gerenciar bind mounts pela CLI, dada a sua natureza dinâmica de existir apenas com o contêiner em execução, e seus dados não passarem de algum diretório que já exista no host.
tmpfs mounts#
Se um contêiner gerar dados não persistentes então é possível utilizar um tmpfs mount, que armazena os arquivos diretamente em memória RAM, o que proporciona duas coisas:
Garante que os dados não sejam armazenados permanentemente;
Aumenta a performance de IO do que estiver nesse tmpfs mount, pois acessar a memória RAM é mais rápido que acessar o disco;
Uma introdução às redes#
Redes em Docker é um conteúdo relativamente avançado, pois para compreensão total dos seus conceitos é necessário ter uma base sobre Redes de Computadores que não cabe a esse guia explicar.
TLDR: Too Long Didn’t Read
Redes Docker permitem que contêineres se comuniquem diretamente.
90% das vezes será utilizada a rede padrão bridge, que permite que N contêineres nela se comuniquem utilizando seus nomes.
Por exemplo, suponha que há dois contêineres ‘API-A’ e ‘API-B’. O contêiner ‘API-A’ pode mandar uma requisição HTTP para ‘API-B’, e esse segundo receberá a requisição e retornará normalmente, do mesmo jeito que se faz uma requisição HTTP para um website, por exemplo.
Redes Docker permitem que contêineres se comuniquem diretamente. Por exemplo um contêiner de backend pode se comunicar diretamente com o contêiner de banco de dados no mesmo host, ao invés de fazer uma requisição que saia do host e volte depois e etc.
Docker permite isso pois gerencia suas próprias interfaces de rede. Se você
verificar o output de ip a
com um contêiner rodando irá se deparar com
interfaces com nomes como docker0
ou vethcee46c9@if17
. Além disso Docker
também usa iptables
no Linux para filtrar pacotes e gerência de
firewall[13].
Além disso há dezenas de outras funcionalidades que podem ser obtidas utilizando os diversos tipos de drivers de redes, como eliminar o isolamento entre o host e o contêiner, isolar completamente a rede de um contêiner, dar um endereço MAC a um contêiner, controlar por completo os endereços IPv{4,6} dele, dentre outras ações interessantes.
Tipos de redes#
Contêineres têm networking habilitado por padrão, e eles podem fazer requisições ‘para fora’ do host. Um contêiner não tem informações sobre que tipo de rede ele está utilizando, nem se quem recebe suas requisições é outro contêiner Docker ou não. Isso é transparente para o contêiner.
É possível ver as redes presentes no sistema com docker network ls
:
root@devops ~/node-test# docker network ls
NETWORK ID NAME DRIVER SCOPE
aa4e1b063bfe bridge bridge local
696da25ad0fa host host local
cb3bee259cd9 none null local
root@devops ~/node-test#
Há 6 drivers de rede distintos, cada um descrito na documentação oficial[14]. É interessante pesquisar seus usos, já que esta seção meramente introduz o conceito das redes Docker.
Docker plugins#
Para estender as funcionalidades do Docker existem plugins, os quais são instalados separadamente e permitem fazer algo a mais com Docker ou facilitam algo que já é possível.
Há inúmeros plugins para inúmeros casos de uso, vale a pena pesquisar mas é inviável descrevê-los todos neste guia.
Buildx#
Anteriormente vimos um aviso de depreciação do builder padrão. Isso pois no
momento em que esse guia estava sendo escrito a ferramenta para build do Docker
estava em fase de transição. Em versões mais novas do Docker o comando docker build
já irá utilizar automaticamente o novo buildx
, de modo transparente.
O buildx
estende as habilidades de criar imagens utilizando o builder
BuildKit.
Por que um plugin?
Por que não simplesmente substituíram o builder padrão do Docker pelo
buildx
ao invés de transformá-lo em um plugin que deve ser instalado separadamente?
Por que ainda estavam em fase de transição, e não era possível simplesmente
trocar de uma vez o build sem afetar incontáveis projetos. Entretanto no
futuro o plano é sim substituir o docker build
padrão pelo novo.
Docker Compose#
Como orquestrar múltiplos contêineres elegantemente?
Vamos supor que desejamos executar aquele mesmo contêiner com base na imagem do Dockerfile que foi criado logo acima. Seria necessára a seguinte sequência de comandos longos:
TODO: até eu to com preguiça de escrever todos
Um bom programador teria preguiça de executar esses comandos todos, e dado que a configuração para os contêineres será sempre a mesma ele provavelmente acabaria criando um script que lê um arquivo de configuração para ele e os executa corretamente.
Felizmente, já existe uma ferramenta nativa para orquestrar múltiples
contêineres com um único arquivo de configuração, com comandos curtos: docker compose
.
docker-compose
vs. docker compose
O comando docker-compose
é o jeito antigo de utilizar compose
, o modo ideal
é instalando o plugin e utilizar o comando docker compose
(com espaço ao
invés do traço).
A versão com traço é a V1, enquanto a mais nova é a V2, e chamá-las assim é menos confuso do que procurar pelo traço.
O plugin é a versão mais nova do compose
, que foi migrada para a linguagem
Go, e com isso a versão antiga foi depreciada e seu uso é fortemente
desencorajado. (A partir de Julho de 2023 V1 parou de receber updates)
Na verdade, mesmo com um único contêiner, para o caso de uso para os projetos do
C3SL, por exemplo, ainda é melhor usar compose
. Isso pois ele é mais simples
de executar, tem um arquivo de configuração descritivo e no geral facilita a
vida de quem usa. Não há por que executar 6 ou 7 comandos quando basta executar
1 (fora o fato de que o arquivo docker-compose.yml
é bem descritivo, serve
parcialmente de ‘documentação’ sobre o contêiner sendo utilizado).
Attention
O problema com isso é que geralmente uma única pessoa aprende o suficiente de Docker para fazer essa configuração inicial, e depois todos aceitam que funciona e usam, mas eventualmente quem fez e conhece Docker sai.
Um dia algum problema surge, com ele a necessidade de mexer no Dockerfile ou no docker-compose.yml, e muito tempo é perdido tentando arrumar algo que deveria facilitaria o trabalho caso todos conhecessem o básico.
Isso não é problema com Docker em si, mas sim com não conhecer uma tecnologia essencial para o projeto (o que é relativamente compreensível, se funciona e não há por que mexer ninguém irá mexer).
O arquivo docker-compose.yml
#
Com compose
ao invés de executar diversos comandos você descreve os seus
contêineres dentro de um arquivo YAML. O comando docker compose
vai ler
esse arquivo e executar os comandos adequados para o usuário, que precisará
executar apenas um comando, ao invés de vários.
Um exemplo de arquivo docker-compose.yml
que foi utilizado no projeto do
ensalamento do C3SL:
version: '3'
services:
ensalamento-postgres:
container_name: ensalamento-postgres
image: postgres:13.8
env_file:
- .env
volumes:
- ~/.local/docker-volumes/ensalamento/db:/var/lib/postgresql/data
networks:
- backend
backend:
container_name: ensalamento-backend
build: ./ensalamento-back-v2
env_file:
- .env
volumes:
- ./ensalamento-back-v2/bin/:/app/bin/:ro
- ./ensalamento-back-v2/src/:/app/src/:ro
- ./ensalamento-back-v2/volumes/:/app/volumes
ports:
- '8000:8000'
depends_on:
- ensalamento-postgres
networks:
- backend
restart: on-failure
frontend:
container_name: ensalamento-frontend
build: ./ensalamento-front-v2
env_file:
- .env
volumes:
- ./ensalamento-front-v2/src/:/app/src/:ro
- ./ensalamento-front-v2/public/:/app/public/:ro
- ./ensalamento-front-v2/swap/:/app/swap/
ports:
- '3000:3000'
links:
- backend:backend
networks:
- backend
restart: on-failure
networks:
backend:
name: ensalamento-network
Note
Essa é uma versão um pouco mais antiga do docker-compose.yml
, essa é a versão
3, mas já há a versão 3.8, por exemplo.
Há pequenas diferenças quanto às versões, em especial sobre as versões 3.x há a documentação oficial[15] que descreve brevemente as diferenças, mas a essência dos arquivos será sempre a mesma.
A maioria dos elementos do arquivo acima são reconhecíveis, então vamos analisar apenas algumas partes do arquivo:
services
: Dentro disso fica a lista de contêineres a serem executados. No caso do arquivo acima são 3 (ensalamento-backend, ensalamento-frontend e ensalamento-postgres).build
: Indica o diretório que contém oDockerfile
para o contêiner.env_file
: Arquivo que define as variáveis de ambiente, ao invés de especificarmos uma por uma como anteriormente.restart: on-failure
: Caso oCMD
do contêiner retorne um erro em algum momento ele será reiniciado automaticamente, ao invés de falhar e não retornar.depends_on
: Isso define que o contêiner depende da lista de contêineres passadas aqui, e não será iniciado até que todos dessa lista já tenham iniciado.
Ressaltando, de novo: esse guia é introdutório, para uma descrição melhor do
docker-compose.yml
é recomendado ver a documentação oficial, assim como
pesquisar samples de projetos reais para compreender como ele é utilizado.
Utilizando docker compose
#
O projeto tem um arquivo
docker-compose.yml
, e agora?
Para iniciar todos os contêineres com todas as informações descritas no arquivo
docker-compose.yml
basta executar docker compose up
no mesmo diretório
desse arquivo.
Ainda, há duas flags importantes para docker compose up
:
--build
: Executa odocker build
antes de iniciar os contêineres, reconstruindo a imagem.--detach
/-d
: Funciona do mesmo modo que com o comandodocker run
visto anteriormente.
Para parar todos os contêineres descritos no arquivo basta executar docker compose down
no mesmo diretório do docker-compose.yml
.
Note
Perceba que o docker compose
é essencialmente uma interface. Ele utiliza um
arquivo de configuração e nada mais do que executa os diversos comandos
necessários para você enquanto você apenas digita um único comando.
Por que só agora?#
Por que ver toda a parte difícil antes de
compose
sendo que ele que é realmente utilizado?
Para entender como ele funciona, por que ele existe e o que acontece por
debaixo dos panos. Além disso saber compose
não exclui a necessidade de saber
o ‘docker puro’ (às vezes deseja-se um contêiner rapidamente, para testar uma
imagem nova, para executar algum comando, para aprender, etc.).
Por exemplo, se você tem uma aplicação com vários contêineres e precisa atualizar
algo dentro de um deles, você (idealmente) não vai parar todos os outros com
docker compose down
, mas vai parar apenas o necessário com docker stop
.
Note
Isso é parcialmente uma mentira. Há como executar docker compose
para apenas
parte dos contêineres, mas a essência do comando é a mesma.
Utilizando um registry#
Frequentemente é feita a comparação de registry como o DockerHub com
plataformas git como GitHub, mas faltam alguns pedaços dessa analogia, por
exemplo: como fazer o equivalente de git push
?
Para isso existem os comandos docker login
, docker pull
e docker push
,
vale a pena pesquisar mais sobre eles.
Como não é comum utilizarem o registry do C3SL essa parte do guia ficará pendente por enquanto. Idealmente no futuro ele será mais utilizado.
Exemplo de deploy manual#
TODO: fazer o exemplo (provavelmente com o quadro de avisos digital)
Esse não é necessariamente o melhor método.
Idealmente com Docker separa-se o código da infraestrutura completamente.
Logo, um fluxo de trabalho ideal para um projeto em Docker seria realizar o
push
de sua imagem para um registry e o ambiente de deploy fazer o pull
dela. Tarefas que podem até ser realizadas por times distintos. (Claro, há
outros métodos também)
Esse tipo de automação pode ser integrada ao desenvolvimento utilizando
ferramentas como GitLabCI[16] para realizar esse deploy com
qualquer merge na branch main
, por exemplo.
Ciclo de desenvolvimento#
TODO: aproveitar e utilizar o mesmo exemplo daqui de cima
TODO: Considerações finais#
TODO: cheatsheet#
Não queria fazer isso, mas acredito que vai facilitar a vida de alguns.