Aplikasi yang nyaman di laptop belum tentu siap di produksi — dua lingkungan itu menuntut disiplin berbeda: log DEBUG yang tak berkonsekuensi di laptop membanjiri disk di produksi, kata sandi di berkas menjadi pintu bocor, dan proses yang dimatikan mendadak memutus request yang sedang berjalan. Production readiness adalah daftar periksa untuk melewati batas itu secara sadar: artefak mandiri, konfigurasi dari luar, probe kesiapan, dan keamanan berlapis. Pada Chapter penutup Section ini kita susun daftar periksa itu lalu mengeksekusinya sebagai program Java murni.
Mengemas sebagai Artefak Mandiri #
Satu-satunya artefak yang naik ke server produksi adalah fat jar — berkas jar yang memuat kode aplikasi beserta seluruh dependensinya, dibuat oleh spring-boot-maven-plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>Hasilnya dijalankan tanpa server aplikasi terpisah — java -jar aplikasi.jar — karena Tomcat tertanam. Untuk container image, plugin mendukung layered jar (layers.enabled=true): dependensi, resource, dan kode dipisah menjadi lapisan sehingga Docker hanya mengunggah lapisan yang berubah. Aturan emasnya: satu artefak, banyak lingkungan — jar yang sama berjalan di staging dan produksi; yang membedakan hanya konfigurasi luarnya.
Konfigurasi Luar dan Perlakuan Rahasia #
Chapter Application Properties membangun dasarnya: profil, nilai bawaan, urutan override. Di produksi prinsipnya mengeras menjadi dua aturan. Pertama, semua yang berbeda antar-lingkungan hidup di luar jar — variabel lingkungan, berkas application-prod.yaml di luar repositori, atau config server. Kedua, rahasia tidak pernah tertulis di repositori — bukan di application.yaml, bukan di komentar:
spring:
datasource:
url: jdbc:postgresql://${DB_HOST}:5432/toko
username: ${DB_USER}
password: ${DB_PASSWORD} # diisi runtime dari lingkungan/orkestratorPlaceholder ${DB_PASSWORD} diselesaikan Spring dari variabel lingkungan saat startup; Kubernetes menyuntikkannya dari Secret, vault menyuntikkan versi terkelola, dan pemindaian rahasia di pipeline menjadi pagar terakhir. Kredensial pihak ketiga mengikuti pola sama: YOUR_API_TOKEN milik lingkungan, bukan kode.
Graceful Shutdown dan Probe Produksi #
Ketika versi baru digulirkan, orkestrator menghentikan instance lama — request yang sedang diproses ikut terputus bila shutdown default dipakai. server.shutdown=graceful membaliknya: berhenti menerima request baru, menyelesaikan yang berjalan sampai batas waktu, baru menutup:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoint:
health:
probes:
enabled: trueOrkestrator lalu mengurus dua momen kritis lewat probe dari Chapter Actuator dan Health Checks: readinessProbe menahan trafik sebelum aplikasi siap, livenessProbe memutuskan restart saat proses macet, dan preStopHook memberi jeda pencabutan instance dari rotasi load balancer. Kombinasi graceful shutdown + probe membuat rolling release tanpa request gagal.
Menjalankan Daftar Periksa #
Seluruh bab sebelumnya menyumbang satu-dua butir; sekarang kita rangkai. Program Java murni berikut menilai dua set konfigurasi — satu gaya pengembangan, satu gaya produksi — terhadap tujuh butir kesiapan:
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class DaftarSiapProduksi {
// Hasil satu pemeriksaan: lulus atau tidak, dengan keterangan singkat.
record Hasil(boolean lulus, String keterangan) { }
// Satu butir daftar periksa: nama + evaluator atas peta konfigurasi.
record Pemeriksa(String nama, Function<Map<String, String>, Hasil> cek) { }
static Map<String, String> konfigDev() {
Map<String, String> cfg = new LinkedHashMap<>();
cfg.put("server.port", "8080");
cfg.put("logging.level.root", "DEBUG");
cfg.put("management.endpoints.web.exposure.include", "*");
cfg.put("spring.datasource.password", "sandi-dev");
cfg.put("server.shutdown", "immediate");
cfg.put("spring.servlet.multipart.max-file-size", "5MB");
return cfg;
}
static Map<String, String> konfigProd() {
Map<String, String> cfg = new LinkedHashMap<>();
cfg.put("server.port", "8443");
cfg.put("logging.level.root", "INFO");
cfg.put("management.endpoints.web.exposure.include", "health,info");
cfg.put("spring.datasource.password", "${DB_PASSWORD}");
cfg.put("server.shutdown", "graceful");
cfg.put("management.endpoint.health.probes.enabled", "true");
cfg.put("spring.servlet.multipart.max-file-size", "5MB");
return cfg;
}
static List<Pemeriksa> daftarPeriksa() {
List<Pemeriksa> butir = new ArrayList<>();
butir.add(new Pemeriksa("port server dalam rentang valid", cfg -> {
int port = Integer.parseInt(cfg.getOrDefault("server.port", "8080"));
return port >= 1 && port <= 65_535
? new Hasil(true, "port=" + port)
: new Hasil(false, "port=" + port + " di luar 1-65535");
}));
butir.add(new Pemeriksa("log level bukan DEBUG/TRACE", cfg -> {
String tingkat = cfg.getOrDefault("logging.level.root", "INFO").toUpperCase();
return tingkat.equals("DEBUG") || tingkat.equals("TRACE")
? new Hasil(false, "root=" + tingkat)
: new Hasil(true, "root=" + tingkat);
}));
butir.add(new Pemeriksa("eksposur Actuator tidak \"*\"", cfg -> {
String incl = cfg.getOrDefault(
"management.endpoints.web.exposure.include", "health");
return incl.equals("*")
? new Hasil(false, "include=* (semua endpoint terbuka)")
: new Hasil(true, "include=" + incl);
}));
butir.add(new Pemeriksa("rahasia ter-eksternalisasi", cfg -> {
String sandi = cfg.get("spring.datasource.password");
if (sandi == null) {
return new Hasil(false, "tidak disetel");
}
return sandi.startsWith("${")
? new Hasil(true, "placeholder lingkungan")
: new Hasil(false, "nilai tetap tertulis di berkas");
}));
butir.add(new Pemeriksa("graceful shutdown aktif", cfg -> {
String mode = cfg.getOrDefault("server.shutdown", "immediate");
return mode.equals("graceful")
? new Hasil(true, "shutdown=graceful")
: new Hasil(false, "shutdown=" + mode);
}));
butir.add(new Pemeriksa("health probe aktif", cfg -> {
String aktif = cfg.get("management.endpoint.health.probes.enabled");
return "true".equals(aktif)
? new Hasil(true, "probes=true")
: new Hasil(false, "probes tidak disetel");
}));
butir.add(new Pemeriksa("batas unggahan berkas disetel", cfg -> {
String batas = cfg.get("spring.servlet.multipart.max-file-size");
return batas != null
? new Hasil(true, "max-file-size=" + batas)
: new Hasil(false, "max-file-size tidak disetel");
}));
return butir;
}
public static void main(String[] args) {
Map<String, String> dev = konfigDev();
Map<String, String> prod = konfigProd();
List<Pemeriksa> butir = daftarPeriksa();
System.out.println("== Konfigurasi yang diperiksa ==");
tampilKonfigurasi("dev ", dev);
tampilKonfigurasi("prod", prod);
System.out.println();
System.out.println("== Hasil daftar periksa ==");
int lulusDev = 0;
int lulusProd = 0;
int no = 0;
for (Pemeriksa p : butir) {
no++;
Hasil d = p.cek().apply(dev);
Hasil pr = p.cek().apply(prod);
lulusDev += d.lulus() ? 1 : 0;
lulusProd += pr.lulus() ? 1 : 0;
System.out.printf("%d. %-42s dev %s prod %s%n",
no, p.nama(), ringkas(d), ringkas(pr));
}
System.out.println();
System.out.println("== Rekap ==");
System.out.printf("dev : %d/%d lulus -> jangan naikkan konfigurasi ini ke produksi%n",
lulusDev, butir.size());
System.out.printf("prod: %d/%d lulus -> siap dikemas dan digulirkan%n",
lulusProd, butir.size());
}
static void tampilKonfigurasi(String label, Map<String, String> cfg) {
StringBuilder baris = new StringBuilder("[" + label + "] ");
for (Map.Entry<String, String> e : cfg.entrySet()) {
baris.append(e.getKey()).append('=')
.append(e.getValue()).append(" ");
}
System.out.println(baris);
}
static String ringkas(Hasil h) {
return (h.lulus() ? "LULUS " : "GAGAL ") + "(" + h.keterangan() + ")";
}
}Output yang Dihasilkan:
== Konfigurasi yang diperiksa ==
[dev ] server.port=8080 logging.level.root=DEBUG management.endpoints.web.exposure.include=* spring.datasource.password=sandi-dev server.shutdown=immediate spring.servlet.multipart.max-file-size=5MB
[prod] server.port=8443 logging.level.root=INFO management.endpoints.web.exposure.include=health,info spring.datasource.password=${DB_PASSWORD} server.shutdown=graceful management.endpoint.health.probes.enabled=true spring.servlet.multipart.max-file-size=5MB
== Hasil daftar periksa ==
1. port server dalam rentang valid dev LULUS (port=8080) prod LULUS (port=8443)
2. log level bukan DEBUG/TRACE dev GAGAL (root=DEBUG) prod LULUS (root=INFO)
3. eksposur Actuator tidak "*" dev GAGAL (include=* (semua endpoint terbuka)) prod LULUS (include=health,info)
4. rahasia ter-eksternalisasi dev GAGAL (nilai tetap tertulis di berkas) prod LULUS (placeholder lingkungan)
5. graceful shutdown aktif dev GAGAL (shutdown=immediate) prod LULUS (shutdown=graceful)
6. health probe aktif dev GAGAL (probes tidak disetel) prod LULUS (probes=true)
7. batas unggahan berkas disetel dev LULUS (max-file-size=5MB) prod LULUS (max-file-size=5MB)
== Rekap ==
dev : 2/7 lulus -> jangan naikkan konfigurasi ini ke produksi
prod: 7/7 lulus -> siap dikemas dan digulirkanMari kita bedah hasil pemeriksaannya:
Pemeriksa(nama, cek): tiap butir memasangkan nama dengan fungsiMap<String,String> -> Hasil— konfigurasi sebagai data, keputusan sebagai kode. Bentuk ini meniru policy check pipeline CI — aturan ter-versi dan dievaluasi ulang otomatis tiap perubahan konfigurasi.- Butir 2–3 (log dan Actuator): konfigurasi dev memakai
root=DEBUGdaninclude=*— nyaman saat mengembangkan, berbahaya di produksi. Keduanya GAGAL pada dev dan LULUS pada prod — jarak inilah yang harus ditutup sebelum naik, bukan sekadar “aplikasi berjalan”. - Butir 4 (rahasia): pemeriksa menolak nilai apa pun yang tidak berbentuk placeholder
${...}—sandi-devdi berkas dev gagal,${DB_PASSWORD}di prod lulus karena nilainya datang dari runtime: repositori tidak lagi memuat satu rahasia pun. - Butir 5–6 (shutdown dan probe):
immediateversusgraceful, probe tidak disetel versus aktif — penentu pengalaman rolling release. Tanpa keduanya, deployment meninggalkan request terputus dan instance yang tak pernah dianggap siap. - Butir 1 dan 7 (port dan batas unggahan): kedua konfigurasi lulus — daftar periksa tidak selalu gagal semuanya; sebagian aturan sudah terpenuhi sejak awal dan sisanya menjadi kerja spesifik, bukan tebakan.
- Rekap 2/7 versus 7/7: angka penutup yang bisa ditagih. Daftar periksa yang sama dapat dijalankan ulang di pipeline setiap hari — kesiapan produksi berhenti menjadi perasaan dan menjadi metrik.
Perjalanan Spring Framework kita kini lengkap: dari Inversion of Control dan dependency injection hingga MVC, data, keamanan, cache, penjadwalan, pengujian, Actuator, dan observability — seluruhnya bermuara pada aplikasi yang siap melayani di produksi. Aplikasi tunggal yang kokoh adalah fondasi; saat bebannya tumbuh melampaui satu proses, langkah berikutnya adalah memecahnya menjadi layanan yang bekerja sama — dibuka oleh Section Microservices dan Distributed Systems.
Referensi #
- Spring Boot How-to: Deployment
- Spring Boot Reference: Externalized Configuration
- Spring Boot Reference: Graceful Shutdown
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
DaftarSiapProduksi.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class DaftarSiapProduksi {
// Hasil satu pemeriksaan: lulus atau tidak, dengan keterangan singkat.
record Hasil(boolean lulus, String keterangan) { }
// Satu butir daftar periksa: nama + evaluator atas peta konfigurasi.
record Pemeriksa(String nama, Function<Map<String, String>, Hasil> cek) { }
static Map<String, String> konfigDev() {
Map<String, String> cfg = new LinkedHashMap<>();
cfg.put("server.port", "8080");
cfg.put("logging.level.root", "DEBUG");
cfg.put("management.endpoints.web.exposure.include", "*");
cfg.put("spring.datasource.password", "sandi-dev");
cfg.put("server.shutdown", "immediate");
cfg.put("spring.servlet.multipart.max-file-size", "5MB");
return cfg;
}
static Map<String, String> konfigProd() {
Map<String, String> cfg = new LinkedHashMap<>();
cfg.put("server.port", "8443");
cfg.put("logging.level.root", "INFO");
cfg.put("management.endpoints.web.exposure.include", "health,info");
cfg.put("spring.datasource.password", "${DB_PASSWORD}");
cfg.put("server.shutdown", "graceful");
cfg.put("management.endpoint.health.probes.enabled", "true");
cfg.put("spring.servlet.multipart.max-file-size", "5MB");
return cfg;
}
static List<Pemeriksa> daftarPeriksa() {
List<Pemeriksa> butir = new ArrayList<>();
butir.add(new Pemeriksa("port server dalam rentang valid", cfg -> {
int port = Integer.parseInt(cfg.getOrDefault("server.port", "8080"));
return port >= 1 && port <= 65_535
? new Hasil(true, "port=" + port)
: new Hasil(false, "port=" + port + " di luar 1-65535");
}));
butir.add(new Pemeriksa("log level bukan DEBUG/TRACE", cfg -> {
String tingkat = cfg.getOrDefault("logging.level.root", "INFO").toUpperCase();
return tingkat.equals("DEBUG") || tingkat.equals("TRACE")
? new Hasil(false, "root=" + tingkat)
: new Hasil(true, "root=" + tingkat);
}));
butir.add(new Pemeriksa("eksposur Actuator tidak \"*\"", cfg -> {
String incl = cfg.getOrDefault(
"management.endpoints.web.exposure.include", "health");
return incl.equals("*")
? new Hasil(false, "include=* (semua endpoint terbuka)")
: new Hasil(true, "include=" + incl);
}));
butir.add(new Pemeriksa("rahasia ter-eksternalisasi", cfg -> {
String sandi = cfg.get("spring.datasource.password");
if (sandi == null) {
return new Hasil(false, "tidak disetel");
}
return sandi.startsWith("${")
? new Hasil(true, "placeholder lingkungan")
: new Hasil(false, "nilai tetap tertulis di berkas");
}));
butir.add(new Pemeriksa("graceful shutdown aktif", cfg -> {
String mode = cfg.getOrDefault("server.shutdown", "immediate");
return mode.equals("graceful")
? new Hasil(true, "shutdown=graceful")
: new Hasil(false, "shutdown=" + mode);
}));
butir.add(new Pemeriksa("health probe aktif", cfg -> {
String aktif = cfg.get("management.endpoint.health.probes.enabled");
return "true".equals(aktif)
? new Hasil(true, "probes=true")
: new Hasil(false, "probes tidak disetel");
}));
butir.add(new Pemeriksa("batas unggahan berkas disetel", cfg -> {
String batas = cfg.get("spring.servlet.multipart.max-file-size");
return batas != null
? new Hasil(true, "max-file-size=" + batas)
: new Hasil(false, "max-file-size tidak disetel");
}));
return butir;
}
public static void main(String[] args) {
Map<String, String> dev = konfigDev();
Map<String, String> prod = konfigProd();
List<Pemeriksa> butir = daftarPeriksa();
System.out.println("== Konfigurasi yang diperiksa ==");
tampilKonfigurasi("dev ", dev);
tampilKonfigurasi("prod", prod);
System.out.println();
System.out.println("== Hasil daftar periksa ==");
int lulusDev = 0;
int lulusProd = 0;
int no = 0;
for (Pemeriksa p : butir) {
no++;
Hasil d = p.cek().apply(dev);
Hasil pr = p.cek().apply(prod);
lulusDev += d.lulus() ? 1 : 0;
lulusProd += pr.lulus() ? 1 : 0;
System.out.printf("%d. %-42s dev %s prod %s%n",
no, p.nama(), ringkas(d), ringkas(pr));
}
System.out.println();
System.out.println("== Rekap ==");
System.out.printf("dev : %d/%d lulus -> jangan naikkan konfigurasi ini ke produksi%n",
lulusDev, butir.size());
System.out.printf("prod: %d/%d lulus -> siap dikemas dan digulirkan%n",
lulusProd, butir.size());
}
static void tampilKonfigurasi(String label, Map<String, String> cfg) {
StringBuilder baris = new StringBuilder("[" + label + "] ");
for (Map.Entry<String, String> e : cfg.entrySet()) {
baris.append(e.getKey()).append('=')
.append(e.getValue()).append(" ");
}
System.out.println(baris);
}
static String ringkas(Hasil h) {
return (h.lulus() ? "LULUS " : "GAGAL ") + "(" + h.keterangan() + ")";
}
}