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.

Syntaxhelm upgrade --install shop-api ./charts/shop-api -f values-prod.yaml --set image.tag=1.4.2

Applying 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

Example · go
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=10

When 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.

Example · yaml
# 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: warn

A 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.

Example · yaml
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.

Example · bash
# 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

  • Be the first to comment on this lesson.