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 imagem node com tag lts.

  • 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 arquivo package.json do host para o contêiner, e o coloca em /app/package.json.

  • EXPOSE 3000: Informa o leitor do Dockerfile 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 executar npm 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:

  1. 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.

  2. Sending build context to Docker daemon:

  3. Os STEPs: São as camadas do Dockerfile sendo executadas, e gravadas para a imagem.

  4. Successfully built e tagged: 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 de docker run.

  • --interactive/-i: Mantém a entrada padrão aberta mesmo que o comando não seja executado em modo attached.

  • --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 o Dockerfile 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 o CMD 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 o docker build antes de iniciar os contêineres, reconstruindo a imagem.

  • --detach/-d: Funciona do mesmo modo que com o comando docker 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.