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:

dockerfile
# 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:

  1. 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 ada pip install — pustaka standar saja yang dipakai, jadi layer FROM adalah satu-satunya beban berat image ini.
  2. LABEL org.opencontainers.image.title="proxy-service-a" — label OCI yang menjuluki image, kebiasaan dari Volume dan LABEL; docker inspect kelak menampilkan identitas ini pada image yang lahir dari Dockerfile service-a.
  3. ENV SERVICE_NAME=service-a dan ENV 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.
  4. WORKDIR /app — direktori kerja berikutnya; COPY dan CMD berjalan relatif ke /app, kebiasaan yang kita bandingkan dengan menyalin polos ke root filesystem di WORKDIR dan USER.
  5. COPY server.py . — satu berkas, satu layer. Context build-nya folder services/service-a itu sendiri (dari build: ./services/service-a di compose.yaml), sehingga hanya berkas di folder inilah yang bisa disalin.
  6. EXPOSE 8080 — dokumentasi port yang didengarkan service, satu suara dengan kunci expose di compose.yaml Bagian 1: sama-sama deklarasi, bukan penerbitan.
  7. CMD ["python", "server.py"] — bentuk exec sehingga python menjadi PID 1 tanpa perantara shell — detail yang menentukan nasib sinyal SIGTERM di Bagian 4, persis pembahasan CMD dan ENTRYPOINT.

server.py Backend Minimal #

Masih di folder yang sama, ketik server.py:

python
#!/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:

  1. Konfigurasi lewat environment dengan nilai cadangan — tiga baris os.environ.get(...) membaca SERVICE_NAME, SERVICE_COLOR, dan PORT dengan 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.
  2. Payload tiga field sebagai bukti routing — json.dumps menyusun {"service": …, "color": …, "path": self.path} dan self.path adalah bintang utamanya: path persis seperti diterima backend, lengkap atau tanpa awalan /a/ tergantung perilaku proxy_pass. Content-Length dihitung dari len(body) sehingga angka byte di header selalu jujur terhadap isi.
  3. log_message mencetak ke stdout dengan flush=True — kita menimpa metode bawaan agar setiap permintaan tercetak ke stdout yang langsung dibersihkan; itulah jalur menuju docker compose logs, persis mekanisme driver json-file yang kita bedah di Docker Logs. Tanpa flush=True, buffer Python bisa menahan baris-baris itu berlama-lama.
  4. ThreadingHTTPServer terikat "0.0.0.0" — dua hal sekaligus: mendengarkan di semua interface container, dan melayani tiap permintaan di thread terpisah sehingga tidak ada antrean.
  5. serve_forever() sebagai loop utama — proses menangani permintaan tanpa akhir hingga dibunuh sinyal; bersama bentuk exec CMD, 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:

dockerfile
# 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:

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: hijau

Dua 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:

bash
$ docker compose up -d --build

Output yang Dihasilkan (dipotong):

text
[+] 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.7s

Dua 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:

  1. transferring dockerfile: 401B lalu 402B — BuildKit membaca kedua Dockerfile melalui jalurnya sendiri dan mencatat ukuran aslinya; selisih satu byte itu adalah hijau yang satu karakter lebih panjang dari biru. transferring context: 2B menandai context tanpa .dockerignore — tanda penyaring kosong yang kita kenal dari section Image Build dan Optimisasi.
  2. [1/8] FROM berdurasi 4,2 detik — unduhan basis pertama; seluruh delapan langkah (FROM sampai CMD) adalah seluruh isi Dockerfile kita, dan naming to docker.io/library/proxy-service-a:1.0 adalah buah kunci image: di compose.yaml — build Compose menyimpan hasilnya dengan tag pilihan kita, bukan nama acak.
  3. [+] Building 2.0s untuk service-b dengan CACHED [1/8] FROM — basis yang sama tidak diunduh dua kali; cache BuildKit mengenali layer python:3.12-alpine yang barusan jatuh ke penyimpanan lokal.
  4. ✔ Network proxy-latihan_default Created — network dari Bagian 1 tercipta; mulai baris ini, nama service-a dan service-b resmi menjadi alamat DNS.
  5. Urutan Running: service-a, service-b, baru proxy — depends_on bekerja 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 #

Berkas Sumber

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

compose.yaml

Unduh berkas
yaml
name: 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
python
#!/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
python
#!/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()