Docker e Docker Compose um guia para iniciantes.
Quando comecei a usar Docker e o Docker Compose foi fácil entender quais eram suas funções, mas encontrei dificuldade em descobrir como eu poderia aplicá-los em meus projetos. Depois de muita leitura, tentativa e erro e algumas implementações eu consegui "decifrar" os pedaços que estavam faltando e aprendi o básico para utilizá-lo.
Nesse artigo tento explicar alguns desses pedaços que, os inciantes em Docker, geralmente se confundem.
Antes de começarmos
Caso você não conheça nada sobre Docker, sugiro que você leia a documentação oficial antes e se você ainda não tem Docker e Docker Compose instalado, aqui estão alguns links para ajuda-lo:
- Docker
- Docker Compose
Dividi este artigo por partes que considero importante, sinta-se livre para pular as que você já esteja familiarizado.
Parte 01 - Dockerfile, Imagens e Containers
Geralmente o arquivo Dockerfile
é o ponto de entrada para quem começa a utilizar o Docker. Embora o guia explique bem as instruções definidas no arquivo eu não conseguia entender muito bem o que eram as imagens e o que eram os containers.
O Dockerfile
define uma imagem. E o que é uma imagem? A imagem é um conjunto de recursos responsável pela execução de um código ou uma aplicação. Geralmente a imagem que você define sempre tem outra imagem como base. Você pode pensar nela como se fosse um pacote semelhante a um módulo do node_modules
, ou um package
dentro do vendor
da sua aplicação, não é a forma mais correta de definir, mas pode ajudar a enxergar como ela funciona. Esses pacotes de recursos servem para executar alguma função dentro da sua aplicação, já as imagens servem para construir o container que você vai rodar sua aplicação.
Todo container roda em cima de uma imagem, é uma dependência direta, se tem um container sendo executado tem uma imagem por trás dele. Por exemplo, vamos supor que nós queremos executar o comando elixir -v
. Nós precisamos definir uma imagem capaz de interpretar a linguagem Elixir e depois colocar um container para executar essa imagem. Vamos criar o Dockerfile
e inserir nele o mínimo do mínimo para nosso exemplo:
FROM elixir:1.10
Agora vamos construir a imagem que estamos definindo com o comando build
(mais sobre a --tag
depois):
docker build --tag=elixir:example . # o ponto final é importante!
Pronto! Temos a imagem criada, você pode conferir ela listando as imagens no seu sistema, executando docker image ls
Agora que temos uma imagem definida, vamos criar nosso container e executar o comando que queremos:
docker run --rm elixir:example elixir -v
E pronto! A imagem que criamos foi utilizada nesse container que executou o comando elixir -v
e foi destruído após essa execução graças a flag --rm
que passamos.
"Pegadinhas"
Nosso Dockerfile
é muito muito simples e nesse caso não precisaríamos ter definido um. O mesmo resultado pode ser obtido ao executar o comando:
docker run --rm elixir:1.10 elixir -v
Agora ao invés rodar um container em cima da imagem que criamos com o docker build ...
, rodamos o container tendo como base a imagem padrão do Elixir 1.10: elixir:1.10
. Já que nossa imagem só tem como dependência a elixir:1.10
cortamos caminho e executamos o container direto com ela.
Mas vamos supor que além da linguagem Elixir instalada, precisássemos também do Git. Ai sim declaramos um Dockerfile
que reuna as duas dependências:
FROM elixir:1.10
RUN apt-get update && apt-get install git -y
Dessa forma a imagem gerada contém esses dois recursos que seriam necessários pela nossa aplicação.
E pra fechar essa primeira parte, e quanto a essa flag que utilizamos --tag
? Quando "tageamos" uma imagem estamos versionando ela, você pode fazer um paralelo com o sistema de branches do Git. Ao tagear uma imagem nós salvamos tudo o que foi instalado nessa imagem naquela versão. Por exemplo, a imagem elixir:1.10
significa que estamos utilizando uma imagem com nome Elixir e que a versão dessa imagem é a 1.10, por convenção a versão de Elixir sendo executada dentro dessa imagem também é a 1.10.
Isso se repete para todas as outras linguagens e recursos: php:7.3
, python:3.8
, ubuntu:18.04
e etc...
Parte 02 - Operando o Container
O container é a parte responsável por executar o que foi definido na sua imagem, sendo assim é recomendado evitar qualquer mudança ou edição diretamente. Caso você instale ou modifique alguma coisa no container a alteração feita fica disponível somente no próprio container e não na imagem base. Dito isso, de vez em quando precisamos debugar, modificar ou experimentar alguma coisa em nossas aplicações.
Vamos cobrir algumas operações e configurações básicas que podem ajudar você a dar aquele ajuste fino na sua aplicação e ver o que está acontecendo dentro dela.
2.1 Criando
O comando docker run
cria um container dada determinada imagem e ele ficará la até que você o remova. Se você usa a forma mais simples do comando, docker run elixir:1.10
o Docker cria um container com um nome default gerado automaticamente, executa o que está definido na imagem, ou o comando que você passou com o output do comando no terminal.
Podemos melhorar isso passando alguns parâmetros para o comando docker run
.
Dê um nome ao seu container usando a flag --name
:
docker run --name=application elixir:1.10
Isso facilita muito no manuseio, se você precisar executar um comando nesse container ou pausar, iniciar e remover ele, basta passar o nome selecionado ao invés do id. Por exemplo:
docker start application
docker stop application
docker exec application {command}
Executando o container em detached mode usando a flag -d
:
docker run --name=application -d elixir:1.10
Após a inicialização do container ele continua rodando em segundo plano, liberando o seu terminal. Provavelmente você vai usar muito essa flag. Você pode conferir o status atual do seu container com o comando docker ps
e ver que ele continua lá, rodando.
Exponha uma porta de rede de seu container para que ele possa receber tráfego com a flag --expose
.
docker run --name=application --expose 4000 -d elixir:1.10
Dessa forma o container deixa a porta 4000
exposta para receber tráfego através dela. Provavelmente você vai expor a porta que sua aplicação está ouvindo. Ou você pode vincular com uma porta no seu localhost
para que toda vez que você faça um request para essa porta, por exemplo: http://localhost:4000
esse request seja encaminhado para o seu container.
docker run --name=application -p 4000:4000 -d elixir:1.10
A flag -p 4000:4000
indica que a porta 4000
da sua máquina será encaminhada para a 4000
do container. Resumindo -p host_port:container_port
.
É importante entender a diferença entre o vinculo -p
e a exposição --expose
de portas do container. Quando expomos uma porta do container, ela não está vinculada à nossa máquina, ela apenas está disponível para o request, dessa forma ao invés de fazer o request assim: http://localhost:4000
fazemos o request assim: http://{container_ip}:4000
. Demorei um tempo para entender isso!
2.2 Gerenciando
Quando um container está rodando é corriqueiro executarmos algumas operações nele durante o desenvolvimento. As vezes precisamos ver os logs da aplicação, modificar algum comportamento, checar alguma outra coisa, enfim... Problemas que aparecem durante o desenvolvimento.
Vamos supor que criamos o container seguindo o exemplo do tópico anterior:
docker run --expose 4000 -d --name application elixir:1.10
Se quisermos ver os logs da aplicação que está sendo executada dentro do container, geralmente basta executar:
docker logs application
Executando esse comando todo o log que existe no seu container será exibido no seu terminal. Você pode aplicar a flag follow -f
, isso fará com que o terminal fique "escutando" os logs e qualquer logs novos serão exibidos na sua tela.
docker logs application -f
Melhorando um pouco mais, podemos filtrar o output inicial desse comando com a quantidade de linhas que quiser ver. Fazemos isso com a flag --tail=number
.
docker logs application -f --tail=10
Dessa forma seguimos os logs do container e será exibido somente as últimas 10 linhas.
Outra operação muito comum é o "ssh login" nos containers. Para tal operação utilizamos o seguinte comando:
docker exec -it application bash
Esse comando não faz um "ssh login". Na verdade ele vai modificar o input do seu terminal para que todos os comandos a seguir passem a rodar dentro do programa iniciado dentro do container, o bash
nesse caso. E também vincula o output do container ao seu terminal. Tudo isso é feito pelas flags -it
.
Você pode rodar comandos para o seu container sem essas flags. Algumas aplicações escrevem arquivos de logs ao invés de só exibi-los no terminal, para ler esses arquivos você poderia uilizar o docker exec
da seguinte forma:
docker exec application tail -f /path/to/log_file
Agora que temos um entendimento claro da diferença entre imagens e containers e de alguns comandos básicos, vamos falar sobre o Docker Compose.
Parte 03 - Docker Compose não é Docker
Pode parecer besteira, mas é importante ressaltar aqui que Docker Compose e Docker são coisas diferentes!
Resumindo, o Docker Compose é uma ferramenta de administração de containers.
Geralmente, no mundo de aplicações descentralizadas, os famosos Micro Services, é comum que seu negócio esteja dividido em aplicações diferentes, as vezes em linguagens de programação diferentes. Por causa dessa separação, teoricamente, você precisaria de mais de uma imagem e mais de um container para que tudo funcione.
Mesmo se sua aplicação for um monolito, a chance de você utilizar um banco de dados, web server, redis e outras tecnologias fora da sua aplicação é grande o que "força" você a dividir cada vez mais em containers e imagem menores que sejam responsáveis somente por uma tarefa.
Então, se você tem uma aplicação monolítica, que se conecta a um banco de dados, o ideal seria ter um container com sua aplicação e outra com seu banco de dados, certo? Com o que vimos até agora você poderia criar esses dois containers rodando o docker run
com especificações um pouco diferentes.
Mas vamos supor que além da aplicação e do banco de dados você também precise do Nginx. Ai você teria que criar mais um container. Percebe que começa a ficar complicado gerenciar esses containers "soltos" que estamos criando para nossa aplicação funcionar? Por causa dessa crescente complexidade você pode optar pelos seguintes cenários:
3.1 Rodar todos os containers de forma independente
Nesse cenário você sairia escrevendo docker run
no seu terminal como se não houvesse amanhã. Destruindo e modificando containers até que eles funcionem da maneira que você espera.
Funciona, mas essa abordagem não é muito prática, imagina que para subir sua aplicação você tenha que iniciar cinco containers diferentes. Lá vai você docker run ...
, docker run ...
, docker run ...
, docker run ...
e por último docker run ...
com flags e imagens diferentes em cada comando... Acho que já deu preguiça em você só de pensar no trabalho desse método.
3.2 Agregar todas as tecnologias em uma única imagem
Você poderia criar uma imagem que contenha todas as dependências do seu negócio. Parece interessante, certo?
Mas imagine o tanto de configuração necessária no seu Dockerfile
para que você rode sua aplicação, seu banco de dados, seu web server e etc. Você acabaria tendo um Dockerfile
mega extenso e difícil de manter. Seria praticamente fazer o setup disso tudo no seu próprio computador. Bem cansativo também.
Essa abordagem não é de todo o mal para ambientes de desenvolvimento ou homologação, mas imagine em um servidor de produção. Na AWS, por exemplo, você utilizar o seu banco de dados dentro de uma EC2 não parece uma boa ideia. Mas pode ser que você esteja usando Docker só em desenvolvimento, nesse caso pode ser uma abordagem válida.
3.3 Criar um docker-compose.yml
Essa provavelmente é a melhor abordagem para gerenciar vários containers dependentes. O arquivo docker-compose.yml
é onde declaramos nossas instruções e o estado que cada container deve ser criado e operado bem como a comunicação entre eles.
Esse tópico é um pouco extenso, vamos elaborar melhor ele na próxima parte deste artigo.
Parte 04 - Docker Compose
Vimos que o Docker Compose é uma ferramenta para gerenciar containers que tenham algum tipo de dependência entre si. Nós declaramos esses containers e seus parâmetros em um arquivo chamado docker-compose.yml
.
Vamos supor que em uma empresa o negócio dela esteja dividido em três partes: Uma aplicação que utiliza o Phoenix (um web framework feito com Elixir), um servidor web Nginx e um banco de dados Postgres.
Dado esse cenário, vamos criar um docker-compose.yml
capaz de definir 3 containers diferentes para que cada tecnologia tenha seu próprio ambiente e sejam capazes de conversar entre si.
4.1 Criando o docker-compose.yml
A primeira coisa que fazemos no arquivo é declarar os 3 containers que precisamos:
version: "3.7"
services:
app:
build: .
command: "mix phx.server"
nginx:
image: nginx:1.17
postgres:
image: postgres:9.5
O bloco version
mostra pra gente qual versão do docker-compose
está sendo utilizada e o bloco services
declara os 3 containers necessários: app, nginx e postgres. Cada service
é um bloco indenpendente com as próprias declarações.
No service app
temos duas declarações. A primeira é a build: .
essa declaração define que para construir esse container deve ser utilizado um Dockerfile
que deve estar no mesmo diretório que o arquivo docker-compose.yml
. Por esse service representar o container da nossa aplicação, é comum que tenhamos um Dockerfile
que declara os recursos que precisamos em nossa imagem para que a aplicação rode corretamente.
Nos demais services, nginx e postgres, nós substituímos a declaração build
pela declaração image
. Dessa forma ao invés de procurar outro Dockerfile
para construir uma imagem do Nginx e outro para construir a imagem do Postgres, nós utilizamos uma imagem pronta, fornecida pelo Docker Hub que já possui essas duas tecnologias prontas para uso.
Você poderia declarar Dockerfiles
diferentes para cada service, mas em nosso caso não é necessário, já que as imagens nginx:1-17
e a postgres:9.5
já tem tudo o que precisamos para as respectivas tecnologias.
E a ultima declaração do app
é a command
. Nela nós colocamos o valor mix phx.server
, que é um comando do Phoenix responsável por inicializá-lo. Assim, quando o container ficar pronto, esse comando será executado e nossa aplicação é iniciada dentro dele.
Não utilizamos command
nos outros services pois as imagens que escolhemos já lidam com essa parte da inicialização do serviço dentro delas.
4.2 Melhorando os serviços
4.2.1 App
Além dos pedaços que já definimos no tópico anterior temos que nos preocupar com alguns outros aspectos da nossa aplicação e do nosso container. Precisamos expor a porta que nossa aplicação ouve, montar um "volume" que vai ligar os arquivos locais com os arquivos do container e também, por comodidade, definir um working directory para facilitar a execução de alguns comandos da nossa aplicação.
Para que todos os requisitos sejam atendidos, modificamos o bloco app
da seguinte forma:
app:
build: .
container_name: "app"
volumes:
- ./:/var/www/application/
working_dir: "/var/www/application/"
expose:
- "4000"
command: "mix phx.server"
Como vimos anteriormente expose
expõe a porta 4000
do container para que ela receba tráfego. working_dir
define que o path que passamos à ela, /var/www/application/
daqui pra frente seja o diretório padrão onde os comandos serão executados. Então quando o container for executar o mix phx.server
ou qualquer outro comando passado à ele, ele vai executar no diretório /var/www/application/
.
O container_name
é basicamente isso, estamos definindo um nome para nosso container.
E por últimos temos o volume
, essa declaração faz uma "ligação" com o diretório da sua máquina (host) com o diretório do container. Então, nós estamos "ligando" o diretório em que o docker-compose.yml
está ./
com o diretório /var/www/application/
dentro do container. Dessa forma todos os arquivos que tem em um diretório aparecerá no outro.
4.2.2 Nginx e Postgres
Para concluir a definição do nosso docker-compose.yml
vamos complementar os serviços restantes:
nginx:
image: nginx:1.17
container_name: "nginx"
volumes:
- ./nginx:/etc/nginx/conf.d
ports:t
- "80:80"
Adicionamos o volume
, que colocará os arquivos de configuração do Nginx dentro do caminho correto do container. E por último adicionamos o port
que vai vincular o nosso localhost
ao container do nginx
. Dessa forma, sempre que fazemos um request http://localhost
, ele será encaminhado para o container do nginx
que, por sua vez, encaminhará o request para o container app
, conforme dita as configurações nos arquivos dentro da pasta./nginx
.
E por último, completamos a definição do Postgres
postgres:
image: postgres:9.5
container_name: "postgres"
volumes:
- ./postgres:/var/lib/postgres
environment:
- POSTGRES_DB=your_db
- POSTGRES_USER=your_user
- POSTGRES_PASSWORD=your_secret
ports:
- "5432:5432"
A única descrição nova aqui é a environment
. Com ela você adiciona env variables ao seu container. As três variáveis que adicionamos servem para o Postgres configurar um banco de dados, usuário e senha.
Vinculamos as ports
do Postgres com o host para facilitar a conexão ao container do banco de dados. Dessa forma, se você possui algum programa para gerenciar banco de dados, por exemplo, facilita a conexão com esse container.
4.3 Completando o docker-compose.yml
Se você seguiu esse guia até agora, a versão final do seu docker-compose.yml
será assim:
version: "3.7"
services:
app:
build: .
container_name: "app"
volumes:
- ./:/var/www/application/
working_dir: "/var/www/application/"
expose:
- "4000"
command: "mix phx.server"
nginx:
image: nginx:1.17
container_name: "nginx"
volumes:
- ./nginx:/etc/nginx
ports:
- "80:80"
postgres:
image: postgres:9.5
container_name: "postgres"
volumes:
- ./postgres:/var/lib/postgres
environment:
- POSTGRES_DB=your_db
- POSTGRES_USER=your_user
- POSTGRES_PASSWORD=your_secret
ports:
- "5432:5432"
Agora que temos o nosso arquivo do docker-compose
configurado, vamos ver como utilizá-lo!
Parte 05 - Usando o Docker Compose
No tópico anterior definimos um docker-compose.yml
com três containers para nossa aplicação ser executada, porém para que tudo funcione nesse momento, vamos fazer pequenas alterações na declaração do nosso serviço app
:
app:
image: elixir:1.10
container_name: "app"
volumes:
- ./:/var/www/application/
working_dir: "/var/www/application/"
expose:
- "4000"
tty: true
Como não temos uma aplicação Phoenix pronta para rodar o comando mix phx.server
não vai funcionar e nosso container não conseguirá subir.
Substituímos por um container criado a partir de uma image padrão de Elixir elixir:1.10
e trocamos a declaração command
por tty
. A declaração tty
faz com que o container fique em execução em segundo plano mesmo se nenhum comando for executado, o que é justamente o que queremos.
Outra modificação que precisamos fazer é remover o volume
do serviço nginx
. Como não temos aplicação rodando, não precisamos configurar o Nginx para rotear o tráfego para o container desejado.
Resumindo, o novo docker-compose.yml
fica assim:
version: "3.7"
services:
app:
image: elixir:1.10
container_name: "app"
volumes:
- ./:/var/www/application/
working_dir: "/var/www/application/"
expose:
- "4000"
tty: true
nginx:
image: nginx:1.17
container_name: "nginx"
ports:
- "80:80"
postgres:
image: postgres:9.5
container_name: "postgres"
volumes:
- ./postgres:/var/lib/postgres
environment:
- POSTGRES_DB=your_db
- POSTGRES_USER=your_user
- POSTGRES_PASSWORD=your_secret
ports:
- "5432:5432"
Feito isso podemos colocar todos esses containers para rodar com o comando
docker-compose up -d
O Docker vai validar nosso arquivo yml
e se tudo estiver ok vai começar a procurar as imagens que nossos containers precisam para serem executados. Aqui nós também adicionamos a flag -d
para que nosso terminal seja liberado após a conclusão desse comando.
A primeira vez que você executa esse comando em um projeto pode demorar um pouco. O Docker precisa preparar todas as imagens que esses containers utilizarão e dependendo da sua internet ou do seu computador essa etapa pode levar alguns minutos.
Feito isso o Docker começa a criar os containers na ordem e inicializá-los. E se tudo correu bem você pode listar os containers criados com docker ps
. O output desse comando mostra todos os containers que estão em execução, adiciona a flag -a
para ver todos os containers que existe na sua máquina.
Deixo abaixo uma lista com os comandos mais corriqueiros do Docker Compose:
Parando todos os containers:
docker-compose stop
Removendo todos os containers:
docker-compose rm -f
Vendo o log de todos os containers:
docker-compose logs -f
Construir os containers sem inicializá-los:
docker-compose build
Vale ressaltar que os containers criados pelo comando docker-compose
são "comuns", sendo assim os outros comandos de Docker também são válidos. Você pode até parar e retomar a execução de um container em específico.
Parte 06 - Interação entre Containers
Teoricamente, todos os containers que criamos no tópico anterior fazem parte da mesma aplicação e por isso precisam se comunicar. Quando criamos containers utilizando o docker-compose
, além dos containers, outras entidades são criadas. Uma das mais importantes é a Network.
Na versão 2.x
todos os containers de todos os arquivos docker-compose.yml
que eram criados se juntavam em uma mesma network
com nome default
. Na versão 3.x
esse comportamento mudou, de modo que cada arquivo docker-compose.yml
tem sua própria network
. O docker-compose
nomeia a network
do arquivo docker-compose.yml
utilizando o nome do diretório concatenado com a string _default
.
O tópico de network
do Docker é muito extenso para esse artigo, então vou deixar ele para um post dedicado. O que você precisa saber aqui é que na versão 3.x
do docker-compose
a network
é definida por arquivos docker-compose.yml
e um container de fora desse arquivo, por padrão, não se comunica com os que estão definidos nele. É possível conectá-lo à network
, mas não está no escopo deste artigo.
Enfim, todos os nossos containers fazem parte da mesma network
e por isso a comunicação entre eles é muito fácil. O Docker facilita nossa vida fornecendo um "DNS" para os containers da mesma rede. Vamos utilizar o container do Postgres como exemplo. Para a nossa aplicação usar o container do postgres, tudo o que precisamos fazer é colocar o nome do container no host do nosso banco de dados na aplicação. Simples assim!
Não se esqueça que outros parâmetros de rede também são importantes. Certifique-se de que a porta correta esteja exposta (expose
) no container desejado e que o request está sendo enviado para aquela porta!
Conclusão
Espero ter ajudado você na compreensão dessa ótima ferramenta de desenvolvimento! Lidar com o Docker pode ser complicado no começo, mas é uma excelente ferramenta para cuidar de um pedaço muito importante da aplicação que geralmente deixamos passar: A Infraestrutura!
Eu acho que o Docker lida com esse lado de nosso trabalho de forma primorosa, possibilitando e facilitando muito do nosso dia-a-dia! Por isso, é uma ferramenta que vale muito a pena!
Obrigado pelo seu tempo e até a próxima 🖖
Top comments (8)
Excelente artigo Leoni, muito completo, didático e muito bem estruturado.
Parabéns ;)
Ótimo Post. Eu estou tentando montar um volume compartilhando um arquivo especifico em um container pelo docker-compose.yml. Porem o mesmo sempre e criado dentro do container como se fosse uma pasta e não um arquivo , sendo assim ele não executa, alguém já passou por isso, ou pode me dizer o que faço de errado, segue abaixo a linha onde monto o volume :
o resultado apos montar o container :
Opa @marciovred, eu acho que pode ser porque tem a '/' na frente do seu caminho do shellscript. Tenta trocar por:
Boas ... não sei se estou escrevendo no lugar certo, mas vou descrever o que preciso.
como criar uma imagem docker tipo VMware ... reinicio o servidor e a imagem única com toda configuração feita de uma única vez.
cada vez que subo a imagem o BD some ... os dados somem ela volta vazia.
sem contar que não carrega automático tem logar no servidor e dar RUM -IT pra imagem subir.
simplificando VMware continua sendo muito melhor ou se puder me ajuda a entender, porque ate o momento não entendi nada, melhor não vi nenhum proposito real.
Valeu pelo post Leoni, me ajudou bastante a entender em como trabalhar com as portas.
Fico feliz em ajudar @lucianoortizsilva :)
Parabéns Leoni, e obrigado por disponibilizar sua experiência conosco!
Obrigado pelo conteúdo!
Parabéns!