Bab 48 — Deployment: Membawa Aplikasi ke Server dan Menjaganya Tetap Sehat
Seri A1 — Rekayasa Perangkat Lunak Profesional
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.
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.

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-stoppedPerintah 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
- Tambahkan jalur
/versipada aplikasi yang mengembalikan nomor versi dari variabel lingkunganAPP_VERSI(bawaan"0.1.0"). Uji denganTestClient. - Tambahkan bidang
"jalur"dan"kode"yang sama pada logwarninguntuk permintaan berkode 404 (petunjuk: di middleware, pilihlog.warningbilastatus_code >= 400). - Tulis ulang job
imagepada CI agar hanya berjalan bila nama cabangmain(petunjuk:if: github.ref == 'refs/heads/main'; belum diuji di sini). - 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.