Packaging with Helm
A chart turns your manifests into a template, one values file per environment fills it in, and a release can be rolled back.
helm upgrade --install shop-api ./charts/shop-api -f values-prod.yaml --set image.tag=1.4.2Applying YAML by hand works until the same service runs in staging and production with four differences between them. A chart is a folder of Kubernetes YAML with holes in it, next to a file of values that fill the holes. Helm is the program that fills them and sends the result to the cluster. One installed copy of a chart is a release: it has a name, a namespace and a revision number that goes up by one on every change.
What is in a chart
Chart.yaml names the chart and carries two versions: version is the chart's own, appVersion is your program's. values.yaml holds the defaults. templates/ holds the manifests, written with the same {{ }} actions as the HTML templates earlier in this course, plus a library of extra functions. templates/_helpers.tpl holds named fragments that other templates include.
Keep one values.yaml of safe defaults and one short file per environment holding only the differences. -f values-prod.yaml layers that file on top.
Keeping it readable
Helm pastes text, so indentation is your job. nindent writes a newline and then indents, which is why template lines end with | nindent 4. Run helm template to print the YAML without installing anything, and read that output rather than the template when something looks wrong.
Example
package main
import (
"fmt"
"os"
"strconv"
)
// config is everything the chart is allowed to change per environment.
type config struct {
Addr string
LogLevel string
MaxConns int
}
// lookup returns the environment variable, or def when it is unset.
func lookup(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func load() (config, error) {
maxConns, err := strconv.Atoi(lookup("MAX_CONNS", "10"))
if err != nil {
return config{}, fmt.Errorf("MAX_CONNS: %w", err)
}
return config{
Addr: lookup("ADDR", ":8080"),
LogLevel: lookup("LOG_LEVEL", "info"),
MaxConns: maxConns,
}, nil
}
func main() {
// Nothing is set here, so every default applies. In the cluster,
// Helm renders these three names into the Deployment's env block.
cfg, err := load()
if err != nil {
fmt.Println("bad config:", err)
return
}
fmt.Printf("addr=%s level=%s maxconns=%d\n", cfg.Addr, cfg.LogLevel, cfg.MaxConns)
}
// Output:
// addr=:8080 level=info maxconns=10When to use it
- Run one service in staging and production where only the replica count and log level differ.
- Ship a new image tag with one command and keep a numbered history to roll back to.
- Hand another team a chart so they can install your service without reading your manifests.
- Print the exact YAML a deploy would send, and diff it, before anything reaches the cluster.
More examples
The files in a chart
Four files, shown together so you can see how little the per-environment file holds. The two versions in Chart.yaml are not the same thing and both matter: you bump version when you change the templates, appVersion when you ship a new build of the program. Helm merges values maps key by key, so values-prod.yaml keeping only LOG_LEVEL leaves the rest of env alone. Lists do not merge: give a list in an override file and it replaces the whole list from values.yaml, which surprises people the first time.
# charts/shop-api/
# Chart.yaml
# values.yaml defaults, safe everywhere
# values-prod.yaml only what production differs in
# templates/
# _helpers.tpl named fragments, no output of its own
# deployment.yaml
# service.yaml
# .helmignore
# ---------- Chart.yaml ----------
apiVersion: v2 # v2 means a Helm 3 chart
name: shop-api
description: The shop API
type: application
version: 0.3.1 # the chart's version, bumped when you edit it
appVersion: "1.4.2" # your program's version; quoted so 1.4 stays text
# ---------- values.yaml ----------
replicaCount: 2
image:
repository: registry.example.com/shop-api
tag: "" # empty means "use appVersion"
pullPolicy: IfNotPresent
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 256Mi}
env:
LOG_LEVEL: info
# ---------- values-prod.yaml ----------
replicaCount: 6
env:
LOG_LEVEL: warnA template, with the longhand it stands for
Everything a service-shaped chart usually needs. The dash in {{- trims the whitespace before the action, which is why the rendered YAML does not end up with blank lines and stray spaces; nindent then supplies exactly the indentation the surrounding block needs, and getting that number wrong is the most common chart bug. range over a map visits the keys in sorted order, so the rendered env block is the same every time and a diff against the previous release is meaningful. quote around $value matters: an unquoted on or 1.10 is not the string you meant once YAML has read it.
apiVersion: apps/v1
kind: Deployment
metadata:
# include returns a string, so it can be piped. This is the short form.
name: {{ include "shop-api.fullname" . }}
labels:
{{- include "shop-api.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: app
# An empty tag falls back to appVersion from Chart.yaml.
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- range $name, $value := .Values.env }}
- name: {{ $name }}
value: {{ $value | quote }}
{{- end }}
# Longhand for the labels line near the top. `template` is a statement,
# not a value, so it cannot be piped and has to be indented by hand:
#
# labels:
# {{ template "shop-api.labels" . }}
#
# and `| nindent 4` is the same as a newline followed by `| indent 4`.The commands
helm template is the habit worth building: read the YAML before the cluster does. In a deploy pipeline, --wait makes Helm wait until the new pods are ready instead of returning as soon as the objects are accepted, and --atomic rolls the release back by itself if they never become ready, so a failed deploy leaves the previous version running. helm get values answers the question people get wrong most often, which is what the live release is actually configured with; Helm 3 stores that in a Secret in the release's own namespace, not in a server-side component.
# Render the chart and print the YAML. No cluster involved.
helm template shop-api ./charts/shop-api -f charts/shop-api/values-prod.yaml
# Static checks: missing fields, bad templates, odd values.
helm lint ./charts/shop-api
# Install if absent, upgrade if present: one command for both cases.
# The longhand is `helm install` or `helm upgrade`, chosen by hand.
helm upgrade --install shop-api ./charts/shop-api \
--namespace shop --create-namespace \
-f charts/shop-api/values-prod.yaml \
--set image.tag=1.4.2 \
--wait --timeout 5m --atomic
# Revisions of this release, what values each used, then step back.
helm history shop-api -n shop
helm get values shop-api -n shop
helm rollback shop-api 3 -n shop
Discussion