# Migration Schema Zero-Downtime di Atas Rolling Deploy

> Rolling deploy menjalankan release lama dan baru di atas satu schema selama window yang tidak Anda kendalikan. Mekanika deploy yang menentukan isi migration.

URL: https://www.matthewswong.com/id/blog/zero-downtime-migration-rolling-deploy/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-09-24
- Updated: 2026-09-24
- Category: DevOps

> **Key takeaway:** 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.

## Window overlap itu sebuah keadaan, bukan sesaat

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.

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

## Migration yang jalan sekali per replica

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.

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

## Jalankan sekali, sebelum satu pod pun diganti

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.

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.

*Band kedua adalah yang harus dipenuhi setiap migration, tetapi band terakhirlah yang menentukan apa yang boleh dilakukan migration itu sejak awal.*

## Kompatibel ke dua arah, bukan hanya ke depan

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.

## Backfill itu job, bukan langkah deploy

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.

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

## Rollback itu constraint-nya, dan ia berjalan mundur

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.

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.

*Panel ketiga bukan daftar SQL yang salah. Setiap barisnya adalah statement yang benar dan hanya berubah menjadi outage karena ada dua release yang hidup bersamaan.*

## Sebuah rename, deploy demi deploy

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

- Dokumentasi Kubernetes - Deployments: strategy rolling update, default maxUnavailable dan maxSurge, minReadySeconds, progressDeadlineSeconds, revision history dan rollout undo
- Dokumentasi Kubernetes - Pod Lifecycle: readiness probe, alur termination pod, dan default termination grace period
- Dokumentasi PostgreSQL - Explicit Locking, bagian 13.3.5 tentang advisory lock serta level session dan transaction-nya
- Dokumentasi PostgreSQL - Advisory Lock Functions: pg_try_advisory_lock, pg_advisory_unlock dan pg_advisory_unlock_all
- Dokumentasi Helm - Chart Hooks: pre-upgrade hook, hook weight, menunggu Job sampai tuntas, dan hook delete policy

## Source links

- https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
- https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/
- https://www.postgresql.org/docs/current/explicit-locking.html
- https://www.postgresql.org/docs/current/functions-admin.html
- https://helm.sh/docs/topics/charts_hooks/

## FAQ

### Berapa lama sebenarnya dua versi berjalan bersamaan saat rolling deploy?

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.

### Kenapa migration database tidak boleh dijalankan di start-up aplikasi?

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.

### Bagaimana mencegah dua job migration berjalan bersamaan?

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.

### Bisakah saya rollback deployment setelah migration terlanjur berjalan?

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.

### Perubahan schema apa saja yang aman dikirim dalam satu deploy?

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.

---

- HTML version: https://www.matthewswong.com/id/blog/zero-downtime-migration-rolling-deploy/
- English: https://www.matthewswong.com/en/blog/zero-downtime-migration-rolling-deploy/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
