Helm na prática: como parar de duplicar YAML entre ambientes
Um guia operacional de Helm: anatomia de um chart, templating com Go templates, values por ambiente, releases, rollback, dependencias e armadilhas.
Neste artigo
Se você já operou uma aplicação em Kubernetes por mais de alguns meses, conhece a dor: o mesmo Deployment copiado três vezes, um para dev, um para staging, um para prod. Cada cópia diferente por meia dúzia de linhas — o número de réplicas, a tag da imagem, um limite de memória, uma variável de ambiente. Aí alguém corrige um securityContext na cópia de produção e esquece das outras duas. Semanas depois você descobre que staging nunca teve aquele ajuste, e o bug que você jurava ter resolvido volta a assombrar. A duplicação de YAML não é um detalhe estético: ela é uma fonte estrutural de divergência entre ambientes, e divergência entre ambientes é onde os incidentes nascem.
O Helm existe para fechar exatamente esse buraco. Ele é, na definição mais curta, o gerenciador de pacotes do Kubernetes — o apt ou o npm do cluster. Mas essa analogia esconde a parte que interessa a quem opera: além de empacotar e distribuir aplicações, o Helm dá a você templates parametrizáveis e, mais importante, versionamento do deploy. Cada vez que você aplica uma mudança, o Helm registra uma revisão. Se a revisão nova quebra, você volta para a anterior com um comando só. Você deixa de tratar "o estado do cluster" como algo que existe apenas no kubectl get do momento e passa a tratá-lo como uma sequência auditável de releases.
Este guia é operacional. Vamos abrir um chart por dentro, entender o templating sem cair nas armadilhas clássicas, separar configuração por ambiente do jeito certo, e passar por releases, rollback, dependências e hooks. No fim, um bloco de boas práticas para você não se enforcar com a corda que o Helm generosamente entrega. Vou assumir que você já sabe o que é um Deployment e um Service, e que já rodou kubectl apply na vida.
O problema que o Helm resolve#
Antes de qualquer sintaxe, vale fixar o problema em duas frentes, porque o Helm ataca as duas.
A primeira é a duplicação. Um manifesto de Kubernetes é declarativo e verboso por natureza. Uma aplicação real — um Deployment, um Service, um Ingress, um ConfigMap, um HorizontalPodAutoscaler, talvez um ServiceAccount e um PodDisruptionBudget — facilmente passa de duzentas linhas de YAML. Multiplique por ambientes e por serviços e você tem um mar de arquivos quase idênticos. Manter isso à mão é insustentável: toda mudança transversal vira uma caçada por cópias. O Helm resolve transformando o manifesto em template: você escreve o esqueleto uma vez e injeta os valores que variam.
A segunda frente é o ciclo de vida do deploy. kubectl apply -f é imperativo o suficiente para aplicar, mas não guarda memória. Ele não sabe que aquele conjunto de recursos forma uma unidade lógica, não sabe qual era o estado anterior e não te oferece um "desfazer". Se você aplicou uma versão ruim, precisa reconstruir o estado bom de cabeça ou do git. O Helm agrupa os recursos em um release e versiona cada mudança como uma revisão, guardando o histórico dentro do próprio cluster. Isso muda a operação: você ganha rollback, history e diff como cidadãos de primeira classe.
Vale registrar desde já que o Helm não é a única resposta. O Kustomize, embutido no próprio kubectl via -k, ataca a duplicação por outra filosofia — sobreposição de patches sobre um YAML base, sem linguagem de template. Voltaremos a ele no fim como contraponto honesto. Por ora, foque no que o Helm entrega de diferente: templating de verdade mais gerenciamento de release.
Anatomia de um chart#
A unidade de empacotamento do Helm é o chart. Um chart é um diretório com uma estrutura convencionada. Quando você roda helm create minha-app, ele gera o esqueleto abaixo:
`` minha-app/ ├── Chart.yaml # metadados do chart ├── values.yaml # valores default ├── charts/ # subcharts (dependencias vendorizadas) ├── templates/ # os templates dos manifestos │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── _helpers.tpl # funcoes/parciais reutilizaveis │ └── NOTES.txt # mensagem exibida pos-install └── .helmignore # o que ignorar ao empacotar ``
Cada peça tem um papel bem delimitado. Vamos abrir as principais.
Chart.yaml — a identidade do pacote#
O Chart.yaml carrega os metadados. É pequeno, mas dois campos merecem atenção especial porque governam versionamento:
```yaml apiVersion: v2 name: minha-app description: API de checkout da plataforma type: application version: 1.4.2 appVersion: "2.11.0" dependencies:
- name: redis
version: "18.6.1" repository: "https://charts.bitnami.com/bitnami" condition: redis.enabled ```
O version é a versão do chart e deve seguir SemVer: você o incrementa toda vez que muda o empacotamento — templates, defaults, dependências. O appVersion é a versão da aplicação que o chart implanta; é só informativo e não precisa acompanhar o version. Confundir os dois é um clássico: bump na imagem da aplicação não obriga bump de version se o template não mudou, mas mudar um default perigoso em values.yaml obriga, porque quem consome o chart precisa perceber a mudança pelo número.
values.yaml — a superfície de configuração#
O values.yaml é o coração da parametrização. Ele declara todos os valores default que os templates vão consumir. É, na prática, o contrato de configuração do seu chart — o que um operador pode ajustar sem tocar em template:
```yaml replicaCount: 2
image: repository: registry.exemplo.com/checkout tag: "" # vazio: cai no appVersion do Chart.yaml pullPolicy: IfNotPresent
resources: requests: cpu: 100m memory: 128Mi limits: memory: 256Mi
autoscaling: enabled: false minReplicas: 2 maxReplicas: 8 targetCPUUtilizationPercentage: 75
redis: enabled: true ```
Repare que o values.yaml é onde mora a decisão de default. Um default bem escolhido faz o chart funcionar razoavelmente sem configuração; um default mal escolhido é uma bomba-relógio, e vamos falar disso nas armadilhas. Note também que redis.enabled casa com a condition da dependência no Chart.yaml: é assim que você liga e desliga subcharts.
templates/ — o manifesto que vira molde#
Dentro de templates/ ficam os arquivos que serão renderizados para YAML de Kubernetes de verdade. Eles são YAML com marcações de template embutidas, delimitadas por {{ }}. Um deployment.yaml enxuto fica assim:
```gotmpl apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "minha-app.fullname" . }} labels: {{- include "minha-app.labels" . | nindent 4 }} spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} selector: matchLabels: {{- include "minha-app.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "minha-app.selectorLabels" . | nindent 8 }} spec: containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} resources: {{- toYaml .Values.resources | nindent 12 }} ```
Aqui já aparecem várias engrenagens que vamos destrinchar: .Values para ler o values.yaml, include para chamar parciais, nindent para indentar corretamente, default para fallback e toYaml para serializar um bloco inteiro. Segure a curiosidade — a próxima seção é sobre isso.
_helpers.tpl — não se repita nos templates#
O arquivo _helpers.tpl (o underscore o marca como parcial, ele não vira manifesto) guarda definições reutilizáveis. Nome do release, conjunto de labels padrão, tudo que apareceria copiado em vários templates mora aqui:
```gotmpl {{- define "minha-app.fullname" -}} {{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} {{- end -}}
{{- define "minha-app.labels" -}} app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end -}} ```
Cada bloco define cria um template nomeado que você invoca com include. Isso é o equivalente a extrair uma função: um único lugar para corrigir os labels, e todos os manifestos herdam a correção.
Templating: Go templates sem se cortar#
O motor de templates do Helm é o Go templates, o mesmo pacote text/template da linguagem Go, turbinado com a biblioteca Sprig de funções auxiliares. Entender três ou quatro conceitos economiza horas de frustração.
Os objetos built-in#
Dentro de um template você tem acesso a alguns objetos de topo, sempre começando com maiúscula:
.Values— o resultado do merge de todos os arquivos e flags de values..Release— dados do release:.Release.Name,.Release.Namespace,.Release.IsUpgrade,.Release.IsInstall..Chart— o conteúdo doChart.yaml, então.Chart.Name,.Chart.AppVersion..Capabilities— o que o cluster suporta, útil para.Capabilities.KubeVersion..Files— acesso a arquivos não-template do chart, para embutir configuração.
O ponto (.) sozinho é o contexto atual. Ele muda dentro de blocos range e with, e essa é a origem de metade da confusão de quem começa: dentro de um range, . já não é a raiz. Por isso os helpers costumam receber $ (a raiz absoluta) ou o . de fora explicitamente.
Whitespace: o detalhe que quebra tudo#
YAML é sensível a espaço e indentação, e templates cospem texto cru. O hífen dentro das chaves — {{- e -}} — come o espaço em branco do lado indicado, incluindo a quebra de linha. Sem ele, cada {{- if }} deixaria uma linha em branco no YAML final, às vezes inofensiva, às vezes fatal.
A dupla indent/nindent resolve indentação de blocos. nindent 8 adiciona uma nova linha e indenta o bloco inteiro em 8 espaços — é o que você usa para encaixar a saída de toYaml ou de um include na profundidade certa do manifesto. Errar a contagem aqui é a causa número um de "meu YAML renderizou mas o kubectl recusou".
A função tpl: templating dentro do value#
Há um caso especial que confunde muita gente. E quando o próprio valor no values.yaml contém sintaxe de template que você quer resolver? Por padrão, values são strings literais — {{ .Values.foo }} escrito dentro de um value não é interpretado. A função tpl existe justamente para isso: ela renderiza uma string como template, usando o contexto que você passar.
```gotmpl # values.yaml config: greeting: "Ola do release {{ .Release.Name }} no namespace {{ .Release.Namespace }}"
# templates/configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: {{ include "minha-app.fullname" . }}-config data: greeting: {{ tpl .Values.config.greeting . | quote }} ```
O segundo argumento de tpl é o contexto (.). Isso é poderoso para charts que precisam permitir que o operador escreva expressões nos values — configurações de anotações, hostnames derivados do release, e por aí vai. Use com parcimônia: tpl sobre input de usuário é também uma porta para complexidade difícil de depurar.
Funções que você vai usar todo dia#
Da Sprig e do núcleo, o punhado que aparece em quase todo chart: default (fallback quando o value é vazio), quote (envolve em aspas, essencial para strings que parecem números ou booleanos), toYaml (serializa um mapa/lista inteiro), required (aborta a renderização com mensagem se um value obrigatório faltar), b64enc (para Secret), include (chama parcial), e os condicionais if/else/with/range. O required merece destaque operacional:
``gotmpl image: repository: {{ required "image.repository e obrigatorio" .Values.image.repository }} ``
Isso transforma um esquecimento silencioso num erro imediato e legível, em vez de um Deployment apontando para uma imagem vazia.
Values e overrides por ambiente#
Aqui está a razão de o Helm existir para quem tem mais de um ambiente. Você mantém um chart e injeta configuração diferente por ambiente. Existem duas formas de sobrescrever, e elas se combinam por precedência.
A primeira é o arquivo de values por ambiente. Você cria values-prod.yaml com apenas o que difere do default e passa com -f:
```yaml # values-prod.yaml replicaCount: 6
resources: requests: cpu: 500m memory: 512Mi limits: memory: 1Gi
autoscaling: enabled: true minReplicas: 6 maxReplicas: 20 ```
``bash helm upgrade --install checkout ./minha-app \ -f values.yaml \ -f values-prod.yaml \ --namespace prod ``
Os arquivos são aplicados na ordem: o values.yaml do chart entra primeiro, o values-prod.yaml sobrescreve o que declara, e nada além disso muda. Esse override por diferença é o padrão de ouro — o arquivo de ambiente é curto e diz exatamente o que aquele ambiente tem de especial.
A segunda forma é o --set na linha de comando, para overrides pontuais, tipicamente valores dinâmicos de pipeline:
``bash helm upgrade --install checkout ./minha-app \ -f values-prod.yaml \ --set image.tag=2.11.3 \ --namespace prod ``
A precedência importa: --set vence os arquivos -f, e arquivos -f posteriores vencem os anteriores, todos vencendo o values.yaml do chart. Use --set para o que muda a cada deploy (a tag da imagem vinda do CI, por exemplo) e arquivos para o que é estável por ambiente. Injetar tudo por --set num pipeline gera comandos ilegíveis e overrides invisíveis — o arquivo versionado é sempre mais auditável.
Uma dica que economiza incidentes: antes de aplicar, renderize o resultado e olhe com os próprios olhos. helm template e helm upgrade --dry-run mostram exatamente o YAML que iria para o cluster, com todos os overrides já resolvidos. Você confere se replicaCount virou 6 mesmo, se a tag entrou, se a indentação bateu.
Releases e revisões: o superpoder operacional#
Instalar um chart cria um release — uma instância nomeada daquele chart naquele namespace. Cada helm upgrade gera uma revisão nova, e o Helm guarda o histórico dentro do cluster (num Secret por revisão, por padrão). É isso que dá a você memória e reversão.
```bash # instala ou atualiza (idempotente) o release "checkout" helm upgrade --install checkout ./minha-app -f values-prod.yaml -n prod
# lista os releases do namespace helm list -n prod
# historico de revisoes do release helm history checkout -n prod
# volta para a revisao 4 helm rollback checkout 4 -n prod
# desinstala (por padrao mantem o historico com --keep-history) helm uninstall checkout -n prod ```
O helm history te mostra cada revisão com status, o chart e a descrição. Quando a revisão 7 sobe quebrada, você não abre o git para reconstruir o estado da 6: helm rollback checkout 6 e o Helm reaplica exatamente o conjunto de manifestos daquela revisão. Isso reduz drasticamente o tempo de recuperação num incidente.
Release atômico#
Por padrão, um helm upgrade que falha no meio pode deixar o release num estado parcial — alguns recursos atualizados, outros não. A flag --atomic muda isso: se qualquer parte falhar, o Helm faz rollback automático para a revisão anterior, deixando o release consistente. Combine com --timeout para dar um teto de espera:
``bash helm upgrade --install checkout ./minha-app \ -f values-prod.yaml \ --set image.tag=2.11.3 \ --atomic --timeout 5m \ -n prod ``
Com --atomic, ou o upgrade converge inteiro dentro do timeout, ou o cluster volta ao estado bom anterior — nada de meio-termo. Em produção, considere isso o default mental. O custo é a espera pela convergência (readiness dos pods), mas é um custo que você quer pagar.
Dependências: subcharts e o Chart.lock#
Aplicações reais raramente vivem sozinhas. Sua API precisa de um Redis, de um Postgres, de um exportador de métricas. O Helm modela isso com dependências declaradas no Chart.yaml, cada uma podendo ser um subchart — um chart empacotado dentro do seu, no diretório charts/.
``bash # baixa as dependencias declaradas para charts/ e gera o Chart.lock helm dependency update ./minha-app ``
O comando resolve as versões declaradas, baixa os charts para charts/ e escreve um Chart.lock. Esse arquivo fixa as versões exatas resolvidas — o equivalente ao package-lock.json do npm. Comitá-lo garante que todo mundo, e o CI, monte o mesmo conjunto de subcharts. Sem lock, um dependency update amanhã pode puxar uma versão diferente dentro do range de SemVer e te dar um deploy diferente sem que uma linha sua tenha mudado.
Você configura o subchart pelo seu próprio values.yaml, aninhando sob o nome dele. Aquele redis.enabled: true do começo, mais qualquer valor que o chart do Redis aceite:
``yaml redis: enabled: true architecture: standalone auth: enabled: true ``
E a condition: redis.enabled no Chart.yaml faz o subchart ser incluído ou pulado conforme a flag. É assim que um mesmo chart serve ao ambiente que usa o Redis embutido e ao que aponta para um Redis gerenciado externo.
Hooks: agir nos momentos do ciclo de vida#
Às vezes você precisa executar algo em um ponto específico do deploy — rodar uma migração de banco antes de subir a versão nova, ou limpar um recurso depois de desinstalar. Os hooks do Helm resolvem isso com uma anotação no recurso:
```gotmpl apiVersion: batch/v1 kind: Job metadata: name: {{ include "minha-app.fullname" . }}-migrate annotations: "helm.sh/hook": pre-upgrade,pre-install "helm.sh/hook-weight": "0" "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded spec: template: spec: restartPolicy: Never containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" command: ["./manage", "migrate"] ```
Os pontos de hook mais usados são pre-install, post-install, pre-upgrade, post-upgrade, pre-delete e post-delete. O hook-weight ordena hooks do mesmo tipo (menor primeiro), e o hook-delete-policy controla quando o Helm remove o recurso do hook — hook-succeeded limpa o Job de migração se ele passou, before-hook-creation apaga a execução anterior antes de criar a nova.
Um aviso operacional: hooks rodam fora do fluxo de rollback normal. Um Job de migração que já alterou o schema não é desfeito por um helm rollback — o rollback reverte os manifestos, não os efeitos colaterais do banco. Migrações precisam ser reversíveis por conta própria; não confie no rollback do Helm para desfazer o que o hook fez no dado.
Boas práticas para não se enforcar#
O Helm te dá muita corda. Templating é uma linguagem, e linguagem convida a esperteza. Estas práticas separam o chart que se mantém sozinho do que vira um pesadelo.
Segredo nunca vai em values.yaml. Este é o piso inegociável. Senha de banco, token de API, chave privada — nada disso entra no values.yaml, que é versionado e visível. Um Secret renderizado a partir de um value em texto plano vaza o segredo para o git e para o histórico de releases dentro do cluster. Use um gerenciador de segredos de verdade (External Secrets Operator puxando de um Vault ou do secret manager da nuvem, ou SOPS para criptografar em repouso) e injete a referência, nunca o valor. Se você digitou uma senha num -f values.yaml, considere-a comprometida.
Sempre lint, --dry-run e template antes de aplicar. O helm lint ./minha-app pega erros estruturais e convenções quebradas. O helm template e o helm upgrade --dry-run renderizam o YAML final com os overrides resolvidos, para você inspecionar antes de tocar o cluster. Renderizar e ler é barato; descobrir a indentação errada em produção é caro. Coloque lint e template no CI do chart como gate obrigatório.
``bash helm lint ./minha-app -f values-prod.yaml helm template checkout ./minha-app -f values-prod.yaml | kubectl apply --dry-run=client -f - ``
Versione o chart com SemVer disciplinado. Toda mudança de template ou de default incrementa o version do Chart.yaml. Mudança que quebra compatibilidade (renomear um value, mudar um default de forma que altera comportamento) é major. Quem consome o seu chart confia nesse número para saber o que esperar. Chart sem bump de versão a cada mudança é chart que ninguém consegue rastrear.
Cuidado com o default perigoso. Um default é uma decisão que roda quando ninguém decidiu. replicaCount: 1 como default parece inofensivo até um operador instalar em produção sem override e ficar com uma réplica só, sem redundância. resources sem limits como default deixa o pod livre para consumir o nó inteiro. imagePullPolicy: Always por default espanca o registry. Escolha defaults seguros por omissão: o comportamento de "não configurei nada" deve ser o comportamento conservador, não o arriscado. E documente cada value non-trivial num comentário no values.yaml.
Fuja do templating cego. É tentador transformar cada linha do manifesto em {{ }}, parametrizar tudo "por via das dúvidas". O resultado é um template que ninguém consegue ler, onde a lógica de negócio se perde em condicionais aninhados e range sobre range. Parametrize o que de fato varia entre ambientes ou instalações; deixe o resto fixo. Um if que nunca é falso é ruído. Prefira mais valores explícitos a menos valores com mais esperteza de template. Quando um helper cresce a ponto de você precisar depurar nindent, é sinal de que o chart está tentando ser esperto demais.
Use required para o que não pode faltar. Todo value cuja ausência produz um recurso quebrado deve passar por required, com uma mensagem que diz ao operador o que fornecer. É a diferença entre um erro na renderização, imediato e claro, e um Deployment que sobe apontando para o nada e só falha em runtime.
Prenda dependências com Chart.lock comitado. Rode helm dependency update, comite o Chart.lock, e trate atualização de subchart como uma mudança deliberada com bump de versão — não como algo que acontece sozinho quando o CI resolve o range.
E o Kustomize?#
Como prometido, o contraponto honesto. O Kustomize resolve a duplicação sem linguagem de template: você tem um base/ com o YAML puro e overlays/ por ambiente que aplicam patches sobre a base. Ele é declarativo do começo ao fim, vem embutido no kubectl -k, e evita a categoria inteira de bugs de whitespace e de "template esperto demais". A troca é que ele não faz gerenciamento de release — não tem rollback, history, --atomic nem hooks. Ele monta o YAML; a aplicação e o ciclo de vida ficam com você (ou com o seu GitOps, tipo Argo CD).
A escolha prática costuma ser: Kustomize quando você quer configuração declarativa simples e já tem GitOps cuidando do ciclo de vida; Helm quando você precisa distribuir um pacote parametrizável para terceiros (é o padrão de fato no ArtifactHub, onde vivem os charts públicos), ou quando quer o gerenciamento de release, os hooks e o rollback embutidos. Não são inimigos: há quem use Helm para os charts de terceiros e Kustomize para os manifestos próprios, e o Argo CD renderiza os dois. Conheça as duas ferramentas e escolha pela natureza do problema, não pela moda.
No fim, o Helm ganha seu lugar quando você para de ver deploy como "aplicar um arquivo" e passa a ver como "publicar uma revisão versionada de um pacote". É essa mudança de mentalidade — release atômico, histórico auditável, rollback de um comando, configuração por diferença entre ambientes — que transforma a operação de Kubernetes de uma sequência de kubectl apply nervosos numa disciplina com memória. Escreva o template uma vez, escolha defaults seguros, mantenha o segredo fora do YAML, renderize antes de aplicar, e o Helm vira aquilo que promete: o gerenciador de pacotes que faz o cluster parar de ser um campo minado de cópias divergentes.