Authoring and Shipping Helm Charts
The Template Engine — Producing Strings and Pretending They Are YAML
Summary in one line
Helm does not edit YAML. It builds a string with Go templates and parses it as YAML only after it is finished.
Why this is needed
If you do not know this one sentence, you lose a day. You add a resources block and it vanishes wholesale from the rendered output, or you insert a single value and get a parser error. The cause is almost always indentation. To the template engine, YAML is just characters, and a block that is off by two spaces does not become an error but a document with a different meaning.
So using Helm well is not about memorizing many functions but about always being aware of "in what shape does the string get inserted at this spot?" The reason four things — toYaml, nindent, default, and required — cover 80 percent of real-world use is all tied to this sense.
How it works
Template processing has two stages: parsing and execution. In parsing, the template text becomes a syntax tree, and in execution, a data context (the thing shown as a single dot) is applied to produce the final string. The built-in objects available during execution are .Values (the merged result of defaults and user values), .Release (name, namespace, revision), .Chart (the contents of Chart.yaml), .Capabilities (the APIs the cluster supports), .Files, and .Template.
The core functions divide up like this.
| Function | When to use it | Pitfall |
|---|---|---|
default |
Decides what to use in place of an empty value | Defaulting to latest makes deployments that cannot be reproduced |
required |
Fails the rendering itself if the value is missing | The message is useful only if it says "what is missing" |
toYaml |
Spreads a map or list out wholesale as a string | Used alone, the indentation does not line up |
nindent |
Puts a newline first and indents by n spaces | indent has no newline, so the first line sticks to the preceding key |
range |
Expands a list or a map | When looping over a map, the key order is sorted and therefore deterministic |
include |
Calls a named template | template prints its result directly and cannot be chained with a pipe |
The difference between template and include looks minor but is decisive. template spits the rendering result out on the spot, so you cannot attach a pipe after it. include returns the result as a string, so you can chain post-processing such as | nindent 4. That is why every place that needs indentation, such as a label block, uses include.
There are also rules for where values come from. From weakest to strongest, it is the chart's values.yaml → the value files given with -f (in order from left to right) → --set. There is one more thing to remember here. Maps are merged deeply, but lists are replaced as a whole. If you design environment variables as a list, changing just one item through a value file becomes impossible. So it is better to design "values that are often overridden" as maps.
One last convention. Even when the content of a ConfigMap changes, the Pods stay as they are. Because the Pod spec has not changed, no rollout happens. That is why you put a hash of the configuration file into the Pod template annotations as checksum/config. When the content changes the hash changes, and when the hash changes the Pod spec changes, so a rollout naturally occurs.
What it looks like in the field
First, the vanished block. If resources or nodeSelector is entirely absent from the rendered output, the value was empty or the indentation was off. If a single field is wrong you get an error, but if the indentation of a whole block is off, it quietly attaches somewhere else or vanishes. In such cases, looking at the helm template output with your own eyes is the only way to diagnose.
Second, the lookup trap. The lookup function, which queries the cluster, always returns an empty result in helm template. This is the typical reason a conditional that worked fine locally behaves differently in a real installation.
Third, reproducibility. If you use now or a random function inside a template, the result differs every time it is rendered, so every deployment is detected as a change. With a GitOps tool, it ends up in a state where synchronization never finishes.
Places where templates are quietly wrong
Helm templates are a tool for producing strings, so if the syntax is right, they pass even when the meaning is wrong. Decide on four frequent traps and check for them.
Indentation goes off. The result of toYaml comes out without indentation, so
align it with nindent. The difference between indent and nindent is whether a newline is put first.
resources:
{{- toYaml .Values.resources | nindent 2 }}
An empty value and a missing value are different. If .Values.foo is missing it becomes <no value>,
and that string goes into the YAML as it is. Block it with required or fill it in with default.
image: {{ required "image.repository 가 필요합니다" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
Numbers and strings get swapped. YAML reads 1.0 as a number and "1.0" as a string.
If a tag is 1.10, it is read as a number and becomes 1.1, an accident that really happens. For values that must be
strings, such as tags and ports, add quote.
tag: {{ .Values.image.tag | quote }}
The scope of if and with differs. with changes ., so inside it you must not
use .Values or .Release as they are. In that case, point to the top level with $.
{{- with .Values.ingress }}
host: {{ .host }}
release: {{ $.Release.Name }}
{{- end }}
Check in three stages. Syntax, result, and the difference from the actual cluster.
helm lint .
helm template . -f values-prod.yaml | kubeconform -strict -
helm template . -f values-prod.yaml | kubectl diff -f -
helm template does not look at the cluster, so the lookup function gives an empty value.
A template that depends on it cannot be judged from the rendered result alone.
What you will do in the next lab
In the /root/helm/tpl/labhub-api chart, you fill empty values with default, enforce required values with required and make it fail on purpose. You pass the resource block wholesale with toYaml and nindent, and expand environment variables with range. You apply two value files and --set at the same time to see which one wins, and finally build a complete rendering that has a configuration hash and a security context.