As boas práticas oficiais para Dockerfile têm muito conteúdo excelente sobre como melhorar os teus Dockerfiles.
Deves otimizar sobretudo o desempenho (especialmente para test runners). Isto garante que as tuas ferramentas correm o mais depressa possível e não excedem o tempo limite.
Medir o tempo de execução com frequência é uma excelente forma de perceber o desempenho das ferramentas. Cria o hábito de medir o tempo de execução tanto depois como antes de uma alteração. Mesmo quando tens a "certeza" de que uma alteração vai melhorar o desempenho, deves continuar a medir o tempo de execução.
Sempre que possível, cria scripts para medir o desempenho automaticamente (também conhecido como benchmarking). Uma ferramenta de linha de comandos muito útil é o hyperfine, mas podes usar à vontade o que fizer mais sentido para as tuas ferramentas.
Os repositórios de ferramentas de track mais recentes têm acesso aos dois scripts seguintes:
./bin/benchmark.sh: mede o desempenho do código das ferramentas de track (código-fonte)./bin/benchmark-in-docker.sh: mede o desempenho da imagem Docker das ferramentas de track (código-fonte)Se estás a trabalhar num repositório de ferramentas de track sem estes ficheiros, podes copiá-los para o teu repositório através das ligações de código-fonte acima.
Os scripts de benchmarking podem ajudar a estimar o desempenho das ferramentas. Tem em conta, no entanto, que o desempenho nos servidores de produção do Exercism é muitas vezes inferior.
Tenta experimentar imagens base diferentes (por exemplo, Alpine em vez de Ubuntu) para ver se uma tem um desempenho (significativamente) melhor do que a outra. Se o desempenho for relativamente igual, escolhe a imagem mais pequena.
Verifica se usar a rede internal em vez de none melhora o desempenho.
Consulta a documentação sobre redes para mais informações.
As ferramentas de track executam um contentor Docker pontual e de curta duração, que realiza os seguintes passos.
Por isso, o código que corre no passo 2 corre em todas as execuções das ferramentas, sem exceção. Por esta razão, reduzir a quantidade de código que corre no passo 2 é uma excelente forma de melhorar o desempenho. Uma forma de o fazer é passar código de tempo de execução para tempo de construção. Enquanto o código de tempo de execução corre em todas as execuções das ferramentas, o código de tempo de construção só corre uma vez (quando a imagem Docker é construída).
O código de tempo de construção corre uma vez, como parte de um fluxo de trabalho do GitHub Actions. Por isso, não há problema se o código que corre em tempo de construção for (relativamente) lento.
Ao correr testes no test runner de Haskell, este precisa de compilar algumas bibliotecas base. Como cada execução de testes acontece num contentor novo, isto significa que essa compilação era feita em todas as execuções de testes, sem exceção! Para contornar isto, o Dockerfile do test runner de Haskell tem os dois comandos seguintes:
COPY pre-compiled/ .
RUN stack build --resolver lts-20.18 --no-terminal --test --no-run-tests
Primeiro, o diretório pre-compiled é copiado para dentro da imagem.
Este diretório está configurado como um exercício de teste e depende das mesmas bibliotecas base de que o exercício real depende.
Depois, corremos os testes nesse diretório, o que é semelhante à forma como se correm os testes num exercício real.
Correr os testes vai fazer com que a base seja compilada, mas a diferença é que isto acontece em tempo de construção.
A imagem Docker resultante vai, assim, ter as suas bibliotecas base já compiladas.
Isto significa que não é preciso compilar em tempo de execução, o que resulta numa execução (muito) mais rápida.
Algumas linguagens permitem compilar o código ahead-of-time ou just-in-time. É um compromisso entre o tempo de construção e o tempo de execução e, mais uma vez, por razões de desempenho, preferimos a execução em tempo de construção.
O Dockerfile do test runner de C# usa esta abordagem, em que o test runner é compilado para um binário ahead-of-time (em tempo de construção), em vez de compilar o código just-in-time (em tempo de execução). Isto significa que há menos trabalho a fazer em tempo de execução, o que deve ajudar a aumentar o desempenho.
Deves tentar reduzir o tamanho da imagem, o que significa que ela vai:
Diferentes imagens de distribuições têm tamanhos diferentes.
Por exemplo, a imagem alpine:3.20.2 é dez vezes mais pequena do que a imagem ubuntu:24.10:
REPOSITORY TAG SIZE
alpine 3.20.2 8.83MB
ubuntu 24.10 101MB
De um modo geral, as imagens baseadas em Alpine estão entre as mais pequenas, por isso muitas imagens de ferramentas são baseadas em Alpine.
Algumas imagens têm variantes "slim" especiais, nas quais algumas funcionalidades foram removidas, o que resulta em imagens mais pequenas.
Por exemplo, a imagem node:20.16.0-slim é cinco vezes mais pequena do que a imagem node:20.16.0:
REPOSITORY TAG SIZE
node 20.16.0 1.09GB
node 20.16.0-slim 219MB
A razão pela qual as variantes "slim" são mais pequenas é que têm menos funcionalidades. Pode ser que a tua imagem não precise das funcionalidades adicionais; se não precisar, considera usar a variante "slim".
Uma forma óbvia, mas excelente, de reduzir o tamanho da tua imagem é remover tudo o que não precisas. Pode incluir coisas como:
A maioria das imagens Docker precisa de instalar pacotes adicionais, o que normalmente é feito através de um gestor de pacotes. Estes pacotes têm de ser instalados em tempo de construção (uma vez que não há ligação à internet em tempo de execução). Por isso, quaisquer ficheiros de cache ou de registo do gestor de pacotes devem ser removidos depois de instalares os pacotes adicionais.
As distribuições que usam o gestor de pacotes apk (como o Alpine) devem usar a opção --no-cache ao usar apk add para instalar pacotes:
RUN apk add --no-cache curl
As distribuições que usam o gestor de pacotes apt-get/apk (como o Ubuntu) devem executar os comandos apt-get autoremove -y e rm -rf /var/lib/apt/lists/* depois de instalarem os pacotes e no mesmo comando RUN:
RUN apt-get update && \
apt-get install curl -y && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
O Docker tem uma funcionalidade chamada construções em várias etapas. Estas permitem-te dividir o teu Dockerfile em etapas separadas, e só a última etapa acaba na imagem Docker produzida (o resto existe apenas para ajudar a construir a última etapa). Podes pensar em cada etapa como um mini Dockerfile próprio; as etapas podem usar imagens base diferentes.
As construções em várias etapas são especialmente úteis quando o teu Dockerfile precisa de instalar pacotes que só são necessários em tempo de construção. Nesta situação, a estrutura geral do teu Dockerfile é assim:
Com esta configuração, os pacotes adicionais são instalados apenas na etapa "build" e não na etapa "runtime", o que significa que não vão acabar na imagem Docker produzida.
O test runner de Fortran precisa do curl para descarregar alguns ficheiros.
No entanto, a sua imagem de tempo de execução não precisa do curl, o que faz disto um caso de uso perfeito para uma construção em várias etapas.
Primeiro, o seu Dockerfile define uma etapa (chamada "build") na qual o pacote curl é instalado.
Depois, usa o curl para descarregar ficheiros para essa etapa.
FROM alpine:3.15 AS build
RUN apk add --no-cache curl
WORKDIR /opt/test-runner
COPY bust_cache .
WORKDIR /opt/test-runner/testlib
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/testlib/CMakeLists.txt
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/testlib/TesterMain.f90
WORKDIR /opt/test-runner
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/config/CMakeLists.txt
A segunda parte do Dockerfile define uma nova etapa e copia os ficheiros descarregados da etapa "build" para a sua própria etapa, usando o comando COPY:
FROM alpine:3.15
RUN apk add --no-cache coreutils jq gfortran libc-dev cmake make
WORKDIR /opt/test-runner
COPY --from=build /opt/test-runner/ .
COPY . .
ENTRYPOINT ["/opt/test-runner/bin/run.sh"]
O test runner de Ruby precisa que os pacotes git, openssh, build-base, gcc e wget estejam instalados antes de poder instalar as bibliotecas (gems) de que precisa.
O seu Dockerfile começa com uma etapa (com o nome build) que instala esses pacotes (através de apk add) e depois instala as dependências (através de bundle install):
FROM ruby:3.2.2-alpine3.18 AS build
RUN apk update && apk upgrade && \
apk add --no-cache git openssh build-base gcc wget git
COPY Gemfile Gemfile.lock .
RUN gem install bundler:2.4.18 && \
bundle config set without 'development test' && \
bundle install
De seguida, define a etapa que vai formar a imagem Docker resultante.
Esta etapa não instala as dependências que a etapa anterior instalou; em vez disso, usa o comando COPY para copiar as bibliotecas instaladas da etapa "build" para a sua própria etapa:
FROM ruby:3.2.2-alpine3.18
RUN apk add --no-cache bash
WORKDIR /opt/test-runner
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY . .
ENTRYPOINT [ "sh", "/opt/test-runner/bin/run.sh" ]
O Dockerfile do test runner de C# faz algo semelhante, só que neste caso a etapa de construção pode usar uma imagem Docker existente que já traz pré-instalados os pacotes adicionais necessários para instalar bibliotecas.
Os testes unitários podem ser muito úteis, mas recomendamos que te concentres em escrever testes de integração. A sua principal vantagem é que testam melhor como as ferramentas correm em produção e, por isso, ajudam a aumentar a confiança na implementação das tuas ferramentas.
Para imitar o melhor possível o ambiente de produção, os testes de integração devem executar as ferramentas como no ambiente de produção. Isto significa construir a imagem Docker e depois executar a imagem construída sobre uma solução para verificar a sua saída.
Os testes de integração devem ser definidos como testes golden, que são testes em que a saída esperada é guardada num ficheiro. Isto é perfeito para testes de integração de ferramentas de track, uma vez que a saída das ferramentas também consiste em ficheiros.
Quando executas o test runner sobre uma solução, a sua saída é um ficheiro results.json.
Podemos depois comparar este ficheiro com um ficheiro de saída "conhecido como bom" (isto é, "esperado"), chamado expected_results.json, para verificar se o test runner funciona como pretendido.
A segurança é uma das principais razões pelas quais usamos contentores Docker para executar as nossas ferramentas.
Há muitas imagens Docker no Docker Hub, mas tenta usar as oficiais. Estas imagens são cuidadas e têm (muito) menos probabilidade de serem inseguras.
Para garantir que as construções são estáveis (ou seja, que não se estragam de repente), deves fixar sempre as tuas imagens base em etiquetas específicas. Isto significa que, em vez de:
FROM alpine:latest
deves usar:
FROM alpine:3.20.2
Com a segunda opção, as construções usam sempre a mesma versão.
Por predefinição, muitas imagens são executadas com um utilizador que tem privilégios de root. Deves considerar executar como um utilizador sem privilégios.
FROM alpine
RUN groupadd -r myuser && useradd -r -g myuser myuser
# RUN <COMMANDS THAT REQUIRE ROOT USER, E.G. INSTALLING PACKAGES>
USER myuser
É (quase) sempre uma boa ideia instalar as versões mais recentes
RUN apt-get update && \
apt-get install curl
Encorajamos que os ficheiros Docker sejam escritos a pensar num sistema de ficheiros só de leitura. Os únicos diretórios que deves assumir como sendo de escrita são:
/tmp
O nosso ambiente de produção atualmente não impõe um sistema de ficheiros só de leitura, mas poderá vir a impor no futuro. Por esta razão, o modelo base para um novo test runner/analyzer/representer começa com um sistema de ficheiros só de leitura. Se não conseguires que as coisas funcionem num ficheiro só de leitura, podes (por agora) assumir um sistema de ficheiros de escrita.