Pertanyaan penting terakhir dari siklus deployment bukan “apakah rilis baru gagal” melainkan “seberapa cepat kita bisa pulih ketika gagal” — kegagalan rilis adalah kepastian statistik, bukan kemungkinan yang bisa dihapus. Tim yang matang mengukurnya sebagai MTTR (mean time to recovery): selang waktu dari gejala terdeteksi sampai versi stabil kembali melayani pengguna. Rollback yang butuh mencari-cari binary lama, mengingat konfigurasi, dan menebak langkah berikutnya mengubah insiden 10 menit menjadi downtime sejam. Chapter ini menyiapkan jalur pulang yang mekanis: versi lama yang tetap tersimpan, pengalihan lewat symlink atomik, strategi tingkat lanjut (blue-green dan canary), serta disiplin kompatibilitas database yang membuat rollback tetap mungkin.

Pola deploy paling sederhana yang tetap bisa dipulihkan memakai tiga elemen di server: direktori releases/ berisi setiap versi yang pernah terpasang, symlink current menunjuk versi aktif, dan service (chapter Linux Service (systemd) ) menjalankan current/sapa-server. Rilis baru berarti menambah direktori dan menggeser penunjuk; rollback berarti menggeser penunjuk kembali — binary lama tidak pernah ditimpa:

text
/opt/sapa/
├── releases/
│   ├── v1.4.1/   ← versi stabil, disimpan
│   └── v1.4.2/   ← versi baru
└── current -> releases/v1.4.2

Program rollback-demo.go pada bagian bawah halaman membangun struktur tersebut dan memainkan tiga kejadian: rilis normal, rollback, lalu roll forward:

go
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// pasang membuat direktori releases/<versi> berisi penanda versi.
func pasang(root, versi string) error {
	dir := filepath.Join(root, "releases", versi)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	penanda := "berkas ini menandai versi " + versi + "\n"
	return os.WriteFile(filepath.Join(dir, "VERSION"), []byte(penanda), 0o644)
}

// alihkan mengarahkan symlink current ke versi tertentu.
// Symlink sementara dibuat dulu, lalu diganti lewat rename yang bersifat atomik.
func alihkan(root, versi string) error {
	target := filepath.Join("releases", versi) // relatif agar pindah folder tetap valid
	link := filepath.Join(root, "current")
	sementara := link + "-baru"
	if err := os.Remove(sementara); err != nil && !os.IsNotExist(err) {
		return err
	}
	if err := os.Symlink(target, sementara); err != nil {
		return err
	}
	return os.Rename(sementara, link)
}

// versiAktif membaca versi yang ditunjuk symlink current.
func versiAktif(root string) (string, error) {
	target, err := filepath.EvalSymlinks(filepath.Join(root, "current"))
	if err != nil {
		return "", err
	}
	return filepath.Base(target), nil
}

// lapor mencetak keadaan current beserta isi berkas VERSION di dalamnya.
func lapor(root, kejadian string) {
	v, err := versiAktif(root)
	if err != nil {
		fmt.Println("gagal membaca current:", err)
		os.Exit(1)
	}
	isi, err := os.ReadFile(filepath.Join(root, "current", "VERSION"))
	if err != nil {
		fmt.Println("gagal membaca VERSION:", err)
		os.Exit(1)
	}
	fmt.Printf("%-20s current -> %s (VERSION: %s)\n", kejadian, v, strings.TrimSpace(string(isi)))
}

func main() {
	root, err := os.MkdirTemp("", "sapa-rilis-*")
	if err != nil {
		fmt.Println("gagal membuat direktori sementara:", err)
		os.Exit(1)
	}
	defer os.RemoveAll(root)

	// Dua rilis terpasang berdampingan; versi lama tidak dihapus.
	for _, v := range []string{"v1.4.1", "v1.4.2"} {
		if err := pasang(root, v); err != nil {
			fmt.Println("gagal memasang", v, ":", err)
			os.Exit(1)
		}
	}

	// Rilis normal: alihkan current ke versi terbaru.
	if err := alihkan(root, "v1.4.2"); err != nil {
		fmt.Println("gagal rilis:", err)
		os.Exit(1)
	}
	lapor(root, "setelah rilis")

	// Insiden: v1.4.2 bermasalah; rollback hanya mengarahkan ulang symlink.
	if err := alihkan(root, "v1.4.1"); err != nil {
		fmt.Println("gagal rollback:", err)
		os.Exit(1)
	}
	lapor(root, "setelah rollback")

	// Roll forward: perbaikan sudah dirilis ulang sebagai v1.4.2.
	if err := alihkan(root, "v1.4.2"); err != nil {
		fmt.Println("gagal roll forward:", err)
		os.Exit(1)
	}
	lapor(root, "setelah roll forward")
}

Output yang Dihasilkan:

text
setelah rilis        current -> v1.4.2 (VERSION: berkas ini menandai versi v1.4.2)
setelah rollback     current -> v1.4.1 (VERSION: berkas ini menandai versi v1.4.1)
setelah roll forward current -> v1.4.2 (VERSION: berkas ini menandai versi v1.4.2)

Mari kita telaah bagian penting program di atas:

  1. Baris 14–22 (pasang): setiap rilis mendapat direktori sendiri berisi berkas VERSION — di server sungguhan direktori ini berisi binary, template, dan aset dari chapter Release dan Versioning .
  2. Baris 25–38 (alihkan): kunci operasinya ada di baris 30–37 — symlink baru dibuat dengan nama sementara lalu diganti lewat os.Rename yang bersifat atomik di POSIX. Pembaca current melihat seluruhnya versi lama atau versi baru, tidak pernah keadaan setengah terpasang.
  3. Baris 41–47 (versiAktif): filepath.EvalSymlinks menelusuri symlink sampai direktori asli, dan filepath.Base memotong nama versinya — cara paling jujur membaca versi aktif.
  4. Baris 50–64 (lapor): membaca berkas VERSION lewat jalur current/VERSION, membuktikan bahwa pembacaan berkas ikut beralih bersama symlink.
  5. Baris 97 dan 104 (rollback dan roll forward): keduanya memanggil fungsi alihkan yang sama — jalur pulang dan jalur maju tidak berbeda mekanismenya, hanya arah penunjuknya.

Di server sungguhan, setelah alihkan selesai, satu perintah sudo systemctl restart sapa memuat binary versi baru — restart cepat yang aman karena service berhenti dengan graceful shutdown dari chapter sebelumnya.

Strategi Lanjutan: Blue-Green dan Canary #

Pengalihan symlink mengganti satu server. Platform dengan banyak instance punya dua pola lanjutan:

StrategiMekanismeKeunggulanBiaya
Blue-greenDua lingkungan penuh; load balancer bergeser serentakRollback = geser balik, hitungan detikKapasitas server dua kali lipat
CanaryPersentase kecil trafik ke versi baru, naik bertahapMasalah terdeteksi dini dengan dampak kecilRilis berlangsung lama, butuh metrik
RollingInstance diganti satu per satu oleh orchestratorTanpa downtime, hemat sumber dayaSelama transisi dua versi hidup bersama

Pilihan strategi menentukan alat yang dipakai (load balancer, Kubernetes, flag service mesh), tetapi fondasinya tetap sama: versi lama harus masih ada dan masih bisa menerima trafik dalam hitungan menit.

Checklist dan Latihan #

Rollback yang tidak pernah dilatih bukan rollback — itu naskah yang belum pernah dibaca. Simpan checklist ini bersama runbook deployment:

  • Dua versi terakhir tetap ada di releases/ dengan checksum yang bisa dicocokkan.
  • Alur rollback ditulis sebagai urutan perintah yang bisa ditempel ke terminal (alihkan, restart, verifikasi).
  • Metrik pemantauan (error rate, latensi, koneksi database) terlihat dari tempat yang sama dengan tombol rollback.
  • Migration yang menyertai rilis sudah diperiksa kompatibilitas mundurnya.
  • Kepada siapa keputusan rollback diambil — dan kriteria alarmnya — disepakati sebelum insiden, bukan saat insiden.

Section deployment ditutup dengan lingkaran yang lengkap: binary yang dibangun bersih dan reproducible, dikemas dan diberi versi, dijalankan sebagai service yang berhenti dengan tertib, dan — ketika semuanya tetap gagal — kembali ke versi stabil lewat jalur yang sudah teruji. Keahlian berikutnya yang melengkapi operasional ini adalah melihat ke dalam aplikasi yang berjalan: logging terstruktur, metrik, dan tracing di section Logging dan Observability .

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

rollback-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// pasang membuat direktori releases/<versi> berisi penanda versi.
func pasang(root, versi string) error {
	dir := filepath.Join(root, "releases", versi)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	penanda := "berkas ini menandai versi " + versi + "\n"
	return os.WriteFile(filepath.Join(dir, "VERSION"), []byte(penanda), 0o644)
}

// alihkan mengarahkan symlink current ke versi tertentu.
// Symlink sementara dibuat dulu, lalu diganti lewat rename yang bersifat atomik.
func alihkan(root, versi string) error {
	target := filepath.Join("releases", versi) // relatif agar pindah folder tetap valid
	link := filepath.Join(root, "current")
	sementara := link + "-baru"
	if err := os.Remove(sementara); err != nil && !os.IsNotExist(err) {
		return err
	}
	if err := os.Symlink(target, sementara); err != nil {
		return err
	}
	return os.Rename(sementara, link)
}

// versiAktif membaca versi yang ditunjuk symlink current.
func versiAktif(root string) (string, error) {
	target, err := filepath.EvalSymlinks(filepath.Join(root, "current"))
	if err != nil {
		return "", err
	}
	return filepath.Base(target), nil
}

// lapor mencetak keadaan current beserta isi berkas VERSION di dalamnya.
func lapor(root, kejadian string) {
	v, err := versiAktif(root)
	if err != nil {
		fmt.Println("gagal membaca current:", err)
		os.Exit(1)
	}
	isi, err := os.ReadFile(filepath.Join(root, "current", "VERSION"))
	if err != nil {
		fmt.Println("gagal membaca VERSION:", err)
		os.Exit(1)
	}
	fmt.Printf("%-20s current -> %s (VERSION: %s)\n", kejadian, v, strings.TrimSpace(string(isi)))
}

func main() {
	root, err := os.MkdirTemp("", "sapa-rilis-*")
	if err != nil {
		fmt.Println("gagal membuat direktori sementara:", err)
		os.Exit(1)
	}
	defer os.RemoveAll(root)

	// Dua rilis terpasang berdampingan; versi lama tidak dihapus.
	for _, v := range []string{"v1.4.1", "v1.4.2"} {
		if err := pasang(root, v); err != nil {
			fmt.Println("gagal memasang", v, ":", err)
			os.Exit(1)
		}
	}

	// Rilis normal: alihkan current ke versi terbaru.
	if err := alihkan(root, "v1.4.2"); err != nil {
		fmt.Println("gagal rilis:", err)
		os.Exit(1)
	}
	lapor(root, "setelah rilis")

	// Insiden: v1.4.2 bermasalah; rollback hanya mengarahkan ulang symlink.
	if err := alihkan(root, "v1.4.1"); err != nil {
		fmt.Println("gagal rollback:", err)
		os.Exit(1)
	}
	lapor(root, "setelah rollback")

	// Roll forward: perbaikan sudah dirilis ulang sebagai v1.4.2.
	if err := alihkan(root, "v1.4.2"); err != nil {
		fmt.Println("gagal roll forward:", err)
		os.Exit(1)
	}
	lapor(root, "setelah roll forward")
}