นากการ์ตูนใส่เอี๊ยมสีเขียวยืนวัดความสูงแก้วกาแฟกับไม้วัดระดับขนาดใหญ่ ข้างไฟสัญญาณเขียวเหลืองและแผงกั้นลายทาง บนโต๊ะไม้ในห้องสว่างที่มีต้นไม้

เช็ก Java ตอนเริ่มระบบ ให้ JVM ผ่านระดับแพตช์ขั้นต่ำก่อนรับงาน

น้องกายEngineering

หลังรอบแพตช์ Java แต่ละครั้ง ทีมอาจอัปเดตเครื่อง build แล้ว แต่บริการรับชำระเงินยังเปิดด้วย Java ตัวเดิมในเครื่อง production ซึ่งเป็นเครื่องที่รับงานจริง การตรวจตอนเริ่มโปรแกรมช่วยหยุดงานก่อนใช้ JVM หรือเครื่องเสมือนที่รัน Java ซึ่งต่ำกว่าเกณฑ์ของทีม ผมจะทำตัวตรวจเล็ก ๆ ที่บอกได้ทั้ง “ผ่าน” “เก่าเกินไป” และ “ยังไม่มีนโยบายสำหรับรุ่นนี้” ครับ

WHY: กันเครื่องหลุดแพตช์ก่อนเริ่มรับงาน

  • ตรวจ runtime ที่โปรแกรมใช้จริงบนเครื่องปลายทาง
  • แยกเกณฑ์ตาม feature release หรือสายรุ่นหลัก เช่น 21 และ 25 ไม่ให้รุ่นใหม่ผ่านโดยอัตโนมัติ
  • บันทึกผู้ผลิตและเวอร์ชัน ช่วยฝ่าย IT ตามแก้เครื่องที่ไม่ผ่าน

ทีมต้องกำหนด baseline หรือเวอร์ชันขั้นต่ำจากประกาศผู้ผลิต Java ที่ใช้ และทดสอบก่อนปรับเกณฑ์ การผ่านเกณฑ์ไม่ได้รับรองความปลอดภัยทั้งหมด

ทำงานยังไง

Runtime.version() คืนเวอร์ชันของ Java Runtime Environment เป็นออบเจ็กต์ Runtime.Version จึงนำมาเทียบกับเกณฑ์ที่สร้างด้วย Runtime.Version.parse(String) ได้เลย ไม่ต้องตัดข้อความเอง

เลขสี่ตำแหน่งแรกคือ feature.interim.update.patch อ่านผ่าน feature(), interim(), update() และ patch() ได้ ตำแหน่งที่ไม่มีจะได้ศูนย์ ยกเว้น feature ซึ่งต้องมีอยู่ เช่น 21.0.13 มี update เท่ากับ 13 และ patch เท่ากับ 0 คำว่า “ระดับแพตช์” ในงานดูแลระบบจึงอาจหมายถึงระดับอัปเดตโดยรวม อย่าสับสนกับเมธอด patch() ที่อ่านเฉพาะตำแหน่งที่สี่

เหมือนตรวจตั๋วรถทัวร์ ต้องดูสายรถก่อนดูเที่ยว เลขเที่ยวของอีกสายที่มากกว่าไม่ได้แปลว่าขึ้นคันนี้ได้ เราใช้ map หรือตารางจับคู่ feature กับเกณฑ์ขั้นต่ำ และคืน UNKNOWN_FEATURE เมื่อไม่มีสายรุ่นนั้น

Runtime.Version เทียบส่วนตัวเลขทีละตำแหน่ง ดังนั้น 21.0.9 ต่ำกว่า 21.0.13 แต่ String เรียงตามตัวอักษรจึงให้ผลกลับกัน เพราะเจอ 9 กับ 1 ก่อน

compareTo() เทียบตัวเลข ตามด้วยข้อมูล pre-release, หมายเลข build และข้อมูล optional ตามลำดับ ส่วน compareToIgnoreOptional() ข้ามเฉพาะข้อมูล optional ยังเทียบ pre-release และ build อยู่ ตัวอย่างนี้เลือกแบบหลัง เพื่อไม่ให้ข้อความท้าย build เป็นตัวตัดสินเกณฑ์ และใช้เงื่อนไขแยกห้าม pre-release ก่อนเปิดบริการ

ข้อควรจำ: 21.0.13.0.2 มี ห้าตำแหน่ง ไม่ใช่สี่ เอกสารอนุญาตลำดับตัวเลขยาวกว่าสี่ และให้ผู้ผลิตใช้ตำแหน่งที่ห้าเป็นต้นไปสำหรับแพตช์เฉพาะของตนได้ เทสต์ด้านล่างตรวจว่าค่านี้ parse ได้ มี patch() เป็น 0 และมากกว่า 21.0.13 โดยไม่ตีความเลข 2 แทนผู้ผลิต

โค้ดตัวอย่างพร้อมเทสต์

บันทึกเป็น Main.java รัน javac -encoding UTF-8 Main.java แล้ว java -ea Main เพื่อเปิด assert เกณฑ์ 21 → 21.0.13 และ 25 → 25.0.5 เป็นตัวอย่างสมมติ ต้องเปลี่ยนตามประกาศผู้ผลิต โค้ดเทสต์เวอร์ชันจำลอง แล้วตรวจ JVM จริงก่อนเริ่มงานร้านค้า

import java.util.List;
import java.util.Map;

public class Main {
    enum Status { OK, TOO_OLD, UNKNOWN_FEATURE }

    static final class MinimumJava {
        private final Map<Integer, Runtime.Version> minimums;

        MinimumJava(Map<Integer, Runtime.Version> minimums) {
            this.minimums = Map.copyOf(minimums);
            for (var entry : this.minimums.entrySet()) {
                if (entry.getKey().intValue() != entry.getValue().feature()) {
                    throw new IllegalArgumentException("Feature does not match baseline");
                }
            }
        }

        Status check(Runtime.Version actual) {
            Runtime.Version minimum = minimums.get(actual.feature());
            if (minimum == null) return Status.UNKNOWN_FEATURE;
            return actual.compareToIgnoreOptional(minimum) >= 0
                    ? Status.OK : Status.TOO_OLD;
        }

        void requireAllowed(Runtime.Version actual) {
            Status status = check(actual);
            if (status != Status.OK || actual.pre().isPresent()) {
                throw new IllegalStateException(
                        "Java blocked: " + actual + ", status=" + status
                        + ", preRelease=" + actual.pre().isPresent());
            }
        }
    }

    static Runtime.Version v(String value) {
        return Runtime.Version.parse(value);
    }

    static boolean blocked(MinimumJava policy, String version) {
        try {
            policy.requireAllowed(v(version));
            return false;
        } catch (IllegalStateException expected) {
            return true;
        }
    }

    public static void main(String[] args) {
        MinimumJava policy = new MinimumJava(Map.of(
                21, v("21.0.13"),
                25, v("25.0.5")));

        assert "21.0.9".compareTo("21.0.13") > 0;
        assert v("21.0.9").compareTo(v("21.0.13")) < 0;
        assert policy.check(v("21.0.9")) == Status.TOO_OLD;
        assert policy.check(v("21.0.13")) == Status.OK;
        assert policy.check(v("17.0.21+9")) == Status.UNKNOWN_FEATURE;
        assert policy.check(v("25")) == Status.TOO_OLD;
        assert policy.check(v("25.0.5")) == Status.OK;
        assert policy.check(v("26")) == Status.UNKNOWN_FEATURE;

        Runtime.Version extended = v("21.0.13.0.2");
        assert extended.version().equals(List.of(21, 0, 13, 0, 2));
        assert extended.feature() == 21;
        assert extended.interim() == 0;
        assert extended.update() == 13;
        assert extended.patch() == 0;
        assert extended.compareTo(v("21.0.13")) > 0;
        assert policy.check(extended) == Status.OK;
        assert v("21.0.13.1").patch() == 1;
        assert v("25").interim() == 0;
        assert v("25").update() == 0;
        assert v("25").patch() == 0;

        Runtime.Version a = v("21.0.13+9-teamA");
        Runtime.Version b = v("21.0.13+9-teamB");
        assert a.compareTo(b) < 0;
        assert a.compareToIgnoreOptional(b) == 0;
        assert a.equalsIgnoreOptional(b);
        assert v("21.0.13+9").compareToIgnoreOptional(v("21.0.13")) > 0;
        assert v("21.0.13-ea").compareTo(v("21.0.13")) < 0;
        assert blocked(policy, "21.0.13-ea");
        assert policy.check(v("21.0.14-ea")) == Status.OK;
        assert blocked(policy, "21.0.14-ea");
        assert blocked(policy, "17.0.21+9");
        assert !blocked(policy, "21.0.13");

        Runtime.Version actual = Runtime.version();
        System.out.println("vendor=" + System.getProperty("java.vendor")
                + ", java.version=" + System.getProperty("java.version")
                + ", status=" + policy.check(actual));
        try {
            policy.requireAllowed(actual);
            System.out.println("เริ่มงานร้านมะลิ วันที่ 2026-10-22 ยอด 1290 บาท");
        } catch (IllegalStateException blocked) {
            // Demo catches this to finish tests; production should stop startup.
            System.out.println(blocked.getMessage());
        }
        System.out.println("OK: all assertions passed");
    }
}

ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 โดยใช้ javac 21.0.12.1 และ java -ea Main แล้ว เทสต์ผ่านทั้งหมดและพิมพ์บรรทัดสุดท้าย OK: all assertions passed ครับ

เครื่องทดสอบต่ำกว่าเกณฑ์สมมติ จึงถูกบล็อกก่อนเริ่มงานร้านมะลิ OK สุดท้ายหมายถึงเทสต์ผ่าน ในระบบจริงให้เรียก requireAllowed(Runtime.version()) ก่อนรับงาน และปล่อยข้อผิดพลาดให้หยุด startup

อ่านผู้ผลิตและเวอร์ชันด้วย System.getProperty("java.vendor") / System.getProperty("java.version") ตาม เอกสาร System เพื่อบันทึกบริบท helper นี้สมมติว่าทีมเลือกเกณฑ์ของผู้ผลิตแล้ว ไม่ได้ตรวจรายชื่อผู้ผลิตที่อนุมัติ

จุดที่คนพลาดบ่อย

  • เทียบ String หรืออ่านแค่ feature: 21.0.9 กับ 21.0.13 อยู่สายเดียวกันแต่ผ่านเกณฑ์ต่างกัน แก้ด้วยการ parse แล้วเทียบทั้ง Version
  • ใช้เกณฑ์เดียวข้ามผู้ผลิต: เลข build และส่วนต่อท้ายอาจเกี่ยวกับแพ็กเกจเฉพาะราย แก้ด้วยการผูกนโยบายกับผู้ผลิตและสายแจกจ่ายที่ทีมอนุมัติ หากต้องกำหนด build ขั้นต่ำ ให้ระบุใน baseline ด้วย การข้าม optional ไม่ได้ทำให้ทุก build เทียบเท่ากัน
  • เช็กเฉพาะเครื่อง build: การทดสอบบนเครื่องสร้างโค้ดไม่ได้ยืนยัน runtime ของบริการ แก้ด้วยการตรวจทุก process ตอนเริ่มบน production
  • คิดว่าข้าม optional คือข้ามข้อมูลท้ายทั้งหมด: เมธอดยังสนใจ pre-release และ build แก้ด้วยการเขียนเทสต์ให้ตรงนโยบาย โดยเฉพาะรุ่น ea ที่เลขตัวหน้าอาจสูงกว่าเกณฑ์
  • เปิด assert ไม่ครบ หรือใช้แทนตัวกั้นจริง: เทสต์ชุดนี้ต้องมี -ea ส่วนตัวกั้นใช้ if และ exception เพื่อไม่ผูกการตัดสินเปิดบริการกับการเปิด assert

ใช้ตรงไหนในงานจริง

  • บริการชำระเงิน: ตรวจให้ผ่านก่อนเริ่มรับคำสั่งซื้อ
  • งาน batch หรืองานประมวลผลเป็นรอบ: ตรวจก่อนอ่านรายการโอนเงิน
  • ระบบหลายเครื่อง: รวมบันทึกผู้ผลิต เวอร์ชัน และสถานะ เพื่อหาเครื่องที่ไม่ผ่าน
  • ขั้นตอนส่งมอบ: ให้ผู้รับผิดชอบทบทวน baseline หลังรอบแพตช์

เทียบกับทางเลือกอื่น

วิธีดีตรงไหนระวังอะไร
ตัวตรวจตอน startupผูกเกณฑ์กับ JVM ของ process ที่เริ่มงานต้องดูแล baseline และนโยบายผู้ผลิตเอง
สั่ง java -version ด้วยมือตรวจเบื้องต้นได้ง่ายต้องตรวจ Java ตัวเดียวกับที่บริการใช้ และคนอาจลืมตรวจ
เครื่องมือ SBOM / inventoryทำบัญชีส่วนประกอบซอฟต์แวร์และภาพรวมเครื่องได้ ตามขอบเขตเครื่องมือต้องตรวจว่าข้อมูลทันสมัยและครอบคลุม runtime ที่รันจริง
สแกน container imageช่วยตรวจแพ็กเกจในอิมเมจก่อนนำขึ้นระบบต้องยืนยันว่าอิมเมจที่ตรวจตรงกับตัวที่เปิดใช้งาน และตรวจขอบเขตของเครื่องมือ

Recap

  • อ่าน JVM ปัจจุบันด้วย Runtime.version()
  • แปลงเกณฑ์ด้วย Runtime.Version.parse() แล้วเทียบตัวเลข
  • แยก baseline ตาม feature และจัดการ UNKNOWN_FEATURE ให้ชัด
  • ตั้งเกณฑ์จากประกาศผู้ผลิต แทนการคัดลอกเลขสมมติ
  • กำหนดกติกา pre-release, build และ optional ให้ตรงการใช้งาน
  • ทดสอบด้วย java -ea Main และให้ตัวกั้นจริงใช้เงื่อนไขปกติ
  • ตรวจตอนเริ่มบน production ก่อนเปิดรับงาน