Tăng tốc CI cho Golang bằng cách thay thế actions/setup-go

Công nghệ16 tháng 9, 2026·7 phút đọc

CloudX đã thay thế action actions/setup-go chính thức của GitHub bằng một giải pháp tương thích có tên cloudx-io/setup-go, giúp giảm 69% thời gian chạy các job kiểm thử trong pipeline CI song song cho dự án Golang. Bí quyết nằm ở cách xây dựng cache key thông minh hơn, tận dụng tối đa build cache và test cache của Go toolchain.

Tăng tốc CI cho Golang bằng cách thay thế actions/setup-go

Tăng tốc CI cho Golang bằng cách thay thế actions/setup-go

CloudX vừa công bố một giải pháp thay thế cho action actions/setup-go chính thức của GitHub, giúp giảm tới 69% thời gian chạy các job kiểm thử trong pipeline CI song song cho dự án Golang. Giải pháp có tên cloudx-io/setup-go này đã được mã nguồn mở, hứa hẹn mang lại cải thiện hiệu năng đáng kể cho các dự án Go có quy mô vừa và lớn.

Bài toán đặt ra rất thực tế: khi số lượng job CI song song tăng lên — một job chạy lint, một job chạy test, một job chạy build — thì action mặc định của GitHub lại khiến các job này "giẫm chân" lên nhau, làm giảm hiệu năng tổng thể thay vì tận dụng được sức mạnh song song.

Vì sao actions/setup-go gây ra vấn đề?

Action actions/setup-go của GitHub sử dụng actions/cache ở bên trong để lưu và khôi phục thư mục module cache và build cache của Go. Về nguyên lý, điều này giúp mã nguồn module đã tải và các artifact build/test từ một lần chạy job trước đó có thể tái sử dụng cho các lần chạy sau.

Tuy nhiên, cache key mặc định lại có vấn đề:

setup-go-${os}-${arch}-go-${goVersion}-${hashFiles('**/go.mod')}

Cache key này thiếu sót nghiêm trọng: trong một dự án đang phát triển tích cực, chỉ một tỷ lệ rất nhỏ các thay đổi mã nguồn thực sự động đến hệ điều hành, kiến trúc, phiên bản Go hay file go.mod.

Hệ quả là lần đầu tiên một job tính ra hash key, nó sẽ lưu trạng thái cache cuối cùng vào dịch vụ cache của GitHub. Cho đến khi có thay đổi ở một trong các yếu tố trên, mọi lần chạy CI đều tải lại giá trị cache đầu tiên đó. Khi mã nguồn thay đổi, các archive module build được khôi phục từ lần chạy đầu tiên ngày càng lỗi thời — mỗi lần build tiếp theo phải làm lại nhiều việc hơn từ đầu. Kết quả test cũng trở nên cũ kỹ, khiến các job sau phải chạy lại nhiều test hơn. Chất lượng CI giảm dần cho đến khi bạn cập nhật go.mod.

So sánh setup-go truyền thống với cloudx-io/setup-goSo sánh setup-go truyền thống với cloudx-io/setup-go

Cuộc đua cache giữa các job song song

Vấn đề trầm trọng hơn khi có nhiều job chạy song song. Hãy xem ví dụ workflow đơn giản sau:

jobs:
  test:
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
      - run: go test ./...
  lint:
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
      - uses: golangci/golangci-lint-action@v9

Cả hai job đều phân giải cùng một cache key mặc định, rồi đua nhau ghi giá trị cho key đó. Giả sử job lint hoàn thành trước: nó lưu một giá trị cache không có trạng thái test cache được cập nhật. Các job test tiếp theo sẽ tiếp tục dùng giá trị cũ đó cho đến khi cache key thay đổi, đồng nghĩa với việc chạy lại test một cách không cần thiết.

Đây chính là điều CloudX từng gặp phải: job lint song song lưu một Go cache không có kết quả test, khiến thời gian chạy test trung vị tăng từ 76 giây lên 180 giây — một sự chậm trễ hoàn toàn vô ích vì logic không hề thay đổi.

Go toolchain cache hoạt động như thế nào?

Điểm mấu chốt mà CloudX khai thác nằm ở chỗ Go toolchain đã có sẵn cơ chế cache rất tốt. Có ba loại cache chính:

  • Module cache (GOMODCACHE, mặc định tại $GOPATH/pkg/mod): lưu mã nguồn các dependency đã tải về.
  • Build cache (GOCACHE, mặc định tại ~/.cache/go-build): lưu package archive và các file trung gian được liên kết thành binary cuối cùng.
  • Test cache (cũng nằm trong GOCACHE): lưu stdout, stderr và exit code của lần chạy test trước đó.

Cả build cache và test cache đều hash toàn bộ input để làm cache key, sau đó tổ chức thành các thư mục con và dùng hash làm tên file cho output có thể tái sử dụng. Điểm đặc biệt là Go test runner tự động theo dõi những file mà tiến trình test đọc và đưa nội dung của chúng vào cache key.

Nguyên lý chung rất hợp lý: tối đa hóa tỷ lệ cache hit bằng cách tạo key từ tập dependency đầy đủ nhưng tối thiểu, để chỉ xảy ra cache miss khi thật sự cần thiết. Mỗi khi có miss, kết quả mới luôn được lưu lại để các tiến trình sau tái sử dụng.

Vấn đề là cơ chế này hoạt động hoàn hảo trên một filesystem cố định, còn runner CI lại là môi trường tạm thời. Trên GitHub Actions, cache của toolchain phải được "chuyển" từ runner tạm này sang runner tạm khác thông qua một lớp cache khác — actions/cache — với những ưu tiên thiết kế hoàn toàn khác.

Giải pháp của CloudX

Thay vì dựa vào cache key thiếu sót của GitHub, cloudx-io/setup-go xây dựng key đầy đủ hơn, bao gồm cả danh tính của job:

go-cache-${os}-${inputs.cache-key-prefix}-${goVersion}-${ref_name}-${hashFiles('**/go.sum')}-${run_id}

Có hai điểm khác biệt then chốt:

  • Tách cache theo job: job lint và job test lưu và khôi phục các cache riêng biệt, không còn tranh chấp. Người dùng có thể truyền bất kỳ tiền tố cache key tùy ý nào.
  • Ghi cache sau mỗi lần chạy: vì phần tử cuối của key là run ID của GitHub Actions, không bao giờ có khớp key chính xác. Điều này đảm bảo mỗi job đều kết thúc bằng việc ghi một blob cache mới vào dịch vụ cache của GitHub, thay vì chỉ ghi khi go.mod thay đổi.

Cơ chế cache trong GitHub ActionsCơ chế cache trong GitHub Actions

Kết quả đo lường thực tế

Sau khi loại bỏ cuộc đua cache bằng cách tách cache cho từng loại job, kết quả rất ấn tượng:

  • Thời gian chạy test trung vị giảm từ 131 giây xuống 41 giây — mức cải thiện 69%.
  • Trên thực tế, CloudX nhận thấy họ phải chờ ít hơn 86% số package test phải chạy, nhờ luôn tải được cache mới hơn.

Để kiểm chứng, nhóm đã lấy chuỗi 4.000 commit thật, tính action ID cho từng snapshot của các package test, và mô phỏng tỷ lệ cache hit theo cách xây dựng key cũ và mới. Kết quả cho thấy action mặc định của GitHub thực hiện tới 526.166 lượt chạy package test, trong khi cách tiếp cận mới chỉ còn 71.928 lượt — tương đương mức giảm 86%.

Biểu đồ cải thiện hiệu năng của cloudx-io/setup-goBiểu đồ cải thiện hiệu năng của cloudx-io/setup-go

Ngay cả khi chạy lint và test tuần tự, cloudx-io/setup-go vẫn vượt trội hơn mặc định vì nó lưu trạng thái cache mới sau mỗi lần chạy. Cache được tải luôn tươi mới từ lần chạy trước, nên lần test cho một commit chỉ thực sự chạy các package test đã bị commit đó sửa đổi.

Đánh đổi cần lưu ý

CloudX thẳng thắn thừa nhận hai hạn chế của giải pháp, đều xuất phát từ việc lưu nhiều cache object hơn:

  • Cache phình to: ban đầu các blob cache tăng tuyến tính theo mỗi lần chạy, đến mức thời gian tải cache trở thành yếu tố lớn trong tổng thời gian CI. Lưu ý rằng Go cache không tự động dọn dẹp — nó phình ra cho đến khi bạn xóa. CloudX giải quyết bằng cơ chế tự động prune.
  • Cần mở rộng dung lượng cache GitHub Actions: điều này được bù đắp bởi job chạy nhanh hơn (runner tính phí theo phút), nhưng việc tìm đúng cài đặt trong GitHub khá phiền phức.

"Chúng tôi sẵn sàng trả thêm chi phí để giữ cho kỹ sư và coding agent không bị chặn. Điều đó có nghĩa là công việc có thể song song hóa phải được chạy song song" — nhóm CloudX chia sẻ.

Lời kết

cloudx-io/setup-go đã ổn định trong nội bộ CloudX từ tháng 11 năm ngoái và hiện được mã nguồn mở. Với các đội ngũ đang vận hành dự án Go trên GitHub Actions, đây là một giải pháp đáng cân nhắc: chỉ cần thay thế một dòng trong workflow, bạn có thể tiết kiệm đáng kể thời gian chờ đợi CI mỗi ngày.

Điều đáng chú ý là bài toán này không chỉ đơn thuần là tối ưu cấu hình, mà còn là minh chứng cho nguyên lý quan trọng trong kỹ thuật hệ thống: hiểu rõ cơ chế cache của công cụ bạn dùng quan trọng không kém gì việc chọn đúng công cụ. Go toolchain vốn đã rất tốt; nhiệm vụ của chúng ta là bảo toàn các đặc tính quan trọng của nó qua môi trường runner tạm thời của CI.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗