Otimizando builds e pipelines
LidoEsta aula ensina como otimizar builds e pipelines de CI/CD, abordando técnicas fundamentais como cache, paralelização, builds incrementais e medição de tempo. Ao final, você terá um repertório prático para reduzir o tempo de execução dos seus pipelines e melhorar a eficiência do fluxo de desenvolvimento.
Em pipelines de CI/CD, o tempo de execução é um recurso crítico: builds demorados atrasam o feedback aos desenvolvedores, aumentam o custo de infraestrutura e podem até levar a práticas inadequadas, como desabilitar testes ou reduzir a frequência de integração. Nesta aula, vamos explorar técnicas e ferramentas para otimizar builds e pipelines, com foco em quatro pilares: cache, paralelização, builds incrementais e medição de tempo. Cada técnica será explicada com exemplos práticos em ambientes comuns, como GitHub Actions, GitLab CI e Jenkins.
O objetivo é que você entenda não apenas o 'como', mas também o 'porquê' de cada otimização, para que possa aplicá-las de forma consciente em seus próprios pipelines. Vamos começar pelo cache, que é a base para reduzir drasticamente o tempo de execução.
Cache
O cache é uma das formas mais eficazes de otimizar pipelines, pois evita repetir trabalhos que já foram feitos em execuções anteriores. Em pipelines, normalmente há dependências que são baixadas e instaladas a cada execução, como pacotes npm, pip, Maven, ou imagens Docker. Sem cache, cada execução começa do zero, desperdiçando tempo e banda de rede. Com cache, podemos armazenar esses artefatos em um local persistente e reutilizá-los nas próximas execuções.
Existem dois tipos principais de cache: cache de dependências (ex.: ~/.npm, ~/.cache/pip) e cache de artefatos de build (ex.: diretórios de saída de compilação). A implementação varia conforme a plataforma de CI/CD. Vamos ver exemplos em GitHub Actions e GitLab CI.
No GitHub Actions, usamos a ação actions/cache para definir chaves de cache e caminhos a serem armazenados. A chave pode ser baseada em um hash dos arquivos de dependência, como package-lock.json, para invalidar o cache quando as dependências mudarem.
# Exemplo de cache em GitHub Actions para Node.js
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
No GitLab CI, o cache é configurado no nível do job, com a palavra-chave cache. Os caminhos são relativos ao diretório do projeto e podem ser armazenados entre execuções.
# Exemplo de cache em GitLab CI para Python
cache:
paths:
- .cache/pip
- venv/
build:
script:
- pip install -r requirements.txt
É importante definir boas chaves de cache: use o hash dos arquivos de bloqueio (lock files) para garantir que o cache seja invalidado quando as dependências mudarem. Evite caches muito amplos que podem não ser reutilizados. Lembre-se também de que o cache pode trazer problemas se não for bem gerenciado, como o uso de versões antigas de dependências.
Paralelização
Paralelizar é a técnica de dividir o trabalho em tarefas independentes que podem ser executadas simultaneamente. Em pipelines, isso pode ser feito em dois níveis: paralelizar jobs (por exemplo, rodar testes em diferentes sistemas operacionais ou em diferentes partes do código) e paralelizar etapas dentro de um mesmo job (usando ferramentas como Make, Bazel, ou scripts com xargs). A paralelização reduz o tempo total de execução, aproveitando melhor os recursos disponíveis.
No GitHub Actions, você pode usar uma matriz (matrix) para rodar variações do mesmo job em paralelo. Por exemplo, testar em múltiplas versões de uma linguagem ou em múltiplos sistemas operacionais.
# Exemplo de matrix em GitHub Actions
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [16, 18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
No GitLab CI, você pode usar parallel para rodar o mesmo job várias vezes em paralelo, ou definir jobs separados que serão executados em paralelo automaticamente se não houver dependências. Por exemplo, para dividir testes em grupos:
# Exemplo de job paralelo em GitLab CI
test:
parallel: 4
script:
- python -m pytest --splits 4 --group $CI_NODE_INDEX
Em pipelines mais complexos, você pode usar a paralelização para dividir a compilação de diferentes módulos, como em projetos monorepo. Ferramentas como Bazel e Nx são projetadas para isso, mas mesmo com scripts simples é possível paralelizar tarefas independentes com xargs -P ou GNU parallel.
É crucial identificar as dependências entre tarefas: paralelizar tarefas que dependem umas das outras pode causar falhas. Use grafos de dependência (DAG) para modelar corretamente o fluxo.
Builds incrementais
Builds incrementais são aqueles que compilam apenas as partes do código que mudaram desde a última execução, em vez de recompilar tudo. Isso é essencial em projetos grandes, onde compilar do zero pode levar minutos ou até horas. A ideia é reutilizar artefatos de compilação anteriores sempre que possível.
Muitas ferramentas de build já oferecem suporte a incrementalidade: por exemplo, make compara timestamps de arquivos de origem com os artefatos gerados; gulp e webpack têm modos de watch; compiladores como gcc e javac têm opções de cache. Em pipelines, você precisa garantir que o estado entre execuções seja preservado, o que geralmente é feito com cache de diretórios de build.
No exemplo a seguir, usamos o cache do GitHub Actions para armazenar o diretório target de um projeto Maven, permitindo que o Maven compile apenas o necessário:
# Exemplo de build incremental com Maven em GitHub Actions
- uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- run: mvn package
Para linguagens como C/C++, podemos usar ccache, que é um compilador cache que armazena resultados de compilação e os reutiliza quando o código-fonte não mudou. Isso é particularmente útil em pipelines com múltiplas execuções.
# Usando ccache em um pipeline
- run: |
sudo apt-get install ccache
export CC="ccache gcc"
make
É importante entender que builds incrementais dependem de que os artefatos sejam consistentes e que as dependências estejam corretamente declaradas. Se o sistema de build não detectar mudanças corretamente, pode gerar artefatos desatualizados. Portanto, é crucial usar ferramentas maduras e testar bem o processo.
Medindo tempo
Para otimizar de forma eficaz, você precisa medir onde o tempo está sendo gasto. A medição permite identificar gargalos e avaliar o impacto das otimizações. Existem várias maneiras de medir o tempo de execução de pipelines e de etapas individuais.
As plataformas de CI/CD geralmente fornecem relatórios de tempo para cada job e etapa. Por exemplo, o GitHub Actions mostra a duração de cada job na interface web, e você pode usar a API para extrair esses dados. O GitLab CI também oferece métricas e gráficos de duração.
Além disso, você pode instrumentar seu próprio código para medir o tempo de etapas específicas. Use comandos como time no shell para medir a duração de um comando, ou ferramentas como strace e perf para análise detalhada. Em scripts, você pode usar variáveis de data para calcular tempos parciais.
# Medindo tempo de um comando com time
$ time make build
# Medindo tempos parciais em um script
START=$(date +%s)
# ... executa algo ...
END=$(date +%s)
echo "Tempo: $((END - START)) segundos"
Em pipelines, você pode adicionar passos de medição para registrar a duração de cada estágio e enviar essas métricas para um sistema de monitoramento, como Prometheus ou CloudWatch. Isso permite acompanhar a evolução do desempenho ao longo do tempo.
É importante medir não apenas o tempo total, mas também o tempo de cada etapa, para identificar onde estão os maiores ganhos. Por exemplo, se a instalação de dependências leva 80% do tempo, focar em cache será mais eficaz do que paralelizar testes.
Boas práticas e observações finais
Antes de encerrar, vamos listar algumas boas práticas para otimização de pipelines:
- Comece medindo: não otimize às cegas. Identifique os gargalos reais.
- Aplique cache primeiro: é geralmente a otimização com maior retorno e menor esforço.
- Paralelize com moderação: excesso de paralelismo pode gerar custos altos e problemas de recursos.
- Use builds incrementais quando possível, mas valide que o sistema de build é confiável.
- Monitore continuamente: otimizações podem degradar com o tempo, então acompanhe as métricas.
Lembre-se de que cada projeto é único: o que funciona para um pode não funcionar para outro. Teste cada otimização em um ambiente controlado e meça o impacto real. Com essas técnicas, você conseguirá reduzir significativamente o tempo de seus pipelines, melhorando a produtividade do time e a qualidade do software.
Exercícios
- Explique como o cache de dependências pode reduzir o tempo de um pipeline e cite um exemplo prático em GitHub Actions para uma aplicação Node.js.
- Descreva uma situação em que a paralelização de jobs não é possível e explique por quê.
- O que são builds incrementais e como o ccache ajuda nesse processo? Dê um exemplo de uso.
- Como você mediria o tempo de cada etapa de um pipeline no GitLab CI? Cite pelo menos duas formas.
- Você tem um pipeline que demora 10 minutos: 5 minutos para instalar dependências, 3 minutos para rodar testes e 2 minutos para empacotar. Proponha duas otimizações e estime o novo tempo total.
actions/cache com a chave baseada no hash do package-lock.json. Exemplo: - uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}export CC="ccache gcc" e depois make.time ou usando variáveis de data. Também é possível usar a API do GitLab para obter métricas de tempo.