Kubernetes Deployments: Jobs, Init Containers, and Helm Hooks
Spring Boot’s Liquibase auto-run works fine for a single instance. In Kubernetes, where multiple pods start simultaneously, auto-run at application startup creates a race: every pod acquires DATABASECHANGELOGLOCK, one holds it, the rest wait, and if Kubernetes kills a pod mid-migration (because it failed its readiness probe while waiting on the lock), the lock remains set and blocks every subsequent pod.
The solution is to run migrations exactly once, before application pods start, using either an init container or a Helm pre-upgrade Job.
The Problem with Auto-Run in Kubernetes
When a Kubernetes Deployment scales from 0 to 3 replicas, all three pods start simultaneously. Each Spring Boot pod tries to run Liquibase:
Pod A: Acquires DATABASECHANGELOGLOCK → running migrations...
Pod B: Waiting for lock (LOCKED=1)...
Pod C: Waiting for lock (LOCKED=1)...
If Pod A takes more than the readiness probe timeout to complete migrations:
Kubernetes: Pod A failed readiness probe → killing Pod A
Pod A killed mid-migration → LOCKED=1 left in DATABASECHANGELOGLOCK
Pod B: Still waiting for lock...
Pod C: Still waiting for lock...
Now all pods are stuck waiting for a lock held by a dead process. The deployment hangs until someone manually runs liquibase releaseLocks.
The fix: Run Liquibase once, as a separate process, before application pods start. When application pods start, spring.liquibase.enabled=false — they connect to an already-migrated database.
Approach 1: Helm Pre-Upgrade Job (Recommended)
A Kubernetes Job runs migration to completion before Helm proceeds with the deployment. The helm.sh/hook: pre-install,pre-upgrade annotation tells Helm to create and complete this Job before updating the application Deployment.
Helm chart structure
charts/ecommerce/
├── Chart.yaml
├── values.yaml
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap-changelog.yaml ← Liquibase changelogs as ConfigMap
│ └── job-migration.yaml ← Migration Job with Helm hook
templates/configmap-changelog.yaml
For small changelog sets, store them in a ConfigMap (mounted into the migration Job):
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "ecommerce.fullname" . }}-changelog
labels:
{{- include "ecommerce.labels" . | nindent 4 }}
data:
db.changelog-master.yaml: |
databaseChangeLog:
- includeAll:
path: /liquibase/changelog/migrations/
relativeToChangelogFile: false
For larger changelog sets, package the changelogs into the application Docker image and reference them from there (shown in the Job spec below).
templates/job-migration.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "ecommerce.fullname" . }}-db-migration-{{ .Release.Revision }}
labels:
{{- include "ecommerce.labels" . | nindent 4 }}
annotations:
# Run before install and upgrade; delete Job after completion
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
backoffLimit: 3 # Retry up to 3 times on failure
activeDeadlineSeconds: 600 # Fail the hook after 10 minutes
template:
metadata:
labels:
app: {{ include "ecommerce.fullname" . }}-migration
spec:
restartPolicy: OnFailure
containers:
- name: liquibase
image: liquibase/liquibase:4.31.1
args:
- "--changelog-file=/liquibase/changelog/db.changelog-master.yaml"
- "--url=$(DB_URL)"
- "--username=$(DB_MIGRATION_USER)"
- "--password=$(DB_MIGRATION_PASSWORD)"
- "--contexts=$(LIQUIBASE_CONTEXTS)"
- "update"
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: {{ include "ecommerce.fullname" . }}-db-secret
key: migration-url
- name: DB_MIGRATION_USER
valueFrom:
secretKeyRef:
name: {{ include "ecommerce.fullname" . }}-db-secret
key: migration-username
- name: DB_MIGRATION_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "ecommerce.fullname" . }}-db-secret
key: migration-password
- name: LIQUIBASE_CONTEXTS
value: {{ .Values.liquibase.contexts | default "default" | quote }}
volumeMounts:
- name: changelog
mountPath: /liquibase/changelog
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: changelog
# Mount changelogs from the application image via an init container
# OR use a ConfigMap for small changelog sets
emptyDir: {}
initContainers:
- name: copy-changelog
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
command: ["/bin/sh", "-c", "cp -r /app/resources/db/changelog/* /changelog/"]
volumeMounts:
- name: changelog
mountPath: /changelog
values.yaml
image:
repository: your-registry/ecommerce-api
tag: latest
liquibase:
contexts: default
db:
secretName: ecommerce-db-secret
Secret (applied separately, not in Helm chart):
kubectl create secret generic ecommerce-db-secret \
--from-literal=migration-url='jdbc:mysql://mysql-host:3306/ecommerce?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC' \
--from-literal=migration-username='lb_migration_user' \
--from-literal=migration-password='supersecret'
Deployment with Liquibase disabled:
# templates/deployment.yaml
env:
- name: SPRING_LIQUIBASE_ENABLED
value: "false" # Migration runs in the Job; app pods skip it
Approach 2: Init Container
An init container runs inside each pod before the main container starts. Migrations are guaranteed to complete before the application container starts for that specific pod.
# templates/deployment.yaml (relevant section)
spec:
initContainers:
- name: liquibase-migration
image: liquibase/liquibase:4.31.1
args:
- "--changelog-file=/changelog/db.changelog-master.yaml"
- "--url=$(DB_URL)"
- "--username=$(DB_MIGRATION_USER)"
- "--password=$(DB_MIGRATION_PASSWORD)"
- "update"
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: ecommerce-db-secret
key: migration-url
- name: DB_MIGRATION_USER
valueFrom:
secretKeyRef:
name: ecommerce-db-secret
key: migration-username
- name: DB_MIGRATION_PASSWORD
valueFrom:
secretKeyRef:
name: ecommerce-db-secret
key: migration-password
volumeMounts:
- name: changelog
mountPath: /changelog
containers:
- name: ecommerce-api
image: your-registry/ecommerce-api:latest
env:
- name: SPRING_LIQUIBASE_ENABLED
value: "false"
Init Container vs Helm Job: Choosing the Right Approach
| Consideration | Init Container | Helm Job |
|---|---|---|
| Runs on every pod start | Yes — every new/restarted pod | No — once per Helm release |
| Multiple pods starting simultaneously | Each runs update (idempotent, lock-protected) | Only one Job runs |
| Migration fails | Pod stays in Init:Error, app pod doesn’t start | Helm deploy fails, existing pods unchanged |
| Lock stuck from previous pod kill | Next pod waits for lock timeout | Job retry handles it |
| Rollback possible via Helm | No — migrations already ran | No — migrations already ran |
Use Helm Job (pre-upgrade hook) when:
- You want migrations to run exactly once per deployment, not per pod
- Your deployment scales to many replicas (N init containers all acquiring the lock is wasteful)
- You want to decouple migration completion from pod health
Use init container when:
- You don’t use Helm
- Your deployment is single-replica
- You want the guarantee that each pod individually verified migrations before starting
Handling DATABASECHANGELOGLOCK in Kubernetes
The biggest operational issue with Liquibase in Kubernetes is a stuck lock from a killed pod. Options for handling it:
Option 1: Configure lock wait time
# In application.yml or passed to liquibase CLI
spring:
liquibase:
parameters:
liquibase.lock-wait-time-in-minutes: 2 # Default is 5 minutes
Or via Liquibase CLI:
liquibase --changelog-lock-wait-time-in-minutes=2 update
After 2 minutes of waiting for the lock, Liquibase fails the Job. Helm marks the hook as failed. The deployment does not proceed. Kubernetes restarts the Job (up to backoffLimit times).
Option 2: Release locks before migration (risky if another process is running)
In the Helm Job, add a releaseLocks step before update:
args:
- /bin/sh
- -c
- |
liquibase --url="${DB_URL}" --username="${DB_MIGRATION_USER}" --password="${DB_MIGRATION_PASSWORD}" \
--changelog-file=/changelog/db.changelog-master.yaml \
releaseLocks
liquibase --url="${DB_URL}" --username="${DB_MIGRATION_USER}" --password="${DB_MIGRATION_PASSWORD}" \
--changelog-file=/changelog/db.changelog-master.yaml \
update
Only use this if you are certain no other Liquibase process is actively holding the lock — which is guaranteed with a single Helm Job.
Option 3: Kubernetes liveness probe timeout
Size your Job’s activeDeadlineSeconds to be longer than any expected migration. If migration takes more than activeDeadlineSeconds, Kubernetes kills the Job and Helm fails the hook.
Disabling Spring Boot Liquibase Auto-Run
When migrations run in a separate Job or init container, the application must not also run Liquibase at startup:
# values.yaml
env:
- name: SPRING_LIQUIBASE_ENABLED
value: "false"
Or in application-kubernetes.yml:
spring:
liquibase:
enabled: false
Activate via profile:
# In the Deployment
env:
- name: SPRING_PROFILES_ACTIVE
value: kubernetes
Docker Image for Liquibase CLI
Use the official Liquibase Docker image in Jobs and init containers:
liquibase/liquibase:4.31.1
If you need the MySQL JDBC driver bundled (the official image includes common drivers), verify it:
docker run --rm liquibase/liquibase:4.31.1 \
--url=jdbc:mysql://host:3306/db \
--username=user --password=pass \
validate
If the driver is missing, build a custom image:
FROM liquibase/liquibase:4.31.1
COPY mysql-connector-j-*.jar /liquibase/lib/
Complete Helm Deployment Flow
# 1. Build and push application image (includes changelogs)
docker build -t your-registry/ecommerce-api:v1.2.0 .
docker push your-registry/ecommerce-api:v1.2.0
# 2. Update values.yaml image tag
# image.tag: v1.2.0
# 3. Helm upgrade (triggers pre-upgrade Job automatically)
helm upgrade --install ecommerce ./charts/ecommerce \
--namespace ecommerce \
--set image.tag=v1.2.0 \
--set liquibase.contexts=prod,default \
--wait \ # Wait for all resources including hooks to complete
--timeout 10m
# What happens:
# Step 1: Helm runs the pre-upgrade Job (migration)
# Step 2: Job runs: validate → update
# Step 3: Job completes successfully
# Step 4: Helm updates the Deployment (with spring.liquibase.enabled=false)
# Step 5: New pods start (migration already done)
If the migration Job fails, helm upgrade exits with an error. The existing Deployment is unchanged — the old pods continue serving traffic. No partial deployment.
Common Mistakes
Leaving spring.liquibase.enabled=true alongside the init container: Both the init container and each Spring Boot pod try to run migrations. The init container holds the lock while running; the application container waits for it to release — which defeats the purpose of the init container. Always disable Spring Boot auto-run when using external migration.
Not setting backoffLimit on the migration Job: A migration that fails once (e.g., transient network issue connecting to MySQL) would mark the entire Helm hook as failed with no retry. Set backoffLimit: 3 to allow retries.
Using the same database user for migration and runtime: The migration Job needs DDL privileges (CREATE, ALTER, DROP). The application pods need only DML (SELECT, INSERT, UPDATE, DELETE). Use separate secrets for DB_MIGRATION_USER and DB_RUNTIME_USER.
Best Practices
- Helm pre-upgrade Job over init containers for multi-replica deployments — runs once, not per pod
--waitinhelm upgrade— ensures the migration Job completes before Helm returns successactiveDeadlineSecondson the Job — prevents a stuck migration from blocking the deployment indefinitely- Separate migration user from runtime user — scope credentials to the minimum required for each role
SPRING_LIQUIBASE_ENABLED=falsein application pods — migrations run in the Job; the app must not re-run them- Package changelogs in the application image — ensures the migration Job and application pods always use the same changelog version
What You’ve Learned
- Spring Boot auto-run in Kubernetes causes lock contention when multiple pods start simultaneously
- Helm pre-upgrade Job runs migrations once before the Deployment is updated — the cleanest Kubernetes pattern
- Init containers run migrations per pod — simpler but wasteful for multi-replica deployments
SPRING_LIQUIBASE_ENABLED=falsedisables Spring auto-run when an external migration process is usedbackoffLimitandactiveDeadlineSecondson the Job control retry and timeout behaviour- The migration user needs DDL privileges; the runtime application user needs only DML
Next: Article 22 — Adopting Liquibase on an Existing Production Database — the full step-by-step process for onboarding a database that has been running in production without version control.