Tiga container menyala, satu pintu terbuka, dan seluruh janji proyek ini kini menunggu sidang. Bagian terakhir proyek penutup course ini adalah pembuktian menyeluruh: kita mengetuk indeks routing, menelusuri dua jalur prefix, menabrakkan permintaan ke jalur yang tidak ada, lalu — puncaknya — sengaja mematikan satu service di tengah stack yang hidup untuk menyaksikan seberapa jauh kerusakannya menjalar. Setiap angka yang muncul punya asal-usul di berkas yang kita tulis sendiri: panjang JSON dihitung dari kode server.py, alamat IP di log adalah buah resolusi DNS Compose, dan angka 143 pada status container adalah kosakata sinyal yang kita hafal sejak Memahami Exit Code. Setelah pembongkaran yang bersih, kita tutup dengan rangkuman seluruh perjalanan course.

Indeks Routing dari Pintu Depan #

Saksi pertama adalah halaman depan pintu itu sendiri. Flag -i menampilkan header lengkap sebelum isi:

bash
$ curl -i http://localhost:8080/

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Thu, 15 Oct 2026 09:41:12 GMT
Content-Type: application/json
Content-Length: 43
Connection: keep-alive

{"proxy":"nginx","routes":["/a/*","/b/*"]}

Mari kita telaah bukti-buktinya:

  1. Server: nginx/1.29.4 — versi persis image nginx:1.29-alpine yang kita pin di compose.yaml; proxy inilah yang menjawab, bukan backend mana pun.
  2. Content-Length: 43 — string literal pada location = / di nginx.conf berisi 42 karakter, ditambah satu \n di ujungnya: 43 byte, dan nginx menghitungnya untuk kita. Angka yang sama kelak muncul sebagai byte terakhir baris akses log.
  3. Content-Type: application/json — buah default_type pada blok indeks; dan karena location = / memakai pencocokan eksak, hanya permintaan ke / persis yang dijawab begini — tidak ada rute lain yang bocor ke indeks ini.

Dua Jalur, Dua Jawaban #

Sekarang dua jalur routing. Permintaan pertama melewati prefix /a/:

bash
$ curl http://localhost:8080/a/halo

Output yang Dihasilkan:

json
{"service": "service-a", "color": "biru", "path": "/halo"}

Permintaan kedua, jalur kembarannya:

bash
$ curl http://localhost:8080/b/halo

Output yang Dihasilkan:

json
{"service": "service-b", "color": "hijau", "path": "/halo"}

Tiga field pada respons ini adalah tiga bukti berlapis yang kita cari sejak Bagian 2:

  1. "service": "service-a" dan "service": "service-b" — nilai ENV SERVICE_NAME di masing-masing Dockerfile sampai utuh ke payload: dua container menjalankan kode yang sama, tetapi identitasnya berbeda karena disuntik lewat environment. Routing nginx memilih ruangan yang tepat.
  2. "path": "/halo" di keduanya — bukti trailing slash bekerja: permintaan /a/halo dan /b/halo sama-sama kehilangan awalannya di proxy dan tiba di backend sebagai /halo. Field ini adalah self.path mentah dari server.py — backend tidak berbohong tentang apa yang diterimanya.
  3. "color": "biru" dan "color": "hijau" — penanda identitas kedua yang membuat dua jawaban mudah dibedakan dalam satu pandangan; selisih satu karakter inilah yang membuat respons service-b 60 byte dan service-a 59 byte — angka yang kita jumpai lagi di log.

Jalur yang Tak Ada #

Bagaimana dengan jalur yang tidak pernah kita daftarkan? Prefiks /c/ tidak punya location di nginx.conf:

bash
$ curl -i http://localhost:8080/c/halo

Output yang Dihasilkan (dipotong):

text
HTTP/1.1 404 Not Found
Server: nginx/1.29.4
Date: Thu, 15 Oct 2026 09:43:05 GMT
Content-Type: text/html

<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.29.4</center>
</body>
</html>

Tidak ada location /c/, jadi permintaan tidak cocok dengan blok mana pun yang kita definisikan — dan jawaban 404 datang dari nginx sendiri, lengkap dengan tanda tangan nginx/1.29.4 di kaki halamannya. Perhatikan siapa yang tidak ikut bicara: tidak satu byte pun dari respons ini menyentuh service-a atau service-b. Proxy menolak permintaan yang tak punya alamat tujuan tanpa membebani backend — perilaku yang sama dengan fallback try_files =404 pada Proyek: Container Web Statis, kali ini terjadi secara default.

Uji Ketahanan: Mematikan Satu Service #

Inilah ujian yang membedakan stack yang dirancang dari stack yang kebetulan jalan. Kita matikan service-b sementara — container tidak dihapus, hanya dihentikan:

bash
$ docker compose stop service-b

Output yang Dihasilkan:

text
[+] Stopping 1/1
 ✔ Container proxy-latihan-service-b-1  Stopped

Lalu kita ketuk kedua jalur lagi, berurutan:

bash
$ curl -i http://localhost:8080/a/halo

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Thu, 15 Oct 2026 09:45:58 GMT
Content-Type: application/json
Content-Length: 59
Connection: keep-alive

{"service": "service-a", "color": "biru", "path": "/halo"}
bash
$ curl -i http://localhost:8080/b/halo

Output yang Dihasilkan (dipotong):

text
HTTP/1.1 502 Bad Gateway
Server: nginx/1.29.4
Date: Thu, 15 Oct 2026 09:46:03 GMT
Content-Type: text/html

<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.4</center>
</body>
</html>

Dua respons itu adalah definisi blast radius — radius ledakan. Kegagalan service-b tidak menjalar: jalur /a/ tetap dilayani penuh oleh service-a yang tidak tahu apa-apa tentang kemalangan tetangganya, proxy tetap berdiri menjawab indeksnya sendiri, dan satu-satunya korban adalah jalur yang memang kehilangan pelananya. Perhatikan pula header Server: nginx/1.29.4 dan Content-Length: 59 pada respons yang selamat itu — nginx mengganti header Server milik backend dengan namanya sendiri, sementara 59 byte dihitung jujur oleh server.py dari len(body). nginx bahkan menjawab keheningan /b/ dengan sopan: 502 Bad Gateway, halaman error buatannya sendiri sepanjang 150 byte — bukan koneksi yang menggantung tanpa penjelasan. Inilah nilai nyata arsitektur satu pintu yang kita rancang di Bagian 1: saat satu ruangan gelap, tamu lain tetap dilayani di ruangan sisanya.

Keadaan tiga container terbaca dalam satu pandangan:

bash
$ docker compose ps -a

Output yang Dihasilkan:

text
NAME                         IMAGE                 COMMAND                  SERVICE     CREATED         STATUS                       PORTS
proxy-latihan-proxy-1        nginx:1.29-alpine     "/docker-entrypoint.…"   proxy       8 minutes ago   Up 8 minutes                 0.0.0.0:8080->80/tcp
proxy-latihan-service-a-1    proxy-service-a:1.0   "python server.py"       service-a   8 minutes ago   Up 8 minutes                 8080/tcp
proxy-latihan-service-b-1    proxy-service-b:1.0   "python server.py"       service-b   8 minutes ago   Exited (143) 2 minutes ago   8080/tcp

Dua kolom di tabel ini menutup dua pembuktian sekaligus. Pertama, PORTS: hanya baris proxy yang memuat pemetaan 0.0.0.0:8080->80/tcp; kedua backend hanya menampilkan 8080/tcp — port yang terekspos namun tidak dipublikasikan, bukti visual satu pintu yang kita rancang sejak Bagian 1. Kedua, Exited (143) pada service-b: angka itu bukan galat — 143 adalah 128 + 15, penanda proses berhenti karena SIGTERM, tepat sinyal yang dikirim docker compose stop ke PID 1; kosakata lengkapnya kita bedah di Graceful Shutdown, dan python server.py dalam bentuk exec CMD memang menerimanya tanpa penanganan khusus.

Pemulihannya semudah kejadiannya:

bash
$ docker compose start service-b

Output yang Dihasilkan:

text
[+] Running 1/1
 ✔ Container proxy-latihan-service-b-1  Started
bash
$ curl http://localhost:8080/b/halo

Output yang Dihasilkan:

json
{"service": "service-b", "color": "hijau", "path": "/halo"}

Container yang sama menyala kembali, dan jawaban 60 byte yang hilang pulih tanpa satu pun service lain disentuh.

Kronologi di Log Proxy #

Seluruh drama barusan terekam di satu tempat. docker compose logs menggabungkan stdout dan stderr yang ditampung driver json-file:

bash
$ docker compose logs proxy

Output yang Dihasilkan (dipotong):

text
proxy-latihan-proxy-1  | ...
proxy-latihan-proxy-1  | 172.20.0.1 - - [15/Oct/2026:09:41:12 +0000] "GET / HTTP/1.1" 200 43 "-" "curl/8.14.1"
proxy-latihan-proxy-1  | 172.20.0.1 - - [15/Oct/2026:09:41:30 +0000] "GET /a/halo HTTP/1.1" 200 59 "-" "curl/8.14.1"
proxy-latihan-proxy-1  | 172.20.0.1 - - [15/Oct/2026:09:41:41 +0000] "GET /b/halo HTTP/1.1" 200 60 "-" "curl/8.14.1"
proxy-latihan-proxy-1  | 2026/10/15 09:46:03 [error] 31#31: *7 connect() failed (111: Connection refused) while connecting to upstream, client: 172.20.0.1, server: , request: "GET /b/halo HTTP/1.1", upstream: "http://172.20.0.3:8080/halo", host: "localhost:8080"
proxy-latihan-proxy-1  | 172.20.0.1 - - [15/Oct/2026:09:46:03 +0000] "GET /b/halo HTTP/1.1" 502 150 "-" "curl/8.14.1"
proxy-latihan-proxy-1  | 172.20.0.1 - - [15/Oct/2026:09:47:18 +0000] "GET /b/halo HTTP/1.1" 200 60 "-" "curl/8.14.1"

Baris startup nginx, akses /c/halo yang berujung 404, dan pengulangan /a/halo saat kegagalan dipotong agar kronologi intinya terbaca — enam baris di atas adalah tulang punggungnya. Mari kita telaah satu per satu:

  1. Format combined pada baris akses — alamat - - [waktu] "permintaan" status byte "referrer" "user-agent"; referrer kosong dan curl/8.14.1 di ujung karena kita memang klien telanjang tanpa perantara browser.
  2. Semua klien tercatat sebagai 172.20.0.1 — alamat gateway network proxy-latihan_default, bukan alamat laptop kita: permintaan dari host masuk lewat pemetaan port dan tampil di sisi container sebagai datang dari gerbang jaringan. Wajah klien asli justru berlayar di header X-Real-IP yang proxy teruskan ke backend.
  3. Angka byte yang jujur — 43, 59, 60, dan 150 persis seperti yang kita hitung di atas: panjang string indeks, panjang json.dumps masing-masing service, dan panjang halaman error 502 milik nginx.
  4. Baris [error] berdampingan dengan baris akses — error log mengalir ke stderr dan akses log ke stdout; keduanya ditampung driver log yang sama sehingga kronologi lintas aliran terbaca dalam satu output, persis mekanisme yang kita bedah di Docker Logs.
  5. Nilai upstream pada baris galat — 172.20.0.3:8080/halo — memuat dua bukti sekaligus — pertama, nama service-b telah menjadi alamat IP 172.20.0.3: buah resolusi DNS yang dilakukan nginx satu kali saat memuat konfigurasi di Bagian 3. Kedua, path yang tercantum adalah /halo, bukan /b/halo: prefix dibuang sebelum koneksi dibuka, sehingga alamat lengkap yang gagal dihubungi adalah 172.20.0.3:8080/halo itu.
  6. (111: Connection refused) — tidak ada lagi proses yang mendengarkan di ujung alamat itu karena container service-b sudah diberhentikan; server: yang kosong pada baris yang sama adalah wajah lain dari keputusan kita tidak menetapkan server_name di nginx.conf.
  7. Dua baris terakhir adalah kejatuhan dan kebangkitan — 502 150 pada 09:46:03, lalu 200 60 pada 09:47:18 setelah docker compose start service-b: sekitar delapan menit sejak stack menyala, seluruh siklus hidup satu service terekam lengkap di log pintu depan.

Membongkar Stack #

Sidang selesai; stack dibongkar dengan bersih:

bash
$ docker compose down

Output yang Dihasilkan:

text
[+] Running 4/4
 ✔ Container proxy-latihan-proxy-1       Removed                         0.4s
 ✔ Container proxy-latihan-service-a-1   Removed                         0.2s
 ✔ Container proxy-latihan-service-b-1   Removed                         0.2s
 ✔ Network proxy-latihan_default         Removed                         0.3s

Tiga container dan satu network hilang dalam satu perintah — proxy dihapus lebih dulu, kebalikan urutan penyalaannya. Yang tidak ikut hilang: kedua image proxy-service-a:1.0 dan proxy-service-b:1.0 tetap di penyimpanan lokal, sehingga docker compose up -d berikutnya berlangsung cepat tanpa build ulang — down tidak pernah menyentuh image, pembatasan yang kita kenal dari Menjalankan dan Mengelola Stack.

Pencapaian Seluruh Course #

Proyek ini adalah stasiun terakhir sebuah perjalanan panjang. Dari container pertama yang menyapa Hello from Docker! sampai stack reverse proxy yang kita bongkar barusan, setiap etape meninggalkan bekal yang benar-benar terpakai di sini:

Etape perjalananBekal yang terpakai di proyek ini
Pengantar Docker sampai Instalasi dan Konfigurasipemahaman container dan daemon yang terverifikasi
Dasar Docker CLI, Docker Imagesiklus hidup container, exit code, image, tag, dan digest
Dockerfile, Image Build dan Optimisasiresep image dua backend, output BuildKit, cache layer
Konfigurasi Container, Penyimpanan Dataenvironment, restart policy, bind mount :ro
Jaringan Container, Docker Composecustom network, DNS 127.0.0.11, deklarasi tiga service
Aplikasi Multi-Containerreverse proxy, depends_on, kosakata Exited (143)
Docker dan Database sampai Orchestration dan Ekosistemkesehatan stack produksi, TLS, registry, CI/CD, dan batas Compose
Section Proyek — empat proyek sebelumnyasitus statis, dua aplikasi database, WordPress dengan disaster drill

Pola tiga langkah itu — merangkai, menghubungkan, lalu membuktikan — sama persis dengan empat proyek sebelumnya, dan inilah bekal yang dibawa ke dunia sesudah course. Dunia itu punya dua arah lanjutan yang alami. Ke arah orchestration: Konsep Kubernetes menunggu dengan pola pikir yang sama dalam skala berbeda — Deployment alih-alih docker compose up, Service alih-alih DNS Compose. Ke arah otomasi: GitHub Actions membangun dan menguji image setiap commit, sehingga rantai docker build yang kita tulis manual berubah menjadi pipeline. Keduanya adalah kelanjutan, bukan materi baru dari nol.

Proyek ini sendiri punya daftar pengembangan yang panjang untuk dilatih mandiri. Tambahkan healthcheck pada kedua backend dan naikkan depends_on ke condition: service_healthy — gerbang kesiapan dari Healthcheck dan Service Readiness yang kita tunda sengaja di Bagian 1. Pasang sertifikat TLS di proxy — satu tempat, satu perintah, persis janji Reverse Proxy dan TLS. Dan untuk merasakan load balancing sungguhan, tuliskan dua baris server di dalam satu upstream: nginx akan membagi permintaan round-robin ke keduanya — dari satu kode dua identitas menjadi satu pintu banyak replika.

Perjalanan Course Docker resmi selesai di sini. Section Referensi dan Lampiran yang menyusul bukan materi baru melainkan meja bantu: tabel perintah, kunci compose, pesan error, dan checklist yang akan sering kita buka kembali saat bekerja sungguhan. Simpan folder proyek ini baik-baik — docker compose up -d --build akan selalu menyambut kita kembali ke satu pintu di port 8080.

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()