ย้าย secret ออกจาก .env ใน Docker Compose แต่ไฟล์ต้นทางยังต้องป้องกัน

|ผู้เขียน: กองบรรณาธิการ QUASA|4 นาทีในการอ่าน
ย้าย secret ออกจาก .env ใน Docker Compose แต่ไฟล์ต้นทางยังต้องป้องกัน

หากต้องการย้ายรหัสผ่านและ API key ออกจากตัวแปรแวดล้อม ให้เก็บค่าไว้ในไฟล์ ประกาศไฟล์นั้นใน secrets ระดับบนสุดของ compose.yaml และเพิ่มชื่อ secret เฉพาะใน service ที่ต้องใช้ คู่มือ Compose secrets ของ Docker อธิบายว่าค่าจะถูกเมานต์เป็นไฟล์ใต้ /run/secrets ภายใน container ที่ได้รับสิทธิ์ และการส่งรหัสผ่านผ่าน environment variable เสี่ยงต่อการเปิดเผยโดยไม่ตั้งใจ

ตัวอย่างนี้ใช้ฐานข้อมูล MySQL เพื่อแสดงการเปลี่ยนจาก .env และใช้ API key เพื่อแสดงการแบ่งสิทธิ์ระหว่าง service ผลของการย้ายคือ container อ่านค่าจากพาธของไฟล์แทนการรับค่ารหัสผ่านตรง ๆ ใน environment แต่ไฟล์ต้นทางยังอยู่บนเครื่องที่รัน Compose ผู้ที่อ่านไฟล์นั้นได้ก็ยังอ่านความลับได้ จึงต้องป้องกันทั้งจุดเก็บและทางที่ค่าจะถูกบันทึกออกมา

การเมานต์แบบนี้ใช้กับ Linux container ตามขอบเขตที่ Docker ระบุ หากทีมพัฒนาบนระบบปฏิบัติการอื่น ให้ดูชนิด container ที่ Docker รันจริง ไม่ใช่ดูเพียงระบบของเครื่องผู้ใช้ ขอบเขตนี้สำคัญก่อนนำไฟล์ตัวอย่างไปใช้กับสภาพแวดล้อมที่ต่างจากเครื่องพัฒนา

แยกบทบาทของ .env กับ environment ก่อนแก้ compose.yaml

สมมติว่ามี service ชื่อ db ซึ่งรับ MYSQL_ROOT_PASSWORD และ MYSQL_PASSWORD ผ่านส่วน environment ของ compose.yaml โดยใช้ค่า DB_ROOT_PASSWORD และ DB_PASSWORD จาก .env รูปแบบก่อนย้ายแบบย่อคือ services: {db: {image: mysql, environment: {MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}, MYSQL_PASSWORD: ${DB_PASSWORD}}}} ตัวอย่างนี้เป็นการตั้งค่าสมมติ ชื่อไฟล์และชื่อบัญชีจริงของแต่ละโครงการอาจต่างกัน

ไฟล์ .env ของ Compose ใช้เป็นแหล่งค่าให้การแทนตัวแปรในไฟล์กำหนดค่า เมื่อเขียน MYSQL_PASSWORD: ${DB_PASSWORD} ค่าใน .env จึงถูกแทนลงในรายการ environment และส่งเข้าตัวแปรแวดล้อมของ db ส่วน env_file เป็นอีกวิธีที่นำรายการตัวแปรจากไฟล์เข้า environment ของ service โดยตรง การย้าย secret ต้องตรวจทั้งสองจุด ไม่ใช่เพียงลบบรรทัดรหัสผ่านออกจาก .env แล้วปล่อยรายการ environment เดิมไว้

แยกรหัสผ่านผู้ดูแลฐานข้อมูลออกจากรหัสผ่านบัญชีที่แอปใช้เชื่อมต่อก่อน เพราะสองค่านี้มีหน้าที่ต่างกัน db ต้องใช้ค่าที่ภาพ MySQL ต้องการตอนเริ่มต้น แต่ app ควรได้รับเพียงรหัสผ่านของบัญชีแอป การใช้ชื่อ db_root_password และ db_password ในตัวอย่างช่วยให้เห็นจาก compose.yaml ว่าค่าใดอนุญาตให้ service ใดอ่านได้

ก่อนแก้ไฟล์ให้ค้นว่ารหัสผ่านเดียวกันถูกส่งผ่านช่องทางอื่นด้วยหรือไม่ เช่น รายการ env_file ของ db ค่าที่ export ไว้ใน shell หรือคำสั่งเริ่ม container ที่ส่ง environment เพิ่ม หากยังมีช่องทางเก่า แอปอาจรับค่าจาก environment ต่อไปทั้งที่เพิ่ม secrets แล้ว การตรวจจุดรับค่าของแอปจึงสำคัญพอ ๆ กับการแก้ไวยากรณ์ใน compose.yaml

ย้ายรหัสผ่าน MySQL ไปเป็นไฟล์ที่ service อ่านได้

สร้าง ./secrets/db_root_password.txt และ ./secrets/db_password.txt บนเครื่องที่รัน Compose โดยให้แต่ละไฟล์เก็บค่ารหัสผ่านของตัวเอง จากนั้นประกาศในระดับบนสุดของ compose.yaml ว่า secrets: {db_root_password: {file: ./secrets/db_root_password.txt}, db_password: {file: ./secrets/db_password.txt}} ชื่อทางซ้ายคือชื่อที่ service จะอ้างอิง ส่วน file บอกตำแหน่งไฟล์ต้นทาง อย่าใส่ค่ารหัสผ่านจริงลงใน YAML

ตัวอย่างนี้ใช้ไฟล์แยกกันแทนไฟล์เดียวที่รวมหลายรหัสผ่าน เพราะ Compose ให้สิทธิ์เป็นราย secret ถ้าเอา root password กับรหัสผ่านบัญชีแอปไปรวมในไฟล์เดียว service ที่ต้องใช้เพียงค่าหลังจะเห็นค่าหน้าไปด้วย การแยกไฟล์จึงเป็นส่วนของการกำหนดขอบเขตสิทธิ์ ไม่ใช่แค่การตั้งชื่อเพื่อให้อ่าน YAML ง่าย

ใน service db เปลี่ยนเป็น services: {db: {image: mysql, environment: {MYSQL_DATABASE: appdb, MYSQL_USER: appuser, MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password, MYSQL_PASSWORD_FILE: /run/secrets/db_password}, secrets: [db_root_password, db_password]}} นี่เป็นตัวอย่างแบบย่อเพื่อให้เห็นบรรทัดที่เกี่ยวข้องกับความลับ ค่า MYSQL_DATABASE และ MYSQL_USER เป็นข้อมูลตั้งต้นสมมติ ไม่ใช่ค่าที่ต้องใช้กับทุกโครงการ

เมื่อ Compose เริ่ม service นี้ ไฟล์จาก host จะปรากฏใน db ที่ /run/secrets/db_root_password และ /run/secrets/db_password รายการ secrets ใต้ db เป็นการให้สิทธิ์ที่ทำให้การเมานต์เกิดขึ้น ส่วนการประกาศ secrets ระดับบนสุดอย่างเดียวไม่ได้แจกไฟล์ให้ทุก container ชื่อหลัง /run/secrets/ ต้องตรงกับชื่อที่ให้สิทธิ์ในตัวอย่างนี้ ถ้าเปลี่ยนชื่อ secret ให้แก้พาธที่แอปหรือภาพใช้อ่านตามด้วย

ตัวแปรที่ลงท้าย _FILE บอกภาพ MySQL ให้เปิดไฟล์ตามพาธแล้วใช้เนื้อหานั้นเป็นค่าตั้งต้น แทนการใส่รหัสผ่านจริงใน environment ของ db เอกสารภาพ MySQL อย่างเป็นทางการ ระบุว่าภาพรองรับรูปแบบ _FILE สำหรับตัวแปรรหัสผ่านที่เกี่ยวข้อง และค่าตั้งต้นเหล่านี้ไม่มีผลเปลี่ยนฐานข้อมูลที่สร้างไว้แล้ว จึงต้องเปลี่ยนรหัสผ่านในฐานข้อมูลเดิมด้วยวิธีจัดการบัญชี MySQL หากกำลังย้ายระบบที่มีข้อมูลอยู่

ค่าที่อยู่ใน environment หลังย้ายคือพาธ เช่น /run/secrets/db_password ไม่ใช่เนื้อหารหัสผ่าน อย่างไรก็ดี _FILE เป็นข้อตกลงของภาพที่รองรับ ไม่ใช่ความสามารถที่ Compose เพิ่มให้โปรแกรมทุกตัวโดยอัตโนมัติ ถ้าภาพอื่นไม่รู้จักตัวแปรนี้ แอปต้องอ่านไฟล์เองหรือมีจุดเริ่มทำงานที่รับพาธของไฟล์อย่างชัดเจน

สำหรับฐานข้อมูลใหม่ ภาพ MySQL ใช้ MYSQL_DATABASE และ MYSQL_USER เพื่อสร้างฐานข้อมูลและบัญชีตั้งต้นพร้อมรหัสผ่านที่อ่านจากไฟล์ สำหรับฐานข้อมูลที่เก็บข้อมูลไว้ใน volume อยู่แล้ว อย่าคาดว่าการแก้ค่าตั้งต้นเหล่านี้จะสร้างบัญชีใหม่หรือแก้บัญชีเดิม การย้ายระบบที่ใช้งานอยู่ควรแยกงานเปลี่ยนช่องทางส่งค่าจากงานแก้บัญชีในฐานข้อมูล แล้วประสานค่าที่ app ใช้ให้ตรงกัน

ให้ db, app และ service ที่เรียก API ได้รับค่าต่างกัน

เมื่อ service app ต้องเชื่อมต่อฐานข้อมูล ให้ใส่ db_password ใน secrets ของ app และตั้งค่าแอปให้อ่าน /run/secrets/db_password แต่ไม่ใส่ db_root_password ในรายการของ app การที่ app และ db อยู่ใน compose.yaml เดียวกันไม่ได้ทำให้ app ได้ไฟล์ root password ไปด้วย ถ้า app ใช้ภาพที่รองรับตัวแปรแบบ _FILE จึงค่อยกำหนดตัวแปรพาธตามชื่อที่ภาพนั้นระบุ

สำหรับ API key ให้สร้างไฟล์ต้นทางแยก เช่น ./secrets/api_key.txt ประกาศ api_key: {file: ./secrets/api_key.txt} ใน secrets ระดับบนสุด แล้วเพิ่ม api_key เฉพาะให้ service ที่ติดต่อ API นั้น โปรแกรมใน service นั้นอ่าน /run/secrets/api_key โดยตรงได้หากรองรับการอ่านไฟล์ การเพิ่มชื่อ secret ลงใน YAML เพียงอย่างเดียวไม่ได้ทำให้โปรแกรมที่อ่านแต่ environment variable เริ่มใช้ไฟล์ใหม่

ถ้า app ต้องใช้ทั้งรหัสผ่านฐานข้อมูลและ API key รายการ secrets ของ app จึงมีเพียง [db_password, api_key] ขณะที่ db มี [db_root_password, db_password] นี่เป็นตัวอย่างสิทธิ์ตามหน้าที่ ไม่ใช่กฎว่าทุกแอปต้องใช้ค่าเหมือนกัน ให้ไล่จากการทำงานจริงของแต่ละ service แล้วตัดชื่อ secret ที่ไม่เกี่ยวข้องออก

อย่าเมานต์โฟลเดอร์ ./secrets ทั้งโฟลเดอร์เข้า app ผ่าน volumes เพื่อชดเชยแอปที่ยังอ่านพาธใหม่ไม่ได้ การทำเช่นนั้นเปิดไฟล์ทุกตัวในโฟลเดอร์ให้ service เดียว รวมถึงค่าที่ไม่ได้อยู่ใน secrets ของ service นั้น ถ้าจำเป็นต้องปรับแอป ให้กำหนดพาธไฟล์เฉพาะค่าที่ต้องใช้และทดสอบจุดอ่านค่าแทน

การให้สิทธิ์ราย service จำกัดว่าตัว container ใดได้รับไฟล์ แต่ไม่ได้ควบคุมว่ากระบวนการใดภายใน container ที่ได้รับสิทธิ์จะเปิดไฟล์ได้ การเข้าถึงภายใน container ยังขึ้นกับสิทธิ์ของระบบไฟล์และบัญชีที่รันโปรแกรม จึงไม่ควรส่ง secret ให้ service รวมหลายงานเพียงเพื่อความสะดวก หากงานใดไม่จำเป็นต้องใช้ค่า ก็ไม่ควรอยู่ในรายการของ service นั้น

ดูแลไฟล์ต้นทาง สิทธิ์อ่าน และการรั่วผ่าน log

เมื่อใช้ file เป็นแหล่งของ secret Compose ใช้ไฟล์บน host เป็นแหล่งข้อมูลสำหรับการเมานต์ จึงควรเก็บไฟล์ในตำแหน่งที่บัญชีสำหรับรัน Compose เข้าถึงได้ แต่ผู้ใช้อื่นบนเครื่องเข้าถึงโดยไม่จำเป็นไม่ได้ การย้ายค่าจาก .env ไป ./secrets ไม่ได้เข้ารหัสไฟล์หรือย้ายความรับผิดชอบเรื่องการสำรองข้อมูล ผู้ดูแลควรตรวจด้วยว่าระบบสำรองข้อมูลหรือการคัดลอกโครงการพาโฟลเดอร์นี้ไปที่ใด

อย่า commit โฟลเดอร์ secrets หรือ .env ที่ยังมีข้อมูลอ่อนไหวลง Git ใส่รูปแบบไฟล์เหล่านี้ใน .gitignore แล้วตรวจด้วยว่า Git เคยติดตามไฟล์ดังกล่าวหรือไม่ เพราะ .gitignore ไม่ลบไฟล์จากประวัติที่ commit ไปแล้ว หากค่าถูกเผยแพร่ใน repository หรือส่งให้คนที่ไม่ควรเข้าถึง ต้องเปลี่ยนรหัสผ่านหรือเพิกถอน API key ที่รั่วด้วย การลบไฟล์ใน commit ใหม่อย่างเดียวไม่ทำให้ค่าที่เคยเผยแพร่กลับเป็นความลับ

หลังย้ายให้ลบค่าลับจาก .env และจากไฟล์ตัวแปรอื่นที่ service เคยอ่าน แต่เก็บค่าทั่วไป เช่น ชื่อฐานข้อมูลหรือโหมดทำงาน ไว้ได้ตามความจำเป็น หากมีสำเนาเก่าของ .env อยู่ในเครื่องพัฒนา ระบบสำรอง หรือไฟล์ที่ส่งต่อกัน ให้จัดการสำเนาเหล่านั้นด้วย มิฉะนั้นการกำหนดค่าใหม่จะลดความเสี่ยงตอนรัน แต่ข้อมูลชุดเดิมยังคงอยู่ตามที่เก็บเดิม

ถ้าโครงการสร้าง image จากโฟลเดอร์เดียวกับที่เก็บ secret ให้กันไฟล์ต้นทางออกจาก build context ด้วย .dockerignore และตรวจคำสั่ง COPY ใน Dockerfile ด้วย มิฉะนั้นไฟล์ที่ตั้งใจใช้ตอนรันอาจถูกส่งไปในขั้นสร้าง image หรือถูกคัดลอกเข้า image ได้ .gitignore กับ .dockerignore ทำหน้าที่ต่างกัน: อันหนึ่งควบคุมไฟล์ที่ Git ติดตาม อีกอันควบคุมไฟล์ที่ส่งให้ตัวสร้าง image

รายการ secrets ของ service มีผลตอนรัน container ส่วนขั้น build เป็นอีกช่วงหนึ่ง หาก Dockerfile ต้องใช้ token เพื่อดาวน์โหลด dependency ให้ใช้กลไก build secrets ที่กำหนดสำหรับขั้น build แยกต่างหาก การเพิ่ม token ใต้ service อย่างเดียวไม่ได้ให้สิทธิ์ขั้น build และไม่ควรใช้ COPY นำไฟล์ token เข้า image เพื่อแก้ปัญหานี้

สำหรับ secret ที่มาจาก file อย่าคิดว่าการใส่ uid, gid หรือ mode ในรูปแบบ secrets แบบยาวจะเปลี่ยนสิทธิ์ไฟล์ที่เมานต์ได้ ข้อกำหนด service secrets ของ Docker ระบุว่า Compose ใช้ bind mount สำหรับแหล่งแบบ file และไม่รองรับการตั้งค่าสามตัวนี้ในกรณีนั้น ให้จัดสิทธิ์ไฟล์ต้นทางและตรวจว่าบัญชีที่แอปรันใน container อ่านพาธที่จำเป็นได้จริง โดยไม่เปิดสิทธิ์บน host กว้างเกินไป

อย่าแสดงเนื้อหาไฟล์เพื่อทดสอบการเมานต์ เพราะค่าที่พิมพ์อาจไปอยู่ในประวัติการแสดงผลของ terminal, log หรือระบบเก็บผลคำสั่ง หากต้องตรวจเบื้องต้น ให้ดูเพียงการมีอยู่และการอ่านได้ เช่น docker compose exec db sh -c 'test -r /run/secrets/db_password' คำสั่งนี้ไม่พิมพ์ค่า secret; ผลสำเร็จหรือข้อผิดพลาดบอกสถานะของการอ่านจากมุมของกระบวนการที่เรียกคำสั่ง

คำสั่งตรวจการอ่านไฟล์ต้องสะท้อนบัญชีที่โปรแกรมใช้จริง หาก shell ที่เปิดเพื่อตรวจรันด้วยสิทธิ์ต่างจากตัวแอป ผลว่าอ่านได้อาจทำให้เข้าใจผิดว่าแอปอ่านได้เช่นกัน หลังตรวจพาธ ให้ดูผลการเริ่มงานหรือ health check ของ service โดยไม่เปิดเผยค่าในข้อความผิดพลาดหรือบันทึกเหตุการณ์

ตรวจทางรั่วในแอปด้วย โดยเฉพาะข้อความ debug ที่พิมพ์ environment, ค่า config หรือคำขอไปยัง API การย้ายรหัสผ่านเข้ามาเป็นไฟล์ลดการเผยผ่านช่องทาง environment แต่ถ้าแอปอ่านแล้วพิมพ์ค่าทิ้งลง log ความลับก็ยังรั่วอยู่ การทดสอบควรยืนยันว่า service ที่ไม่ควรได้ secret ไม่มีไฟล์นั้น และ service ที่ต้องใช้สามารถเริ่มงานด้วยพาธที่กำหนดโดยไม่แสดงเนื้อหา

เมื่อใช้บน production ต้องวางแผนการส่งและเปลี่ยนค่า

การประกาศ Compose secrets ทำให้กำหนดได้ว่า service ใดอ่านไฟล์ใด แต่ไม่ได้เป็นคลังเก็บความลับภายนอก เมื่อใช้แหล่งแบบ file ทีมยังต้องตัดสินใจว่าใครสร้างไฟล์ ใครส่งไฟล์ไปยังเครื่องที่รัน Compose ใครเก็บสำรอง และใครลบไฟล์เก่า หากมีหลายเครื่อง ขั้นตอนเหล่านี้ต้องทำให้ตรงกันทุกเครื่องที่รัน service ที่ใช้ค่าเดียวกัน

ถ้าต้องหมุนรหัสผ่านหรือ API key ให้เตรียมลำดับการเปลี่ยนค่าที่ปลายทางและการเริ่ม service ใหม่ตามวิธีที่แอปอ่านไฟล์ การแก้ไฟล์ต้นทางอย่างเดียวไม่รับประกันว่าโปรแกรมที่อ่านค่าเพียงตอนเริ่มทำงานจะใช้ค่าใหม่ทันที สำหรับฐานข้อมูลที่มีอยู่แล้ว การเปลี่ยนไฟล์ MYSQL_PASSWORD_FILE ไม่ได้เปลี่ยนรหัสผ่านบัญชีใน MySQL จึงต้องจัดการบัญชีฐานข้อมูลและค่าของ app ให้สอดคล้องกันในรอบเดียว

เมื่อเลือก secret manager ต้องกำหนดจุดเชื่อมต่อด้วยว่าแอปจะดึงค่าจากบริการนั้นโดยตรง หรือมีขั้นตอนส่งค่ามาสร้างไฟล์บนเครื่องก่อน Compose เริ่มทำงาน วิธีหลังยังต้องคุมอายุและสิทธิ์ของไฟล์ชั่วคราว ส่วนวิธีแรกทำให้แอปรับผิดชอบการอ่านและการต่ออายุค่าเอง คำว่าใช้ secret manager จึงยังต้องมีแผนว่า service ได้ค่าอย่างไรในเวลาที่ต้องใช้

แชร์:

สมัครรับจดหมายข่าวของเรา

รับข่าวสารล่าสุดเกี่ยวกับ Web3, AI และคริปโตส่งตรงถึงกล่องจดหมายของคุณ

0