Fail-open หรือ fail-closed เป็นการตัดสินใจต่อ guard ไม่ใช่ค่า default ทั้งระบบ
เวลา dependency ล่มหรือ config หาย ระบบของคุณ "พังไปทางไหน"? คำตอบที่ถูกไม่ใช่ค่าเดียวทั้งโปรเจกต์ แต่ต้องเลือกต่อจุดตรวจตาม blast radius — rate limiter ควร fail-open, auth ต้อง fail-closed, และ secret ที่มี default fallback คือ backdoor
อ่าน ~8 นาที
Thesis: fail-open/closed ไม่ใช่สวิตช์ระดับโปรเจกต์
ผมเคยเห็นทีมเถียงกันในรีวิวว่า "ระบบเราควร fail-open หรือ fail-closed?" เหมือนกับว่ามันเป็นค่าเดียวที่ตั้งครั้งเดียวจบ ความจริงคือคำถามนี้ตั้งผิดตั้งแต่ต้น fail-open และ fail-closed คือการตัดสินใจต่อ "จุดตรวจ" (guard) แต่ละจุด ไม่ใช่ค่า default ที่ครอบทั้งระบบ
guard คือโค้ดที่ตัดสินใจว่า "จะให้ผ่านหรือไม่ให้ผ่าน" โดยพึ่งพา dependency บางอย่าง เช่น Redis, ฐานข้อมูล, ค่า config, หรือ service ภายนอก คำถามที่ต้องถามต่อทุก guard คือ: ถ้า dependency ตัวนั้นหายไปกลางทาง ระบบควร "ปล่อยผ่าน" (fail-open) หรือ "ปฏิเสธ" (fail-closed)? คำตอบขึ้นกับ blast radius — ผลกระทบเมื่อตัดสินใจผิดในแต่ละทิศ ไม่ใช่ความชอบส่วนตัว
QoS/convenience checks → fail open
เริ่มจากตัวที่ชัดที่สุด rate limiter คือ guard ที่มีไว้ปกป้อง quality of service ไม่ใช่ความปลอดภัย ถ้า Redis ที่เก็บ counter ล่ม แล้วคุณเขียนโค้ดให้ "ปฏิเสธทุก request เมื่ออ่าน counter ไม่ได้" คุณเพิ่งเปลี่ยน Redis outage เล็กๆ ให้กลายเป็น full outage ของทั้ง service — dependency ตัวเดียวล้มลากทั้งระบบลงไปด้วย นี่คือ cascade ที่เลี่ยงได้
async function rateLimit(userId) {
try {
const count = await redis.incr(`rl:${userId}`);
await redis.expire(`rl:${userId}`, 60);
return count <= LIMIT;
} catch (err) {
// Redis ล่ม: guard นี้เป็นแค่ QoS → fail OPEN
// ยอมให้หลุด limit ชั่วคราว ดีกว่าล้มทั้ง service
logger.warn('rate limiter degraded, failing open', { err });
metrics.increment('ratelimit.fail_open'); // สำคัญ: ต้องเห็นว่ามัน degrade
return true;
}
}
จุดที่คนพลาดบ่อยคือ fail-open แบบเงียบ การ return true เฉยๆ ไม่พอ ต้องมี metric/log บอกว่า guard กำลัง degrade อยู่ ไม่งั้นคุณจะรันแบบไม่มี rate limit เป็นสัปดาห์โดยไม่รู้ตัว
Auth/ACL → fail closed เสมอ
ตรงข้ามกันสุดขั้ว guard ที่ตอบคำถาม "คนนี้มีสิทธิ์ไหม" ต้อง fail-closed เสมอ ถ้าคุณอ่าน permission จาก DB ไม่ได้ คำตอบที่ถูกคือ "ปฏิเสธ" ไม่ใช่ "เออ อ่านไม่ได้ก็ปล่อยผ่านไปก่อน" เพราะ blast radius ของการปล่อยผ่านผิดคือข้อมูลรั่วหรือ privilege escalation ซึ่งกู้คืนไม่ได้
async function canAccess(userId, resourceId) {
try {
const acl = await db.getAcl(resourceId);
return acl.allows(userId);
} catch (err) {
// อ่าน ACL ไม่ได้ = สงสัย → ปฏิเสธ (fail CLOSED)
logger.error('ACL check failed, denying', { err, userId, resourceId });
return false;
}
}
หลักการง่ายๆ: เมื่อสงสัย ให้ปฏิเสธ การปฏิเสธ user ที่ควรมีสิทธิ์ทำให้เขาหงุดหงิดและเปิด ticket — น่ารำคาญแต่ย้อนกลับได้ การอนุญาต user ที่ไม่ควรมีสิทธิ์คือ security incident ที่ย้อนกลับไม่ได้
Per-call-site: guard เดียวกันอาจต้องคนละทิศ
ที่ลึกกว่านั้นคือ operation เดียวกันอาจต้องพังคนละทิศตาม call site ผมเคยเจอฟังก์ชัน decrypt ที่ถูกเรียกจากสองที่ ที่หนึ่งคือ admin path ที่โหลด config ตอน boot อีกที่คือ hot path ที่ต้องส่งข้อมูลออกทุกวินาที ถ้าใช้พฤติกรรม error เดียวกันทั้งคู่ จะมีที่หนึ่งพังแบบผิด เลยแยกเป็นสองฟังก์ชันตั้งใจ:
// admin/config path: key พังต้องรู้ทันที → throw (fail loud)
function decryptOrThrow(blob) {
const val = tryDecrypt(blob);
if (val === null) throw new Error('decrypt failed — bad key/config');
return val;
}
// hot send path: key พังไม่ควรล้มทั้ง pipeline → return null แล้ว degrade
function decryptOrNull(blob) {
const val = tryDecrypt(blob);
if (val === null) metrics.increment('decrypt.degraded');
return val; // caller ตัดสินใจ skip record นี้แทนที่จะ crash
}
key mishap แบบเดียวกันจะ "ดังลั่น" บน config path (ที่มีคนดูตอน deploy) แต่ "degrade เงียบๆ พร้อม metric" บน critical path (ที่ล้มไม่ได้) นี่คือหัวใจ — ทิศที่พังเป็น property ของ call site ไม่ใช่ของฟังก์ชัน
stale-while-revalidate = fail-open ที่ต้องระวัง
pattern ฝั่ง frontend อย่าง keepPreviousData หรือ stale-while-revalidate ก็คือ fail-open รูปแบบหนึ่ง เมื่อ backend ตอบ 500 กลับมา library เหล่านี้จะโชว์ข้อมูลเก่าต่อไปเงียบๆ ข้อดีคือ UX ไม่กระตุก ข้อเสียคือมัน "กลบ" backend error ทำให้ทีมไม่รู้ว่ามี incident จนกว่าจะมีลูกค้าบ่นเข้ามา — คุณกำลัง detect incident ด้วย social signal ซึ่งช้ากว่า monitoring หลายชั่วโมง
const { data, error } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
placeholderData: keepPreviousData, // fail-open: โชว์ของเก่าเมื่อ fetch พัง
});
// แก้: อย่ากลบ 5xx เงียบ — แยก error state ที่ 5xx-aware
if (error?.status >= 500) {
reportToMonitoring(error); // alert ที่ server error rate
return <StaleBanner>ข้อมูลอาจไม่เป็นปัจจุบัน กำลังลองใหม่</StaleBanner>;
}
ทางออกไม่ใช่เลิกใช้ fail-open แต่ต้องมี error state ที่รู้จักแยก 5xx และมี alert ผูกกับ server error rate ฝั่ง backend ไม่ใช่รอ signal จากผู้ใช้
Safety default ต้องขยับไป "ทางปลอดภัย" เสมอ
มี guard อีกประเภทที่ไม่ใช่ทั้ง open/closed ตรงๆ แต่เป็น "ค่า fallback ของ threshold" หลักการคือ เมื่อ input หาย fallback ต้องขยับไปทางที่ปลอดภัยกว่า ไม่ใช่หลวมกว่า ตัวอย่างที่ผมเจอในระบบคำนวณ capacity: มีตัวคูณ derating ที่ปรับ output ลงตามสภาพแวดล้อม ถ้าดึงค่าตัวคูณไม่ได้ โค้ดเก่า fallback เป็นค่ากลางที่ "หลวม" ทำให้ระบบประเมินความสามารถเกินจริง
// ผิด: fallback หลวมกว่า → ประเมินเกินจริง (อันตราย)
const derate = fetchDerating() ?? 1.15;
// ถูก: fallback = ค่า nameplate ปลอดภัยที่สุด ไม่มี bonus
const derate = fetchDerating() ?? 1.0;
ระวัง "legacy fail-open mode" ที่ flip เงียบด้วย config flag เก่า — flag ที่ครั้งหนึ่งเคยตั้งเพื่อ debug แล้วลืมถอด กลายเป็นทางลัดที่ปิด safety check ทั้งหมดโดยไม่มีใครสังเกต ทุก mode ที่ทำให้ระบบ "ผ่อนปรน" ต้อง log ดังๆ ทุกครั้งที่ทำงาน
Secret ที่มี default fallback = backdoor
เคสสุดท้ายอันตรายที่สุดและพบบ่อยที่สุด การใส่ default ให้ secret คือการสร้าง backdoor ลงในโค้ด
// อันตรายมาก: ถ้า env หาย ทุก instance ใช้ secret เดียวกันที่อยู่ใน public repo
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret';
ใครก็ตามที่อ่านซอร์สเจอ 'default-secret' สามารถปลอม JWT ที่ถูกต้องได้ทันที เพราะถ้า env ไม่ถูกตั้งใน prod (ซึ่งเกิดขึ้นบ่อยกว่าที่คิด) server จะ sign token ด้วยค่าที่ทุกคนรู้ วิธีที่ถูกคือ อ่าน secret ครั้งเดียวตอน boot แล้ว fail loud ถ้าไม่มี — misconfig ในเรื่อง secret ต้อง fail-closed คือระบบต้องไม่ยอมสตาร์ท
function requireSecret(name) {
const val = process.env[name];
if (!val) {
// ไม่มี secret = ห้ามรัน (fail closed ตั้งแต่ boot)
throw new Error(`FATAL: ${name} is not set — refusing to start`);
}
return val;
}
const JWT_SECRET = requireSecret('JWT_SECRET'); // อ่านครั้งเดียว, ตาย fast
การ fail ตอน boot ดีกว่า fail ตอน request แรกที่ user จริงเข้ามา เพราะมันเจอในขั้น deploy/health check ก่อนที่ traffic จะเข้า และมันบังคับให้ env config ถูกต้องก่อน service จะขึ้นมา live
สรุป
- ถามต่อ guard ไม่ใช่ต่อโปรเจกต์: "ถ้า dependency นี้หาย ควรปล่อยผ่านหรือปฏิเสธ?" คำตอบต่างกันไปตามแต่ละจุดตรวจ
- QoS/convenience (rate limit) → fail open เพื่อไม่ให้ dependency outage ลากทั้ง service ลง แต่ต้องมี metric บอกว่ากำลัง degrade
- Auth/ACL → fail closed เสมอ: เมื่อสงสัยให้ปฏิเสธ เพราะปล่อยผ่านผิดย้อนกลับไม่ได้
- ทิศที่พังเป็นของ call site: operation เดียวอาจต้อง throw บน config path แต่ return null + degrade บน hot path
- fail-open ที่กลบ error (stale data) ต้องมี 5xx-aware error state + alert ที่ผูกกับ server error rate ไม่ใช่รอลูกค้าบ่น
- Safety fallback ต้องขยับไปทางปลอดภัย ไม่ใช่หลวมกว่า และระวัง legacy flag ที่ปิด safety เงียบๆ
- Secret ห้ามมี default:
SECRET || 'default'คือ backdoor — อ่านครั้งเดียวตอน boot แล้ว fail loud ถ้าไม่มี




