Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 28 — Build Performance Optimization

Đã đọc 0/14 mục · còn khoảng 11 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là một lập trình viên ở một startup fintech tại Quận 1, TP.HCM. Đội của bạn có 12 người, mỗi ngày push code trung bình 40 lần. Mỗi lần push, pipeline CI chạy mất 18 phút. Nhân lên: 40 × 18 = 720 phút mỗi ngày, tức 12 giờ đồng hồ mà cả đội phải chờ đợi. Chưa kể mỗi lần chờ, developer bị "ngắt mạch tư duy" (context switching), mở Facebook, pha cà phê, rồi mất thêm 10 phút để quay lại đúng chỗ đang code.

Đó chính là lý do Build Performance Optimization không phải là chuyện "cho vui" của mấy anh DevOps rảnh rỗi. Nó là bài toán kinh tế trực tiếp: pipeline nhanh hơn nghĩa là developer feedback nhanh hơn, deploy nhiều hơn, và hóa đơn CI/CD (tính theo phút chạy) rẻ hơn.

Trong bài này, chúng ta tập trung đúng một việc: làm cho pipeline chạy nhanh hơn. Chúng ta sẽ không bàn về caching sâu (đã có Bài 11), cũng không bàn về cost optimization tổng thể (Bài 50) hay observability (Bài 26). Trọng tâm ở đây là: đo lường thời gian build, tìm nút thắt cổ chai, và áp dụng các kỹ thuật tăng tốc cụ thể như parallelization, sharding, và tối ưu từng stage. Đây là kỹ năng mà bất kỳ ai vận hành pipeline nghiêm túc đều phải nắm.

Khái niệm cốt lõi

Nguyên tắc số 1: Không đo thì không tối ưu

Câu nói kinh điển trong performance engineering: "Measure, don't guess" (Đo, đừng đoán). Rất nhiều đội lao vào tối ưu ngay khi thấy pipeline chậm — họ bật cache, họ nâng runner mạnh hơn — nhưng lại tối ưu nhầm chỗ. Kết quả là tốn tiền mà build vẫn ì ạch, vì nút thắt thực sự nằm ở nơi khác.

Vì vậy bước đầu tiên luôn luôn là profiling (đo hiệu năng từng phần). Bạn cần trả lời câu hỏi: trong 18 phút đó, mỗi phút đi về đâu?

Đọc timestamp trong log để xác định stage chậm nhất

Với GitHub Actions, mỗi step trong log đều có timestamp. Bạn vào tab Actions, mở một run, và mỗi step hiển thị thời gian chạy bên phải (ví dụ 2m 34s). Đây là cách profiling nhanh nhất, không cần công cụ gì thêm:

Set up job              3s
Checkout code           8s
Setup Node.js          12s
npm ci                 4m 10s   ← nghi phạm số 1
Lint                    45s
Unit tests             6m 20s   ← nghi phạm số 2
Build                   3m 15s
Upload artifact         52s

Chỉ cần liếc qua, bạn đã thấy npm ci và Unit tests chiếm hơn 10 phút trong tổng số 15 phút. Tối ưu 2 chỗ này sẽ có tác động lớn nhất — đây chính là ứng dụng của định luật Amdahl: chỉ nên tối ưu phần chiếm nhiều thời gian nhất.

Nếu muốn dữ liệu chi tiết hơn, bạn có thể bật timestamp cho từng dòng log bằng biến môi trường:

env:
  ACTIONS_STEP_DEBUG: true

Bốn "đòn bẩy" chính để tăng tốc build

Sau khi profiling, bạn có bốn nhóm kỹ thuật chính:

  • Parallelization (chạy song song) — Các job/step độc lập thì cho chạy đồng thời thay vì tuần tự. Ví dụ lint, test, và security scan không phụ thuộc nhau, tại sao phải chạy nối đuôi?
  • Test sharding (chia nhỏ test) — Chia bộ test thành N phần, chạy trên N runner song song. 1000 test chạy 10 phút trên 1 máy → chia 5 shard chạy 2 phút trên 5 máy.
  • Loại bỏ công việc thừa (avoid redundant work) — Chỉ build/test những gì đã thay đổi. Dùng path filter để bỏ qua job không liên quan, dùng incremental build.
  • Chọn đúng phần cứng và giảm khối lượng — Runner mạnh hơn, image Docker nhẹ hơn, dependency ít hơn.

Wall-clock time vs. tổng compute time

Một khái niệm quan trọng dễ nhầm. Khi bạn chia 1 job 10 phút thành 5 shard, wall-clock time (thời gian đồng hồ mà developer phải chờ) giảm còn ~2 phút, nhưng tổng compute time (tổng số phút runner tính tiền) vẫn là ~10 phút, thậm chí hơn do overhead khởi động 5 runner.

Đây là sự đánh đổi kinh điển: nhanh hơn cho developer, nhưng có thể tốn tiền hơn. Bạn phải cân nhắc dựa trên bối cảnh — với đội đông người push liên tục, tiết kiệm thời gian chờ thường đáng giá hơn nhiều so với chi phí runner tăng thêm.

Tình huống thực tế

Ví dụ 1: Tiki tối ưu pipeline monolith từ 24 phút xuống 7 phút

Giả định một đội backend tại Tiki (sàn TMĐT lớn của Việt Nam) vận hành một ứng dụng Java Spring Boot khá lớn. Pipeline CI của họ chạy mất 24 phút, khiến các developer than phiền vì mỗi PR phải chờ gần nửa tiếng mới biết pass hay fail.

Họ bắt đầu bằng profiling qua log GitHub Actions và phát hiện:

  • mvn dependency resolution: 6 phút (tải lại toàn bộ dependency mỗi lần)
  • Unit + Integration test: 13 phút (chạy tuần tự, đơn luồng)
  • Docker build: 5 phút
Giải pháp họ áp dụng:
  • Bật cache cho thư mục ~/.m2 — dependency resolution từ 6 phút xuống còn 40 giây.
  • Sharding test — chia bộ test thành 4 nhóm bằng Maven Surefire, chạy trên 4 runner song song, mỗi runner gánh ~3.25 phút.
  • Tách integration test ra pipeline riêng — chỉ chạy khi merge vào main, không chạy trên mọi PR.
Kết quả: wall-clock time xuống còn 7 phút. Bài học rút ra: nút thắt lớn nhất (test 13 phút) được giải quyết bằng sharding, còn dependency chậm được xử lý bằng cache. Không đo trước thì rất dễ đổ tiền nâng runner mạnh hơn — vốn chẳng giải quyết được gì vì vấn đề là tính tuần tự, không phải CPU yếu.

Ví dụ 2: Startup SaaS ở Singapore và cái bẫy "cài lại mọi thứ mỗi lần"

Một startup SaaS 8 người ở Singapore build một app React + Node. Pipeline của họ chạy 16 phút, và điều kỳ lạ là bước npm install một mình đã ngốn 9 phút.

Khi điều tra, họ phát hiện hai vấn đề:

  • Họ dùng npm install thay vì npm ci — install phải giải quyết lại dependency tree mỗi lần, chậm và không ổn định. npm ci đọc thẳng từ package-lock.json, nhanh và deterministic (kết quả nhất quán).
  • Runner mặc định của họ đặt ở region us-east, trong khi npm registry mirror gần nhất lại xa, mỗi lần tải package đều bị độ trễ mạng cao.
Họ chuyển sang npm ci kết hợp cache thư mục node_modules theo hash của package-lock.json, và trỏ về registry mirror gần hơn. npm install từ 9 phút xuống 50 giây. Toàn bộ pipeline còn 6 phút.

Bài học: đôi khi tối ưu lớn nhất chỉ là đổi một câu lệnh (install → ci) chứ không cần kiến trúc phức tạp. Luôn kiểm tra xem bạn có đang làm việc thừa hay không trước khi nghĩ đến parallelization.

Ví dụ 3: Đội frontend chia test 800 test case bằng matrix sharding

Một đội frontend tại một công ty gaming ở TP.HCM có bộ 800 test (Jest), chạy tuần tự mất 12 phút. Họ dùng matrix strategy của GitHub Actions để chia thành 6 shard:

strategy:
  matrix:
    shard: [1, 2, 3, 4, 5, 6]
steps:
  - run: npx jest --shard=${{ matrix.shard }}/6

Mỗi runner chỉ chạy ~133 test, hoàn thành trong ~2 phút 20 giây. Wall-clock giảm từ 12 phút xuống hơn 2 phút.

Tuy nhiên họ gặp một cái bẫy: ban đầu họ chia shard theo số lượng file đều nhau, nhưng một vài file test integration nặng gấp 10 lần các file unit test nhỏ. Kết quả là shard 3 chạy 5 phút trong khi shard 1 chỉ chạy 30 giây — toàn bộ pipeline bị "kéo" bởi shard chậm nhất. Họ phải chuyển sang chia theo thời gian chạy dự kiến (timing-based splitting), cân bằng lại tải giữa các shard.

Bài học: sharding chỉ hiệu quả khi các shard cân bằng tải. Wall-clock time luôn bằng shard chậm nhất, không phải trung bình.

Hướng dẫn từng bước

Đây là quy trình chuẩn để tối ưu một pipeline chậm:

Bước 1 — Profiling: đo baseline. Mở vài run gần đây trên GitHub Actions, ghi lại thời gian từng step. Lập một bảng đơn giản: step nào chiếm bao nhiêu phần trăm tổng thời gian. Đừng làm gì khác cho đến khi có bảng này.

Bước 2 — Xác định top 2-3 nút thắt. Áp dụng nguyên tắc 80/20: thường 2-3 step chiếm 80% thời gian. Tập trung vào chúng.

Bước 3 — Xử lý dependency install trước. Đây là "quả ngọt dễ hái" (low-hanging fruit) phổ biến nhất. Kiểm tra:

  • Có đang dùng lệnh deterministic không? (npm ci, yarn --frozen-lockfile, pip install --require-hashes)
  • Đã bật cache đúng cách chưa? (chi tiết ở Bài 11)
Bước 4 — Song song hóa các job độc lập. Nếu lint, test, build đang chạy tuần tự trong một job, tách chúng ra thành các job riêng để chạy đồng thời:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    runs-on: ubuntu-latest
    steps: [...]
  build:
    runs-on: ubuntu-latest
    steps: [...]

Bước 5 — Sharding cho bước tốn nhiều thời gian nhất. Nếu test là nút thắt, dùng matrix để chia shard. Nhớ cân bằng tải theo timing.

Bước 6 — Cắt bỏ công việc không cần thiết. Dùng path filter để chỉ chạy job liên quan:

on:
  push:
    paths:
      - 'backend/**'

Bước này đảm bảo sửa file README không kích hoạt cả pipeline build backend.

Bước 7 — Đo lại và so sánh. Chạy pipeline mới, so với baseline ở Bước 1. Ghi lại con số cụ thể (ví dụ: "18 phút → 6 phút, giảm 66%"). Đây là dữ liệu để bạn báo cáo và để chính bạn biết mình đã thực sự cải thiện.

Bước 8 — Lặp lại. Tối ưu là quá trình liên tục. Sau khi giải quyết nút thắt số 1, nút thắt số 2 sẽ lộ ra. Quay lại Bước 1.

Lỗi thường gặp & mẹo

Lỗi 1: Tối ưu mà không đo. Đây là sai lầm phổ biến nhất. Bạn nâng runner từ 2 core lên 8 core, tốn gấp 4 lần tiền, mà pipeline chỉ nhanh hơn 10% vì nút thắt thật sự là I/O mạng chứ không phải CPU. Luôn profiling trước.

Lỗi 2: Song song hóa quá đà. Chia 50 shard cho bộ 100 test nghe có vẻ nhanh, nhưng overhead khởi động mỗi runner (checkout, setup, cài dependency) có thể là 40-60 giây. 50 shard × 50 giây overhead = lãng phí khổng lồ, và bill CI tăng vọt. Có một "điểm ngọt" (sweet spot) — thường 4-8 shard là hợp lý cho hầu hết trường hợp.

Lỗi 3: Quên rằng overhead setup cũng tính vào thời gian. Nhiều người tối ưu phần "chạy test" nhưng quên rằng checkout + setup Node + npm ci lặp lại trên mỗi shard. Nếu setup mất 2 phút và bạn có 6 shard, đó là 12 phút compute time chỉ để setup. Cân nhắc dùng Docker image đã cài sẵn dependency, hoặc self-hosted runner (Bài 25) có sẵn môi trường.

Mẹo 1: Fail fast. Đặt các bước nhanh và hay fail (như lint, type-check) chạy trước hoặc song song, để nếu code có lỗi cú pháp thì developer biết ngay trong 30 giây thay vì chờ 6 phút test mới báo lỗi.

Mẹo 2: Chia shard theo thời gian, không theo số file. Như ví dụ đội gaming, hãy dùng công cụ hỗ trợ timing-based splitting để các shard cân bằng.

Mẹo 3: Đừng bỏ qua bước Docker build. Nếu bạn build image trong pipeline, thứ tự các layer trong Dockerfile ảnh hưởng lớn đến khả năng cache layer. Đặt COPY package.json và npm ci trước, COPY . . sau — để thay đổi code không làm invalidate cache cài dependency.

Mẹo 4: Đặt timeout cho job. Thêm timeout-minutes để job treo không ngốn runner vô hạn:

jobs:
  test:
    timeout-minutes: 15

Bài tập thực hành

Bài 1 — Profiling. Lấy một repo có GitHub Actions (của bạn hoặc một dự án open-source). Mở 3 run gần nhất, lập bảng thời gian từng step. Xác định 2 step chiếm nhiều thời gian nhất và tính phần trăm của chúng so với tổng.

Bài 2 — Sharding. Cho một bộ test chạy tuần tự 8 phút. Viết đoạn YAML dùng matrix.strategy để chia thành 4 shard. Ước tính wall-clock time mới (giả sử tải cân bằng) và tổng compute time. Giải thích tại sao hai con số này khác nhau.

Bài 3 — Phân tích đánh đổi. Một pipeline chạy npm install (7 phút) rồi test (10 phút, tuần tự). Có 30 người push 50 lần/ngày. Hãy đề xuất 3 tối ưu, ước tính thời gian tiết kiệm được mỗi ngày (tính bằng giờ), và nêu tối ưu nào có thể làm tăng chi phí runner.

Bài 4 — Path filter. Viết một workflow trigger sao cho: job backend-test chỉ chạy khi có thay đổi trong backend/, và job frontend-test chỉ chạy khi thay đổi trong frontend/. Giải thích lợi ích cho một monorepo.

Tóm tắt

Build Performance Optimization là kỹ năng biến pipeline từ "gánh nặng phải chờ" thành "công cụ feedback tức thì". Những điểm cốt lõi cần nhớ:

  • Đo trước, tối ưu sau. Dùng timestamp trong log để profiling và xác định nút thắt. Không bao giờ đoán.
  • Tập trung vào 2-3 step chiếm nhiều thời gian nhất — đó là nơi có tác động lớn nhất (định luật Amdahl).
  • Bốn đòn bẩy chính: song song hóa job độc lập, sharding test cân bằng tải, loại bỏ công việc thừa (dùng npm ci, path filter), và chọn đúng phần cứng.
  • Phân biệt wall-clock time và compute time — sharding làm developer chờ ít hơn nhưng có thể tốn tiền runner hơn. Cân nhắc đánh đổi theo bối cảnh đội.
  • Tránh các bẫy: tối ưu không đo, song song hóa quá đà, quên overhead setup, shard mất cân bằng.
Một pipeline nhanh không phải điều xa xỉ — với vài giờ profiling và tối ưu đúng chỗ, bạn có thể cắt giảm 50-70% thời gian build, giải phóng hàng chục giờ chờ đợi mỗi ngày cho cả đội. Đây là một trong những khoản đầu tư có ROI cao nhất mà một kỹ sư DevOps có thể tạo ra.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi