Alura > Cursos de DevOps > Cursos de Builds > Conteúdos de Builds > Primeiras aulas do curso Docker: construindo imagens para produção

Docker: construindo imagens para produção

Geração de imagens - Apresentação

Boas-vindas à Alura, sou Vinicius Dias e este curso será sobre integração contínua com Docker.

Audiodescrição: Vinicius é um homem branco, cabelo curto e escuro, bigode e cavanhaque. Utiliza uma camiseta preta e, atrás de si, há uma parede branca iluminada com luzes de tonalidades rosa e roxa.

Vamos conhecer um pouco mais sobre esse mundo de integração contínua, principalmente em ambientes que utilizam Docker. Ou seja, entenderemos como incluir a construção de uma imagem Docker na nossa esteira de integração contínua. Essa imagem precisa estar pronta para um ambiente de produção.

Aprenderemos bastante sobre construção de imagens, como garantir que essa construção seja focada num ambiente de produção, como diminuir o tamanho das imagens geradas, e como automatizar a geração de imagens, tratando inclusive do Docker Hub.

Ao final do curso, quando um Pull Request (PR) for mesclado à main, teremos uma imagem latest sendo atualizada e uma nova imagem sendo criada para manter um histórico. Vamos conversar sobre o motivo dessa abordagem e entender todo o processo mas, basicamente, teremos uma imagem Docker sendo construída de forma automática em GitHub Actions.

Para isso, é necessário um bom conhecimento em Docker, além de assuntos vistos em cursos anteriores de integração contínua com GitHub Actions. Também é interessante saber bem de Git e ter familiaridade com o terminal.

Caso haja alguma dúvida no processo, não hesite em abrir um tópico no nosso fórum ou, então, acessar nosso servidor do Discord. Lá, as discussões são um pouco mais dinâmicas. Te convidamos também para, além de fazer perguntas, responder as dúvidas de outras pessoas, pois isso é uma ótima forma de fixar o conhecimento.

Começaremos a criar uma imagem Docker adiante. Até lá.

Geração de imagens - Criação do Dockerfile

Começaremos a colocar a mão na massa utilizando o Docker para além do ambiente de desenvolvimento, um objetivo a mais na nossa integração contínua, evoluindo na infraestrutura!

Temos um serviço do banco de dados no docker-compose.yml utilizado no ambiente de desenvolvimento para subirmos a aplicação. Conforme conversamos anteriormente, num cenário mais real, num ambiente de produção, teremos um banco de dados "de pé", isto é, rodando, e poderíamos ter um banco de dados somente para testes.

Deixaremos o banco de dados da forma como está atualmente por realmente só ser usado no nosso docker-compose.yml para o ambiente de desenvolvimento. Vamos focar no nosso serviço, que vai virar um contêiner de aplicação, isto é, o que realmente estamos executando.

Estamos utilizando uma imagem que possui o compilador de Go, com a qual criamos um volume. Isto porque enviamos o nosso código para dentro do container, para ser compilado e executado. Porém, em um ambiente de execução de containers, quando colocamos essa aplicação para rodar utilizando uma imagem para criar containers, não teremos o nosso código lá, e sim esta imagem dela.

Precisamos, então, que uma imagem Docker esteja previamente pronta, sem necessitar de um volume ou detalhes externos. Assim, nosso código, ou então o executável, já que ele foi compilado, precisa estar na imagem a ser utilizada em produção.

O mesmo deve ser considerado em relação ao working_dir (, working directory, ou "diretório de trabalho"), em que a execução acontecerá. Além disso, estamos expondo uma porta de acordo com o banco de dados, algo que também não será necessário. E então estamos definindo algumas variáveis de ambiente em environment.

Dito isso, não podemos depender de volumes para a imagem a ser utilizada em produção. Portanto, por enquanto teremos não o código, e sim somente o executável. Neste caso, teoricamente nem precisaríamos depender da imagem de golang, mas por ora manteremos assim, adiante pensaremos em outras alternativas.

Na raiz do projeto, criaremos o "Dockerfile". Ele vai ser FROM golang na mesma versão utilizada no docker-compose.yml, "1.22". Mais uma vez, é possível conversarmos sobre essa imagem base mais para frente.

Já que em docker-compose.yml estamos expondo uma porta 8080, então repetiremos esta informação para efeito de documentação, uma vez que o EXPOSE não possui efeito muito prático. Se quisermos expor a porta para o host na criação de contêineres a partir dessa imagem, precisaremos informar, seja no docker-compose.yml via ports, seja pela linha de comando com o parâmetro -p. Assim, o EXPOSE serve muito mais para fins documentais de qual porta a imagem que cria contêineres expõe.

Trabalharemos na pasta /app, de acordo com o working directory. E copiaremos de host o arquivo já compilado ./main para a /app/main. Não precisaríamos indicar o caminho completo por estarmos na pasta correta, mas é uma boa prática colocar caminhos absolutos quando eles são curtos por trazer maior clareza.

Então, poderemos definir nosso ENTRYPOINT como ./main, ou nosso CMD (command), como ./main. No nosso cenário, não vai fazer nenhuma diferença. A diferença entre essas opções aparece nos cursos de Docker. Como o nosso ./main não espera nenhum parâmetro, não faz diferença qual vamos utilizar. Em Dockerfile, então, teremos bom início para a criação da nossa imagem Docker.

FROM golang:1.22

EXPOSE 8080

WORKDIR /app

COPY ./main /app/main

CMD [ "./main" ]

No docker-compose.yml, vamos parar de utilizar a imagem golang, e construir a nossa própria imagem a partir do Dockerfile. Inseriremos após o app um build: ., que procurará um arquivo denominado Dockerfile na mesma pasta que o docker-compose.yml está sendo executado, e então construirá uma imagem a partir desse arquivo, que gerará um contêiner para o nosso app.

app:
    build: .
    command:
        - go
        - run
        - main.go

Ppossivelmente teremos alguns problemas neste momento, mas vamos tentar rodar o código. Antes disso, removeremos o seguinte trecho, já que passamos a ter o executável, e também o command, pois definimos que command será a execução do ./main previamente compilado. Também removeremos o working_dir:

app:
    build: .
    ports:
        - 8080:8080
    depends_on:
        - postgres

Como mudamos a definição da imagem, rodaremos docker compose up --build no terminal, para que a imagem seja construída. Caso alteremos o Dockerfile durante o curso, precisaremos rodar com o --build, se não o docker-compose.yml utilizará a versão anterior da imagem.

Rodaremos em modo de debug, como esperado, pois estamos no ambiente de desenvolvimento, referente a um detalhe de Go. Vamos falar mais sobre modo de produção em breve. Temos um erro de execução porque faltam algumas implementações no nosso Dockerfile, e podemos dizer que começamos a criar um Dockerfile que teoricamente ainda não está pronto para produção, o cenário ainda não é muito realista.

Copiamos o arquivo ./main previamente compilado, e o enviamos para o contêiner. Tivemos a construção da imagem no Dockerfile em um ambiente de integração contínua, e talvez precisemos construi-la novamente em um ambiente de produção, isto é, podemos ter múltiplas construções.

Então nem sempre, ou não necessariamente teremos o arquivo compilado. Precisamos considerar um cenário em que enviaríamos o código para o contêiner, que então seria compilado, e só depois rodaríamos o executável. Este não é o único ponto que precisamos ajustar, mas é um bom próximo passo para a melhoria do nosso Dockerfile.

A seguir, evoluiremos um pouco mais o Dockerfile, mesmo que tenhamos alguns erros ainda. Até lá!

Geração de imagens - Criação de imagem real

Vamos recapitular um dos problemas que temos até agora: se executarmos a construção de uma imagem utilizando o Dockerfile em outro ambiente, precisaremos que esse outro ambiente tenha o arquivo ./main já compilado na pasta principal. No entanto, o local onde formos executar a construção de tal imagem não precisa necessariamente ter Go instalado.

Da mesma forma, não precisamos garantir que o ambiente tenha a mesma versão do Go. A utilização de Docker, ou de algum outro sistema de contêiners traz justamente essa flexibilidade de execuções a partir da imagem, independentemente de onde ela esteja. E a construção das imagens também segue um princípio parecido. Não podemos depender muito do ambiente para termos a construção correta.

Então, em vez de copiar o arquivo já compilado, vamos copiar o código do projeto para o Dockerfile. Tendo o código, podemos rodar a etapa de compilação, e inclusive trocar o comando para go, utilizando a sintaxe de array run e main.go.

FROM golang:1.22

EXPOSE 8080

WORKDIR /app

COPY ./main /app/main

CMD [ "go", "run", "main.go" ]

Obviamente ainda não temos o arquivo main.go. No Dockerfile de produção, por enquanto não precisaremos rodar os testes. Vamos copiar primeiro cada uma das pastas: assets/, controllers/, database/, models/, routes/. Reparem que estamos pulando algumas pastas. A pasta pkg, por exemplo, será criada ao compilarmos o projeto, quando as dependências forem baixadas.

A postgres-data se refere ao banco de dados, que não é necessária também. Não estamos copiando tudo da nossa raiz para dentro do nosso container. Em um projeto mais organizado, o ideal seria que todas as pastas estivessem dentro de uma pasta src, ou algo do tipo, que é a organização mais usual. De novo, isso é detalhe de código, e por ora focaremos na parte de operações. Continuando, copiaremos também os templates, o main.go, e go.mod, que são as definições do módulo, das nossas dependências.

Inclusive, o erro que está acontecendo na execução é justamente por não termos a pasta templates no contêiner. Assim, o Go não consegue encontrar os arquivos da visualização, do HTML. Os arquivos HTML que estão na pasta "templates", portanto, não são compilados, nem vão para o nosso binário, porém precisam ser encontrados.

WORKDIR /app

COPY ./assets/ /app/assets/
COPY ./controllers/ /app/controllers/
COPY ./database/ /app/database/
COPY ./models/ /app/models/
COPY ./routes/ /app/routes/
COPY ./templates/ /app/templates/
COPY ./main.go /app/main.go/
COPY ./go.mod /app/go.mod/
COPY ./go.sum /app/go.sum/

CMD [ "go", "run", "main.go" ]

O go.sum "guarda" o que for baixado, todas as dependências que foram utilizadas, na versão exata. Algumas linguagens vão ter arquivos diferentes com a gestão de dependência. Por exemplo, no PHP, há o composer.json e o composer.lock. No JavaScript, temos package.json, package.lock ou yarn.lock. Ou seja, cada linguagem terá alguns arquivos além do seu código, indicando as dependências necessárias. Para cada linguagem, será preciso conversar com a equipe de desenvolvimento e entender o que precisa ser enviado para o contêiner.

Quando falarmos melhor sobre as etapas de construção, podemos conversar sobre o que realmente precisaria ser enviado para o contêiner, etc. Por enquanto, não precisaremos enviar o main_test.go, já que não executaremos os testes no contêiner.

O ideal é termos o mínimo necessário, para as imagens na produção serem menores. Isso porque podemos acabar armazenando-as em algum lugar que cobre pelo seu tamanho. Ter uma imagem menor agiliza o processo de deploy, pois ela precisará ser baixada de algum lugar. Então, quanto menor, melhor.

Ao executarmos e tentamos rodar docker compose up, teremos que o nosso banco de dados está "de pé", mas a aplicação não. Então, com "Ctrl + C" interromperemos todo o processo e em seguida rodaremos docker compose up --build, para reconstruirmos a imagem. Os arquivos são devidamente copiados, o banco de dados sobe, enquanto isso, não notamos nada de diferente. As dependências do Go são baixadas, esperaremos um pouco e, finalmente, nossa aplicação sobe. Há alguns avisos, principalmente, de debug.

Basicamente, o problema ocorre porque não definimos uma variável de ambiente, PORT, que é o que o Go utiliza para saber em qual porta subir o servidor, por meio do GIN. Porém, o padrão é o que já estava sendo usado anteriormente, ou seja, esse aviso já existia. Teoricamente, a nossa aplicação está pronta para ser acessada!

Tentaremos acessar https://localhost:8080, e encontraremos nossa aplicação "de pé", com o nosso template rodando. Se acessarmos https://localhost:8080/Vinicius, teremos a nossa saudação personalizada na tela. Em https://localhost:8080/alunos, esperamos encontrar o array com os alunos criados, contidos no nosso banco de dados. Aparentemente, tudo está funcionando conforme esperado, nossa API está perfeitamente funcional com o nosso novo Dockerfile.

Ainda existem alguns detalhes a serem ajustados, principalmente documentais. Depois, poderemos lidar com outras questões para além daquelas encontradas por quem lê nosso Dockerfile, como diminuir o tamanho da imagem, pensar em quais arquivos precisam estar no contêiner, e assim por diante.

Temos uma imagem criada a partir de um Dockerfile, que pode ser utilizada no ambiente de integração contínua como o GitHub Actions, em produção, que poderíamos ter com o ambiente de entrega contínua e deploy contínuo (CI/CD). Basicamente, significa que temos um bom primeiro passo.

Então, adiante, vamos recapitular alguns conceitos e melhorar um pouco mais a nossa imagem, para garantir que ela consiga ser executada em outros ambientes que não seriam possíveis no momento.

Sobre o curso Docker: construindo imagens para produção

O curso Docker: construindo imagens para produção possui 100 minutos de vídeos, em um total de 36 atividades. Gostou? Conheça nossos outros cursos de Builds em DevOps, ou leia nossos artigos de DevOps.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda Builds acessando integralmente esse e outros cursos, comece hoje!

Conheça os Planos para Empresas