- ระหว่างย้าย CI ที่ซับซ้อนกลับมาใช้ GitHub Actions พบว่า merge queue, รันเนอร์หลายตัว, การบิลด์ Rust, Docker image และการทดสอบแบบรวมเชื่อมกันจนทำให้ต้นทุนการดีบักเด่นชัดยิ่งกว่าการตั้งค่า
- การเปลี่ยนแปลงทั้งหมดที่จะเข้า
main ต้องผ่านการทดสอบ และ ข้อผิดพลาดเล็กน้อยจะถูกแก้อัตโนมัติ เช่น การจัดรูปแบบ, dependency ที่ไม่ได้ใช้, และ lint โดยที่ผลลัพธ์จาก CI ต้องเหมือนกับผลลัพธ์สำหรับรีลีส
- หากต้องการบังคับการตรวจสอบทั้งก่อนและหลัง merge queue จำเป็นต้องตั้ง ชื่อ job ของสองขั้นตอนให้เหมือนกัน ไม่เช่นนั้น check อาจค้างหรือการเปลี่ยนแปลงที่ล้มเหลวอาจถูก merge ได้
GITHUB_TOKEN, permissions ของ workflow, โทเค็นแบบกำหนดเอง, และข้อยกเว้นของ fork กับ self-hosted runner พันกันจน โมเดลความปลอดภัย เข้าใจได้ยากและมีโอกาสพลาดสูง
- การรัน Docker container, workflow แบบ YAML และการทดสอบในเครื่องที่มีข้อจำกัดทำให้ความเร็วในการพัฒนาช้าลง แต่สคริปต์ CI ชุดใหม่ช่วยลดเวลา merge ได้มาก
CI ที่ซับซ้อนและกลับมาใช้ GitHub Actions
- ในช่วง 2 สัปดาห์ที่ผ่านมา ได้เขียนสคริปต์ CI ใหม่บน GitHub Actions
- นี่เป็นการปรับโครงสร้าง CI ครั้งที่สามแล้ว
- ครั้งแรกใช้ GitHub Actions
- จากนั้นย้ายไป Earthly
- เมื่อ Earthly ยุติการให้บริการ จึงกลับมาใช้ GitHub Actions อีกครั้ง
- ปัจจุบัน CI ต้องรับมือทั้ง merge queue, รันเนอร์หลายตัว, การบิลด์ Rust, Docker image และการทดสอบแบบรวมที่หนักหน่วง
- รันเนอร์ใช้งานทั้ง self-hosted, blacksmith.sh และ GitHub-hosted ร่วมกัน
- การ merge PR หนึ่งรายการใช้เวลา CI รวมราว 1 ชั่วโมงบนรันเนอร์แบบขนานหลายตัว
เงื่อนไขที่ CI ต้องตอบโจทย์
- ทุกการเปลี่ยนแปลงที่จะเข้า
main ต้องผ่าน การทดสอบทั้งหมด
- ความผิดพลาดเล็กน้อย เช่น การจัดรูปแบบ, dependency ที่ไม่ได้ใช้, หรือปัญหา lint ควรถูก แก้อัตโนมัติ แทนที่จะจบด้วยความล้มเหลว
- ผลลัพธ์ที่ทดสอบใน CI ต้องเหมือนกับผลลัพธ์รีลีสจริง
- เพื่อประสบการณ์ของนักพัฒนา CI ต้องจบได้อย่างรวดเร็ว
- GitHub Actions รองรับเงื่อนไขเหล่านี้ในทางเทคนิค แต่กระบวนการตั้งค่ามาพร้อมกับกับดักที่ซ่อนอยู่ พฤติกรรมที่ไม่สม่ำเสมอ และการดีบักที่ยาก
Merge queue และ status check
- กุญแจสำคัญในการรักษา branch
main ให้สะอาดคือ merge queue ของ GitHub
- merge queue จะ rebase PR ขึ้นบน
main ก่อนรัน CI
- การรัน CI ที่ต้องการแบ่งเป็น 2 ขั้น
- รัน CI ก่อนเข้าคิวเพื่อแก้ปัญหาเล็กน้อยแบบอัตโนมัติ
- รัน CI อีกครั้งภายในคิวเพื่อตรวจสอบสถานะสุดท้ายก่อน merge
- หากต้องการให้ทั้งสองการรันเป็นข้อบังคับใน GitHub Actions ต้องกำหนด ชื่อ job ของสองขั้นตอนให้ตรงกัน
- GitHub จะมองทั้งสองการรันเป็น check เดียวกัน และต้องสำเร็จทั้งคู่จึงจะ merge ได้
- วิธีนี้ค้นพบหลังจากดีบักอยู่หลายชั่วโมง แล้วไปเจอ คำตอบใน Stack Overflow
- วิธีอื่นอาจทำให้ status check ค้างรอก่อนเข้าคิวจน job ไม่เริ่ม หรือทำให้ job ที่ควรล้มเหลวใน merge queue กลับถูก merge ได้แม้จะล้มเหลว
โมเดลความปลอดภัยของ GitHub Actions ที่เข้าใจยาก
- หลังเกิดเหตุการณ์ที่ GitHub Action ยอดนิยม ถูกเจาะระบบ มีคำแนะนำให้อ้างอิง dependency แบบตรึง hash แต่ในคอมเมนต์กลับมีเสียงตอบรับว่าแทบไม่มีใครทำแบบนั้น
- GitHub Actions มีโทเค็นเริ่มต้นชื่อ
GITHUB_TOKEN
- โทเค็นนี้ถูกเริ่มต้นด้วยสิทธิ์เริ่มต้น
- สิทธิ์เริ่มต้นตั้งค่าได้จาก Actions → General → Workflow Permissions ในการตั้งค่า repository
- ถ้าสิทธิ์เริ่มต้นของ
GITHUB_TOKEN จำกัดเกินไป ก็ต้องเพิ่มสิทธิ์เพื่อให้ action และคำสั่งที่ต้องการทำงานได้ แต่ถ้าสิทธิ์เริ่มต้นเปิดกว้าง ก็สามารถลบสิทธิ์บางส่วนออกในไฟล์ workflow ได้
- ค่าตั้งต้นที่ดีกว่าคือเริ่มจาก ไม่มีสิทธิ์เลย แล้วให้ผู้ใช้เพิ่มเฉพาะสิทธิ์ที่จำเป็น
- ประเภทของสิทธิ์มีจำนวนมาก และถ้าไม่ใช่ผู้เชี่ยวชาญ GitHub ก็ยากจะรู้ว่าแต่ละรายการปกป้องอะไรอยู่
โทเค็นและข้อยกเว้นของสิทธิ์
- ตอนสร้าง GitHub release อัตโนมัติด้วย
softprops/action-gh-release มีการใช้โทเค็นแบบกำหนดเอง CI_RELEASE
- name: Release on GitHub
if: env.version_exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.CURRENT_VERSION }}
generate_release_notes: true
make_latest: true
token: ${{ secrets.CI_RELEASE }}
- แม้จะใช้โทเค็นเริ่มต้นก็สร้าง release ได้สำเร็จ แต่ workflow หลัง release จะไม่ถูก trigger
- ไม่มีการแสดงผลบอกชัดเจน จึงต้องไปหา issue ที่คนอื่นเจอปัญหาเดียวกันเพื่อรู้สาเหตุ
- สามารถเพิ่มสิทธิ์ภายใน workflow YAML ได้เช่นกัน
- โครงสร้างที่ยกระดับสิทธิ์จากภายในโค้ดที่ต้องการปกป้องเองให้ความรู้สึกแปลกอยู่บ้าง
- ตามเอกสารของ GitHub คีย์
permissions สามารถเพิ่มหรือลบสิทธิ์อ่านของ repository แบบ fork ได้ แต่โดยทั่วไปไม่สามารถให้สิทธิ์เขียนได้
- ข้อยกเว้นคือเมื่อผู้ดูแลเลือกตัวเลือก Send write tokens to workflows from pull requests ในการตั้งค่า GitHub Actions
- ด้วยข้อยกเว้นและกับดักจำนวนมาก โมเดลความปลอดภัยของ GitHub Actions จึงทั้งทรงพลังและเพิ่มทั้งพื้นผิวการโจมตีรวมถึงโอกาสผิดพลาดไปพร้อมกัน
ความไม่แน่นอนของ self-hosted runner
- เอกสารของ GitHub ไม่แนะนำให้ใช้ self-hosted runner กับ repository สาธารณะ
- เพราะ fork ของ repository สาธารณะอาจรันโค้ดอันตรายบนเครื่อง self-hosted runner ผ่าน PR ได้
- GitHub ยังมีการตั้งค่า self-hosted runner ที่บังคับให้ต้องอนุมัติการรัน PR จากผู้มีส่วนร่วมภายนอกก่อน
- แต่เมื่อใช้การตั้งค่านี้ร่วมกับ self-hosted runner ก็ไม่มีคำตอบชัดเจนในเอกสารว่าปลอดภัยหรือไม่ และบนอินเทอร์เน็ตก็ไม่มีฉันทามติ
- ความซับซ้อนสูงจนยังคงยากที่จะมั่นใจได้ 100%
การปะทะกันระหว่าง Docker กับ GitHub Actions
- GitHub Actions สามารถ รัน job ภายใน container ได้
- ข้อดีคือสามารถแพ็ก dependency ไว้ล่วงหน้าใน dev container โดยไม่ต้องติดตั้งใหม่ทุกครั้ง
- แต่ในการใช้งานจริงกลับเจอปัญหา สิทธิ์ของไฟล์ ซ้ำแล้วซ้ำอีก
- container อาจบิลด์ไฟล์ด้วยผู้ใช้คนหนึ่ง ขณะที่ GitHub runner รันด้วย uid/gid อีกชุดหนึ่ง
- ผลคืออาจเข้าถึงไฟล์ภายใน container, GitHub workspace หรือไดเรกทอรีชั่วคราวของโฮสต์ไม่ได้
- ไดเรกทอรี
$HOME ก็อาจไม่ตรงกัน
- dev container อาจติดตั้งเครื่องมือไว้ที่
/home/ubuntu
- แต่ภายใน GitHub Actions ค่า
$HOME อาจเป็น /github/home
- เครื่องมือที่พึ่งพาไฟล์ใต้
$HOME จึงอาจหาไฟล์ที่ต้องใช้ไม่เจอ
- action ที่ต้องโต้ตอบกับระบบโฮสต์ก็อาจพังได้เช่นกัน
- แคชของ GitHub จำกัดไว้ที่ 10GB จึงมีการเมานต์ NVMe drive สำหรับแคชด้วย sticky disk action ของ blacksmith
- แต่สิ่งนี้ไม่ทำงานภายใน container และเพิ่งแก้ได้หลัง blacksmith.sh ปรับแก้
- ฟิลด์
container เองก็มีข้อจำกัด
- ไม่สามารถ override entrypoint ได้
- และไม่สามารถให้บาง step รันใน container แล้วให้ส่วนที่เหลือรันภายนอกได้
การพัฒนาและดีบัก workflow แบบ YAML
- ลอจิกของ GitHub Actions เขียนด้วย YAML และยิ่งซับซ้อนก็ยิ่งพลาดได้ง่าย
- ตัวตรวจ GitHub YAML linter ของ RustRover ช่วยได้มาก แต่ยังต้องการ การตรวจแบบสแตติก ที่ดีกว่านี้
- ในเครื่อง local ก็ยากที่จะทดลองพฤติกรรม CI จริงได้เพียงพอ
- act เป็นเครื่องมือที่รู้จักกันดี แต่รองรับได้เพียงส่วนเล็กน้อยของสิ่งที่ต้องการทำใน CI
- วิธีดีบักที่ดีที่สุดกลับเป็นการสร้าง repository อีกอันที่เหมือนกัน แล้ววน
git commit -a -m "wip" && git push test-ci branch ซ้ำไปเรื่อย ๆ จนกว่า CI จะทำงานตรงตามคาด
การแยก workflow และการนำผลลัพธ์กลับมาใช้
- เพื่อไม่ให้ต้องรัน CI pipeline ทั้งชุดทุกครั้ง แต่ละ workflow จึงถูกทำให้เล็กแยกจากกัน
- เมื่อจบแต่ละ workflow จะอัปโหลดผลลัพธ์ แล้วให้ workflow ถัดไปดาวน์โหลดต่อเพื่อไม่ต้องบิลด์ใหม่ตั้งแต่ต้น
- สามารถดาวน์โหลดผลลัพธ์จากการรันก่อนหน้าเพื่อทดสอบ workflow แบบแยกส่วนได้
- แต่ถ้าจะดาวน์โหลดจากการรันก่อนหน้า ต้องส่งโทเค็นให้ action
download-artifact
- โทเค็นนี้จะเป็นโทเค็นเริ่มต้นก็ได้ แต่ยังคงเป็นคำถามที่ยังหาคำตอบไม่ได้ว่าทำไมถึงยังต้องระบุอย่างชัดเจน
- ไฟล์ workflow หลักจึงกลายเป็นเชนที่เรียกไฟล์ YAML อื่นต่อกัน
jobs:
invoke-build-rust:
name: Build Rust
uses: ./.github/workflows/build-rust.yml
invoke-build-java:
name: Build Java
uses: ./.github/workflows/build-java.yml
invoke-tests-unit:
name: Unit Tests
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/test-unit.yml
invoke-tests-adapter:
name: Adapter Tests
needs: [invoke-build-rust]
uses: ./.github/workflows/test-adapters.yml
secrets: inherit
invoke-build-docker:
name: Build Docker
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/build-docker.yml
invoke-tests-integration:
name: Integration Tests
needs: [invoke-build-docker]
uses: ./.github/workflows/test-integration.yml
invoke-tests-java:
name: Java Tests
needs: [invoke-build-java]
uses: ./.github/workflows/test-java.yml
- บาง job ต้องมี
secrets: inherit
- เมื่อ workflow หนึ่งเรียกอีก workflow หนึ่ง secret จะไม่ถูกแชร์โดยอัตโนมัติ
- นี่คือสาเหตุของปัญหาที่ทั้ง CI pipeline ล้มเหลว ทั้งที่การรัน step เดี่ยว ๆ กลับทำงานได้
Merge ได้เร็วขึ้น แต่การดีบักยังมีต้นทุนสูง
- สคริปต์ CI ชุดใหม่ ช่วยลดเวลา merge ได้มาก และพอใจกับผลลัพธ์
- แต่กว่าจะไปถึงจุดนั้นใช้เวลามากเกินไป และเมื่อเกิดปัญหา การดีบักก็ควรง่ายกว่านี้
ยังไม่มีความคิดเห็น