5 คะแนน โดย GN⁺ 2025-01-24 | 4 ความคิดเห็น | แชร์ทาง WhatsApp
  • ระหว่างที่ EdgeDB ย้ายงาน network I/O จาก Python ไปยัง Rust การทดสอบ HTTP fetch ใหม่ที่อิง reqwest ดูเหมือนจะค้างเฉพาะบน ARM64 CI แต่ความจริงคือโปรเซสแครช
  • จากการวิเคราะห์ core dump พบว่าจุดที่มีปัญหาไม่ใช่โค้ด HTTP ใหม่ แต่เป็นภายใน getenv() ของ libc ซึ่งล้มเหลวขณะวนดูอาร์เรย์ตัวแปรสภาพแวดล้อมแล้วพยายามอ่านพอยน์เตอร์ที่ผิดคือ 0x220
  • อีกเธรดหนึ่งกำลังตั้งค่า SSL_CERT_FILE และ SSL_CERT_DIR ผ่านเส้นทางของ openssl-probe ทำให้ setenv() จัดสรร environ ใหม่ และในเวลาเดียวกันเส้นทางจัดการข้อผิดพลาดของ Python ก็เรียก getenv() จนเกิด race condition
  • แม้โค้ด Rust จะไม่มี unsafe แบบชัดเจน แต่ระหว่างที่ std::env::set_var() เปลี่ยน environment แบบ global ก็ไม่ได้ซิงก์กับ runtime อื่นหรือการเรียก libc โดยตรงด้วยล็อกภายในของ Rust
  • วิธีแก้คือย้ายบน Linux จากแบ็กเอนด์ rust-native-tls/openssl ของ reqwest ไปใช้ rustls แทน และทั้ง Rust 2024 edition กับ glibc ก็เริ่มเปลี่ยนไปในทิศทางที่ลดปัญหากลุ่มนี้

แครชที่โผล่เฉพาะบน ARM64 CI

  • EdgeDB กำลังสร้างฟีเจอร์ HTTP fetch ใหม่ โดยย้ายโค้ด network I/O จำนวนมากจาก Python ไปยัง Rust
  • ใช้ reqwest เป็นไลบรารี HTTP client และฟีเจอร์นี้ผ่านทั้งบนเครื่องโลคัลและ CI runner แบบ x86_64 แต่ล้มเหลวเป็นพักๆ บน ARM64 CI runner
  • ตอนแรกดูเหมือน test runner ค้างแบบไม่มีกำหนด และใน CI log ก็ไม่มี error มีเพียงสถานะว่ามีเทสต์หนึ่งยังรันอยู่ ก่อนจะ timeout หลังผ่านไปหลายชั่วโมง
  • สมมติฐานแรกคือความต่างของ memory model ระหว่าง Intel กับ ARM64
    • Intel มี memory model ที่ค่อนข้างเข้มงวด และมีลำดับรวมที่ทุกโปรเซสเซอร์เห็นตรงกันสำหรับการเขียนหน่วยความจำ
    • ARM ใช้ memory model ที่มีลำดับอ่อนกว่า โดยการเขียนอาจถูกมองเห็นในคนละลำดับกันระหว่างเธรดต่างๆ

ตามรอย core dump ในสภาพแวดล้อม Docker CI

  • เครื่อง CI ตอนกลางคืนรันอยู่บน Amazon AWS จึงสามารถล็อกอินเป็น root จริงนอกคอนเทนเนอร์เพื่อดู dmesg และ system log ได้
  • เมื่อตามหา PID ที่ดูเหมือนค้างทั้งในและนอกคอนเทนเนอร์ กลับไม่พบโปรเซสนั้น จึงทำให้รู้ว่า ไม่ใช่ deadlock แต่เป็นแครช
  • เนื่องจาก Docker container ใช้ process namespace ดังนั้น core dump จึงถูกส่งไปยัง Docker host และพบ core dump ของโปรเซส python3 ใน journalctl
  • ตอนเปิด core ด้วย gdb ครั้งแรก backtrace ใช้งานแทบไม่ได้ เพราะไม่มีไฟล์ .so จากในคอนเทนเนอร์
  • หลังคัดลอก /lib, /usr และส่วนอื่นๆ ออกมาจากคอนเทนเนอร์ แล้วตั้งค่า solib-absolute-prefix ของ gdb ก็ยืนยันได้ว่าจุดแครชคือ getenv() ใน libc.so.6

พอยน์เตอร์ตัวแปรสภาพแวดล้อมที่เสียหายซึ่ง getenv() อ่านเข้าไป

  • backtrace ทั้งหมดเป็นเส้นทาง getenv()__dcigettext()strerror_r()strerror()PyErr_SetFromErrnoWithFilenameObjects()
  • ไม่ใช่โค้ด HTTP ใหม่ที่แครชโดยตรง แต่เป็น Python ที่กำลังสร้าง exception จาก errno แล้ววิ่งผ่านเส้นทางที่เกี่ยวข้องกับ gettext ก่อนจะเรียก getenv()
  • อิมพลีเมนเทชันของ getenv() ใน GLIBC 2.17 จะวนผ่าน environ ของ POSIX ซึ่งเป็นรายการ char ** และตรวจพอยน์เตอร์สตริงของตัวแปรสภาพแวดล้อมไปจนถึงพอยน์เตอร์ NULL ตัวสุดท้าย
  • จาก disassembly และสถานะรีจิสเตอร์ getenv() แครชตอนพยายามอ่านไบต์จากแอดเดรส x19 = 0x220
    • 0x220 เป็นแอดเดรสหน่วยความจำที่ใช้ไม่ได้อย่างชัดเจน
    • เมื่อตรวจ environ เอง รายการตัวแปรสภาพแวดล้อมปัจจุบันกลับดูสอดคล้องกันดี

สาเหตุ: race condition ระหว่าง setenv() กับ getenv()

  • setenv() ไม่ใช่ฟังก์ชันที่เรียกได้อย่างปลอดภัยในสภาพแวดล้อมหลายเธรด และมีการค้นพบซ้ำมาหลายครั้งแล้วว่ามันทำให้เกิดแครชประหลาดใน getenv() ของ libc
  • เมื่อนำ disassembly มาเทียบกับโค้ด C พบว่า x20 ตรงกับพอยน์เตอร์ ep ที่ใช้เดินอาร์เรย์ environ
  • ตอนเกิดแครช x20 มีค่า 0x248b5000 ขณะที่ environ ปัจจุบันอยู่ที่ 0x28655750 ซึ่งห่างออกไปราว 60MB
  • เมื่อเปรียบเทียบหน่วยความจำรอบอาร์เรย์ environment เก่ากับ environ ปัจจุบัน พบว่าความต่างสุดท้ายอยู่ที่รายการ SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt และ SSL_CERT_DIR=/etc/ssl/certs
  • จึงมองได้ว่าอีกเธรดหนึ่งย้าย environ ระหว่างการเรียก setenv() และ getenv() ยังคงอ่านอาร์เรย์ environment เก่าต่อไป จนกลายเป็นสถานะ use-after-free

การเชื่อมโยงกับ openssl-probe และ TLS backend

  • จาก issue เก่าที่เกี่ยวกับ rust-native-tls พบเบาะแสว่า openssl-probe จะตั้งค่าตัวแปรสภาพแวดล้อม SSL_CERT_FILE และ SSL_CERT_DIR เพื่อค้นหา system certificate
  • บน Linux หากใช้แบ็กเอนด์ openssl ของ rust-native-tls ก็จะเรียกเส้นทางนี้
  • โค้ด openssl-probe ที่เป็นปัญหาตั้งค่าตัวแปรทั้งสองผ่าน env::set_var() โดยไม่มี unsafe แบบชัดเจน
    • SSL_CERT_FILE
    • SSL_CERT_DIR
  • การจับคู่แบบนี้ทำให้ โค้ด Rust ที่ไม่มี unsafe โต้ตอบกับการใช้งาน libc ภายในโปรเซสเดียวกันได้ไม่ดี และนำไปสู่การแครช

เหตุผลที่มันถูกทำซ้ำได้บน ARM64 Linux

  • แครชนี้จะเกิดขึ้นเมื่อ setenv() ย้ายอาร์เรย์ environment ด้วย realloc และในจังหวะเดียวกันอีกเธรดหนึ่งเรียก getenv()
  • การทำให้เกิดซ้ำต้องมีหลายเงื่อนไขตรงกัน
    • จำนวนตัวแปรสภาพแวดล้อมต้องพอดีจนทำให้เกิด realloc
    • ต้องมี I/O failure ที่ไม่เกี่ยวกันถูกจับโดย asyncio
    • เส้นทางจัดการข้อผิดพลาดของ Python ต้องเรียก getenv() เพื่ออ่านตัวแปรสภาพแวดล้อม LANGUAGE ในจังหวะเดียวกันพอดี
  • ค่าที่ผิดอย่าง 0x220 มีขนาดใกล้กับขนาด environment เดิมเมื่อคิดเป็น 64-bit word
    • 0x220 / 8 = 68
    • ค่านี้ถูกเขียนทับลงในตำแหน่ง NULL ท้ายบล็อก environment เดิม และดูเหมือนเป็นค่าที่ system malloc ใช้บอกขนาดของ free block
  • เพราะมีเงื่อนไขล่วงหน้าหลายอย่างมาก การที่มันทำซ้ำได้ค่อนข้างสม่ำเสมอบนแพลตฟอร์มเดียวจึงนับว่าโชคดีเสียด้วยซ้ำ

เบาะแสที่ยืนยันได้จาก disassembly บน ARM64

  • ใน disassembly ของ getenv() ดูสับสนอยู่พักหนึ่ง เพราะมองไม่เห็นชัดว่า x20 เปลี่ยนค่าตรงไหน
  • กุญแจสำคัญคือ โหมดอ้างแอดเดรสแบบ pre-index ของ AArch64
  • ldr x19, [x20, #8]! ทำงานดังนี้
    • x19 = *(x20 + 8)
    • x20 = x20 + 8
  • เพราะโหมดอ้างแอดเดรสนี้ x20 จึงกำลังไล่เดินอาร์เรย์พอยน์เตอร์ของตัวแปรสภาพแวดล้อมอยู่ แม้จะไม่ได้ถูกเขียนไว้ทางซ้ายแบบชัดเจน

การแก้ไขที่นำมาใช้และความเปลี่ยนแปลงของโปรเจกต์ที่เกี่ยวข้อง

  • สุดท้ายจึงตัดสินใจย้ายบน Linux จากแบ็กเอนด์ rust-native-tls/openssl ของ reqwest ไปใช้ rustls แทน
  • เดิมทีเหตุผลที่เลือก native TLS backend ก็เพื่อหลีกเลี่ยงการต้องพก TLS engine สองตัวพร้อมกันระหว่างย้ายโค้ด Python ไปเป็น Rust
  • แต่หลังจากปัญหานี้ จึงตัดสินใจว่าในระยะสั้นการพก TLS engine สองตัวก็ยอมรับได้
  • อีกทางเลือกหนึ่งคือเรียก try_init_ssl_cert_env_vars() ครั้งแรกขณะถือ GIL ของ Python อยู่
    • Rust มีล็อกภายในเพื่อกัน race ระหว่างโค้ด Rust ด้วยกันเองเวลาอ่านและเขียน environment
    • แต่ไม่ได้กันกรณีที่โค้ดภาษาอื่นเรียก libc โดยตรง
    • หากถือ GIL อยู่ อย่างน้อยก็จะกันการแข่งขันกับ Python thread ได้
  • โปรเจกต์ Rust รับรู้ปัญหานี้อยู่แล้ว และมีแผนทำให้ฟังก์ชัน setter ของ environment กลายเป็น unsafe ใน 2024 edition
  • ฝั่งโปรเจกต์ glibc ก็เพิ่งเพิ่มการเปลี่ยนแปลงที่หลีกเลี่ยง realloc และยอมให้ environment array เก่ารั่วทิ้งไป เพื่อเพิ่ม thread safety ของ getenv()

4 ความคิดเห็น

 
y15un 2025-01-24

ผมคิดว่าจะตั้งชื่อว่า "ความไม่ปลอดภัยด้านเธรดของ C stdlib แม้แต่ Rust ที่ว่าปลอดภัยก็ยังช่วยไม่ได้" ครับ :)

 
halfenif 2025-01-24

เข้าใจอย่างชัดเจนแล้ว

 
GN⁺ 2025-01-24
ความคิดเห็นบน Hacker News
  • ประเด็นใหญ่ที่สุดตรงนี้คือ ใน edition ถัดไปของ Rust ฟังก์ชันตั้งค่าตัวแปรสภาพแวดล้อม จะกลายเป็น unsafe
    ถ้าโชคดี ผลกระทบน่าจะลามลงไปถึง crate ต่าง ๆ ที่ทำให้เกิด crash แบบนี้ด้วย และระหว่างนี้ก็มี issue ถูกเปิดไว้ที่ upstream: https://github.com/alexcrichton/openssl-probe/issues/30

    • แต่ปัญหาต้นตอจริง ๆ คือการที่ไม่สามารถเรียก getenv กับ setenv หรือ unsetenv จากคนละเธรดได้อย่างปลอดภัยนั้น ยังไม่ได้รับการแก้ไขจริง
      ทางออกที่น่าเชื่อถือดูเหมือนจะมีเพียงการเปลี่ยนให้ฟังก์ชันเหล่านี้ต้องจับ mutex เสมอ
    • การตัดสินของผู้เขียนไลบรารีที่ว่า “ทางแก้ที่ดีที่สุดสำหรับปัญหานี้ในยุคนี้คือใช้ไลบรารีอย่าง rustls แล้ว เลิกใช้ crate นี้” ถือว่าสมเหตุสมผลและน่ายินดี
      แต่น่าเสียดายที่ ecosystem ไม่ได้เป็นแบบนั้น: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
    • ผู้คนถูกฝึกให้มองข้ามบล็อกและ prefix อย่าง ____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___ ไปแล้ว
      ในเว็บเฟรมเวิร์กก็คล้ายกัน Vue มี directive v-html ส่วน React มี dangerouslySetInnerHTML ซึ่งในแง่นี้ผมมองว่า ฝั่ง Vue ดีกว่าอย่างชัดเจน
  • set_var และ remove_var ใน standard library ของ Rust จะเริ่มต้องใช้บล็อก unsafe {} อย่างถูกต้องใน edition ถัดไปคือ 2024 edition
    เอกสารตอนนี้ก็กล่าวถึงประเด็นด้านความปลอดภัยแล้ว แต่การทำให้ฟังก์ชันเหล่านี้เป็น safe ตั้งแต่แรกเป็นความผิดพลาด และภาษาระดับสูงกว่านี้หลายภาษาก็ทำผิดแบบเดียวกัน
    https://doc.rust-lang.org/stable/std/env/fn.set_var.html
    glibc มีแพตช์ที่ทำให้ getenv ปลอดภัยขึ้นในกรณีที่สภาพแวดล้อมถูกแก้ไขมากกว่าเดิม แต่ใน C ยังสามารถเข้าถึง environ ได้โดยตรงอยู่ ดังนั้นในสถานการณ์ที่มีการแก้ไข จึงไม่อาจปลอดภัยได้อย่างสมบูรณ์: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...

    • น่าแปลกที่ตอนนี้ glibc เก็บเวอร์ชันเก่าไว้ และใช้ นโยบายปรับขนาดแบบเอ็กซ์โปเนนเชียล
      จะเกิดหน่วยความจำรั่วขนาดคงที่ในเชิง amortized ต่อหนึ่งตัวแปรสภาพแวดล้อมที่ยังใช้งานอยู่ แต่ตัวแปรเองก็มีการรั่วแบบนั้นอยู่แล้ว แถมยังขึ้นกับความยาว และรวมถึงค่าที่ไม่ได้ใช้อีกต่อไปด้วย
      ดูเหมือนว่าน่าจะมี use case แบบพยาธิสภาพบางอย่าง ที่ทำให้หน่วยความจำเพิ่มขึ้นไม่สิ้นสุดแม้ในโปรแกรมที่ถูกต้องตาม API
      เป็นเรื่องน่าสนใจแต่ก็รู้สึกคาใจ เพราะดูเหมือนเป็นการใส่บั๊ก หน่วยความจำเพิ่มขึ้นไม่สิ้นสุด ให้กับโปรแกรมที่ทำตาม API เพื่อแก้โปรแกรมที่ละเมิดกฎด้วยการใช้ API จากหลายเธรด ให้ความรู้สึกว่าเป็น pragmatism มากกว่า dogma
    • ถ้า implementation ของ standard library สามารถจัดการ synchronization ได้ ก็สงสัยว่าทำไมต้องบังคับให้เป็น unsafe
  • ต่อให้ผู้ดูแล C standard library คัดค้านการทำให้ setenv ปลอดภัยสำหรับหลายเธรด อย่างน้อยก็ควรกำหนด API ใหม่ที่ปลอดภัยต่อเธรด ไม่ว่าจะภายใน POSIX หรือทำเป็นมาตรฐานโดยพฤตินัยก่อนแล้วให้ POSIX รับไปทีหลัง
    ถ้าเอาเวลาที่ใช้ไปกับการอธิบายว่าทำไมทำอะไรไม่ได้ มาแก้ปัญหานี้ ก็น่าจะมีตัวแทนของ setenv แบบเก่าที่สามารถเลิกใช้และถอดออกจากโปรเจกต์ซอฟต์แวร์จำนวนมากได้แล้ว
    เมื่อเห็นว่า glibc กำลังทำการเปลี่ยนแปลงที่แทบจะกำจัดปัญหานี้ออกไปได้ คำพูดของผู้ดูแล Musl ที่ว่าภายใน Musl แก้ไม่ได้จึงฟังไม่ค่อยมีน้ำหนัก

    • ปัญหาใหญ่ที่สุดไม่ใช่การไม่มี API ที่ปลอดภัยต่อเธรด แต่คือการมีอยู่ของ extern char **environ;
      ตราบใดที่ environ ยังเข้าถึงได้แบบสาธารณะ ก็ไม่มีหลักประกันเลยว่า setenv กับ getenv จะถูกใช้ด้วยซ้ำ เพราะสองอย่างนี้ไม่ใช่สิ่งบังคับ
      ถ้ากำจัด environ ได้ การทำให้ setenv และ getenv ปลอดภัยต่อเธรดก็ค่อนข้างง่าย แต่ถ้ากำจัดไม่ได้ก็เป็นไปไม่ได้ ถึงอย่างนั้นก็ยังโต้แย้งได้ว่าการทำให้ setenv และ getenv ปลอดภัยต่อเธรดเป็นการปรับปรุง แม้จะไม่ใช่ทางออกที่สมบูรณ์ก็ตาม
    • ฟังก์ชัน exec() ทั้งหมดที่ไม่ได้รับสภาพแวดล้อมเป็นอาร์กิวเมนต์ หรือที่ค้นหา PATH เพื่อหาไฟล์ executable ก็ดูเหมือนจะต้องมี การล็อก ด้วย
    • ผมไม่เชื่อว่าคุณจะรู้ดีกว่าผู้เชี่ยวชาญที่สรุปแล้วว่าแก้ให้เข้ากันได้ย้อนหลังไม่ได้
  • การเจอบั๊กเกี่ยวกับตัวแปรสภาพแวดล้อมบน Linux เหมือนเป็นพิธีผ่านด่านอย่างหนึ่ง และบน Unix อื่น ๆ กลับมักจะเป็นปัญหาน้อยกว่าอย่างแปลก ๆ
    Linus กับเคอร์เนลแก้บั๊กของ POSIX แบบใช้งานจริง คือทำให้มันไม่ระเบิดขึ้นมาจริง ๆ แต่ glibc กลับตามหลังอยู่ ทั้งที่ผู้คนพยายามลดปัญหานี้แม้เพียงเล็กน้อยมาหลายสิบปีแล้ว ตรงนี้ก็น่าขำอยู่
    จริงอยู่ว่ามีสารพัดเรื่องปวดหัวอย่าง TZ แต่ถ้าแค่มี getenv_r() ให้ใช้ ซิงโครไนซ์กับ setenv() และเตือนตอนคอมไพล์/ลิงก์เมื่อใช้ getenv() ปัญหาจำนวนมากก็น่าจะหายไปแล้ว
    ยิ่งไปกว่านั้น ยังอาจทำแบบ คัดลอกเมื่อเขียน (COW) โดยทำให้พอยน์เตอร์ของสภาพแวดล้อมเป็นแบบอ่านอย่างเดียวก็ได้
    แต่กลับโยนปัญหาไปให้แต่ละแอปพลิเคชัน ซึ่งเป็นความผิดพลาดครั้งใหญ่ เพราะผู้เขียนแอปแทบไม่มีทางรู้ได้ว่า dependency ทำอะไรอยู่ สถานการณ์ที่ผมเคยเจอเมื่อนานมาแล้วก็เป็นแบบนั้น และตอนนั้นผู้ขายไลบรารีแบบปิดซอร์สก็บอกให้เลิกใช้ Linux ซึ่งเป็น Unix clone ของเล่นนั่นเสีย

    • ไม่รู้เหมือนกันว่าข้อสรุปที่ว่า “มีบั๊กเกี่ยวกับตัวแปรสภาพแวดล้อมบน Linux และบน Unix อื่นมีปัญหาน้อยกว่า” มาจากไหน
      ปัญหาไม่ใช่ implementation แต่เป็น ตัว API เอง setenv(), unsetenv(), putenv() และโดยเฉพาะ environ นั้นโดยเนื้อแท้แล้วไม่ปลอดภัยในโปรแกรมหลายเธรด
      getenv_r() ก็ไม่ได้ช่วยได้ทั้งหมด เพราะระหว่างที่เธรดหนึ่งกำลังคัดลอกค่าเก่าของตัวแปรสภาพแวดล้อมลงบัฟเฟอร์ที่ให้มา อีกเธรดหนึ่งก็อาจเรียก setenv() ได้
      แน่นอนว่า getenv_r() แก้กรณีที่หลังจากรับค่าจาก getenv() แล้วอีกเธรดเรียก setenv() จนทำให้หน่วยความจำนั้นใช้ไม่ได้ แต่ก็ไม่มีทางกันการเรียกอื่น ๆ ที่ทำให้ API พังได้
      libc อาจลดปัญหาบางส่วนได้ด้วยการจับ mutex ภายใน getenv()/setenv()/putenv()/unsetenv() แต่ libc ก็ยังไม่มีทางรับประกันได้ว่าค่าที่ getenv() คืนมาจะยังใช้ได้ยาวนานพอให้โค้ดฝั่งผู้เรียกนำไปใช้
      และก็ไม่มีวิธีดี ๆ ที่จะทำให้การเข้าถึง environ โดยตรงปลอดภัย อาจทำให้ environ เป็น thread-local ได้ แต่แบบนั้นมุมมองต่อ environment ของแต่ละเธรดอาจคลาดเคลื่อนกันถาวร และผลจากการเรียก getenv_r() กับการตรวจ environ โดยตรงก็อาจต่างกัน
      การรักษา ความเข้ากันได้ย้อนหลัง ในเรื่องนี้ยากจริง ๆ และแค่เพิ่ม mutex เพื่อป้องกันฟังก์ชันเหล่านี้ ก็อาจเปลี่ยนความหมายของโปรแกรมเดิมจนพังได้
  • เคยมีบทความว่า setenv แย่มาก: https://www.evanjones.ca/setenv-is-not-thread-safe.html
    มีการถกเถียงด้วย และตั้งแต่คอมเมนต์แรกก็พูดถึงว่ามันก่อปัญหาใน Rust: https://news.ycombinator.com/item?id=38342642

    • เรื่องนั้นเป็นที่รู้กันอยู่แล้ว
      ปัญหาที่เหลือส่วนใหญ่ตรงนี้ดูเหมือนจะเป็นเรื่องของ สภาพแวดล้อมการพัฒนา เขาทดสอบโดยใช้ Docker บนเครื่องระยะไกลในศูนย์ข้อมูลของ Amazon และเครื่องนั้นรายงานโปรเซสแครชไม่ได้
      แถมในคอนเทนเนอร์ก็ไม่มีข้อมูล debug symbol เพียงพอที่จะได้ backtrace ด้วย ถ้าได้ backtrace ที่ชัดเจนตั้งแต่ครั้งแรกที่ล้มเหลว ก็คงเห็นชัดทันที
      ที่น่าสงสัยตั้งแต่แรกคือ ทำไมถึงใช้ setenv กันแน่
  • เรื่องนี้ทำให้นึกถึงขบวนการ 12-factor app ที่อดีตเพื่อนร่วมงานบางคนเคยเชื่อกันมาก หนึ่งใน “factor” เหล่านั้นคือการตั้งค่าแอปพลิเคชันควรทำผ่าน environment variable
    ผมคิดมาตลอดว่ามันค่อนข้างงี่เง่า เพราะวิธีตั้งค่าแบบนั้นเป็นโครงสร้างที่เหมือนเอาค่าสตริงมาใส่รวมกันในตะกร้าภายใต้ namespace แบบแบน
    ความเสี่ยงของ getenv()/setenv()/environ ก็เป็นเหตุผลหนักแน่นอีกอย่างว่าทำไมไม่ควรใช้ environment variable สำหรับการตั้งค่า
    แน่นอนว่าไม่ได้มีทางเลือกที่ยอดเยี่ยมและรองรับดีเสมอไป ผมชอบไฟล์ตั้งค่ามากกว่า และก็ใช้ template configuration ที่ใส่แค่ค่าของ development, staging, production ได้ด้วย ปกติถึงแม้จะมีข้อเสียและกับดัก ผมก็ใช้ YAML และถึงจะมีรูปแบบไฟล์ตั้งค่าที่ดีกว่านี้ได้ แต่ผมมองว่า YAML ดีกว่า environment variable มาก

    • มีความรู้สึกต่อต้าน Windows และ Microsoft อย่างแรงอยู่มาก แต่เมื่อเวลาผ่านไปก็มีหลายกรณีที่ การออกแบบ API ของพวกเขาได้รับการพิสูจน์ว่ามีเหตุผล
      ใน NT นั้น environment variable สามารถมีชนิดและทำเป็น template ได้ และยังมี registry ซึ่งเป็นฐานข้อมูลการตั้งค่าที่มี namespace ด้วย ถึงจะเยิ่นเย้อและแปลก ๆ ก็เถอะ
      นอกจากนี้ MSVC ยังมีเวอร์ชันที่ thread-safe ของฟังก์ชัน standard library แทบทั้งหมด
      มักได้ยินนักพัฒนา C/C++ หน้าใหม่บ่นว่า MSVC ขาดความเข้ากันได้กับ POSIX แต่ดูเหมือนไม่ค่อยได้คิดลึก ๆ ว่านั่นหมายถึงอะไรจริง ๆ มันเหมือนแค่หวังว่าจะ cross-compatible กับโปรแกรม C ที่เขียนไว้ในยุค 1990 มากกว่า
    • ผมมีข้อกังวลคล้าย ๆ กันเกี่ยวกับ environment variable ไม่ชอบที่มันอ่านได้จากทุกที่
      มันขัดขวางความสามารถในการอนุมานพฤติกรรมจาก signature ของฟังก์ชันเพียงอย่างเดียว และทำให้ฟังก์ชันจำนวนมากที่ควรเป็น pure function กลายเป็น impure
      ถ้ามีฟีเจอร์ภาษาให้ทำเครื่องหมายแอปว่าในโปรเซสใด ๆ ก็ตาม environment variable ใช้งานไม่ได้ และอ่านได้เพียงครั้งเดียว—ไม่ใช่ทีละตัวแปร แต่ อ่านทั้งหมดเป็นชุดได้ครั้งเดียวเท่านั้น—ผมคงใช้ทุกที่
    • ตัว getenv() เองนั้นไม่มีปัญหาเลย ปัญหาคือ setenv()
      ตามทฤษฎีแล้ว environment ควรถูกตั้งค่าก่อนแอปลึกลับนั้นเริ่มทำงาน ดังนั้นจึงไม่ควรจำเป็นต้องใช้สิ่งนี้
      แต่การเป็น namespace แบบแบน ค่าที่เป็นสตริง และพื้นที่ global อิสระที่ทุกคนใช้ร่วมกันโดยไม่รู้ด้วยซ้ำว่าจะมีไลบรารีและโมดูลใดเข้ามา ก็ไม่ใช่ความคิดที่ดีอยู่แล้ว แม้จะไม่มีปัญหาความปลอดภัยของ setenv() ก็ตาม
    • “12-factor app” ควรมีภาคผนวกเพิ่มว่าในระหว่างที่โปรเซสยังมีชีวิตอยู่ ควรถือว่า environment เป็นแบบอ่านอย่างเดียว
      ปัญหาส่วนใหญ่ที่คนพูดถึงกันตรงนี้ดูเหมือนมาจากการพยายามใช้ environment ในทางที่ผิด ให้เป็น key-value store สำหรับ mutable global state ไม่รู้ว่าทำไมถึงอยากทำแบบนั้น
      JVM ถือว่า environment แทบจะเป็น immutable และบริษัทที่ใช้ Scala/Java อย่าง SoundCloud ก็อาจมีอิทธิพลต่อขบวนการ 12-factor app ด้วย ผมไม่เคยเจอกรณีที่ environment เปลี่ยนหรือก่อปัญหา threading เลย
      แม้ environment จะเปลี่ยนไป สำเนา immutable ที่สร้างขึ้นตอน JVM เริ่มทำงานก็ยังคงเดิม และโค้ดที่โต้ตอบกับ environment ผ่าน Java API ปกติจะมองไม่เห็นการแก้ไขนั้น
      ปัญหาของไฟล์ตั้งค่าคือการ parse เป็นเรื่องของแต่ละโปรเซส นั่นจึงทำให้ Linux/Unix เละเทะขนาดนั้น เครื่องมือแต่ละตัวมีธรรมเนียมและกลไกการตั้งค่าต่างกัน และไม่มีมาตรฐาน
      ใน ecosystem ของ Docker นั้น ภายใน container จะทำอะไรก็ช่าง แต่อินเทอร์เฟซกับภายนอกคือการ mount volume แล้วทำตามวิธีตั้งค่าซับซ้อนของแต่ละแอป หรือไม่ก็ใช้ environment variable ไปเลย
      ซอฟต์แวร์สมัยใหม่ส่วนใหญ่ที่รันด้วย Docker ทุกวันนี้เป็นมิตรกับ Docker จนควบคุมพฤติกรรมทั้งหมดผ่าน environment ได้ และในหลายกรณีก็เพียงพอ
      ถ้าใช้ Docker Compose หรือ Kubernetes ก็จะได้รายการ environment variable ที่กำหนดวิธีเริ่มโปรเซสอยู่ในไฟล์ YAML ดังนั้นก็ได้โครงสร้างที่ต้องการในระดับหนึ่ง ผมไม่ได้ชอบ YAML แต่ก็ใช้งานได้ดีพอ และแม้ปัญหา syntax จะมีโอกาสทำให้ทั้งวันพังได้มาก ทางเลือกอื่น ๆ ก็ไม่ได้ไร้ปัญหาเหมือนกัน
    • จริง ๆ นี่เป็นคนละประเด็นกัน ถ้าอ่าน environment variable เข้ามาเป็นการตั้งค่าแล้วไม่ไปแตะอีก ก็ปลอดภัยโดยสมบูรณ์
      ผมเองก็ใช้ สไตล์ 12-factor app แต่หลังจากข้อมูลเข้ามาในแอปแล้ว จะตรวจสอบและจัดเก็บ environment variable กับข้อมูลไว้ จากนั้นก็ไม่มีปัญหาอะไร
  • เป็นบทความที่ยอดเยี่ยมในการขุดคุ้ยบั๊กที่สังเกตเห็นได้ยาก
    มีครบทั้งบั๊กที่เกิดเป็นครั้งคราว ความเฉพาะของสถาปัตยกรรม การซ่อนอยู่ใน dependency, Rust, Python GIL ไปจนถึง gettext
    รายงานการแก้ปัญหาอย่างละเอียดแบบนี้เป็นแหล่งข้อมูลที่ใกล้เคียงกับการเจอเองที่สุด ในสถานการณ์ที่ dependency ของเรากำลังใช้สิ่งนั้นอยู่ แล้วเราจะไปรู้ได้อย่างไร จึงพูดง่าย ๆ ว่า “ก็อย่าใช้ X สิ” ได้ยาก

  • บอกว่า “เครื่อง CI ตอนกลางคืนรันบน Amazon AWS และมีข้อดีคือใช้ผู้ใช้ root จริง ๆ ไม่ใช่ container” แต่ขณะเดียวกันก็บอกว่า “นอก container ไม่มีไฟล์ที่จำเป็น และ container ก็ถูกทำให้ minimal มากจนติดตั้ง gdb ได้ไม่ง่าย”
    ตอนนี้ผู้คนสูญเสียความสามารถในการ build และ debug ในเครื่อง local โดยไม่พึ่ง cloud กับ container ไปแล้วหรือ?

    • ใช่ น่าตกใจมากว่า cloud SaaS บิดเบือนความเข้าใจของผู้คนไปมากแค่ไหน
      แค่ทำเรื่องเล็กน้อยมาก ๆ ก็ต้องใช้ความซับซ้อนของ cloud และชั้น deployment สารพัด กลายเป็นการย้อนการปฏิวัติ PC กลับไป 100% สู่ยุค mainframe computing ที่เทอะทะและแพง
      เหตุผลคือมีเงินอยู่ใน cloud และเพราะ cloud คือ DRM ถ้าเอาซอฟต์แวร์ไปไว้บนนั้นก็เก็บค่าสมัครสมาชิกได้ หลีกเลี่ยงไม่ได้ และรักษาการผูกมัดแบบสมบูรณ์ได้ตลอดไป หลายครั้งผู้ใช้เอาข้อมูลของตัวเองออกมาไม่ได้ด้วยซ้ำ
      การทำ real-time analytics เพื่อปรับแต่งผลิตภัณฑ์ก็ทำได้สะดวก
      สถาปัตยกรรมคอมพิวเตอร์เป็นสิ่งที่ไหลตาม business model มาอีกที mainframe ตายไปครั้งแรกเพราะไม่มีอินเทอร์เน็ตและ PC ถูกกว่า แต่ก็เพราะ vendor สูญเสียอำนาจในการผูกมัดไปมากด้วย
      ตอนนี้มีวิธีชุบชีวิตโมเดลที่ทำกำไรได้สูงกว่ามากแล้ว เสรีภาพอันน่ารำคาญของผู้ใช้หายไป และพูดตามตรง เมื่อผู้ใช้ได้เสรีภาพแบบนั้น ก็มักไม่ยอมจ่ายเงิน ทำให้ธุรกิจซอฟต์แวร์คุณภาพดีเกิดขึ้นได้ยาก
    • นี่เป็นปัญหาแบบ memory corruption แบบสุ่มที่เกิดเฉพาะบน ARM
      เป็นไปได้มากว่า local ไม่สามารถ reproduce crash ได้ เครื่องของนักพัฒนาส่วนใหญ่น่าจะเป็น x86 และที่นั่นก็คงไม่ crash
      การจัดการ crash ควรทำได้ดีกว่านี้ แต่พวกเขาก็ดูเหมือนจะรับรู้ปัญหานั้นอยู่แล้ว และไม่ใช่ประเด็นหลักที่พูดถึงตรงนี้
    • แน่นอนว่าไม่ได้สูญเสียความสามารถไป แต่บนเครื่องของเราไม่เกิด crash
    • ถ้าไม่มีอุปกรณ์ที่รัน สถาปัตยกรรม ที่เกิดปัญหา แล้วจะ debug ในเครื่อง local ได้อย่างไร? ยังไงการ debug ในสภาพแวดล้อมจริงที่เกิด failure ก็เร็วกว่ามาก
  • สถานะโกลบอลที่เปลี่ยนแปลงได้คือสิ่งชั่วร้าย ถ้าเป็นเพื่อนกัน ก็ไม่ควรปล่อยให้เพื่อนใช้สถานะโกลบอลที่เปลี่ยนแปลงได้
    ไม่ชอบตัวแปรสภาพแวดล้อมเลย นี่คือ “วิถีแบบ Linux” แต่ควรหลีกเลี่ยงเหมือนโรคระบาด แนะนำอย่างยิ่ง
    libc แย่มาก และโลกควรก้าวข้ามมันไปได้แล้ว

    • ถ้าปฏิบัติกับตัวแปรสภาพแวดล้อมในโปรเซสเป็นแบบ อ่านอย่างเดียว ก็ไม่เป็นไร
    • ถ้า “สถานะโกลบอลที่เปลี่ยนแปลงได้คือสิ่งชั่วร้าย” งั้นก็คงต้องทิ้ง CPU กับ RAM ด้วย
    • อยากรู้ว่าเสนออะไรเป็นทางเลือก
      ปัญหาไม่ใช่ Linux ไม่ใช่สถานะหรือทรัพยากรโกลบอลที่เปลี่ยนแปลงได้ และไม่ใช่ libc
      ปัญหาคือที่ทำงานไม่ให้เวลาพอที่จะทำงานให้ถูกต้องต่างหาก เช่น ถ้าจะจับปัญหาด้วย GDB ก่อนที่มันจะระเบิด หัวหน้าต้องให้เวลาพอในการดีบักและไล่ย้อนอย่างอดทน ทั้งตัวโค้ดและทุกอย่างที่โค้ดนั้นไปแตะ
      มีเงินจำนวนมากเกินไปไหลไปอยู่กับโค้ดที่ยังสุก ๆ ดิบ ๆ น่าเศร้าแต่จริง
    • libc ผลักโลกเข้าสู่ ยุคสารสนเทศ
    • อยากรู้ว่าทางเลือกที่ชอบคืออะไร
  • เจอปัญหาแบบนี้เยอะมาก จนสุดท้ายต้องแพตช์ getenv / setenv / putenv ด้วย LD_PRELOAD

    • ทำเป็น อิมพลีเมนเทชันแบบแก้คงที่ที่ทำให้ environment รั่ว เหมือนที่เพิ่งเข้า glibc ไปหรือเปล่า?