Chapter Continuous Integration di section Data Engineering Testing ditutup dengan lima gerbang uji yang hijau di mesin CI. Pertanyaan berikutnya muncul segera setelah itu: bagaimana kode yang lolos gerbang sampai ke server production tanpa disalin manual? Penyalinan manual lewat scp atau zip dari laptop selalu meninggalkan tiga pertanyaan terbuka: berkas mana yang terlupa tersalin, versi apa yang sedang berjalan di server, dan bagaimana cara kembali ke versi lama saat versi baru bermasalah. Deployment pipeline menjawab ketiganya lewat jalur baku yang dijalankan mesin, bukan tangan: setiap commit melewati tahap build dan test, dikemas menjadi artifact bernama versi, lalu dipromosikan ke staging dan production sebagai paket yang sama.

Tahapan: dari Commit ke Rilis #

Pipeline deployment menyerupai ban berjalan dengan stasiun tetap. Setiap commit masuk, melewati seluruh stasiun, dan keluar sebagai rilis bernama versi — atau berhenti di stasiun yang menolaknya:

Dua keputusan desain membuat alur ini aman. Pertama, artifact bersifat immutable: sekali dibangun, isi artifact tidak pernah diubah; perbaikan apa pun dilakukan di Git lalu membangun artifact baru. Kedua, promosi memindahkan artifact yang sama dari staging ke production — bukan membangun ulang per lingkungan — sehingga selisih build tidak mungkin menjadi sumber perbedaan perilaku. Pola tiga tahap build, release, run ini dijelaskan lengkap di 12factor.net .

Demo: Skrip Deploy Mini #

Kita praktikkan idenya dengan skrip deploy nyata. Persiapan: satu proyek mini berisi src/pipeline.py dan config/production.yaml, disimpan dalam repo Git dengan satu commit bertag v1.4.0. Skrip berikut melakukan version stamp, membangun artifact, lalu memasangnya:

python
"""Deploy mini TokoKita: version stamp -> artifact zip + MANIFEST -> symlink switch.

Pemakaian:
    python3 deploy.py                  # deploy versi saat ini
    python3 deploy.py rollback <versi> # arahkan ulang symlink current
"""
import hashlib
import json
import os
import subprocess
import sys
import zipfile
from pathlib import Path

AKAR = Path(__file__).parent
PROYEK = AKAR / "proyek"
RILIS = AKAR / "rilis"


def versi() -> str:
    """Version stamp pola git describe: tag, jumlah commit sejak tag, hash."""
    hasil = subprocess.run(
        ["git", "describe", "--tags", "--always", "--dirty"],
        cwd=PROYEK, capture_output=True, text=True, check=True)
    return hasil.stdout.strip()


def sha256(berkas: Path) -> str:
    return hashlib.sha256(berkas.read_bytes()).hexdigest()[:12]


def bangun_artifact(ver: str) -> Path:
    """Bungkus src + config menjadi zip immutable + MANIFEST berisi sha256."""
    RILIS.mkdir(exist_ok=True)
    artifact = RILIS / f"tokokita-etl-{ver}.zip"
    manifest = {}
    with zipfile.ZipFile(artifact, "w") as zipf:
        for berkas in sorted(PROYEK.rglob("*")):
            if berkas.is_file() and ".git" not in berkas.parts:
                nama = berkas.relative_to(PROYEK).as_posix()
                zipf.write(berkas, nama)
                manifest[nama] = sha256(berkas)
        zipf.writestr("MANIFEST.json", json.dumps(manifest, indent=2))
    return artifact


def pasang(target: Path) -> None:
    """Arahkan symlink current ke rilis baru lewat rename (atomic-ish)."""
    current = RILIS / "current"
    sementara = RILIS / "current.tmp"
    if sementara.is_symlink() or sementara.exists():
        sementara.unlink()
    sementara.symlink_to(target.name)
    sementara.replace(current)


def deploy(ver: str) -> Path:
    target = RILIS / f"tokokita-etl-{ver}"
    target.mkdir(exist_ok=True)
    with zipfile.ZipFile(RILIS / f"tokokita-etl-{ver}.zip") as zipf:
        zipf.extractall(target)
    manifest = json.loads((target / "MANIFEST.json").read_text())
    cocok = sum(1 for nama, cek in manifest.items()
                if sha256(target / nama) == cek)
    print(f"verifikasi MANIFEST: {cocok}/{len(manifest)} berkas cocok")
    pasang(target)  # symlink current berpindah lewat satu rename
    return target


if __name__ == "__main__":
    if len(sys.argv) == 3 and sys.argv[1] == "rollback":
        lama = RILIS / f"tokokita-etl-{sys.argv[2]}"
        pasang(lama)
        print(f"rollback: current -> {sys.argv[2]}")
        sys.exit(0)

    ver = versi()
    print(f"versi   : {ver}")
    artifact = bangun_artifact(ver)
    print(f"artifact: {artifact.name} ({artifact.stat().st_size} byte)")
    deploy(ver)
    tersimpan = sorted(p.name for p in RILIS.iterdir()
                       if p.is_dir() and p.name != "current")
    print(f"rilis tersimpan: {tersimpan}")
    print(f"current -> {os.readlink(RILIS / 'current')}")

Mari kita telaah baris-baris kode di atas:

  1. Baris 20-25 (versi): git describe menyusun nama versi dari kondisi repo — v1.4.0 saat commit tepat di tag, atau v1.4.0-1-gc799dcf (satu commit sejak tag, hash pendek) setelah perubahan baru; opsi --dirty menandai perubahan yang belum dikomit.
  2. Baris 32-44 (bangun_artifact): seluruh berkas src dan config dibungkus ke satu zip bersama MANIFEST.json berisi sha256 per berkas; folder .git dikecualikan karena riwayat commit bukan bagian dari rilis.
  3. Baris 47-54 (pasang): symlink baru dibuat dengan nama sementara lalu dipindah lewat replace — satu panggilan rename, sehingga current tidak pernah berada dalam kondisi setengah terpasang.
  4. Baris 57-67 (deploy): sebelum dipasang, sha256 hasil ekstraksi dibandingkan dengan MANIFEST; ketidakcocokan berarti artifact rusak dan deploy wajib berhenti.

Uji sekuensnya berjalan nyata: deploy pertama pada tag, deploy kedua setelah satu commit baru, lalu rollback. Baris pertama keluaran setup (repo siap: 1 commit, tag v1.4.0) dipangkas dari blok di bawah:

bash
python3 deploy.py
# ... satu commit baru: filter pesanan refund ...
python3 deploy.py
python3 deploy.py rollback v1.4.0

Output yang Dihasilkan:

text
=== DEPLOY 1 ===
versi   : v1.4.0
artifact: tokokita-etl-v1.4.0.zip (595 byte)
verifikasi MANIFEST: 2/2 berkas cocok
rilis tersimpan: ['tokokita-etl-v1.4.0']
current -> tokokita-etl-v1.4.0

=== DEPLOY 2 (setelah commit baru) ===
versi   : v1.4.0-1-gc799dcf
artifact: tokokita-etl-v1.4.0-1-gc799dcf.zip (648 byte)
verifikasi MANIFEST: 2/2 berkas cocok
rilis tersimpan: ['tokokita-etl-v1.4.0', 'tokokita-etl-v1.4.0-1-gc799dcf']
current -> tokokita-etl-v1.4.0-1-gc799dcf

=== ROLLBACK ===
rollback: current -> v1.4.0

Perhatikan tiga fakta dari keluaran tersebut. Pertama, nama versi mengikuti riwayat Git, sehingga pertanyaan “versi apa yang sedang berjalan” selalu dijawab dengan membaca git describe pada commit yang bersangkutan. Kedua, deploy kedua tidak menghapus rilis pertama — kedua folder rilis tetap tersimpan, dan rollback hanya memindahkan current kembali tanpa membangun ulang apa pun. Nilai hash gc799dcf berbeda pada setiap repo karena hash commit Git menyertakan waktu pembuatannya; untuk isi proyek yang sama, sisanya identik.

Artifact Immutable dan Rollback #

Kombinasi folder rilis/<versi>/ dan symlink current memberi dua properti sekaligus: pergantian versi berlangsung lewat satu rename, dan kembali ke versi lama sama cepatnya. Struktur folder rilis juga memudahkan inspeksi — isi dua versi dapat dibandingkan berdampingan tanpa mengganggu versi yang sedang berjalan.

Disiplin versi pada level folder rilis adalah fondasi; kebijakan penomoran versi, urutan rollback yang aman, dan cara membatasi dampak versi bermasalah dibahas tuntas pada chapter Versioning dan Rollback. Sebelum sampai di sana, ada satu keping yang belum terpasang: artifact yang sama harus bisa berperilaku berbeda di staging dan production — dan hal itu dicapai lewat konfigurasi, bukan lewat isi artifact. Kita susun lapisan konfigurasinya pada chapter Konfigurasi Environment.