Migration Schema Zero-Downtime di Atas Rolling Deploy

Foto oleh Bill Boaden via Wikimedia Commons (CC BY-SA 2.0)
Lebih lama dari dugaan kebanyakan orang, dan angkanya tidak Anda tentukan langsung. Rolling update Kubernetes mengganti pod per batch yang dibatasi maxUnavailable dan maxSurge, keduanya default 25 persen, dan pod baru baru dihitung setelah ready dan bertahan ready selama minReadySeconds. Kalau readiness probe terus gagal, rollout macet, dan Kubernetes tidak melakukan apa pun pada Deployment yang macet selain melaporkan condition ProgressDeadlineExceeded, jadi overlap-nya bisa berlangsung tanpa batas.
Karena setiap replica ikut menjalankannya. Dengan lebih dari satu pod, beberapa proses mencoba migration yang sama bersamaan, dan memindahkannya ke init container justru mengalikannya dengan jumlah pod. Kegagalan yang mahal bukan statement gandanya, melainkan pod yang crash karena error itu, gagal readiness, lalu memacetkan rollout yang justru mendefinisikan window overlap Anda. Jalankan migration sekali per release sebagai job terpisah.
Jagai migration dengan advisory lock PostgreSQL pada satu key konstan. Lock ini bersifat application-defined, jadi tidak ada yang menegakkannya selain runner Anda sendiri, dan justru itu yang dibutuhkan. Pakai pg_try_advisory_lock yang mengembalikan false seketika alih-alih menunggu, sehingga proses yang kalah bisa melewatinya dan lanjut, bukan memblokir start-up. Advisory lock level session dilepas secara implisit di akhir session bahkan setelah disconnect yang tidak wajar, jadi pod yang di-kill tidak meninggalkan lock tersangkut.
Hanya kalau migration-nya tidak destruktif. Menjalankan kubectl rollout undo memulai rolling update lagi ke arah sebaliknya, tetapi schema tidak ikut mundur bersama pod, jadi release sebelumnya kembali di atas schema yang sudah Anda ubah. Kalau migration itu sudah drop atau rename sesuatu yang masih dipakai release sebelumnya, jalur rollback-nya hilang. Jauhkan setiap langkah contract dari release yang mungkin ingin Anda batalkan.
Sangat sedikit. Menambah table baru dan menambah column nullable memang aman, karena release sebelumnya tidak pernah menyebut keduanya di SQL yang ia generate. Menambah NOT NULL, rename, drop, mengubah tipe, atau menambah unique constraint semuanya merusak release yang masih berjalan, jadi masing-masing butuh urutan expand dan contract penuh yang dipecah ke beberapa deploy dengan backfill di antaranya.

Foto oleh Bill Boaden via Wikimedia Commons (CC BY-SA 2.0)
Ringkasan Utama
Rolling deploy menjalankan release sebelumnya dan release baru di atas satu schema pada saat yang sama, selama window yang bisa diperpanjang tanpa batas oleh rollout yang macet. Karena itu setiap migration harus kompatibel ke dua arah: jalankan sekali sebagai release-phase job, jangan pernah di bootstrap aplikasi, dan jangan menghapus apa pun yang masih dibutuhkan saat rollback.
Migration-nya sudah jalan. Rollout-nya belum. Pod baru terus naik dan gagal di readiness probe, pod lama masih melayani traffic di atas schema yang sudah berubah di bawahnya, dan release yang akan saya rollback justru satu-satunya release yang tidak bisa membaca schema itu. Belum ada yang mati. Semuanya tinggal berjarak satu perintah dari mati.
Tulisan ini soal deploy-nya, bukan SQL-nya. Expand-then-contract sudah dibahas di blog ini dan di sini saya anggap sudah diketahui. Yang ingin saya kunci adalah constraint yang membuat pola itu diperlukan sejak awal: selama rolling update, release lama dan release baru berjalan bersamaan di atas satu schema, selama window yang panjangnya tidak Anda kendalikan, sehingga setiap statement dalam migration harus benar untuk keduanya. Mekanika di bawah ini diambil dari dokumentasi Kubernetes Deployment dan Pod lifecycle, referensi advisory lock PostgreSQL, dan panduan Helm chart hooks.
Hal pertama yang harus diluruskan: window ini bukan beberapa detik di antara dua versi. Ini adalah keadaan yang didiami sistem, dan panjangnya ditentukan oleh field yang jarang disetel orang. Deployment dengan strategy RollingUpdate menurunkan ReplicaSet lama dan menaikkan yang baru di bawah dua batas, maxUnavailable dan maxSurge, yang keduanya default-nya 25 persen. Pod baru baru dihitung ke dalam rollout setelah statusnya available, dan sebuah pod available setelah ia ready dan bertahan ready selama minReadySeconds, yang default-nya nol. Begitu readiness digantungkan pada health check yang sungguhan, window menjadi selama waktu warm-up pod paling lambat Anda, dikali jumlah batch yang diizinkan kedua batas tadi.
# The five fields that decide how long the old release and the new one overlap.
# None of them is about the database, and all of them constrain the migration.
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 6
minReadySeconds: 10 # a pod is NOT available until it has been ready
# this long. Default 0. Raising it lengthens the window.
progressDeadlineSeconds: 600 # default. On expiry Kubernetes only ADDS a
# condition - it does not roll anything back.
revisionHistoryLimit: 10 # default. This is the list of revisions you
# could still roll back to, and therefore the
# list your schema must stay compatible with.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # default is 25%
maxSurge: 1 # default is 25%
template:
spec:
terminationGracePeriodSeconds: 30 # default. The last old pod is still
# querying the NEW schema for this long.
containers:
- name: erp-api
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 5 # a probe that checks the DB will fail the whole
# rollout on a schema it cannot readBagian yang mengubah desain adalah apa yang terjadi ketika rollout tidak pernah selesai. Kalau pod baru gagal readiness probe, Deployment controller berhenti menaikkan ReplicaSet baru, dan dokumentasinya menyebut bahwa perilaku ini bergantung pada maxUnavailable. Pasang progress deadline dan setelah progressDeadlineSeconds, yang default-nya 600, controller menambahkan condition dengan reason ProgressDeadlineExceeded. Hanya itu. Dokumentasinya menyatakannya terang-terangan: Kubernetes tidak melakukan tindakan apa pun pada Deployment yang macet selain melaporkan status condition. Rollout yang di-pause bahkan membuat pengecekan deadline berhenti sama sekali, jadi Deployment yang di-pause duduk di keadaan overlap selama Anda membiarkannya.
Di ujung yang mulus pun masih ada ekornya. Ketika pod lama terakhir akhirnya dihapus, ia diberi termination grace period yang default-nya 30 detik, dan preStop hook mana pun berjalan di dalam jatah itu. Jadi rollout yang berjalan sempurna sekalipun berakhir dengan release sebelumnya masih memegang koneksi terbuka dan menyelesaikan request yang sedang berjalan di atas schema baru. Cara paling jujur merencanakan migration adalah berasumsi overlap berlangsung dalam hitungan menit, dan bisa bertahan sampai ada orang yang sadar.
Cara paling umum merusak semua ini adalah menjalankan migration dari jalur start-up aplikasi itu sendiri. Kelihatannya rapi, jalan di laptop dengan satu proses, dan langsung gagal begitu replica-nya lebih dari satu, karena setiap pod baru menjalankan migration yang sama pada waktu yang sama. Memindahkannya ke init container tidak menolong: init container berjalan per Pod, jadi pekerjaannya sekarang dikali jumlah pod, bukan dikali jumlah proses. Yang Anda butuhkan adalah job yang berjalan sekali per release, dan keduanya bukan itu.
Apa yang sebenarnya terjadi ketika dua replica memulai migration yang sama bersamaan layak diuraikan, karena double-apply bukan bagian yang mahal. Koneksi pertama mengambil lock-nya dan menerapkan perubahan. Koneksi kedua mengantre di belakangnya, lalu entah menerapkan perubahan yang sama lagi atau gagal dengan error seperti column sudah ada. Migration yang melempar error saat start-up ikut menjatuhkan pod-nya, pod itu crash-loop, pod yang crash-loop tidak pernah lolos readiness, dan rollout tanpa pod yang ready persis adalah rollout macet dari bagian sebelumnya. Bug-nya adalah race di entrypoint; outage-nya adalah window overlap yang tidak mau tertutup.
Kalau migration terpaksa berjalan dari tempat yang tidak bisa Anda buat single-shot, jagai dengan advisory lock PostgreSQL. Advisory lock bersifat application-defined dan server tidak memaksakan penggunaannya, jadi ia adalah konvensi yang dipegang migration runner terhadap dirinya sendiri, dan justru itu yang dibutuhkan di sini. Ambil lock level session pada satu key konstan: pg_try_advisory_lock mengembalikan false seketika alih-alih menunggu, sehingga replica yang kalah bisa melewati migration dan lanjut hidup, bukan memblokir start-up. Advisory lock level session tidak mengikuti semantik transaction, jadi transaction yang di-rollback tetap memegang lock-nya, dan pg_advisory_unlock_all dipanggil secara implisit di akhir session bahkan ketika client terputus tidak wajar. Properti terakhir itulah alasan pod migration yang di-kill tidak meninggalkan lock yang tersangkut selamanya.
-- guarded-migrate.sql
-- ONE psql session, start to finish:
-- psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f guarded-migrate.sql
-- A session-level advisory lock dies with its session, so taking the lock in
-- one psql call and running the migration in a second one leaves you
-- completely unguarded. That was my first version of this file.
-- The key is a constant you choose once. It names the migration runner,
-- not the migration.
SELECT pg_try_advisory_lock(4021995) AS got_lock \gset
\if :got_lock
\echo 'lock acquired, applying migrations'
\i migrations/0042_add_amount_cents.sql
SELECT pg_advisory_unlock(4021995);
\else
-- The loser must NOT fail. A non-zero exit here crash-loops the pod, the pod
-- never passes readiness, and the rollout stalls in the overlap state.
\echo 'another process holds the migration lock, skipping'
\endif
-- pg_try_advisory_lock returns false immediately rather than waiting, and
-- pg_advisory_unlock_all runs implicitly at session end - even on an ungraceful
-- disconnect - so a pod killed mid-migration cannot strand the lock.Jangan jalankan migration lewat connection pooler dengan mode transaction pooling. Advisory lock level session baru bermakna kalau session itu milik Anda selama seluruh job berjalan, sementara transaction pooling menyerahkan statement Anda ke backend mana pun yang sedang kosong. Arahkan migration langsung ke database.
Pola yang menghapus seluruh kelas masalah ini adalah release-phase job: satu container, dijalankan sampai selesai, sebelum rollout dimulai. Di Helm itu adalah pre-upgrade hook, yang dokumentasinya jelaskan sebagai berjalan pada permintaan upgrade setelah template di-render tetapi sebelum resource mana pun diperbarui. Ketika resource hook-nya berupa Job, Helm menunggu sampai job itu berjalan tuntas, dan jika hook gagal maka release-nya gagal. Ini operasi yang memblokir, dan justru itu intinya.
# templates/migrate-job.yaml
# pre-upgrade runs after the templates are rendered but BEFORE any resource is
# updated, and Helm blocks on a Job hook until it runs to completion. If it
# fails, the release fails - and not one pod has been replaced.
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ .Release.Name }}-migrate"
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5" # sorted ascending; the default weight is 0
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 0 # a migration that failed wants a human,
# not a blind retry against a half-applied schema
template:
spec:
restartPolicy: Never
containers:
- name: migrate
# the tag being upgraded TO. The migration belongs to the incoming
# release, not the one still serving traffic.
image: "registry.example.com/erp-api:{{ .Values.image.tag }}"
command:
["psql", "-v", "ON_ERROR_STOP=1", "-f", "guarded-migrate.sql"]Baca apa yang Anda dapat dari situ dalam kerangka window overlap. Migration yang gagal sekarang menghasilkan nol pod baru, jadi tidak ada version skew apa pun yang perlu dipikirkan, hanya release yang tidak jadi. Migration yang berhasil sudah selesai sebelum pod baru pertama dijadwalkan, jadi window terbuka dengan schema yang sudah dalam bentuk akhirnya dan menyisakan satu variabel saja: apakah release lama sanggup hidup dengannya. Beri hook itu weight yang eksplisit, karena Helm mengurutkan hook menurut weight secara menaik dan memberi nilai nol secara default, lalu pasang delete policy atau TTL pada Job-nya, karena resource hook tidak dilacak sebagai bagian dari release dan job yang selesai akan menumpuk.
Bentuk yang sama ada tanpa Helm. Mekanisme apa pun yang menjalankan satu container sampai tuntas dan menolak lanjut ketika gagal sudah cukup: release phase milik platform, job CI yang menunggu migration sebelum menerapkan image baru, atau Job biasa yang Anda apply lalu tunggu. Yang penting adalah satu eksekusi per release, bukan satu per replica, dan rollout berada di hilir dari exit code-nya.

Sebuah migration tidak aman atau tidak aman secara mandiri. Ia kompatibel, atau tidak, dengan sepasang release tertentu yang akan berjalan saat ia mendarat. Jadi ada dua pertanyaan per perubahan, dan kebanyakan review hanya menanyakan yang pertama. Bisakah release baru berjalan di atas schema baru? Dan bisakah release sebelumnya, yang masih melayani traffic dan yang akan dikembalikan utuh oleh sebuah rollback, juga berjalan di atas schema baru? Tabel di bawah adalah cara saya memilah satu change set sebelum menulis SQL apa pun.
| Perubahan | Apa yang dilakukan release sebelumnya | Satu deploy? | Yang dibutuhkan sebagai gantinya |
|---|---|---|---|
| Menambah table baru | Tidak pernah query, tidak pernah sadar | Ya | Tidak ada |
| Menambah column nullable | Daftar column yang di-mapping tidak memuat nama baru | Ya | Tidak ada |
| Menambah NOT NULL pada column | Insert-nya tidak menyertakan column itu lalu gagal | Tidak | Default dan backfill dulu, constraint terakhir |
| Rename column | Melakukan SELECT pada column yang sudah tidak ada | Tidak | Tambah, dual-write, pindahkan read, baru drop |
| Drop column | Masih mencantumkannya di setiap SELECT yang digenerate | Tidak | Berhenti menulisnya di satu release, drop di release berikutnya |
| Mengubah tipe column | Membaca nilai yang ditolak oleh mapping-nya | Tidak | Column baru, dual-write, pindahkan read, baru drop |
| Menambah unique constraint | Terus menulis duplikat yang selama ini diizinkan | Tidak | Bersihkan duplikat, build index concurrently, baru tegakkan |
Hanya dua baris pertama yang merupakan perubahan satu deploy, dan rasio itulah temuannya. Pertanyaan yang lebih menarik adalah apa arti backwards compatible dalam praktik, karena kompatibel dengan setiap release yang pernah dirilis bukan target yang bisa dikerjakan. Artinya kompatibel dengan revision yang bisa Anda rollback, dan Kubernetes menyimpan riwayat revision itu pada ReplicaSet lama yang dipertahankannya: sepuluh secara default, dan begitu sebuah ReplicaSet lama dihapus Anda kehilangan kemampuan rollback ke revision tersebut. Jadi targetnya adalah release tepat sebelumnya, dan Anda menjaga target itu tetap mudah dengan deploy cukup sering sehingga release sebelumnya tidak pernah tertinggal lebih dari satu langkah schema.
Mengisi column baru untuk baris yang sudah ada adalah langkah yang biasanya ditempelkan orang ke migration, dan justru itulah satu-satunya hal dalam rangkaian ini yang tidak boleh menempel pada deploy sama sekali. Backfill bisa berjalan lebih lama daripada window rollout yang masuk akal, jadi memblokir release phase padanya berarti window overlap berlangsung persis selama backfill berjalan. Ia juga perlu bisa dihentikan di tengah jalan, dan release phase adalah tempat yang buruk untuk sesuatu yang bisa diinterupsi. Migration menambahkan column-nya; job terpisah mengisinya; deploy berikutnya yang membacanya.
# Deliberately NOT a Helm hook. A hook would block the release for as long as
# the backfill runs, which is the exact thing this design is avoiding. Apply it
# after the migration release has settled and let it run on its own clock.
apiVersion: batch/v1
kind: Job
metadata:
name: backfill-amount-cents
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 86400 # tidy itself away a day after it finishes
template:
spec:
restartPolicy: Never
containers:
- name: backfill
image: registry.example.com/erp-api:2026.9.3
command: ["node", "scripts/backfill-amount-cents.js"]
env:
- name: BATCH_ROWS
value: "20000"
- name: PAUSE_MS
value: "200" # room for autovacuum and the replicasAda dua properti yang membuat job itu aman dibiarkan berjalan. Ia di-batch pada rentang key yang terbatas dengan tiap batch dalam transaction-nya sendiri, sehingga bisa dihentikan, dijalankan ulang, dan dilanjutkan dari titik terakhir tanpa menahan apa pun tetap terbuka, dan ia mencatat progress-nya sendiri alih-alih menghitung ulang. Lalu kode di kedua sisinya harus tahan terhadap column yang baru terisi separuh selama seluruh durasi itu, dan itu properti aplikasi, bukan properti job-nya. Itulah alasan sebenarnya backfill duduk di antara dua deploy dan bukan di dalam salah satunya: release yang mensyaratkan column itu lengkap adalah release yang Anda kirim setelah job selesai, dan sampai saat itu tidak boleh ada yang bergantung padanya.
-- The job's resume point lives in the database, not in the pod's memory.
-- Deleting the Job or losing the node must not restart the work.
CREATE TABLE IF NOT EXISTS backfill_progress (
job_name text PRIMARY KEY,
last_id bigint NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- One batch, one transaction: write the rows AND record the checkpoint
-- together, so the data and the resume point can never disagree, whatever
-- kills the pod between batches.
BEGIN;
UPDATE invoices
SET amount_cents = ammount_cents
WHERE id > :last_id
AND id <= :last_id + :batch_rows
AND amount_cents IS NULL;
INSERT INTO backfill_progress (job_name, last_id)
VALUES ('amount_cents', :last_id + :batch_rows)
ON CONFLICT (job_name) DO UPDATE
SET last_id = EXCLUDED.last_id, updated_at = now();
COMMIT;Rollback bukan undo. Menjalankan kubectl rollout undo memulai satu rolling update lagi ke arah sebaliknya, artinya keadaan overlap terjadi untuk kedua kalinya, dengan pasangan release yang sama, di atas schema yang sudah terlanjur Anda ubah. Schema tidak ikut mundur bersama pod, dan tidak ada bagian dari deploy tool yang berpura-pura sebaliknya. Fakta tunggal itu membuat migration destruktif dan rollback saling meniadakan: kalau migration sudah drop sebuah column, release sebelumnya tidak bisa kembali, dan Anda menukar mekanisme pemulihan tercepat Anda dengan kerapian senilai satu deploy.
# What rollout undo does, and the much more important thing it does not do.
kubectl rollout history deployment/erp-api
# REVISION CHANGE-CAUSE
# 41 image erp-api:2026.9.2
# 42 image erp-api:2026.9.3 (current)
kubectl rollout undo deployment/erp-api # back to revision 41
kubectl rollout undo deployment/erp-api --to-revision=41 # or name it
# This is another rolling update, not an undo. While it runs, 2026.9.2 and
# 2026.9.3 are BOTH live again - against the schema that 2026.9.3 migrated.
# Not one byte of the database is touched by either command above.
#
# So the real check happens before the migration is written, not after it runs:
kubectl rollout history deployment/erp-api --revision=41
# If THAT revision cannot run against the schema you are about to create,
# you have no rollback. Change the migration.Jadi aturan yang saya pegang sekarang adalah langkah contract tidak pernah ikut di release yang mungkin ingin saya batalkan. Drop menunggu sampai release yang berhenti memakai column itu menjadi satu-satunya yang berjalan selama satu siklus penuh, dan pada titik itu target undo pun sudah tidak membutuhkan column tersebut. Perlakukan reversibility sebagai properti migration, bukan properti pipeline, dan periksa dengan cara yang sama setiap kali: lihat dulu apa yang akan Anda rollback sebelum menulis perubahannya.
Sebelum menulis migration, jalankan kubectl rollout history pada Deployment dan lihat revision satu baris di atas yang sekarang. Itulah kode yang akan dikembalikan oleh rollback Anda. Kalau kode itu tidak bisa berjalan di atas schema yang hendak Anda buat, yang perlu diubah adalah migration-nya, bukan rencana rollback-nya.

Berikut rename kanonik itu dilihat dari sisi deploy, sudut pandang yang biasanya ditinggalkan oleh tulisan-tulisan tentang polanya. SQL adalah paruh yang mudah. Yang penting di rolling deploy adalah apa yang berlaku pada kode yang sedang berjalan di setiap langkah, termasuk kode yang sedang Anda gantikan, dan apakah Anda masih bisa melangkah mundur dari tempat Anda berdiri sekarang.
| Langkah | Release sebelumnya | Release baru | Bisa rollback? |
|---|---|---|---|
| Deploy 1: tambah column, tulis keduanya | Membaca dan menulis column lama, mengabaikan yang baru | Menulis keduanya, membaca yang lama | Ya, belum ada yang membaca column baru |
| Job backfill, di antara dua deploy | Tidak berubah, masih melayani | Tidak berubah, masih melayani | Ya, hentikan job-nya |
| Deploy 2: baca column baru | Menulis keduanya, membaca yang lama | Membaca yang baru, menulis keduanya | Ya, kedua column sama-sama mutakhir |
| Deploy 3: berhenti menulis column lama | Membaca yang baru, menulis keduanya | Membaca dan menulis yang baru saja | Ya, column lama masih ada |
| Deploy 4: drop column lama | Membaca dan menulis yang baru saja | Membaca dan menulis yang baru saja | Tidak, dan justru itu intinya |
Perhatikan bahwa kolom release sebelumnya dan kolom release baru tidak pernah berselisih soal nama pada baris yang sama. Itulah invarian yang dijaga oleh keempat deploy tadi, dan ia bisa diperiksa dengan membaca tabelnya, bukan dengan menalar SQL. Perhatikan juga di mana kata tidak muncul: hanya di baris terakhir, dan hanya setelah release sebelumnya sudah lebih dulu dibawa ke bentuk yang baru. Kalau urutan Anda memunculkan kata tidak lebih awal dari itu, berarti deploy-nya belum dipecah cukup halus.
Yang berubah bagi saya adalah pertanyaan yang saya ajukan pertama kali. Dulu pertanyaannya apakah migration ini aman, dan itu pertanyaan tentang SQL yang tidak punya jawaban sendirian. Sekarang pertanyaannya adalah dua release mana yang akan berjalan saat ini mendarat, dan apakah keduanya bisa hidup dengannya. Sisanya jatuh dari situ: job berjalan sekali karena migration per replica justru memacetkan rollout yang mendefinisikan window-nya, backfill duduk di luar deploy karena ia lebih panjang dari window, dan drop menunggu karena window terbuka lagi begitu Anda rollback.
Sumber