Empat berkas backup kini tergeletak di ~/katalog: dua dump PostgreSQL, satu arsip gzip, satu dump MySQL. Sayangnya, keberadaan berkas belum berkata apa-apa tentang isinya — kita belum pernah membukanya sekali pun, belum membuktikan bahwa baris-baris COPY di dalamnya benar-benar bisa menjadi tabel hidup kembali. Industri ini penuh dengan arsip backup yang gagal persis pada malam pemulihan: password yang salah, dump yang terpotong, format yang tidak kompatibel, atau pipe yang menelan data diam-diam. Chapter ini menagih janji setiap berkas itu satu per satu: membedah arah data yang berlawanan dengan backup, menyaksikan restore pertama menabrak data lama, membangun tiga jalur restore yang masing-masing punya tempatnya, lalu menutupnya dengan disaster drill mini yang benar-benar memusnahkan volume dan membangkitkan database dari satu berkas di folder proyek.

Arah yang Berlawanan: stdout Menjadi stdin #

Backup dan restore adalah dua perintah cermin, dan perbedaannya bukan pada alatnya melainkan pada arah datanya. Backup mengalir dari container ke host: pg_dump menulis ke stdout, redirect > milik shell menangkapnya. Restore membalik arah itu: berkas di host dikirim ke stdin proses di dalam container, dan itulah pekerjaan flag -i:

ArahBentuk perintahPeran flag
container → host (backup)docker exec db pg_dump ... > backup.sqlredirect > menangkap stdout
host → container (restore)docker exec -i db psql ... < backup.sql-i + redirect < mengalirkan stdin

Flag -i bukan hiasan. Tanpa -i, klien docker membuka sesi exec tanpa menyalurkan stdin dari terminal host — redirect < berkas tetap mengisi stdin milik klien docker, tapi klien tidak pernah meneruskannya ke dalam container. psql di sisi sana melihat stdin yang tertutup, membaca nol baris, lalu keluar dengan sopan: restore selesai dalam sekejap tanpa satu pun galat dan tanpa satu pun baris data. Kekeliruan tanpa suara semacam ini pernah kita jumpai pada pola docker exec -i di Chapter Migrasi Data Container — dan satu-satunya obatnya sama: pastikan -i selalu hadir setiap kali tanda < berdiri di ujung baris.

Restore Pertama: Menabrak Data Lama #

Insting pertama kita tentu mengembalikan backup ke database asalnya — dan insting itulah yang akan menabrak tembok pertama. Database katalog masih hidup dan berisi tiga baris produk hasil initdb, persis isi si dump:

bash
$ docker exec -i db psql -U katalog -d katalog < backup-katalog.sql

Output yang Dihasilkan:

text
SET
SET
SET
...
ERROR:  relation "produk" already exists
ERROR:  relation "produk_id_seq" already exists
ALTER TABLE
ALTER SEQUENCE
...
ERROR:  duplicate key value violates unique constraint "produk_pkey"
DETAIL:  Key (id)=(1) already exists.
CONTEXT:  COPY produk, line 1
...
      setval
------------------
                3
(1 row)

Baris-baris SET dan bagian akhir dipotong agar ringkas. Mari kita telaah apa yang sebenarnya terjadi:

  1. Bagian struktur menabrak objek yang sudah ada — CREATE TABLE dan CREATE SEQUENCE ditolak karena objek itu sudah berdiri sejak initdb; pg_dump plain tidak menuliskan DROP apa pun sebelum CREATE, jadi restore tidak pernah bermaksud membongkar yang lama.
  2. Bagian data menabrak primary key — COPY mencoba menanam baris id=1 (Kopi Torabika) ke tabel yang barisnya sendiri sudah memegang id=1; kunci utama menolak penggandaan, dan produk_pkey adalah nama constraint yang otomatis lahir bersama PRIMARY KEY pada skema kita.
  3. psql tidak berhenti pada galat — tanpa -v ON_ERROR_STOP=1, psql mencetak galat lalu melanjutkan ke perintah berikutnya; ALTER TABLE, ALTER SEQUENCE, dan setval di baris akhir justru sukses, sehingga proses berakhir dengan exit code 0 — restore yang “berhasil” tanpa memulihkan apa pun.
  4. Data lama tetap utuh — tidak satu baris pun berhasil ditulis, jadi tabel tidak menjadi berlipat; verifikasi cepat membuktikannya.
bash
$ docker exec db psql -U katalog -d katalog -c "SELECT count(*) FROM produk;"

Output yang Dihasilkan:

text
 count
-------
     3
(1 row)

Perlawanan ini muncul dari niat baik: restore menolak menginjak data yang sudah ada. Konsekuensinya harus dipilih jalur yang disengaja:

TRUNCATE lalu Restore Ulang #

Jalur pertama untuk skenario “ganti isi, pertahankan skema”: kosongkan tabel, lalu ulangi restore. Galat-galat struktur akan tetap lewat — objeknya memang sudah ada dan bentuknya sama — tapi COPY kini mendarat di tanah kosong:

bash
$ docker exec db psql -U katalog -d katalog -c "TRUNCATE produk;"
$ docker exec -i db psql -U katalog -d katalog < backup-katalog.sql

Output yang Dihasilkan:

text
TRUNCATE TABLE
SET
SET
SET
...
ERROR:  relation "produk" already exists
ERROR:  relation "produk_id_seq" already exists
ALTER TABLE
ALTER SEQUENCE
COPY 3
...
      setval
------------------
                3
(1 row)

Perhatikan dua hal: COPY 3 kini sukses, dan setval membenahi nomor urut sequence ke angka 3 sehingga INSERT berikutnya tidak menabrak id yang sudah dipakai. Verifikasinya:

bash
$ docker exec db psql -U katalog -d katalog -c "SELECT count(*) FROM produk;"
$ docker exec db psql -U katalog -d katalog -c "SELECT nama, harga FROM produk ORDER BY id LIMIT 2;"

Output yang Dihasilkan:

text
 count
-------
     3
(1 row)

        nama        | harga
--------------------+-------
 Kopi Torabika       | 15000
 Kopi Robusta Lampung | 65000
(2 rows)

Jalur ini bekerja, tapi verifikasi kita harus lebih cermat: galat already exists yang “boleh diabaikan” hanya aman bila skema yang ada memang identik dengan skema di dump. Bila dump dibuat dari versi skema yang berbeda, galat serupa bisa menyembunyikan tabel yang tidak kunjung dibuat. Karena itu praktik yang lebih sehat memakai kanvas yang benar-benar bersih.

Database Baru sebagai Kanvas Bersih #

Jalur kedua memisahkan tempat pulih dari tempat hidup: pulihkan ke database baru, di sisi server yang sama. PostgreSQL membawa pembantu kecilnya, createdb:

bash
$ docker exec db createdb -U katalog katalog-pulih
$ docker exec -i db psql -U katalog -d katalog-pulih -v ON_ERROR_STOP=1 < backup-katalog.sql

Output yang Dihasilkan:

text
SET
SET
SET
...
CREATE TABLE
ALTER TABLE
CREATE SEQUENCE
ALTER SEQUENCE
COPY 3
...
      setval
------------------
                3
(1 row)

Kali ini tidak ada satu pun galat — dan kali ini pula kita berani memasang -v ON_ERROR_STOP=1, karena di kanvas kosong setiap galat adalah kabar buruk yang layak menghentikan proses. Verifikasi singkat lalu membongkar kanvas setelah selesai dipakai:

bash
$ docker exec db psql -U katalog -d katalog-pulih -c "SELECT count(*) FROM produk;"
$ docker exec db psql -U katalog -d katalog -c "DROP DATABASE katalog-pulih;"

Output yang Dihasilkan:

text
 count
-------
     3
(1 row)

DROP DATABASE

Pola katalog-pulih ini punya tempat yang tak tergantikan di dunia nyata: memeriksa isi backup tanpa menyentuh database produksi, membandingkan data lama dan baru berdampingan, atau migrasi antar mesin — server yang menerima pulihan tidak perlu menjadi server asal dump. Titik lemahnya justru kekuatannya: satu server dengan disk yang sama tetap satu titik kegagalan, sehingga pola ini melengkapi — bukan menggantikan — simpanan arsip di luar host.

Restore Format Kustom dengan pg_restore #

Arsip backup-katalog.dump tidak bisa dibaca psql — format kustom adalah bahasa pg_restore. Flag --clean --if-exists membuat pg_restore membuang objek lama sebelum membuat ulang, persis obat untuk database yang sudah berisi:

bash
$ docker exec -i db pg_restore -U katalog -d katalog --clean --if-exists < backup-katalog.dump
$ echo $?

Output yang Dihasilkan:

text
0

pg_restore memang tidak berisik ketika berhasil — exit code 0 dari echo $? adalah seluruh laporannya, kebiasaan membaca exit code yang kita latih sejak Chapter Memahami Exit Code. Verifikasi datanya:

bash
$ docker exec db psql -U katalog -d katalog -c "SELECT count(*), max(harga) FROM produk;"

Output yang Dihasilkan:

text
 count |  max
-------+-------
     3 | 45000
(1 row)

Tiga baris kembali dengan harga tertinggi 45000 milik Gula Aren Cair. Mari kita telaah bentuk restore kustom ini:

  1. --clean membuang sebelum membuat — pg_restore menjatuhkan DROP untuk tiap objek yang akan dibuat ulang, sehingga restore ke database berisi menjadi bersih tanpa perlu TRUNCATE manual.
  2. --if-exists meredam galat pembuangan — tanpa flag ini, DROP TABLE pada objek yang tidak ada memunculkan galat; dengan flag ini, pembuangan dilewati dengan sopan — penting saat restore pertama ke database kosong.
  3. Restore selektif untuk insiden kecil — pg_restore --table=produk mengembalikan satu tabel saja tanpa menyentuh sisanya, kemampuan yang hanya dimiliki format kustom; insiden “satu tabel terhapus oleh migration yang salah” pulih dalam hitungan menit.
  4. Arsip besar lebih nyaman lewat docker cp — menyalin dump ke dalam container (docker cp backup-katalog.dump db:/tmp/) lalu memulihkan dari path lokal container memotong jalur stdin; pola docker cp itu kita kenal dari Chapter Writable Container Layer.

Restore dari Arsip gzip #

Arsip terkompresi dipulihkan dengan membuka gulungannya lebih dulu — gunzip -c menulis isi arsip ke stdout, pipe mengalirkan ke psql di dalam container, dan -i tetap menjaga pintu stdin terbuka. Kanvas bersih kita pakai sekali lagi:

bash
$ docker exec db createdb -U katalog katalog-pulih
$ gunzip -c backup-katalog.sql.gz | docker exec -i db psql -U katalog -d katalog-pulih -v ON_ERROR_STOP=1
$ docker exec db psql -U katalog -d katalog-pulih -c "SELECT count(*) FROM produk;"
$ docker exec db psql -U katalog -d katalog -c "DROP DATABASE katalog-pulih;"

Output yang Dihasilkan:

text
SET
SET
SET
...
COPY 3
...
 count
-------
     3
(1 row)

DROP DATABASE

Empat perintah itu dijalankan bergantian dan keluarannya disatukan — bagian SET dipotong agar ringkas. Perhatikan arsitektur pipa yang terbentuk: alat dekompresi hidup di host, psql hidup di container, dan data mengalir tanpa satu berkas sementara pun ditulis ke disk — kelak, ketika arsip malamannya berukuran puluhan gigabyte, pola yang sama tetap berlaku tanpa memenuhi disk host dua kali.

Restore MySQL #

Mesin MySQL menuntaskan cerita dengan kejutan yang menyenangkan: dump mysqldump memuat DROP TABLE IF EXISTS di depan tiap CREATE TABLE, jadi restore ke database yang sudah berisi tidak menabrak apa pun — tabel lama dibongkar rapi lalu dibangun ulang. Mari kita buktikan dengan menanam baris penyusup lebih dulu:

bash
$ docker exec db-mysql mysql -ukatalog -prahasia123 katalog \
    -e "INSERT INTO produk (nama, harga) VALUES ('Teh Hijau Sencha', 55000);"
$ docker exec -i db-mysql mysql -ukatalog -prahasia123 katalog < backup-katalog-mysql.sql
$ docker exec db-mysql mysql -ukatalog -prahasia123 katalog -e "SELECT COUNT(*) FROM produk;"

Output yang Dihasilkan:

text
mysql: [Warning] Using a password on the command line interface can be insecure.
mysql: [Warning] Using a password on the command line interface can be insecure.
mysql: [Warning] Using a password on the command line interface can be insecure.
+----------+
| COUNT(*) |
+----------+
|        2 |
+----------+

Tiga baris peringatan itu muncul dari tiga perintah klien yang berbeda — sandi di command line masih kebiasaan latihan kita dari chapter sebelumnya. Mari kita telaah hasilnya:

  1. Tiga baris masuk, dua baris keluar — baris Teh Hijau Sencha lenyap bersama tabel lamanya, karena DROP TABLE IF EXISTS membongkar produk sebelum CREATE TABLE membangun ulang dan INSERT menanam ulang dua baris dari dump.
  2. Perilaku bawaan yang berbeda dari pg_dump — mysqldump menulis DROP secara default, pg_dump plain tidak; keduanya sah, hanya saja tiap tool menuntut kebiasaan verifikasi yang sesuai — di PostgreSQL, “menimpa rapi” harus diminta eksplisit lewat pg_restore --clean.
  3. -i tetap wajib — redirect < di ujung baris kedua bekerja karena flag itu; tanpanya klien mysql membaca stdin yang tertutup dan restore berlalu tanpa peristiwa.

Ritual Verifikasi Menyeluruh #

Restore yang berhenti pada “tidak ada galat” belum selesai — galat bisa sunyi dan data bisa setengah. Ritual yang sehat memverifikasi dari tiga sisi, berurutan:

  1. Jumlah baris tiap tabel kunci — bandingkan dengan angka yang dicatat sebelum backup dibuat; untuk katalog malam ini angkanya 3 pada produk, dan selisih satu baris pun layak diinterogasi.
  2. Isi tabel yang paling dikenal — satu SELECT pada tabel kunci dengan kolom yang dikenali manusia (nama produk, harga) memastikan bukan hanya jumlah yang benar tapi juga datanya — COUNT(*) tidak akan membedakan tiga baris kosong dari tiga baris Kopi Torabika.
  3. Smoke test dari sisi aplikasi — jalankan kueri persis yang dipakai aplikasi, atau kalau stack Compose hidup, satu permintaan curl ke endpoint yang membaca database; keberhasilan restore diukur di mata aplikasi, bukan di mata DBA.

Disaster Drill Mini #

Inilah puncaknya: kita benar-benar memusnahkan volume dan membangkitkan database dari arsip. Ritualnya bernomor supaya bisa diulang seperti daftar periksa.

Pertama, musnahkan container dan volumenya — kalimat yang selama ini hanya kita ucapkan dengan hati-hati:

bash
$ docker rm -f db && docker volume rm db-data

Output yang Dihasilkan:

text
db
db-data

Kedua, lahirkan container baru dengan volume baru yang kosong — dan perhatikan satu keputusan penting: kali ini kita tidak memasang folder init/:

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

Output yang Dihasilkan:

text
1c1a0e9775d33dca85b753e8090db172e95ff922213110e39eba4afbe2c5f9f7

Tanpa folder init/ yang terpasang, entry point hanya menjalankan initdb polos tanpa skrip apa pun — cluster lahir dengan database katalog yang masih kosong, menunggu satu-satunya penulis yang kita izinkan malam ini: restore. Ketiga, tunggu server siap, lalu pulihkan:

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

Output yang Dihasilkan:

text
/var/run/postgresql:5432 - accepting connections
SET
SET
SET
...
CREATE TABLE
ALTER TABLE
CREATE SEQUENCE
ALTER SEQUENCE
COPY 3
...
      setval
------------------
                3
(1 row)

Keempat — dan inilah bukti penuh yang kita cari:

bash
$ docker exec db psql -U katalog -d katalog -c "SELECT * FROM produk;"

Output yang Dihasilkan:

text
 id |        nama        | harga
----+---------------------+------
  1 | Kopi Torabika       | 15000
  2 | Kopi Robusta Lampung | 65000
  3 | Gula Aren Cair      | 45000
(3 rows)

Tiga baris produk, primary key utuh, urutan sequence dipulihkan oleh setval — database yang barusan kita musnahkan kini hidup kembali dari satu berkas 18K di folder proyek. Mari kita telaah sisa detailnya:

  1. init/ sengaja tidak dipasang — bila folder itu ikut terpasang, initdb akan menulis skema dan data awal ke volume kosong, dan restore berikutnya akan menabrak duplikat primary key persis seperti percobaan pertama kita; pada drill, restore harus menjadi satu-satunya penulis.
  2. Role pelaporan tidak ikut pulih — dump SQL milik satu database tidak memuat role, dan cluster yang barusan lahir hanya mengenal katalog; skrip 003_user.sh kita tetap bisa dipanggil ulang lewat pola stdin yang sama, karena isinya hanyalah perintah psql yang dibungkus shell.
  3. pg_isready menjaga urutan — memberi server baru beberapa detik sebelum klien pertama mengetuk; pada drill berikutnya di dunia nyata, pola yang setara adalah gerbang service_healthy dari Chapter Healthcheck dan Service Readiness.

Sebelum menutup chapter, mari bereskan arena: mesin MySQL sudah menuntaskan perannya sejak restore terakhir, dan sisanya adalah urusan PostgreSQL pada chapter penutup section ini.

bash
$ docker rm -f db-mysql && docker volume rm db-data-mysql

Output yang Dihasilkan:

text
db-mysql
db-data-mysql

Seluruh skenario yang kita lalui malam ini terangkum pada satu tabel kecil:

Kondisi database tujuanJalur restore yang tepatCatatan
Kosong — volume baru, tanpa initdbpsql -d katalog < backup.sql dengan ON_ERROR_STOP=1jalur drill: hasil bersih tanpa galat
Berisi data lama, skema identikTRUNCATE dulu, atau pg_restore --clean --if-existsverifikasi galat struktur yang lolos
Butuh ruang aman untuk auditcreatedb katalog-pulih lalu restore ke sanadata produksi tidak tersentuh

Data katalog kini punya tiga lapis perlindungan: initdb yang melahirkannya ulang di mesin baru, volume yang menjaganya melewati kematian container, dan arsip yang membangkitkannya dari nol. Satu wilayah terakhir belum tersentuh: bagaimana skema itu sendiri berkembang dari bulan ke bulan tanpa merusak data yang hidup, dan bagaimana suatu hari seluruh cluster berpindah dari PostgreSQL 16 ke 18 tanpa meninggalkan sebaris data pun. Dua persoalan itu ternyata berbagi satu nama — migrasi — dan Chapter Migrasi Versi Database membedah keduanya sampai tuntas.

Referensi #