Tuần này F-Droid 2.0 lên top Hacker News với gần 1000 điểm. Phần lớn mọi người bàn về UI mới, nhưng thứ mình thấy đáng học nhất là thứ F-Droid đã làm suốt nhiều năm: reproducible builds. Ý tưởng rất đơn giản: cùng một source code, build ở hai máy khác nhau, phải ra cùng một file binary, giống nhau từng byte. Nếu không giống, có gì đó sai: hoặc môi trường build khác, hoặc tệ hơn là có ai đó đã chèn thứ gì vào pipeline.
Nghe thì có vẻ chỉ dành cho distro Linux hay app store, nhưng thực tế team nào có CI/CD, Docker image hay package npm/PyPI đều nên quan tâm. Bài này mình chia sẻ cách mình áp dụng reproducible builds vào project thực tế, những chỗ hay bị lệch hash, và cách debug khi hai bản build không khớp.
Tại sao build lại không reproducible?
Bạn thử làm thí nghiệm nhỏ: build project hai lần liên tiếp rồi so sánh hash. Rất có thể kết quả khác nhau, dù bạn không sửa dòng code nào.
# Build 2 lần, so sánh sha256
npm ci && npm run build && tar -cf build1.tar dist/
sleep 2
rm -rf dist && npm run build && tar -cf build2.tar dist/
sha256sum build1.tar build2.tar
# a3f1... build1.tar
# 9c07... build2.tar <- khác nhau!
Những thủ phạm phổ biến nhất mình hay gặp:
-
Timestamp: file mtime trong tar/zip/jar, hoặc
new Date()được nhúng vào bundle làm build version. -
Thứ tự file: filesystem trả về thứ tự khác nhau (ext4 vs APFS),
tarvàzipgiữ nguyên thứ tự đó. -
Dependency trôi:
^1.2.0trongpackage.jsonhôm nay resolve ra 1.2.3, tuần sau ra 1.2.5. -
Path tuyệt đối:
/home/andy/project/src/...bị nhúng vào source map hoặc debug symbol. -
User/group, locale, timezone: UID 1000 trên máy dev, UID 0 trong CI;
LANG=vi_VNlàm sort khácLANG=C. - Randomness: hash map ordering, UUID sinh lúc build, parallel build ghi file theo thứ tự không cố định.
flowchart LR
S[Source code + lockfile] --> B1[Build tại máy dev]
S --> B2[Build tại CI độc lập]
B1 --> A1[Artifact A]
B2 --> A2[Artifact B]
A1 --> C{sha256 giống nhau?}
A2 --> C
C -->|Có| P[Publish + ký]
C -->|Không| D[diffoscope tìm nguyên nhân]
Đây chính là mô hình F-Droid dùng: developer build và ký APK, F-Droid build lại độc lập từ source, nếu hai bản khớp thì F-Droid publish luôn bản có chữ ký gốc của developer. User vừa có chữ ký của tác giả, vừa có bằng chứng binary thật sự đến từ source công khai.
Khử timestamp và thứ tự file với SOURCE_DATE_EPOCH
Chuẩn chung mà cộng đồng reproducible-builds.org đưa ra là biến môi trường SOURCE_DATE_EPOCH. Thay vì dùng giờ hiện tại, mọi tool build dùng một timestamp cố định, thường lấy từ commit cuối cùng. GCC, dpkg, Python setuptools, Go, esbuild, và rất nhiều tool khác đã hỗ trợ sẵn.
#!/usr/bin/env bash
set -euo pipefail
# Timestamp cố định = thời điểm commit cuối
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export TZ=UTC
export LC_ALL=C
npm ci --ignore-scripts
npm run build
# Đóng gói tar deterministic (GNU tar >= 1.28)
tar --sort=name \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-cf release.tar dist/
gzip -n -9 release.tar # -n: không ghi tên file và timestamp vào header
sha256sum release.tar.gz
Vài điểm cần để ý:
-
gzip -nrất hay bị quên. Mặc định gzip ghi mtime vào header nên hash sẽ khác dù nội dung tar giống hệt. - Trên macOS,
tarmặc định là bsdtar, không có--sort. Càignu-tarqua Homebrew rồi dùnggtar, hoặc build trong container luôn cho chắc. - Trong code, đừng nhúng
new Date().toISOString()làm build time. Nếu cần, đọc từprocess.env.SOURCE_DATE_EPOCH.
Với Docker, BuildKit từ v0.13 đã hỗ trợ SOURCE_DATE_EPOCH khi build image:
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
--output type=image,name=myapp:1.4.0,rewrite-timestamp=true \
.
Nhớ pin base image bằng digest (FROM node:22-slim@sha256:...) thay vì chỉ tag, vì tag node:22-slim có thể trỏ sang image khác vào tuần sau.
Khóa chặt dependency
Timestamp chỉ là phần dễ. Phần khó hơn là đảm bảo dependency giống nhau tuyệt đối. Quy tắc của mình:
-
Node.js: luôn commit
package-lock.json, CI dùngnpm cichứ không dùngnpm install. Với pnpm thìpnpm install --frozen-lockfile. -
Python: dùng
uv(0.4+) hoặcpip-toolsđể sinh lockfile có hash. -
Go:
go.sum+ build với-trimpathđể bỏ path tuyệt đối.
Với Python, mình thích cách này vì pip sẽ từ chối cài nếu hash không khớp, chặn luôn cả trường hợp package bị thay thế trên registry:
# Sinh lockfile có hash
uv pip compile requirements.in --generate-hashes -o requirements.txt
# Cài đặt: fail ngay nếu có package nào sai hash
pip install --require-hashes --no-deps -r requirements.txt
# Build wheel reproducible
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export PYTHONHASHSEED=0
python -m build --wheel
PYTHONHASHSEED=0 là chi tiết nhỏ nhưng quan trọng: nếu build script có iterate qua set hoặc sinh file từ dict hash ngẫu nhiên, thứ tự output sẽ thay đổi mỗi lần chạy.
Debug khi hash không khớp: diffoscope
Khi hai bản build khác nhau, sha256sum chỉ cho biết là khác, không cho biết khác ở đâu. Tool cứu cánh là diffoscope, được phát triển bởi chính nhóm Reproducible Builds. Nó mở đệ quy tar, zip, jar, APK, wheel, ELF, PDF... và chỉ ra chính xác chỗ khác biệt.
pip install diffoscope # hoặc: apt install diffoscope
diffoscope build1.tar.gz build2.tar.gz --html report.html
Một lần mình debug một wheel Python mãi không khớp, diffoscope chỉ ra ngay: file RECORD bên trong wheel có thứ tự dòng khác nhau, do một plugin build dùng os.listdir() mà không sort. Sửa một dòng sorted() là xong, trong khi nếu đoán mò có khi mất cả buổi chiều.
sequenceDiagram
participant CI as CI Runner
participant V as Verifier Job
participant R as Registry
CI->>CI: Build + sha256
V->>V: Build lại từ cùng commit
V->>CI: So sánh hash
alt Khớp
CI->>R: Publish artifact đã ký
else Không khớp
V->>V: Chạy diffoscope, fail pipeline
end
Trong GitHub Actions hoặc GitLab CI, mình setup đơn giản: hai job chạy song song trên hai runner khác nhau (ví dụ ubuntu-24.04 và ubuntu-22.04 trong container giống nhau), cả hai upload hash, job thứ ba so sánh. Nếu lệch thì fail pipeline và upload report diffoscope làm artifact để xem.
Kết luận
Reproducible builds không phải chuyện chỉ dành cho Debian hay F-Droid. Với các vụ supply chain attack ngày càng nhiều, việc chứng minh được binary đến từ đúng source code là một lớp phòng thủ rất rẻ mà hiệu quả. Bonus: build reproducible thì cache của CI cũng hit tốt hơn, và bug kiểu chạy được ở máy tôi giảm hẳn.
Những việc bạn có thể làm ngay tuần này:
-
Thử nghiệm: build project hai lần, so
sha256sum. Biết mình đang ở đâu đã. -
Set
SOURCE_DATE_EPOCH,TZ=UTC,LC_ALL=Ctrong script build và CI. -
Dùng lockfile nghiêm túc:
npm ci,pnpm --frozen-lockfile,pip --require-hashes,go build -trimpath. - Pin base image bằng digest, không chỉ bằng tag.
-
Đóng gói deterministic:
tar --sort=name --mtime,gzip -n. - Cài diffoscope và dùng nó mỗi khi hash lệch thay vì đoán.
- Khi đã ổn, thêm một verifier job trong CI để build lại và so hash trước khi publish.
Không cần đạt 100% ngay từ đầu. Chỉ cần artifact chính (Docker image, package release) reproducible là bạn đã hơn phần lớn các project ngoài kia rồi.
Top comments (0)