Bab 48 — Deployment: Membawa Aplikasi ke Server dan Menjaganya Tetap Sehat

Seri A1 — Rekayasa Perangkat Lunak Profesional

CatatanDi bab ini, Anda akan:
  • Memahami Docker: image, container, Dockerfile, dan Compose
  • Memahami alur CI/CD dan menulis alur kerja GitHub Actions
  • Membuat endpoint health check, log terstruktur, dan metrik sederhana (dan menjalankannya)
  • Memisahkan konfigurasi dari kode dengan variabel lingkungan
  • Memahami rilis bernomor versi dan observability (log, metrik, jejak)

Semua skrip bab ini ada di folder kode/bab48/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Aplikasi Anda sudah cepat, rapi, dan berlapis. Tetapi “jalan di komputer saya” belum berarti jalan di server. Bayangkan Anda mengirim hasil kebun ke kota: Anda tidak mengirim kebunnya, melainkan kotak kemasan berisi hasil panen dengan label dan suhu yang terjaga. Container adalah kotak kemasan itu untuk aplikasi: ia membawa kode, Python, dan semua pustaka dalam satu bungkus yang berjalan sama di mana pun.

PeringatanAWAS

Docker tidak terpasang di komputer penulis. Karena itu, semua perintah Docker di bab ini (Dockerfile, Compose, docker build) ditampilkan tetapi tidak dijalankan, dan keluarannya tidak ditampilkan. Berkas-berkasnya kami periksa sejauh bisa tanpa Docker (sintaks YAML valid, struktur Dockerfile terbaca), tetapi belum dibuktikan dengan membangun image sungguhan. Bagian yang tidak butuh Docker (health check, log, metrik, server lokal) benar-benar dijalankan.

Alur CI/CD: commit, tes, build paket dan image, deploy, pantau.

Aplikasi yang akan dibawa: health check

Aplikasi FastAPI kecil kita (folder app/) punya tiga jalur: /health (apakah hidup?), /rekap (hitungan nilai kayu), dan /metrics (statistik). Mula-mula kita uji tanpa server sungguhan, memakai TestClient:

01_health_check.py
# Bab 48 - Endpoint health check: "apakah aplikasi hidup?" (diuji tanpa server sungguhan)
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from fastapi.testclient import TestClient

from app.aplikasi import app

klien = TestClient(app)
respons = klien.get("/health")
print("kode status:", respons.status_code)
print("isi        :", respons.json())

respons = klien.get("/rekap", params={"jumlah": 500})
print("rekap 500 pohon:", respons.json())
{"jalur": "/health", "kode": 200, "level": "INFO", "pesan": "permintaan"}
kode status: 200
isi        : {'status': 'ok', 'lingkungan': 'pengembangan'}
{"jalur": "/rekap", "kode": 200, "level": "INFO", "pesan": "permintaan"}
rekap 500 pohon: {'jumlah_pohon': 500, 'nilai_per_plot': {'A': 369995488, 'B': 408151660, 'C': 376285513}}

Baris berformat JSON dengan "jalur" dan "kode" adalah log yang dicetak aplikasi untuk tiap permintaan (kita bahas sebentar lagi). Jawaban /health berisi {'status': 'ok', ...} dengan kode 200. Health check adalah pertanyaan sederhana “apakah kamu hidup?” yang diajukan terus-menerus oleh penyeimbang beban, Docker, atau pemantau. Bila jawabannya gagal, sistem bisa menyalakan ulang aplikasi otomatis. Kami memberi /rekap 500 pohon, dan jawabannya nilai per plot (harga fiktif, angka contoh).

Konfigurasi lewat variabel lingkungan

Kode yang sama harus berjalan di laptop, server uji, dan server produksi. Bedanya (alamat basis data, kunci API, mode) jangan ditulis di kode, melainkan dibaca dari variabel lingkungan yang diisi tempat aplikasi berjalan:

04_konfigurasi_env.py
# Bab 48 - Konfigurasi lewat variabel lingkungan: satu kode, banyak lingkungan
import os

# Nilai tiruan untuk demo; di server sungguhan nilai ini diisi sistem, bukan ditulis di kode
os.environ["APP_LINGKUNGAN"] = "produksi"
os.environ["APP_KUNCI_API"] = "[disamarkan]"


def baca_konfigurasi():
    return {
        "lingkungan": os.environ.get("APP_LINGKUNGAN", "pengembangan"),
        "port": int(os.environ.get("APP_PORT", "8000")),
        "kunci_api": os.environ.get("APP_KUNCI_API"),
    }


konfig = baca_konfigurasi()
for kunci, nilai in konfig.items():
    # rahasia tidak pernah dicetak utuh
    tampil = "(terisi, disembunyikan)" if kunci == "kunci_api" and nilai else nilai
    print(f"{kunci:11} = {tampil}")
lingkungan  = produksi
port        = 8000
kunci_api   = (terisi, disembunyikan)

Nilai kunci API di skrip hanyalah tiruan [disamarkan], dan hasil cetaknya pun disembunyikan. Aturan emas: rahasia tidak masuk Git dan tidak dicetak. Di Bab 49 kita bahas lebih dalam.

Log terstruktur

Log berupa teks bebas (“Panen dicatat di blok A!”) enak dibaca manusia tetapi sulit dicari mesin. Log terstruktur menulis satu baris JSON per kejadian, sehingga alat pencari bisa menyaring “semua ERROR dari kolam K2”:

02_log_terstruktur.py
# Bab 48 - Log terstruktur (JSON): satu baris, satu kejadian
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from app.logjson import siapkan_log

log = siapkan_log("demo", dengan_waktu=False)   # waktu dimatikan agar keluaran buku tetap sama
log.info("panen dicatat", extra={"ekstra": {"blok": "A", "hasil_kg": 245.5}})
log.warning("pH di luar batas contoh", extra={"ekstra": {"kolam": "K2", "ph": 6.1}})
log.error("sensor tidak menjawab", extra={"ekstra": {"sensor": "S7"}})
{"blok": "A", "hasil_kg": 245.5, "level": "INFO", "pesan": "panen dicatat"}
{"kolam": "K2", "level": "WARNING", "pesan": "pH di luar batas contoh", "ph": 6.1}
{"level": "ERROR", "pesan": "sensor tidak menjawab", "sensor": "S7"}

Tiap baris punya level (tingkat keparahan: INFO, WARNING, ERROR), pesan, dan bidang tambahan. Pada skrip nyata Anda menyalakan dengan_waktu=True agar tiap baris memuat jam kejadian; di buku kita matikan agar keluaran tetap sama setiap dibangun.

Metrik

Metrik adalah angka yang dihitung terus: jumlah permintaan, jumlah error, lama jawaban. Berbeda dari log (cerita per kejadian), metrik merangkum. Aplikasi kita menghitung permintaan per jalur dan kode status:

03_metrik.py
# Bab 48 - Metrik: penghitung permintaan yang bisa dibaca di /metrics
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from fastapi.testclient import TestClient

from app.aplikasi import app

klien = TestClient(app)
for _ in range(3):
    klien.get("/health")
klien.get("/rekap", params={"jumlah": 10})
klien.get("/tidak-ada")          # sengaja jalur salah: akan tercatat sebagai 404

print("--- isi /metrics ---")
print(klien.get("/metrics").text, end="")
{"jalur": "/health", "kode": 200, "level": "INFO", "pesan": "permintaan"}
{"jalur": "/health", "kode": 200, "level": "INFO", "pesan": "permintaan"}
{"jalur": "/health", "kode": 200, "level": "INFO", "pesan": "permintaan"}
{"jalur": "/rekap", "kode": 200, "level": "INFO", "pesan": "permintaan"}
{"jalur": "/tidak-ada", "kode": 404, "level": "INFO", "pesan": "permintaan"}
--- isi /metrics ---
{"jalur": "/metrics", "kode": 200, "level": "INFO", "pesan": "permintaan"}
# jumlah permintaan per jalur dan kode status
permintaan_total{jalur="/health",kode="200"} 3
permintaan_total{jalur="/rekap",kode="200"} 1
permintaan_total{jalur="/tidak-ada",kode="404"} 1

Permintaan /health tiga kali tercatat 3, /rekap satu kali, dan jalur salah /tidak-ada tercatat sebagai kode 404. Bila persentase 404 atau 500 melonjak, itu sinyal ada masalah. Format baris tekstual seperti ini mudah dibaca alat pemantau; alat standar industri memiliki pustaka resmi untuk itu, yang tidak kami uji di sini.

Docker: Dockerfile dan Compose (tidak dijalankan)

Sebuah image adalah cetakan aplikasi beserta lingkungannya; sebuah container adalah cetakan itu yang sedang berjalan. Dockerfile adalah resep pembuatan image:

# syntax=docker/dockerfile:1

FROM python:3.14-slim AS builder
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/

FROM python:3.14-slim
RUN useradd --create-home appuser
WORKDIR /app
COPY --from=builder /venv /venv
COPY --from=builder /app /app
ENV PATH="/venv/bin:$PATH"
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "app.aplikasi:app", "--host", "0.0.0.0", "--port", "8000"]

Kerangkanya mengikuti panduan resmi Docker untuk Python (docs.docker.com/guides/python/containerize, dibaca 2026-10-10): build bertahap (multi-stage) dengan lingkungan virtual /venv yang disalin ke tahap akhir, dan uvicorn sebagai perintah awal. Panduan resminya memakai image khusus (dhi.io) yang butuh login; kami memakai nama python:3.14-slim sebagai gantinya. Nama tag itu belum kami verifikasi ke Docker Hub; periksa dulu sebelum memakai. Tambahan kami sendiri yang belum diuji: pengguna non-root (USER appuser, supaya bila aplikasi ditembus, penyerang tidak langsung berhak penuh) dan HEALTHCHECK yang memanggil /health.

Compose menjalankan satu atau beberapa container lewat satu berkas:

services:
  pantau:
    build:
      context: .
    ports:
      - "8000:8000"
    environment:
      - APP_LINGKUNGAN=produksi
    restart: unless-stopped

Perintah bangun dan jalankan tampil di skrip berikut:

07_docker_perintah.py
# Bab 48 - Membangun dan menjalankan image Docker (perintah yang Anda jalankan di komputer ber-Docker)
import subprocess

# 1. Bangun image dari Dockerfile di folder deploy/
subprocess.run(["docker", "build", "-t", "pantau-lapangan:0.1.0", "deploy"], check=True)

# 2. Jalankan wadah (container), buka port 8000
subprocess.run(["docker", "run", "--rm", "-p", "8000:8000", "pantau-lapangan:0.1.0"], check=True)

# 3. Atau lewat Compose: membangun dan menjalankan sekaligus
# subprocess.run(["docker", "compose", "-f", "deploy/compose.yaml", "up", "--build"], check=True)

Kode ini tidak dijalankan saat buku dibuat karena Docker tidak terpasang di komputer penulis, jadi keluarannya tidak ditampilkan. Skrip berikut mencatat bahwa memang Docker tidak ditemukan (keluaran diambil sekali dari komputer penulis, @statis):

06_cek_docker.py
# Bab 48 - Apakah Docker terpasang di komputer ini?
import shutil

jalur = shutil.which("docker")
print("perintah docker ditemukan:", jalur is not None)
perintah docker ditemukan: False

Sebagai gantinya, kita memeriksa sebagian berkasnya dengan Python:

05_periksa_berkas_deploy.py
# Bab 48 - Memeriksa berkas deploy (Dockerfile, compose, CI) TANPA menjalankan Docker
from pathlib import Path

import yaml

deploy = Path(__file__).parent / "deploy"

for nama in ["compose.yaml", ".github/workflows/ci.yml"]:
    data = yaml.safe_load((deploy / nama).read_text(encoding="utf-8"))
    print(f"{nama}: YAML valid, kunci tingkat atas = {sorted(map(str, data))}")

ci = yaml.safe_load((deploy / ".github/workflows/ci.yml").read_text(encoding="utf-8"))
print("job CI:", list(ci["jobs"]), "| matriks Python:", ci["jobs"]["uji"]["strategy"]["matrix"]["python-version"])

perintah = [b.split()[0] for b in (deploy / "Dockerfile").read_text(encoding="utf-8").splitlines()
            if b and not b.startswith(("#", " "))]
print("perintah Dockerfile:", perintah)
print("tahap build (FROM):", perintah.count("FROM"), "| pakai pengguna non-root:", "USER" in perintah)
compose.yaml: YAML valid, kunci tingkat atas = ['services']
.github/workflows/ci.yml: YAML valid, kunci tingkat atas = ['True', 'jobs', 'name']
job CI: ['uji', 'image'] | matriks Python: ['3.12', '3.13', '3.14']
perintah Dockerfile: ['FROM', 'WORKDIR', 'RUN', 'ENV', 'COPY', 'RUN', 'COPY', 'FROM', 'RUN', 'WORKDIR', 'COPY', 'COPY', 'ENV', 'USER', 'EXPOSE', 'HEALTHCHECK', 'CMD']
tahap build (FROM): 2 | pakai pengguna non-root: True

Hasil ini membuktikan sintaks YAML valid dan struktur Dockerfile terbaca (dua tahap FROM, ada USER). Ia tidak membuktikan image berhasil dibangun. Perhatikan satu kejutan: kunci on: di berkas CI terbaca sebagai 'True'. Itu karena pembaca YAML versi 1.1 menganggap on sebagai nilai benar (true). Tidak apa-apa, GitHub membacanya benar; tetapi inilah contoh mengapa memeriksa keluaran asli lebih baik daripada berasumsi.

CI/CD dengan GitHub Actions (tidak dijalankan)

CI (Continuous Integration) berarti tiap perubahan kode otomatis diuji. CD (Continuous Delivery/Deployment) berarti hasil yang lulus otomatis disiapkan atau dikirim ke server. Di GitHub, alurnya ditulis dalam berkas YAML di .github/workflows/. Kerangka di bawah mengikuti contoh resmi GitHub untuk menguji Python dengan matriks versi (docs.github.com, dibaca 2026-10-10):

name: CI

on:
  push:
  pull_request:

jobs:
  uji:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v6
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt pytest httpx
      - name: Test with pytest
        run: pytest

  image:
    needs: uji
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Build Docker image
        run: docker build -t pantau-lapangan:${{ github.sha }} .

Berkas ini tidak dijalankan saat buku dibuat karena ia hanya berjalan di server GitHub setelah Anda mendorong kode ke sana, jadi keluarannya tidak ditampilkan. Job uji menjalankan tes di tiga versi Python; job image baru jalan bila uji lulus (needs: uji), itulah “gerbang” pada gambar di atas. Langkah build image-nya tidak kami uji.

Rilis, pemantauan, dan observability

Rilis sebaiknya bernomor versi (Bab 45) dan ditandai dengan tag di Git (misalnya v0.1.0), sehingga Anda tahu persis kode mana yang berjalan di server. Siapkan pula rencana kembali (rollback): bila rilis baru bermasalah, deploy ulang tag sebelumnya. Rilis kecil dan sering lebih aman daripada rilis raksasa jarang.

Observability (keteramatan) adalah kemampuan memahami kondisi dalam sistem dari luar, biasanya lewat tiga sumber: log (cerita per kejadian), metrik (angka ringkasan), dan jejak (trace, perjalanan satu permintaan melintasi banyak layanan). Dua yang pertama sudah kita buat. Jejak butuh alat tambahan dan tidak kami bahas.

Proyek Bersambung A1: server sungguhan, dicek seperti pemantau

Tanpa Docker, kita tetap bisa mendemonstrasikan hal terpenting: aplikasi benar-benar berjalan sebagai server dan menjawab pemantau. Skrip berikut menyalakan uvicorn di port kosong, menunggu /health menjawab, lalu memanggil jalur lain dan mematikan server:

08_proyek_server_lokal.py
# Bab 48 - Proyek bersambung A1: menjalankan server sungguhan (uvicorn) lalu mengecek /health seperti pemantau
import socket
import subprocess
import sys
import time
from pathlib import Path

import httpx

folder = Path(__file__).parent

with socket.socket() as s:                 # cari port kosong
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]

server = subprocess.Popen(
    [sys.executable, "-m", "uvicorn", "app.aplikasi:app", "--app-dir", str(folder),
     "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
alamat = f"http://127.0.0.1:{port}"
try:
    siap = False
    for _ in range(50):                    # tunggu sampai /health menjawab
        try:
            if httpx.get(alamat + "/health", timeout=1).status_code == 200:
                siap = True
                break
        except httpx.TransportError:
            time.sleep(0.2)
    print("server siap menjawab /health:", siap)
    print("/health:", httpx.get(alamat + "/health").json())
    print("/rekap :", httpx.get(alamat + "/rekap", params={"jumlah": 200}).json())
    print("/metrics:")
    print(httpx.get(alamat + "/metrics").text, end="")
finally:
    server.terminate()
    server.wait(timeout=10)
print("server dihentikan, kode keluar bukan None:", server.returncode is not None)
server siap menjawab /health: True
/health: {'status': 'ok', 'lingkungan': 'pengembangan'}
/rekap : {'jumlah_pohon': 200, 'nilai_per_plot': {'A': 177787270, 'B': 192120459, 'C': 130402814}}
/metrics:
# jumlah permintaan per jalur dan kode status
permintaan_total{jalur="/health",kode="200"} 2
permintaan_total{jalur="/rekap",kode="200"} 1
server dihentikan, kode keluar bukan None: True

/health dipanggil dua kali (satu saat menunggu server siap, satu saat dicetak), maka metrik mencatat 2. Di dalam Docker, alurnya sama; yang berbeda hanya server dijalankan di dalam container. Proyek A1 kini lengkap sebagai berkas: Dockerfile, Compose, dan alur CI, ditambah aplikasi yang sudah terbukti berjalan.

Latihan

  1. Tambahkan jalur /versi pada aplikasi yang mengembalikan nomor versi dari variabel lingkungan APP_VERSI (bawaan "0.1.0"). Uji dengan TestClient.
  2. Tambahkan bidang "jalur" dan "kode" yang sama pada log warning untuk permintaan berkode 404 (petunjuk: di middleware, pilih log.warning bila status_code >= 400).
  3. Tulis ulang job image pada CI agar hanya berjalan bila nama cabang main (petunjuk: if: github.ref == 'refs/heads/main'; belum diuji di sini).
  4. Tantangan: buat metrik kedua yang menghitung jumlah respons per kelas status (2xx, 4xx) dan tampilkan di /metrics.

Ringkasan Bab

  • Container membungkus aplikasi dan lingkungannya; Dockerfile adalah resep image; Compose menjalankan beberapa container.
  • Docker tidak terpasang di komputer penulis, jadi Dockerfile, Compose, dan CI tidak dijalankan; kami hanya memeriksa sintaksnya.
  • CI/CD: tiap perubahan diuji otomatis, dan hanya yang lulus yang dirilis.
  • Health check, log terstruktur, dan metrik adalah bekal pemantauan; ketiganya kita buat dan jalankan.
  • Konfigurasi dan rahasia lewat variabel lingkungan, bukan di kode.
  • Rilis bernomor versi, bertag, dan punya rencana kembali.

Selanjutnya, di Bab 49: keamanan, menjaga aplikasi dari kebocoran rahasia, dependensi bermasalah, dan serangan umum.