Pintu depan dari Bagian 2 kini punya otak lengkap, tetapi dua nama di dalamnya — service-a dan service-b — masih menunjuk ruangan kosong: belum ada image, belum ada container, belum ada proses yang mendengarkan :8080. Bagian ini mengisi kedua ruangan itu dengan satu bahan yang sengaja dibuat seminimal mungkin: HTTP server Python murni dari pustaka standar, tanpa satu dependensi eksternal pun. Justru karena minim itulah ia cocok — semua yang tampil pada respons adalah bukti routing, bukan suara framework. Kita bangun image pertama, bedah kode backend tuntas, lahirkan kembarannya dengan identitas berbeda, lalu menyalakan seluruh stack dan menyaksikan DNS Compose menghubungkan semua bagian.
Dockerfile service-a #
Buat folder services/service-a/ lalu ketik Dockerfile berikut di dalamnya:
# Service backend minimal: HTTP server Python murni tanpa dependensi.
FROM python:3.12-alpine
LABEL org.opencontainers.image.title="proxy-service-a"
# Identitas disuntik lewat environment sehingga berkas server.py
# yang sama dipakai bergantian oleh service-a dan service-b.
ENV SERVICE_NAME=service-a
ENV SERVICE_COLOR=biru
WORKDIR /app
COPY server.py .
EXPOSE 8080
CMD ["python", "server.py"]Tujuh belas baris ini merangkai hampir semua kebiasaan baik yang kita tanam di section Dockerfile. Mari kita telaah ketujuh instruksinya:
FROM python:3.12-alpine— basis Python 3.12 varian Alpine: kecil dan sudah membawa interpreter yang kita butuhkan, tanpa lebih. Tidak ada proses build aplikasi, tidak adapip install— pustaka standar saja yang dipakai, jadi layerFROMadalah satu-satunya beban berat image ini.LABEL org.opencontainers.image.title="proxy-service-a"— label OCI yang menjuluki image, kebiasaan dari Volume dan LABEL;docker inspectkelak menampilkan identitas ini pada image yang lahir dari Dockerfile service-a.ENV SERVICE_NAME=service-adanENV SERVICE_COLOR=biru— identitas service disuntik sebagai variabel environment, bukan dihardcode di kode. Dua baris inilah seluruh perbedaan identitas antara kedua kembaran kita, dan cara kerja suntikan ini kita bedah tuntas di ENV dan ARG.WORKDIR /app— direktori kerja berikutnya;COPYdanCMDberjalan relatif ke/app, kebiasaan yang kita bandingkan dengan menyalin polos ke root filesystem di WORKDIR dan USER.COPY server.py .— satu berkas, satu layer. Context build-nya folderservices/service-aitu sendiri (daribuild: ./services/service-adi compose.yaml), sehingga hanya berkas di folder inilah yang bisa disalin.EXPOSE 8080— dokumentasi port yang didengarkan service, satu suara dengan kunciexposedi compose.yaml Bagian 1: sama-sama deklarasi, bukan penerbitan.CMD ["python", "server.py"]— bentuk exec sehinggapythonmenjadi PID 1 tanpa perantara shell — detail yang menentukan nasib sinyalSIGTERMdi Bagian 4, persis pembahasan CMD dan ENTRYPOINT.
server.py Backend Minimal #
Masih di folder yang sama, ketik server.py:
#!/usr/bin/env python3
"""Service backend minimal untuk latihan reverse proxy.
Menyajikan satu endpoint GET yang melaporkan nama service, warna,
dan path yang diminta — cukup untuk membuktikan routing nginx
dan pembuangan prefix oleh proxy_pass.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
SERVICE_NAME = os.environ.get("SERVICE_NAME", "service-a")
SERVICE_COLOR = os.environ.get("SERVICE_COLOR", "biru")
PORT = int(os.environ.get("PORT", "8080"))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
payload = json.dumps(
{"service": SERVICE_NAME, "color": SERVICE_COLOR, "path": self.path}
) + "\n"
body = payload.encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(
"%s - - [%s] %s"
% (self.address_string(), self.log_date_time_string(), fmt % args),
flush=True,
)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()Tiga puluh sembilan baris tanpa dependensi ini tetap memuat lima keputusan yang layak dibedah:
- Konfigurasi lewat environment dengan nilai cadangan — tiga baris
os.environ.get(...)membacaSERVICE_NAME,SERVICE_COLOR, danPORTdengan default yang aman. Nilai default membuat berkas tetap bisa dijalankan telanjang di laptop tanpa Docker, sementara Dockerfile menyuntik nilai resminya — pola yang kita kenal dari Environment Variable. - Payload tiga field sebagai bukti routing —
json.dumpsmenyusun{"service": …, "color": …, "path": self.path}danself.pathadalah bintang utamanya: path persis seperti diterima backend, lengkap atau tanpa awalan/a/tergantung perilakuproxy_pass.Content-Lengthdihitung darilen(body)sehingga angka byte di header selalu jujur terhadap isi. log_messagemencetak ke stdout denganflush=True— kita menimpa metode bawaan agar setiap permintaan tercetak ke stdout yang langsung dibersihkan; itulah jalur menujudocker compose logs, persis mekanisme driverjson-fileyang kita bedah di Docker Logs. Tanpaflush=True, buffer Python bisa menahan baris-baris itu berlama-lama.ThreadingHTTPServerterikat"0.0.0.0"— dua hal sekaligus: mendengarkan di semua interface container, dan melayani tiap permintaan di thread terpisah sehingga tidak ada antrean.serve_forever()sebagai loop utama — proses menangani permintaan tanpa akhir hingga dibunuh sinyal; bersama bentuk execCMD, inilah yang menjadikannya warga container yang baik.
Mengapa server seminimal ini cukup? Karena yang diuji proyek kita adalah routing, bukan aplikasi. Framework web akan menambah router sendiri, serializer sendiri, dan log sendiri — semuanya suara yang menutupi sinyal. Dengan server di bawah empat puluh baris, satu-satunya variabel yang tersisa di respons adalah keputusan nginx: service mana yang dipilih dan path apa yang diteruskan. Ia juga menjaga image tetap kurus — hampir seluruh ukurannya adalah python:3.12-alpine yang kita warisi dari FROM.
service-b: Satu Kode, Dua Identitas #
Sekarang kembarannya. Buat folder services/service-b/ dan ketik Dockerfile berikut:
# Service backend minimal: HTTP server Python murni tanpa dependensi.
FROM python:3.12-alpine
LABEL org.opencontainers.image.title="proxy-service-b"
# Identitas disuntik lewat environment sehingga berkas server.py
# yang sama dipakai bergantian oleh service-a dan service-b.
ENV SERVICE_NAME=service-b
ENV SERVICE_COLOR=hijau
WORKDIR /app
COPY server.py .
EXPOSE 8080
CMD ["python", "server.py"]Bedah perbedaannya cepat selesai karena hanya tiga baris: LABEL menamai image proxy-service-b, ENV SERVICE_NAME=service-b dan ENV SERVICE_COLOR=hijau menyuntik identitas hijau. Salin juga server.py dari service-a ke folder ini dengan satu penyesuaian kecil: salinan service-b tidak membaca PORT dari environment dan menuliskan angka 8080 langsung pada baris terakhirnya — ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever(); perilaku keduanya identik di stack ini karena default PORT memang 8080. Sisanya, byte demi byte, kedua server.py adalah berkas yang sama: satu kode, dua identitas, karena identitas hidup di environment — bukan di kode.
Pola dua image ini punya alternatif yang layak dipertimbangkan: satu image untuk dua service, dengan identitas disuntik dari compose.yaml:
service-a:
image: proxy-service:1.0
environment:
SERVICE_NAME: service-a
SERVICE_COLOR: biru
service-b:
image: proxy-service:1.0
environment:
SERVICE_NAME: service-b
SERVICE_COLOR: hijauDua pendekatan ini bertukar kelebihan yang jelas. Satu image berarti build sekali, satu tempat memperbaiki bug kode, dan satu nama repo image yang dipush; tetapi kedua service terikat pada versi kode yang sama persis. Dua image memberi kebebasan versi independen — service-a boleh naik ke 1.1 selagi service-b menunggu — dan label OCI pada masing-masing image membuat identitasnya terbaca di registry; harganya adalah duplikasi resep dan build ganda. Untuk produksi dengan kode yang sama, satu image plus environment adalah pilihan yang lebih lazim; untuk latihan ini, dua Dockerfile membuat mekanisme suntikan ENV tampil terang di dua tempat berbeda.
Nama Service sebagai Alamat #
Bagaimana proxy menemukan kedua backend itu? Tidak ada satu pun alamat IP yang tertulis di proyek kita — nginx.conf hanya menyebut service-a:8080 dan service-b:8080, dan nama-nama itu dijawab oleh embedded DNS di 127.0.0.11 yang menyala pada network proxy-latihan_default, mekanisme penuh yang kita bedah di chapter Container DNS. Karena network Compose adalah user-defined network, setiap nama service otomatis menjadi nama DNS — tanpa --link, tanpa network_alias tambahan.
Ada satu karakter kerja yang perlu dipahami sekarang dan kita buktikan penuh di Bagian 4: nginx menyelesaikan nama upstream satu kali, saat memuat konfigurasi di awal hidupnya — perilaku yang sudah kita tandai pada NOTE chapter Reverse Proxy. Alamat IP hasil resolusi itu yang dipakai selama container proxy menyala. depends_on bentuk daftar dari Bagian 1 mendukung karakter ini: kedua backend diciptakan lebih dulu, sehingga ketika proxy memuat nginx.conf, nama service-a dan service-b sudah punya jawaban DNS — tanpa itu, startup proxy berakhir galat host not found in upstream. Benang merahnya menyambung terus: bagaimana resolusi statis ini bersikap saat service berhenti dan bangkit kembali adalah salah satu sidang utama Bagian 4.
Menyalakan Stack #
Semua berkas di tempatnya — waktunya menyalakan:
$ docker compose up -d --buildOutput yang Dihasilkan (dipotong):
[+] Building 9.1s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 401B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.12-alpine 1.8s
=> [auth] python:3.12-alpine pull token from registry-1.docker.io 0.5s
=> [internal] load build context 0.1s
=> => transferring context: 1.632kB 0.0s
=> [1/8] FROM docker.io/library/python:3.12-alpine 4.2s
=> => resolve docker.io/library/python:3.12-alpine 0.0s
...
=> [2/8] LABEL org.opencontainers.image.title="proxy-service-a" 0.0s
=> [3/8] ENV SERVICE_NAME=service-a 0.0s
=> [4/8] ENV SERVICE_COLOR=biru 0.0s
=> [5/8] WORKDIR /app 0.1s
=> [6/8] COPY server.py . 0.2s
=> [7/8] EXPOSE 8080 0.0s
=> [8/8] CMD ["python" "server.py"] 0.0s
=> exporting to image 0.3s
=> => exporting layers 0.2s
=> => writing image sha256:3f9c2e8a1d6b4f7a0c5e9d2b8a1f4c7e0b3a6d9f2c5e8b1a4d7f0c3e6b9a2d5f 0.1s
=> => naming to docker.io/library/proxy-service-a:1.0 0.0s
[+] Building 2.0s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 402B 0.0s
...
=> CACHED [1/8] FROM docker.io/library/python:3.12-alpine
...
=> => naming to docker.io/library/proxy-service-b:1.0 0.0s
[+] Running 4/4
✔ Network proxy-latihan_default Created 0.2s
✔ Container proxy-latihan-service-a-1 Started 0.5s
✔ Container proxy-latihan-service-b-1 Started 0.4s
✔ Container proxy-latihan-proxy-1 Started 0.7sDua baris unduhan layer python:3.12-alpine dipotong — image ini pertama kalinya dipakai di course kita, sehingga FROM pada build pertama mengunduhnya sungguhan; build kedua menikmatinya sebagai CACHED. Mari kita telaah lima momen pentingnya:
transferring dockerfile: 401Blalu402B— BuildKit membaca kedua Dockerfile melalui jalurnya sendiri dan mencatat ukuran aslinya; selisih satu byte itu adalahhijauyang satu karakter lebih panjang daribiru.transferring context: 2Bmenandai context tanpa.dockerignore— tanda penyaring kosong yang kita kenal dari section Image Build dan Optimisasi.[1/8] FROMberdurasi 4,2 detik — unduhan basis pertama; seluruh delapan langkah (FROMsampaiCMD) adalah seluruh isi Dockerfile kita, dannaming to docker.io/library/proxy-service-a:1.0adalah buah kunciimage:di compose.yaml — build Compose menyimpan hasilnya dengan tag pilihan kita, bukan nama acak.[+] Building 2.0suntuk service-b denganCACHED [1/8] FROM— basis yang sama tidak diunduh dua kali; cache BuildKit mengenali layerpython:3.12-alpineyang barusan jatuh ke penyimpanan lokal.✔ Network proxy-latihan_default Created— network dari Bagian 1 tercipta; mulai baris ini, namaservice-adanservice-bresmi menjadi alamat DNS.- Urutan
Running: service-a, service-b, baru proxy —depends_onbekerja persis desainnya; proxy menyala paling akhir sehingga saat nginx memuat konfigurasi, kedua upstream sudah hidup dan resolusi nama berhasil.
Tiga container kini berjalan dan satu pintu terbuka di port 8080 — tetapi belum satu kalimat pun kita ucapkan ke pintu itu. Bagian 4: Menguji Routing adalah sidangnya: indeks JSON, dua jalur, jalur yang tidak ada, lalu kegagalan parsial yang sengaja kita cetak — semua dibaca dari curl, docker compose ps, dan log proxy, sebelum stack dibongkar dengan bersih dan perjalanan panjang course ini kita rangkum.
Referensi #
- python — Docker Hub
— halaman image resmi Python beserta varian
3.12-alpineyang menjadi basis kedua backend. - Dockerfile reference
— semantik instruksi
FROM,ENV,WORKDIR,COPY,EXPOSE, danCMD.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
compose.yaml
Unduh berkasname: proxy-latihan
services:
proxy:
image: nginx:1.29-alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "8080:80"
depends_on:
- service-a
- service-b
restart: unless-stopped
service-a:
build: ./services/service-a
image: proxy-service-a:1.0
expose:
- "8080"
restart: unless-stopped
service-b:
build: ./services/service-b
image: proxy-service-b:1.0
expose:
- "8080"
restart: unless-stopped
services/service-a/server.py
Unduh berkas#!/usr/bin/env python3
"""Service backend minimal untuk latihan reverse proxy.
Menyajikan satu endpoint GET yang melaporkan nama service, warna,
dan path yang diminta — cukup untuk membuktikan routing nginx
dan pembuangan prefix oleh proxy_pass.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
SERVICE_NAME = os.environ.get("SERVICE_NAME", "service-a")
SERVICE_COLOR = os.environ.get("SERVICE_COLOR", "biru")
PORT = int(os.environ.get("PORT", "8080"))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
payload = json.dumps(
{"service": SERVICE_NAME, "color": SERVICE_COLOR, "path": self.path}
) + "\n"
body = payload.encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(
"%s - - [%s] %s"
% (self.address_string(), self.log_date_time_string(), fmt % args),
flush=True,
)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
services/service-b/server.py
Unduh berkas#!/usr/bin/env python3
"""Service backend minimal untuk latihan reverse proxy.
Menyajikan satu endpoint GET yang melaporkan nama service, warna,
dan path yang diminta — cukup untuk membuktikan routing nginx
dan pembuangan prefix oleh proxy_pass.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
SERVICE_NAME = os.environ.get("SERVICE_NAME", "service-a")
SERVICE_COLOR = os.environ.get("SERVICE_COLOR", "biru")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
payload = json.dumps(
{"service": SERVICE_NAME, "color": SERVICE_COLOR, "path": self.path}
) + "\n"
body = payload.encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(
"%s - - [%s] %s"
% (self.address_string(), self.log_date_time_string(), fmt % args),
flush=True,
)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()