• ระหว่างย้าย 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 ได้มาก และพอใจกับผลลัพธ์
  • แต่กว่าจะไปถึงจุดนั้นใช้เวลามากเกินไป และเมื่อเกิดปัญหา การดีบักก็ควรง่ายกว่านี้

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น