Volumes

Até então aprendemos sobre imagens, containers, e como lidar com ambos. Na aula de hoje, iremos falar sobre volumes.

O que é um Volume?

Volumes são uma funcionalidade voltada a parte de armazenamento do Docker. Existem dois tipos de volumes principais:

  • Volumes nomeados
  • Bind Mount

Volumes nomeados

Os volumes nomeados são os volumes propriamente ditos, eles são um mecanismo de armazenamento persistente gereciados pelo Docker para manter dados que precisam sobreviver ao ciclo de vida do container.

Esse é um assunto que fica mais fácil de entender quando chegarmos nas partes mais avançadas do Docker, então não se preocupe se parecer meio confuso no momento.

Hoje vamos só abordar os volumes nomeados bem por cima, mas não se preocupe pois retornaremos a esse tópico no futuro.

Pensando num cenário de exemplo: quando um container está ligado, criamos um arquivo dentro dele, e depois desligamos. Ao ligar o container novamento, o arquivo continua ali.

Se nós removermos o container (docker rm), ao criar um container novo, o arquivo que existia no outro container não vai estar mais ali, afinal, aquele container não existe mais.

Apesar disso, tem dados que queremos que sejam agnósticos ao container, por exemplo os do nosso banco de dados.

Não queremos que ao remover um container isso também apague o nosso banco de dados inteiro, o ciclo de vida deles é diferente do ciclo de vida do container.

A solução para isso é justamente a criação de um volume nomeado:

docker volume create db_data

O docker volume create é o comando e o db_data o nome do volume que iremos criar.

Para usar ele, nós adicionamos a flag -v ao run:

docker run -v db_data:/var/lib/postgresql/data minha-imagem-postgre

O -v conecta o endereço no lado do nosso computador, que nesse caso é o volume nomeado criado anteriormente, com o endereço de algo no lado do container, que é a pasta onde o postgre salva os dados do banco de dados fisicamente no disco.

Bind Mount

O Bind Mount não é exatamente um volume tecnicamente falando, ele é um tipo de montagem do Docker, que é como ele agrupa os conceitos voltados ao armazenamento.

Então você tem montagens de volume, que ai sim se referem a volumes, e montagens de bind, que se referem a conectar um endereço no seu computador com um endereço no container. Eles não são gerenciados pelo docker, e nem aparecem na listagem dele (docker volume ls).

Eles são úteis para conectar o nosso ambiente de desenvolvimento com os arquivos do container.

Por exemplo, se nós quisessemos modificar nosso index.js para:

const http = require('http');

const port = 3000
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello, Docker! Modificado');
})

server.listen(
  port,
  () => console.log(`Server running at http://localhost:${port}/`)
);

Essa mudança não iria se refletir no container, pois o container é criado a partir da imagem, e a imagem é estática. Os arquivos que estão dentro dela são os arquivos que nós copiamos durante o build.

Então a imagem é como um pen drive que recebe os arquivos durante o build, depois nós removemos do computador, e usamos ela para passar esses arquivos em novos computadores.

Não adianta mudar o código no computador original, pois a cópia nos novos computadores é feita usando os arquivos do pen drive.

Se quisessemos usar a aplicação atualizada, precisariamos deletar tudo dentro do pen drive, voltar para o nosso computador, e copiar ("buildar") os arquivos novos de novo para dentro do pen drive.

No Docker é a mesma coisa, nossa imagem ainda é a mesma de quado criamos ela nas últimas aulas. Então para alterar no Docker, precisaríamos deletar os containers ativos via docker rm, então remover a imagem com docker rmi, e só depois rodar o docker build de novo para então criar um container atualizado com docker run.

Esse processo é bem maçante, se para trabalhar com Docker nós sempre precisassemos disso, provavelmente não utilizariamos ele para ambiente de desenvolvimento, afinal seria um processo bem incômodo.

Seria muito bom se pudéssemos alterar o nosso código e essas alterações já aparecerem dentro do container, certo? Bem, é justamente para isso que podemos usar um bind mount.

Com o bind mount, podemos conectar a pasta do projeto no nosso computador com a pasta do projeto no container.

Assim, mesmo que a imagem tenha o código desatualizado, quando o container subir, ele vai fazer o bind mount e copiar os arquivos atuais do seu computador para o container, que vai rodar com os arquivos atualizados.

O bind mount é chamado de volume também, mesmo não sendo, pois a flag para usar tanto o volume quanto o bind mount é a mesma, o -v:

docker run -v ./:/app -d --rm --name=api my-node-api

Aqui o importante para nós é o -v ./:/app.

O -v é a flag que faz o bind.

O ./ é o caminho do que vai ser copiado do nosso computador.

E o /app é o endereço que vamos copiar os arquivos lá dentro do container, que nesse caso é /app pois foi o que definimos como workdir lá no Dockerfile.

Os : servem para separar o endereço do nosso computador (que vem antes), com o endereço no container (que vem depois).

Observação

Aqui a diferença entre container e imagem fica ainda mais clara. O bind mount funciona, pois ele copia os arquivos da sua máquina para o container, isso sobrescreve os arquivos da imagem, ou seja, as alterações acontecem no container.

Então vai funcionar no container que fez o bind mount, se você criar outro container a partir daquela imagem, se não fizer o bind mount, ele vai criar usando o código original de quando a imagem foi criada.

Isso é importante pois quando você faz o deploy para produção, não vai poder fazer o bind mount, afinal, a máquina que vai rodar o Docker em produção não é a sua, mas sim o servidor, que não tem acesso ao seu código.

Por isso, por mais que localmente o Docker esteja sincronizando tudo via o bind mount.

Como o que está rodando em produção, é um container novo criado a partir da imagem que você enviou para o servidor de produção, ele vai executar o código original justamente pois o que mudou foi o container e não a imagem.

Para garantir que em produção o código está atualizado, é necessário fazer o build de uma nova imagem por mais que isso não seja necessário durante a fase de desenvolvimento.

Com isso, o truque está feito, agora quando atualizarmos o código na nossa máquina, essa mudança irá ser refletida no container. Um log semelhante a esse deve aparecer se executarmos docker logs api:

> hello-node-docker@1.0.0 dev
> node --watch src/index.js

Server running at http://localhost:3000/

Na próxima aula vamos descobrir a última peça para fazer isso funcionar 100% pois se você tentar abrir a url no navegador, isso não vai funcionar ainda, um erro irá aparecer, e vamos nos aprofundar no porquê na aula sobre portas.

Apesar disso, se alterarmos o console.log do server.listen no código, o --watch que registramos no package.json deve fazer o seu trabalho e atualizar o servidor.

Executando o docker logs api novamente e podemos confirmar que a mudança foi refletida:


> hello-node-docker@1.0.0 dev
> node --watch src/index.js

Server running at http://localhost:3000/
Change detected in '/app/src/index.js'
Restarting 'src/index.js'
Server edited and running at http://localhost:3000/

Para usuários do Windows

Se não estiver funcionando para você, isso é um bug do Docker no Windows para quem usa o Docker Deskop. O problema ocorre pois o Docker não consegue sincronizar os sistemas de eventos do Windows e do Linux, então o bind mount funciona, mas o Docker não sabe que o código atualizou para executar a atualização do --watch do Node.

A solução nesse caso seria usar o Docker sempre pelo WSL, pois nesse caso ambos os ambientes seriam Linux resolvendo a incompatibilidade.

Volumes anônimos

Os dois principais tipos de volumes são o nomeado e o bind mount, mas ainda existe um que vale a pena mencionar que são os volumes anônimos.

Esses são volumes reais iguais aos nomeados, só que como o nome implica, eles não tem nome. Quando listamos eles, os nomes que aparecem são hashes gerados automaticamente pelo Docker.

A diferença é só que não precisamos criar eles primeiro com os comandos de volume, para só depois fazer a conexão por meio do -v, podemos declarar eles direto no docker run -v, e o docker vai criar esse volume sem nome automaticamente na criação do container.

Isso é útil para uma prática comum que é o shadowing.

Nosso projeto utiliza Node, normalmente, projetos desse tipo precisam instalar dependências, gerando a famosa pasta node_modules.

Com o bind mount tudo parece funcionar bem, podemos instalar a dependência, por exemplo o Express via npm i express, os arquivos vão ser baixados na nossa node_modules, e sincronizados na node_modules do container.

Podemos até atualizar o código do projeto para ver isso funcionando:

const express = require('express');

const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.json({
 message: "Hello Express"
  })
})

app.listen(
  port,
  () => console.log(`Server running at http://localhost:${port}/`)
);

O problema é que algumas dependências como o sharp precisam compilar binários específicos para o sistema operacional do usuário durante a sua instalação.

Outras como o typescript podem criar links simbólicos na sua node_modules dentro de .bin que é o que permite rodar os comandos no terminal por exemplo o tsc.

Essas são situações onde são gerados arquivos que serão problemáticos quando utilizados entre sistemas operacionais diferentes.

Um binário do Windows não vai funcionar no container do Docker, que é um Linux. Da mesma forma, links simbólicos podem acabar apresentando erros de permissão ou comportamentos inesperados nesse cenário.

Uma forma de resolver esse problema é você ter a sua node_modules e o container a dele. Quando você atualiza a sua, isso não deveria ser sincronizado no container e vice e versa.

Aqui que entra o conceito de shadowing, podemos declarar vários -v no mesmo comando. Então podemos criar nosso container assim:

docker run -v ./:/app -v /app/node_modules -d --rm my-node-api

A única diferença para o que fizemos antes é o novo -v /app/node_modules, isso só informa o caminho dentro do container. Nesse caso, o Docker entende que queremos criar um volume anônimo, e fazer o bind desse volume com o caminho do container, como se fosse:

-v a1b2c3d4e5f6:/app/node_modules

Como tem dois binds afetando o mesmo caminho, o bind que veio por último sobrescreve os anteriores.

Dessa forma, o primeiro bind, que é um bind mount, simplesmente copia o projeto inteiro para o container toda vez.

Ai o segundo, se referindo ao volume anônimo, copia o conteúdo dele (que era a node_modules do container) por cima da nossa node_modules que tinha sido copiada antes.

Isso preserva a node_modules do container "sobrepondo" (shadowing) a nossa.

A desvantagem é que para instalar uma dependência no projeto precisamos parar o container, instalar a dependência, e depois criar ele de novo. Ao invés de instalar e esperar tudo se sincronizar sozinho, mas é um preço pequeno a se pagar para evitar a dor de cabeça com erros de compatibilidade.

Um detalhe sobre a gestão de volumes anônimos

Não tivemos que nos preocupar com a gestão dos volumes anônimos pois usamos a flag --rm na criação do container. Isso garante que quando o container é destruido automaticamente, o volume anônimo morre junto.

Entretanto, se destruirmos o container manualmente, o volume não vai ser destruido junto. Seria necessário o comando de remover o container seguido do comando de remover o volume pelo hash dele.

Isso pode gerar o caso de esquecermos de remover o volume junto com o container, e ter vários volumes anônimos órfãos ocupando espaço sem motivo no computador.

Nesse caso, podemos nos livrar deles por meio do comando docker volume prune que vai remover todos os volumes anônimos do seu computador.

Considerações finais

Com isso nós aprendemos como lidar com os volumes, quais são os seus principais tipos, e gerenciar seu ciclo de vida. Na semana que vem vamos aprender sobre redes e portas no Docker.

Fica ligado, assista a versão em vídeo no Youtube, curta, comente se tiver alguma dúvida, e compartilhe para aquele seu amigo que também está interessado no Docker.

Até a próxima.