skip to content

How does a Helm chart run a database schema migration before the new pods start?

level: juniorimportance: must knowfreq 68%

answer

  1. Something must finish before new pods start
  2. A chart can carry lifecycle work, not just objects
  3. An annotation on a run-to-completion object
  4. helm.sh/hook: pre-install,pre-upgrade
  5. Same image, migrate subcommand

basics

~20 s

Ship the migration as a Kubernetes Job template inside the chart, annotated with helm.sh/hook set to pre-install,pre-upgrade. Helm applies that Job and waits for it to complete before applying the chart's own manifests, so the schema changes before any new pod starts.

solid answer

~50 s

You put the migration in the chart as an ordinary Job manifest under `templates/` and annotate it `helm.sh/hook: pre-install,pre-upgrade`. That annotation takes the manifest out of the normal apply: Helm holds it aside, applies it when the named event fires, and blocks until the Job reaches completion before it applies the Deployment and the rest of the chart. The Job normally runs the application's own image with a `migrate` subcommand and the same `.Values.image.tag` the Deployment will use, so the migration set and the code that needs it are one artifact. Because the Job is created fresh each time, its name must not collide with the one left from the previous release — either template the release revision into the name or give it a delete policy. `helm get hooks <release>` shows the rendered hook manifests, which do not appear in `helm get manifest`.

code

yaml · 17 lines
yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate-{{ .Release.Revision }}
  annotations:
    helm.sh/hook: pre-install,pre-upgrade
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
          args: ["migrate", "up"]
          envFrom:
            - secretRef:
                name: {{ .Release.Name }}-db

go deeper

for a junior

Be ready to name the annotation and the object: a Kubernetes Job under templates/ with helm.sh/hook set to pre-install,pre-upgrade. Knowing that Helm waits for it before applying the rest of the chart is the whole answer at this level.

for a middle

Explain the mechanics: hooks are held out of the release manifest, applied at their event, and waited on to completion, which is what guarantees no new pod starts against the old schema. Mention the naming collision a created-not-updated Job causes.

for a senior

Show the operational judgement — which image the Job runs and why version-matching matters, that the wait is bounded by --timeout while Kubernetes keeps the Job running past it, and when you would deliberately move the migration out of the chart into a pipeline step instead.

for a principal

Own the decision of whether migrations belong in the chart at all across a fleet of services: the chart gives you an ordering guarantee at the cost of coupling schema change to deploy, and you should be able to say which teams get which model and why.

### What a hook actually is A Helm chart is a directory of Go templates. Everything under `templates/` is rendered against the release's values and applied to the cluster as one set. A **hook** is one of those rendered manifests carrying the annotation `helm.sh/hook`. That annotation pulls the manifest out of the ordinary flow: Helm does not apply it alongside the chart's Deployments and Services, and it does not store it in the release manifest that Helm keeps and diffs. Instead Helm holds it aside, applies it at the lifecycle event the annotation names, waits for it to reach a terminal state, and only then continues with the release. That is exactly the shape a schema migration needs. The migration must run while the old code is still the only code running, and the new code must not start until it has succeeded. ### Why a Job, and why those events The object is a Kubernetes Job because a Job runs a Pod to completion and reports a terminal success or failure — the two outcomes Helm needs to decide whether to proceed. A Deployment never completes, so Helm would have nothing to wait for. The annotation normally lists two events: `helm.sh/hook: pre-install,pre-upgrade`. The first install faces an empty database that needs the schema created; every later `helm upgrade` faces a schema one or more versions behind the image about to roll out. A chart that lists only `pre-upgrade` silently skips the very first install, and the application starts against an empty database. ### Which image the Job runs The conventional choice is the application's own image with a migration entrypoint (`args: ["migrate", "up"]`), rendered from the same `.Values.image.repository` and `.Values.image.tag` as the Deployment. That keeps the migration files and the code that depends on them in one versioned artifact: you cannot deploy code whose migration is missing, because they ship together. A separate migration image is possible, but then two version numbers have to be kept in step by hand. ### Waiting Once Helm creates the hook it watches the Job until it completes. This waiting is intrinsic to hooks — it is not something you switch on. It is, however, bounded: Helm's `--timeout` (five minutes by default) applies to the wait, and when it expires Helm treats the release as failed. Note what that does *not* do: Kubernetes keeps running the Job, because nothing told it to stop. A migration that routinely runs longer than the timeout therefore needs the timeout raised, or the long part moved out of the hook entirely. ### Naming and collisions A hook Job is created, not updated. Helm does not perform a three-way merge on it the way it does on the chart's own resources. If a Job with the same name is still in the namespace from the previous release, the create fails — a Job's pod template is immutable, so there is no in-place path. Two conventions solve it: template something that changes into the name, ``` name: {{ .Release.Name }}-migrate-{{ .Release.Revision }} ``` or annotate the Job with a delete policy so Helm removes the old one first. Charts generated by `helm create` do not include a migration hook at all — this is a pattern you add. ### Where it sits relative to the rest of the chart The pre-upgrade phase runs, in full, before any of the chart's own manifests are applied. Several hooks on the same event are ordered among themselves by `helm.sh/hook-weight`. The practical consequence for a migration is the guarantee you actually wanted: no new Pod of the new version can be scheduled until the migration Job has reported success, because the new Deployment spec has not reached the API server yet. ### Patterns this replaces An init container on the application Pod is the common alternative and is usually wrong for migrations: every replica runs it, so a scale-out or a rollout starts several concurrent migrations against the same database. A human running the migration by hand before the deploy is unrecorded and gets forgotten during an incident. Running it as a separate pipeline step is defensible — it decouples the migration's failure from the release's — but it moves the ordering guarantee out of the chart and into whatever runs the steps. ### Inspecting it `helm get hooks <release>` prints the rendered hook manifests for a release; `helm get manifest` deliberately does not include them. `helm template` renders hooks along with everything else, which is how you check the annotation and the name are what you meant before you ever touch a cluster.

  • Why run the migration from the application's own image rather than a dedicated migration image?
    Because the migration files and the code that requires them then ship as one versioned artifact, rendered from the same `.Values.image.tag` as the Deployment. With a separate image you have two versions to keep in step by hand, and the failure mode is a deploy whose matching migration was never built.
  • If the annotation lists only pre-upgrade, what happens on the very first install?
    The hook never fires, so the application starts against a database with no schema. Charts list `pre-install,pre-upgrade` together for exactly that reason — the install case needs the schema created, the upgrade case needs it advanced.
  • Does the migration Job show up in helm get manifest?
    No. Helm stores hook manifests separately from the release manifest, so `helm get manifest` shows the chart's own objects only; `helm get hooks` prints the hooks. `helm template` renders both, which is the cheap way to verify the annotation before touching a cluster.

The hook is the airlock: the door to the new cabin does not open until the pressure check inside the airlock has finished and reported success.

saying these in an interview costs you the question

  • Thinks any Job in a chart runs before the Deployment
  • Puts the migration in an init container on every replica
  • Expects Helm to update an existing hook Job in place
  • Believes hooks appear in helm get manifest
  • Assumes Helm applies the Deployment while the Job still runs

context