Chapter sebelumnya menutup dengan satu kata yang ternyata memikul dua pekerjaan berbeda: migrasi. Makna pertama adalah evolusi skema — tabel bertambah kolom, aturan berubah, indeks baru ditanam — pekerjaan yang terjadi setiap kali aplikasi berkembang, di atas data yang harus tetap hidup. Makna keduanya adalah upgrade versi engine — suatu hari cluster PostgreSQL 16 harus menjadi cluster PostgreSQL 18, dan keduanya sama sekali tidak bisa dipertukarkan sembarangan hanya karena keduanya bernama postgres. Chapter terakhir section ini menuntaskan keduanya: kita bangun pencatat versi schema_migrations yang membuat perubahan skema bisa diulang dan diaudit, menyerahkan ritmenya kepada pelari otomatis, lalu mengangkat cluster warisan dari image 16 ke image 18 lewat satu-satunya jalur yang aman — dump lama, restore baru.

Dua Makna Migrasi #

Dua makna itu berbeda pada hampir semua hal kecuali namanya, jadi mari kita pisahkan sejak awal:

MaknaYang berubahContohPenanganannya
Evolusi skemastruktur dalam databasekolom stok ditambahkan ke produkberkas migrasi bernomor + pelari migrasi
Upgrade engineversi server PostgreSQL itu sendiri16 → 18dump dari versi lama, restore ke versi baru

Keduanya berbagi satu dalil: data yang sudah hidup tidak boleh dibuang demi struktur yang baru. Initdb dari Chapter Inisialisasi Database hanya menjawab kelahiran cluster; untuk segala hal yang terjadi setelahnya, kedua makna migrasi ini adalah satu-satunya jalan.

Masalah dengan CREATE TABLE IF NOT EXISTS #

Kita sebenarnya pernah menjumpai migrasi sederhana di Section Aplikasi Multi-Container: migrate.js milik stack toko menjalankan CREATE TABLE IF NOT EXISTS produk lalu menanam data awal bila tabelnya kosong. Pendekatan idempoten itu pas untuk aplikasi muda — dijalankan berapa pun kali, hasilnya sama. Tapi coba kita ajukan pertanyaan yang hanya muncul setelah proyek berumur beberapa bulan: migrasi mana saja yang sudah diterapkan pada database ini? Kapan diterapkannya? Apakah mesin Siti dan mesin Budi memegang versi skema yang sama? IF NOT EXISTS tidak bisa menjawab satu pun — ia hanya tahu “sudah ada atau belum”, tanpa riwayat, tanpa urutan, tanpa bukti. Perubahan berbentuk ALTER TABLE tambah memperparah: dijalankan dua kali, ADD COLUMN yang sama menjawab galat — kebalikan dari idempoten.

Yang hilang adalah pembukuan. Database yang skemanya dikelola dengan disiplin menyimpan satu tabel khusus sebagai buku kas: daftar migrasi mana yang sudah masuk, lengkap dengan waktunya. Pola ini begitu universal sampai semua tooling migrasi di ekosistem — dari pelari ringan sampai framework besar — menjalankannya dengan variasi kosmetik saja.

Tabel schema_migrations sebagai Pembukuan #

Desain tabelnya sengaja dibuat sekecil mungkin — dua kolom, tanpa hiasan:

sql
CREATE TABLE schema_migrations (
    version BIGINT PRIMARY KEY,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Mari kita telaah dua keputusan desainnya:

  1. version BIGINT PRIMARY KEY — nomor migrasi sebagai kunci utama; primary key membuat pencatatan ganda mustahil, sekaligus menjadi jawaban instan untuk “versi berapa yang sudah masuk?” — cukup SELECT max(version).
  2. applied_at TIMESTAMPTZ NOT NULL DEFAULT now() — stempel waktu tercatat otomatis saat baris dibuat; audit “kapan kolom stok ditambahkan” terjawab tanpa mengarang, dan now() mengembalikan waktu zona UTC yang jujur.

Berkas migrasinya hidup di folder migrasi/ dalam proyek, bernomor tiga digit lalu nama yang menjelaskan isinya. Perubahan pertama menambah kolom stok; kedua melahirkan tabel pelanggan:

sql
ALTER TABLE produk ADD COLUMN stok INTEGER NOT NULL DEFAULT 0;

UPDATE produk SET stok = 24 WHERE nama = 'Kopi Torabika';
UPDATE produk SET stok = 12 WHERE nama = 'Gula Aren Cair';
sql
CREATE TABLE pelanggan (
    id SERIAL PRIMARY KEY,
    nama TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);

INSERT INTO pelanggan (nama, email) VALUES ('Budi', '[email protected]');

Tiga aturan main mengikat folder ini, dan semuanya dipatri dalam praktik: berkas yang sudah diterapkan tidak boleh disunting lagi — perbaikan berarti berkas baru bernomor lebih tinggi; nomor selalu maju — tidak ada migrasi mundur di produksi, hanya berkas lanjutan yang membetulkan; dan satu berkas satu transaksi — skema berubah atau tidak sama sekali. Database katalog kita masih berdiri dari disaster drill chapter sebelumnya, dengan tiga baris produk dan tanpa tabel pembukuan — kondisi sempurna untuk menyaksikan seluruh mekanismenya bekerja.

Satu Putaran dengan Tangan #

Cara paling jujur memahami pelari migrasi adalah menjadi pelarinya selama satu putaran. Pertama, bukunya dibuka lebih dulu:

bash
$ docker exec db psql -U katalog -d katalog -c "CREATE TABLE schema_migrations (version BIGINT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now());"

Output yang Dihasilkan:

text
CREATE TABLE

Kedua, migrasi pertama dijalankan — dan perhatikan dua flag yang menjamin sifat transaksinya:

bash
$ docker exec -i db psql -U katalog -d katalog -v ON_ERROR_STOP=1 -1 < migrasi/001_tambah_kolom_stok.sql

Output yang Dihasilkan:

text
ALTER TABLE
UPDATE 1
UPDATE 1

Ketiga, bukunya dicatat — persis dua pekerjaan yang akan diotomatisasi nanti:

bash
$ docker exec db psql -U katalog -d katalog -c "INSERT INTO schema_migrations (version) VALUES (1);"

Output yang Dihasilkan:

text
INSERT 0 1

Migrasi kedua menempuh ritme yang persis sama, dan keluarannya disatukan:

bash
$ docker exec -i db psql -U katalog -d katalog -v ON_ERROR_STOP=1 -1 < migrasi/002_buat_tabel_pelanggan.sql
$ docker exec db psql -U katalog -d katalog -c "INSERT INTO schema_migrations (version) VALUES (2);"

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 1
INSERT 0 1

Dan inilah keadaan akhir database kita — buku kas di atas, hasilnya di bawah:

bash
$ docker exec db psql -U katalog -d katalog -c "SELECT version, applied_at FROM schema_migrations ORDER BY version;"
$ docker exec db psql -U katalog -d katalog -c "SELECT nama, harga, stok FROM produk ORDER BY id;"

Output yang Dihasilkan:

text
 version |          applied_at
---------+-------------------------------
       1 | 2026-10-13 22:52:07.412881+00
       2 | 2026-10-13 22:52:31.862417+00
(2 rows)

        nama        | harga | stok
--------------------+-------+------
 Kopi Torabika       | 15000 |   24
 Kopi Robusta Lampung | 65000 |    0
 Gula Aren Cair      | 45000 |   12
(3 rows)

Mari kita telaah apa yang barusan dikerjakan tangan kita:

  1. -1 membungkus seluruh berkas dalam satu transaksi — flag itu (--single-transaction) menyisipkan BEGIN di awal dan COMMIT di akhir secara otomatis; bila UPDATE kedua gagal di tengah, ALTER TABLE yang pertama ikut tergulung, dan skema tidak pernah berubah setengah.
  2. -v ON_ERROR_STOP=1 menjaga agar putaran berhenti pada galat pertama — tanpanya psql melanjutkan seperti restore yang kita saksikan di chapter sebelumnya; pada migrasi, lanjut setelah galat berarti buku kas dan kenyataan berpisah jalan.
  3. SQL dan catatan buku dicatat terpisah di tangan kita — dua perintah terpisah adalah kelemahan versi manual: bila pencatatan gagal setelah SQL sukses, buku kas tidak mencatat versi 1 padahal kolomnya sudah ada. Pelari otomatis nanti menjahit keduanya menjadi satu transaksi.
  4. Putaran kedua akan melompati keduanya — bila pelari membaca buku dan mendapati versi 1 dan 2 sudah tercatat, kedua berkas tidak dibuka sama sekali; inilah jawaban yang tak bisa diberikan IF NOT EXISTS: bukan “tabelnya sudah ada”, melainkan “migrasi 001 sudah diterapkan pada 22:52:07”.

Pelari Otomatis: migrate.js #

Empat langkah manual tadi — buka buku, bandingkan dengan berkas di folder, jalankan delta, catat — adalah pekerjaan yang paling pantas diserahkan kepada kode. Inilah migrate.js versi katalog, penerus langsung dari versi idempoten stack toko:

js
const fs = require("fs");
const path = require("path");
const { Pool } = require("pg");

const dirMigrasi = path.join(__dirname, "migrasi");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function jalankan() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS schema_migrations (
      version BIGINT PRIMARY KEY,
      applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
    )`);

  const sudah = new Set(
    (await pool.query("SELECT version FROM schema_migrations")).rows.map(
      (baris) => Number(baris.version)
    )
  );

  const berkas = fs
    .readdirSync(dirMigrasi)
    .filter((nama) => nama.endsWith(".sql"))
    .sort();

  for (const nama of berkas) {
    const versi = Number(nama.split("_")[0]);
    if (sudah.has(versi)) continue;

    const sql = fs.readFileSync(path.join(dirMigrasi, nama), "utf8");
    const klien = await pool.connect();
    try {
      await klien.query("BEGIN");
      await klien.query(sql);
      await klien.query("INSERT INTO schema_migrations (version) VALUES ($1)", [versi]);
      await klien.query("COMMIT");
      console.log(`migrasi ${versi} diterapkan: ${nama}`);
    } catch (galat) {
      await klien.query("ROLLBACK");
      console.error(`migrasi ${versi} gagal: ${galat.message}`);
      process.exit(1);
    } finally {
      klien.release();
    }
  }

  await pool.end();
}

jalankan();

Mari kita telaah bagaimana kode ini menjahit keempat langkah manual kita:

  1. Tabel pembukuan diciptakan IF NOT EXISTS — satu-satunya tempat idempoten berperan; buku hanya perlu lahir sekali, dan pelari boleh menyala di mesin baru mana pun.
  2. SELECT version dibaca ke dalam Set — daftar versi yang sudah masuk; membandingkan isi folder dengan isi buku kini menjadi operasi himpunan: jalankan hanya yang belum tercatat.
  3. readdirSync + sort() — isi folder dibaca lalu diurutkan alfabetis, kebiasaan yang sama dengan folder init/: 001_ sebelum 002_, nomor menentukan urutan, sama seperti urutan eksekusinya di tangan kita barusan.
  4. BEGIN, SQL, catat, COMMIT — dalam satu transaksi klien — inilah jahitan yang hilang dari versi manual: bila pencatatan gagal, SQL-nya ikut tergulung; buku kas dan kenyataan mustahil berpisah.
  5. ROLLBACK lalu process.exit(1) — kegagalan migrasi adalah kegagalan stack, bukan catatan kaki: exit code 1 membuat gerbang service_completed_successfully menolak membuka, aplikasi tidak pernah menyala di atas skema yang setengah jalan — tarian yang kita rancang pada Chapter Startup dan Dependency.
  6. console.log per migrasi yang diterapkan — keluarannya menjadi jejak audit yang terbaca docker logs; pada up berikutnya di mesin yang bukunya penuh, pelari tidak mencetak apa pun dan keluar 0 — senyap, tepat seperti yang kita inginkan.

Di luar sana, kategori ini sudah matang dan boleh disebut namanya: golang-migrate untuk tim Go, Flyway untuk dunia JVM, goose dan dbmate untuk yang suka ringan — semuanya berdiri di atas konvensi yang persis kita bangun dengan tangan: berkas bernomor, tabel pencatat, transaksi per langkah. Migrasi ketiga katalog kelak ditulis sebagai migrasi/003_*.sql, di-commit, dan dijalankan service migrate di compose.yaml — bukan lagi oleh jari kita.

Upgrade Versi Engine: 16 ke 18 #

Kini makna kedua, dan panggungnya bukan katalog. Budi mewarisi proyek lama bernama etalase — aplikasi pencatatan produk kafe milik klien — yang datang bersama cluster PostgreSQL 16 di volume etalase-lama. Klien ingin naik ke PostgreSQL 18 untuk fitur barunya. Database mesin cerita kita bekerja di ~/katalog, jadi folder kerja baru disiapkan lebih dulu (mkdir -p ~/etalase && cd ~/etalase) sebelum cluster warisan itu dinyalakan:

bash
$ docker run -d --name db-lama \
    -e POSTGRES_USER=etalase \
    -e POSTGRES_PASSWORD=rahasia123 \
    -e POSTGRES_DB=etalase \
    -v etalase-lama:/var/lib/postgresql/data \
    postgres:16-alpine

Output yang Dihasilkan:

text
Unable to find image 'postgres:16-alpine' locally
16-alpine: Pulling from library/postgres
...
Digest: sha256:0a9f63e4d8b0f53f76decdb233bfe85b66b475b1ecac32da1542e5f8c7fb00ed
Status: Downloaded newer image for postgres:16-alpine
95588ea319dfb6ca0b97e5ccf5a8d37c2fe11a01f398d2a0cb4c7e04a2a197ae

Image 16 belum pernah dipakai di mesin cerita kita, jadi daemon menariknya lebih dulu — baris-baris layer di tengah dipotong agar ringkas. Cluster warisannya berisi tabel produk yang setia menunggu:

bash
$ docker exec db-lama psql -U etalase -d etalase \
    -c "CREATE TABLE produk (id SERIAL PRIMARY KEY, nama TEXT NOT NULL, harga INTEGER NOT NULL);
        INSERT INTO produk (nama, harga) VALUES ('Kopi Torabika', 15000), ('Sirup Vanila', 32000);"
$ docker run --rm -v etalase-lama:/data alpine:3.20 cat /data/PG_VERSION

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
16

Angka terakhir adalah janji yang ditagih Chapter Persistensi Database: PG_VERSION mencatat versi mayor cluster — dan 16 di volume inilah peta kita. Sekarang godaannya:

Mari kita lihat kegagalan itu sendiri, karena lognya layak dikenali sebelum kita menemuinya di malam yang buruk:

bash
$ docker rm -f db-lama
$ docker run -d --name db-lama \
    -e POSTGRES_USER=etalase \
    -e POSTGRES_PASSWORD=rahasia123 \
    -e POSTGRES_DB=etalase \
    -v etalase-lama:/var/lib/postgresql/data \
    postgres:18-alpine
$ docker logs db-lama

Output yang Dihasilkan:

text
db-lama
66ef0106dcc7f175182b78c5010ed50ef13261e6f0e587c443911cb7dee09619
PostgreSQL Database directory appears to be a database; Skipping initialization

2026-10-13 23:33:41.302 UTC [1] FATAL:  database files are incompatible with server
2026-10-13 23:33:41.302 UTC [1] DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 18.4.

Dua baris FATAL itu adalah kegagalan yang paling jujur di dunia database: server 18 membaca PG_VERSION, menyatakan dirinya tidak kompatibel, lalu memilih mati daripada menyentuh berkas yang tidak dipahaminya. Container berakhir dengan status Exited (1) — exit code yang bukan 0, kebiasaannya sudah kita bedah di Chapter Memahami Exit Code. Berita baiknya tersembunyi di dalam kegagalan itu: tidak satu byte pun dari cluster lama yang ditulis ulang, sehingga volume tetap utuh dan jalan yang benar tetap terbuka. Jalurnya enam langkah, dan seluruh peralatannya sudah kita kuasai dua chapter terakhir.

Langkah 1 — nyalakan ulang server versi lama. Container gagal tadi dibuang lebih dulu, lalu server 16 — satu-satunya yang berhak membuka cluster ini — dinyalakan kembali:

bash
$ docker rm -f db-lama
$ docker run -d --name db-lama \
    -e POSTGRES_USER=etalase \
    -e POSTGRES_PASSWORD=rahasia123 \
    -e POSTGRES_DB=etalase \
    -v etalase-lama:/var/lib/postgresql/data \
    postgres:16-alpine

Output yang Dihasilkan:

text
db-lama
ea6f1f241a627d276411538ed86e2a6216e6ea59421b362c60cc45cc659fbede

Langkah 2 — dump dari container versi lama. Salinan logis adalah satu-satunya barang yang boleh menyeberang antar versi mayor:

bash
$ docker exec db-lama pg_dump -U etalase etalase > backup-etalase.sql
$ ls -lh backup-etalase.sql

Output yang Dihasilkan:

text
-rw-r--r-- 1 budi budi 15K Okt 13 23:38 backup-etalase.sql

Langkah 3 — lahirkan container versi baru dengan volume baru. Dua hal baru sekaligus: image 18 dan volume kosong yang akan menjadi rumah cluster pengganti:

bash
$ docker run -d --name db-baru \
    -e POSTGRES_USER=etalase \
    -e POSTGRES_PASSWORD=rahasia123 \
    -e POSTGRES_DB=etalase \
    -v etalase-baru:/var/lib/postgresql/data \
    postgres:18-alpine

Output yang Dihasilkan:

text
c0b52f3fc5e9127779b225bcfbd8e2bc6cbb9edea80054ff5f141d51c6d1f485

Langkah 4 — pulihkan dump ke server baru. Pola restore dari Chapter Restore Database, utuh termasuk gerbang kesiapannya:

bash
$ docker exec db-baru pg_isready -U etalase -d etalase
$ docker exec -i db-baru psql -U etalase -d etalase -v ON_ERROR_STOP=1 < backup-etalase.sql

Output yang Dihasilkan:

text
/var/run/postgresql:5432 - accepting connections
SET
SET
SET
...
CREATE TABLE
ALTER TABLE
CREATE SEQUENCE
ALTER SEQUENCE
COPY 2
...

Langkah 5 — verifikasi menyeluruh. Data di mata, versi di berkas:

bash
$ docker exec db-baru psql -U etalase -d etalase -c "SELECT * FROM produk;"
$ docker run --rm -v etalase-baru:/data alpine:3.20 cat /data/PG_VERSION

Output yang Dihasilkan:

text
 id |     nama      | harga
----+---------------+-------
  1 | Kopi Torabika | 15000
  2 | Sirup Vanila  | 32000
(2 rows)

18

Langkah 6 — alihkan aplikasi ke cluster baru. Aplikasi etalase kini menunjuk db-baru; setelah beberapa hari berjalan tenang, container db-lama dan volume etalase-lama boleh dipensiunkan. Di dunia Compose, versi mayor yang dipilih dinyatakan eksplisit lewat environment supaya seluruh tim membaca angka yang sama:

yaml
services:
  db:
    image: postgres:${PG_MAJOR:-18}-alpine

Bentuk ${PG_MAJOR:-18} adalah interpolasi dengan nilai bawaan dari Chapter Variable Interpolation — berkas .env yang berisi PG_MAJOR=18 menjadikan pemilihan seri mayor sebuah keputusan yang terlihat dan seragam di seluruh tim, dan tanpa berkas itu angka 18 tetap dipakai. Dump logis menyeberangi versi mayor dengan aman karena isinya adalah SQL — bahasa yang dipahami server baru — dan dokumentasi PostgreSQL bahkan menyarankan memakai pg_dump dari versi yang lebih baru agar memanfaatkan perbaikan terkini; dump dari container lama seperti yang kita jalankan tetap sah karena versi klien dan servernya sama persis.

Seluruh jalurnya terpetakan begini:

Diagram di atas menegaskan dua sifat jalur ini: setiap panah bisa dihentikan dan diulang tanpa kehilangan apa pun — cluster lama tetap hidup sampai cluster baru terbukti sehat — dan satu-satunya barang yang menyeberang adalah dump logis, bukan volume, bukan berkas data mentah. PostgreSQL juga menyediakan pg_upgrade untuk upgrade in-place: lebih cepat pada data berukuran raksasa karena tidak menafsirkan baris demi baris, tapi menuntut binary lama dan baru tersedia bersamaan, ruang disk untuk dua cluster sekaligus selama proses, dan tingkat risiko lebih tinggi bila gagal di tengah jalan. Di dunia container, dump/restore hampir selalu lebih sederhana — kedua versi hanyalah dua container yang berdampingan sebentar.

Keputusan: Minor atau Major #

Batas antara dua jenis kenaikan versi adalah angka tengah, dan keputusannya ringkas:

Jenis pergantianContohBoleh ganti tag image?Jalur yang benar
Minor — versi patch dalam seri mayor sama18.3 → 18.4 lewat tag postgres:18-alpineya, amandocker compose pull lalu recreate container
Major — seri mayor berbeda16 → 18tidak — format data on-disk berbedadump versi lama, restore ke container versi baru

Tag postgres:18-alpine memang mengambang mengikuti rilis patch terbaru dari seri 18 — itulah mekanisme minor upgrade yang sehat: menarik ulang image dan membuat ulang container, sementara PG_VERSION di volume tetap bernilai 18 dan cluster tidak pernah ditanya ulang. Kebalikannya, PG_VERSION inilah detektor paling jujur untuk mengetahui seri mayor sebuah volume sebelum server mana pun mencoba menyala — kebiasaan memeriksanya adalah bagian dari ritual upgrade.

Eksperimen etalase kita tuntas, dan arena dibersihkan — proyek katalog tetap hidup dengan containernya:

bash
$ docker rm -f db-lama db-baru
$ docker volume rm etalase-lama etalase-baru

Output yang Dihasilkan:

text
db-lama
db-baru
etalase-lama
etalase-baru

Malam yang panjang di section ini menutup seluruh lingkarannya. Kita menjalankan empat mesin database — PostgreSQL, MySQL, MariaDB, dan Redis — masing-masing dengan ritual env dan healthcheck-nya sendiri; membuktikan bahwa data bertahan melewati kematian container lewat volume; mengotomatisasi kelahiran skema lewat docker-entrypoint-initdb.d dan service one-shot; membangun serta menguji backup dan restore sampai disaster drill sungguhan; dan menutupnya dengan dua makna migrasi yang kini kita pegang jalurnya. Database dalam container resmi kita kuasai — dan dengan itu, satu pertanyaan berganti: bila toolchain database sampai aplikasi bisa hidup dalam container, bagaimana seharusnya hari-hari kita menulis kode berubah? Section Docker dalam Pengembangan menjawabnya dengan workflow developer bercontainer — source code yang tetap di host, toolchain yang pindah ke container, siklus edit-uji yang menyusut ke hitungan detik — dimulai dari Chapter Workflow Development.

Referensi #