IBM Cloud Docs
Gravando um Dockerfile para o Code Engine

Gravando um Dockerfile para o Code Engine

Antes de construir o seu código em uma imagem de contêiner, conheça o básico de como uma construção do Docker funciona dentro do IBM Cloud® Code Engine. Em seguida, veja algumas melhores práticas para o seu Dockerfile atingir esses objetivos. Esses exemplos se concentram em reduzir o tamanho da imagem, melhorar a interoperabilidade com aplicativos Code Engine e executar contêineres como um usuário não raiz.

Ao criar sua configuração de construção, você decide qual das duas estratégias disponíveis usar.

  1. O Cloud Native Buildpacks inspeciona o seu código-fonte e detecta em qual ambiente de tempo de execução que o seu código é baseado e como uma imagem de contêiner é construída por meio de suas origens. Ele é suportado para vários ambientes de programação. É possível localizar a lista suportada em Escolha uma estratégia de construção.

  2. Uma construção do Docker cria um contêiner com base em como você descreve isso em um Dockerfile. O Dockerfile está, então, comprometido com o seu código-fonte para criar o contêiner.

Embora seja possível usar qualquer estratégia para a construção, recomenda-se o Dockerfile quando, por exemplo,

  • O seu ambiente de programação não é suportado por Buildpacks.
  • A sua construção de projeto deve instalar pacotes adicionais no contêiner.

Fundamentos do Dockerfile

Um Dockerfile descreve como um contêiner é construído. Dentro do seu Dockerfile, você escolhe uma imagem base que inclui as ferramentas necessárias durante a construção e o tempo de execução. É possível copiar arquivos de um contexto de construção na imagem, executar comandos, definir o comportamento de tempo de execução, como variáveis de ambiente e portas expostas, e configurar o ENTRYPOINT. O ENTRYPOINT é configurado pelo comando que é invocado quando o contêiner é iniciado. Para obter mais informações sobre como as instruções do Dockerfile podem ser especificadas, consulte Dockerfile reference.

Em uma construção do Code Engine, você define uma origem que aponta para um repositório Git. O contexto que está disponível para a construção do Docker é, por padrão, o diretório raiz de seu repositório Git. Por exemplo, se você tiver um diretório denominado src em seu repositório, será possível usar a instrução COPY no Dockerfile para copiar esse diretório em sua imagem. Por exemplo,

COPY src /app/src

Para copiar o repositório Git inteiro, especifique a instrução COPY a seguir,

COPY . /app/src

Se você copiar o repositório Git inteiro, mas desejar excluir alguns arquivos, por exemplo, o README.md do repositório, será possível incluir um .dockerignore arquivo Use o mesmo arquivo para também ignorar os arquivos e diretórios especificados em seu arquivo .gitignore. Ao usar o mesmo arquivo, você assegura que uma construção que você executa localmente tenha o mesmo conjunto de arquivos disponível que a construção em execução no Code Engine.

Sempre copie os seus arquivos de aplicativos em um subdiretório da raiz (/) em vez diretamente na raiz, para evitar conflitos com arquivos do sistema operacional. Ao nomear seu diretório de aplicativos, não use um que seja reservado por sistemas operacionais baseados em Unix, Kubernetes ou construções do Code Engine, como /bin, /dev, /etc, /lib, /proc, /run, /sys, /usr, /var ou /workspace. Nomear seu diretório de aplicativos como /app é uma melhor prática.

Se o seu repositório de código de origem contiver as origens para diferentes aplicativos que são organizados em diretórios, semelhante ao repositório de amostras do Code Engine, será possível usar um subdiretório como seu contexto No comando ibmcloud ce build create, especifique subdiretórios usando a opção --context-dir.

Se o seu Dockerfile não estiver no diretório de contexto, será possível apontar para ele usando o argumento --dockerfile.

Para experimentar uma construção do Docker em seu sistema local antes de construí-la no Code Engine, é possível usar o Docker Desktop.

Reduzindo o tamanho de uma imagem de contêiner

A redução do tamanho de uma imagem de contêiner traz um valor em múltiplos aspectos.

  • Menos espaço é necessário para armazenar a imagem no registro de contêiner. Você salva a cota para outras imagens e economiza dinheiro.
  • Às vezes, a execução da construção precisa de menos tempo para ser concluída porque uma imagem menor pode ser transferida mais rápido para o Container Registry do que uma bem maior. Você economiza dinheiro novamente.
  • O aplicativo ou a tarefa que usa a imagem é iniciado mais rapidamente porque o tempo necessário para extrair a imagem é mais curto. Como os recursos que são necessários para executar seu aplicativo ou tarefa são reservados enquanto a imagem é extraída, você novamente economiza dinheiro. Um tempo de inicialização rápido é especialmente relevante para os aplicativos no Code Engine porque ele garante um tempo de resposta aceitável para as solicitações de seus usuários, mesmo que a escala do aplicativo seja ajustada para zero.

Veja algumas das práticas recomendadas a seguir para reduzir o tamanho de sua construção.

Combinar vários comandos em uma única instrução RUN para reduzir o tamanho da imagem

Neste exemplo, deve-se instalar o software na imagem do contêiner, por exemplo, Node.js. Use as imagens de base para Node.js para construir um aplicativo Node.js.

Para instalar manualmente o Node.js, é possível usar o exemplo de Dockerfile a seguir,

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y nodejs
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

Neste exemplo, várias instruções RUN são usadas para atualizar, fazer upgrade, instalar o Node.js, limpar e remover o cache de arquivos. Embora essa sequência de instruções esteja correta, o uso de múltiplas instruções RUN não é a melhor maneira de implementá-lo. Quando um Dockerfile é processado, cada uma de suas instruções cria uma camada. Cada camada contém os arquivos que foram criados ou atualizados e também informações sobre o que foi excluído. Uma imagem de contêiner é a coleção de todas as camadas. Se um arquivo for incluído em uma instrução e excluído em uma segunda, a imagem resultante ainda o conterá na camada da primeira instrução, mas o marcará como excluído na segunda camada.

Para salvar o espaço em disco, especifique uma única instrução RUN para criar uma camada única para esse conjunto de comandos,

FROM ubuntu

RUN apt update && apt upgrade -y && apt install -y nodejs && apt clean && rm -rf /var/lib/apt/lists/\*

Para manter um Dockerfile legível com uma linha por comando, inclua quebras de linha em seu código,

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

Ao usar esse tipo de instrução, é possível reduzir o tamanho da imagem do contêiner de cerca de 174 MB para aproximadamente 147 MB. Observe que os seus tamanhos de exemplo podem diferir, assim como a imagem base do Ubuntu e o pacote Node.js podem mudar.

Esse tipo de melhor prática se aplica não apenas a instalações de pacotes, mas também a tarefas semelhantes. No próximo exemplo, o download e a extração de um pacote de software, novamente Node.js, podem ser semelhantes ao exemplo a seguir,

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y curl
RUN curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz
RUN mkdir /opt/node
RUN tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1
RUN rm /tmp/nodejs.tar.gz
RUN apt remove -y curl
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

O comando rm remove o pacote nodejs transferido por download, mas as múltiplas instruções RUN novamente criam camadas separadas. Além disso, para fazer o download do pacote nodejs, o pacote curl é instalado temporariamente. Enquanto o pacote curl em si é removido posteriormente, suas dependências que estavam implicitamente instaladas ainda estão lá. Um Dockerfile melhor se parece com o exemplo a seguir,

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y curl && \
    curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz && \
    mkdir /opt/node && \
    tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1 && \
    rm /tmp/nodejs.tar.gz && \
    apt remove -y curl && \
    apt auto-remove -y && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

Os comandos relacionados são combinados em uma única instrução RUN e o comando apt auto-remove é incluído para remover as dependências. Este exemplo reduz o tamanho da imagem de cerca de 222 MB para aproximadamente 162 MB.

Usar uma imagem base minúscula

Os exemplos anteriores usam o Ubuntu como uma imagem base. Enquanto essa imagem contém muitos utilitários úteis, quanto mais utilitários estão em uma imagem base, maior é o seu tamanho. Além disso, ao incluir mais utilitários, você aumenta a chance de poder encontrar uma vulnerabilidade de segurança, exigindo que você reconstrua sua imagem. Para evitar esses dois problemas, considere usar uma imagem base menor. Por exemplo,

  • Alpine é uma imagem oficial do Docker com tamanho pequeno. Para ambientes de programação, como Java ou Node.js, tags baseadas em Alpine são frequentemente localizadas.

  • Imagens distroless do Google Container Tools não contêm ferramentas de sistema operacional de forma alguma, mas o ambiente de tempo de execução necessário para diferentes idiomas, como Java e Node.js.

Compare essas imagens base construindo uma imagem de contêiner que execute um programa Node.js em um arquivo program.js. Para o Ubuntu, o Dockerfile é semelhante ao exemplo a seguir,

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Para Alpine, a imagem é obtida diretamente do Node.js,

FROM node:16-alpine

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Para distroless, use o exemplo a seguir,

FROM gcr.io/distroless/nodejs:16

COPY program.js /app/program.js

WORKDIR /app

CMD ["program.js"]

Enquanto a imagem baseada em Ubuntu é de 147 MB, a imagem que é baseada no Alpine é de 90 MB, e a imagem baseada em distroless é de 94 MB.

Não inclua origens e ferramentas de construção para reduzir o tamanho da imagem

Nos exemplos baseados no Node.js anteriores, um único arquivo de origem é incluído na imagem do contêiner. Esse exemplo pode usar um único arquivo de origem porque nenhuma compilação foi necessária. No entanto, se uma compilação for necessária, use as ferramentas necessárias apenas para a construção, mas não as inclua na imagem resultante. Por exemplo, especifique um aplicativo Java que use o Maven como exemplo. Um Dockerfile mal codificado é semelhante ao exemplo a seguir,

FROM maven:3-jdk-11-openj9

WORKDIR /app
COPY . /app

RUN mvn package

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/target/java-application-1.0-SNAPSHOT-fat.jar"]

A imagem de contêiner resultante contém todo o código-fonte e todos os arquivos intermediários do Maven (como o seu cache de artefato, arquivos de classe que são posteriormente empacotados em um arquivo JAR), assim como a ferramenta de construção Maven. Além disso, o Java Development Kit (JDK) também será incluído quando um Java Runtime Environment (JRE) muito menor for requerido no tempo de execução. Como resultado, o tamanho da imagem é 466 MB.

Para tornar a imagem menor, use um recurso chamado construções de vários estágios. Cada estágio em uma construção do Docker tem sua própria imagem base e pode executar comandos em seu estágio. No fim, é construída uma imagem final que copia em artefatos de estágios anteriores. Para construir uma imagem de contêiner por meio do código-fonte, um padrão comum é ter dois estágios:

  1. O estágio do construtor que usa uma imagem base que contém todas as ferramentas necessárias para compilar o código-fonte no binário para o tempo de execução.
  2. O estágio de tempo de execução que usa uma imagem base com o ambiente de tempo de execução necessário para executar o binário. O binário do estágio do construtor é copiado nesse estágio.

Para o projeto Maven, o resultado é semelhante ao exemplo a seguir,

FROM maven:3-jdk-11-openj9 AS builder

WORKDIR /app
COPY . /app

RUN mvn package

FROM adoptopenjdk:11-jre-openj9

WORKDIR /app
COPY --from=builder /app/target/java-application-1.0-SNAPSHOT-fat.jar /app/java-application.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/java-application.jar"]

Neste exemplo, o estágio do construtor usa a imagem base do Maven para construir o arquivo JAR do aplicativo. O estágio de tempo de execução usa a imagem base do JRE menor. Ao usar o comando COPY --from=STAGE_NAME, o arquivo JAR é copiado do estágio do construtor no estágio de tempo de execução. A imagem resultante tem um tamanho de 242 MB, economizando em torno de 48%.

Esse padrão também pode ser usado para outras linguagens de programação nas quais uma compilação precisa ser executada.

  • Os aplicativos de nó que requerem uma construção, por exemplo, uma construção Angular ou React. Aqui, a imagem base do construtor e do tempo de execução pode acabar sendo a mesma (ambos os nós), mas nem todos os artefatos e origens do momento da criação precisam ser copiados na imagem de tempo de execução.
  • Qualquer linguagem de programação que compile o código-fonte em um executável nativo que é executado sem um ambiente de tempo de execução, por exemplo, Go ou Rust.

Para aquelas linguagens que produzem um executável nativo e não precisam de um ambiente de tempo de execução de forma alguma, use outro recurso do Docker para o estágio de tempo de execução: utilizável. O utilizável pode ser usado como base no comando FROM, mas não é uma imagem de contêiner final. Em vez disso, ele informa à construção do Docker para não usar uma imagem base de forma alguma. Sem nenhum arquivo de sistema operacional proveniente de uma imagem base, a imagem de resultado pode conter apenas um único arquivo: o seu binário que é copiado por meio do estágio do construtor. Observe que, dependendo da linguagem de programação e de seu código, pode ser necessário fazer ajustes adicionais nas opções do compilador, pois os arquivos binários podem contar com alguns arquivos do sistema operacional para existir.

Manter sua imagem limpa

Quando você estiver desenvolvendo o seu Dockerfile pela primeira vez e depurando o funcionamento dele, será possível instalar algumas ferramentas temporárias ou aplicar outras cargas de trabalho que não tenham de estar na imagem final do contêiner. A remoção desses arquivos temporários e cargas de trabalho mantém a sua imagem limpa, pequena e mais segura.

Melhorando o horário de início da sua imagem

Para obter a máxima eficiência, a sua imagem de contêiner deve ser iniciada o mais rápido possível. Para os aplicativos, a métrica relevante é quanto tempo leva para que o terminal HTTP esteja disponível e pronto para aceitar e processar solicitações de HTTP recebidas. A velocidade de inicialização é especialmente importante em aplicativos do Code Engine que são baseados no Knative. Esses aplicativos podem ser configurados para ajustar a escala para zero instância de execução quando não há tráfego, não consumindo recursos e não custando nada. Quando uma solicitação é recebida, uma instância é iniciada para lidar com ela. O seu contêiner deve começar o mais rápido possível para responder à nova solicitação.

Para melhorar a inicialização do seu aplicativo, investigue a implementação dele e veja se é possível aplicar padrões como:

  • A paralelização do trabalho de inicialização independente, por exemplo, para estabelecer uma conexão com um banco de dados e para ler o arquivo de configuração a fim de se comunicar com um servidor de e-mail por meio de variáveis de ambiente.
  • O atraso do trabalho de inicialização que não é necessário para a inicialização do aplicativo e, em vez disso, executá-los como primeira necessidade.

Além disso, também é possível evitar uma armadilha comum ao implementar um aplicativo da web que use uma estrutura como Angular, React ou Vue. Cada uma dessas estruturas é baseada no Node.js com NPM e inclui uma interface de linha de comandos que pode facilitar a configuração de um projeto. Por exemplo, um aplicativo React que é criado com o comando create-react-app configura um arquivo package.json que inclui alguns scripts predefinidos. Um desses scripts é start, que traz um servidor da web com o seu aplicativo da web. Seu Dockerfile pode ser semelhante ao exemplo a seguir,

FROM nodejs:16-alpine

COPY . /app
WORKDIR /app

RUN npm install

EXPOSE 3000
ENTRYPOINT ["npm", "run", "start"]

Enquanto esse tipo de Dockerfile funciona, ele não é rápido, pois sempre que o comando npm run start é chamado, o aplicativo é compilado e, em seguida, iniciado. Esse atraso é especialmente perceptível com aplicativos que vão além de um tamanho de amostra pequeno. A abordagem correta é compilar o aplicativo no momento da criação e atendê-lo somente na inicialização,

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app

RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Você vê novamente o padrão de dois estágios do construtor e do tempo de execução. Observe também que a amostra atualizada usa uma porta diferente, 8080. Enquanto este exemplo funciona com qualquer outra porta, 8080 é a porta padrão para aplicativos Code Engine. Além disso, ao utilizar a construção compilada, todas as origens e ferramentas instaladas no node_modules não estão incluídas na imagem final do contêiner, o que reduz seu tamanho 281-97 MB.

Executando um contêiner como não raiz

Os sistemas bem projetados seguem o princípio de menor privilégio - um aplicativo ou um usuário obtém apenas esses privilégios que ele requer para executar uma ação específica. No Code Engine, você executa um servidor de aplicativos ou alguma lógica em lote, que geralmente não requer acesso administrativo ao sistema. Portanto, ele não deve ser executado como raiz em seu contêiner. Uma boa prática é configurar a imagem de contêiner com um usuário definido e ser executado como não raiz. Por exemplo, com base em nosso cenário anterior,

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

O Dockerfile usa o comando USER para especificar que ele deseja executar como usuário e grupo 1100. Observe que esse comando não cria implicitamente um usuário e um grupo nomeados na imagem do contêiner. Geralmente, essa estrutura é aceitável, mas se a sua lógica de aplicativo requer que o usuário e também o seu diretório inicial existam, deve-se criar o usuário e o grupo explicitamente:

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve && \
    addgroup nonroot --gid 1100 && \
    adduser nonroot --ingroup nonroot --uid 1100 --home /home/nonroot --disabled-password

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

O comando RUN no estágio de tempo de execução foi estendido para chamar os comandos addgroup e adduser para criar um grupo e um usuário com um diretório inicial.