Trong mô hình GitOps, pipeline tạo website trên Kubernetes thường kết thúc bằng một bước: commit file values của site mới vào repo Helm rồi git push. Khi nhiều pipeline cùng chạy, lệnh push có thể bị từ chối vì một pipeline khác vừa push trước. Cách phổ biến là viết một hàm Bash push_with_retry để thử lại. Bài này phân tích một tình huống điển hình: hàm push_with_retry trông đúng nhưng thực ra không thể thử lại được vì hai lỗi chồng lên nhau, và lỗi ở bước cuối đủ để job rollback xoá luôn site vừa tạo. Sau đó là cách viết lại hàm, cách phòng ngừa và bài học khi dùng Git làm nơi lưu trạng thái cho nhiều pipeline cùng ghi.

Bối cảnh: một repo Helm, nhiều bên cùng ghi

Như bài Mỗi branch một pipeline vận hành: tự động hoá bằng GitLab CI, mỗi site có một thư mục values riêng trong repo Helm. Sau khi helm install thành công, pipeline tạo site commit thư mục đó. Ngoài nó còn nhiều bên khác cùng ghi vào nhánh chính: pipeline xoá site, pipeline chuyển file lên object storage, và pipeline build (được gọi từ Bitbucket, như bài Cầu nối Bitbucket và GitLab CI bằng API và polling) commit tag image mới mỗi lần có code.

Khi có nhiều commit tự động mỗi ngày và có lúc hai commit chỉ cách nhau vài giây, việc một bên bị từ chối khi push không phải ngoại lệ mà là chắc chắn sẽ xảy ra. Hàm push_with_retry sinh ra để xử lý đúng tình huống này.

Triệu chứng

Khi hai pipeline cùng clone repo, pipeline push sau nhận thông báo chuẩn của Git: ! [rejected] main -> main (fetch first), kèm gợi ý Updates were rejected because the remote contains work that you do not have locally. Lẽ ra push_with_retry phải xử lý được. Thực tế, job deploy vẫn đỏ ở đúng bước push.

Điều nguy hiểm nằm ở thứ tự các bước. Push values là bước cuối của job deploy, sau khi database đã tạo, helm install đã xong, site đã trả HTTP 200. Job tiếp theo là job dọn dẹp chạy với when: on_failure: xoá namespace và chạy DROP DATABASE. Với cấu hình như vậy, một lần push bị từ chối là đủ để pipeline gỡ bỏ một site đang chạy tốt.

Điều tra

Bước 1: đọc lại bản push_with_retry cũ

Một phiên bản push_with_retry rất hay gặp (lược bớt dòng echo):

function push_with_retry() {
  local max_attempts=10
  local attempt=0
  local success=false
  while [ $attempt -lt $max_attempts ]; do
    if git push origin "$BRANCH"; then
      success=true
      break
    else
      echo "Push failed. Merging origin/$BRANCH..."
      git merge "origin/$BRANCH" || exit 1
      sleep 3
    fi
    ((attempt++))
  done
  [ "$success" = true ] && echo "Push successful!" || echo "Failed to push."
}

Bước 2: giả thuyết đầu tiên, conflict nội dung (sai)

Chữ "conflict" khiến người ta nghĩ tới hai pipeline sửa cùng một file. Giả thuyết này không đứng vững: pipeline tạo site chỉ thêm thư mục của domain mới, không đụng thư mục site khác. Nếu push_with_retry thực sự lấy được bản mới nhất từ remote, việc gộp sẽ luôn sạch. Vấn đề phải nằm ở chính hàm thử lại.

Bước 3: git merge một tham chiếu đã cũ

origin/main là tham chiếu cục bộ, chỉ được cập nhật khi chạy git fetch hoặc git pull. Trong vòng lặp không có lệnh nào như vậy, nên git merge origin/main gộp đúng bản đã có từ đầu job, trả về Already up to date với mã thoát 0. Log in "Merge successful", nhưng lần push sau vẫn bị từ chối y như trước. Mười lần thử của push_with_retry thực chất là mười lần push cùng một trạng thái. Cơ chế remote-tracking branch được mô tả trong tài liệu git fetch.

Bước 4: bộ đếm làm job dừng ngay vòng đầu

Lỗi thứ hai lặng lẽ hơn. GitLab Runner chạy script bash với set -eo pipefail, tức là dừng job ngay khi một lệnh trả mã khác 0. Biểu thức ((attempt++)) trả về giá trị trước khi tăng; với attempt=0, giá trị đó là 0, nên lệnh có mã thoát 1 và set -e coi là lỗi. Kết quả: lần push đầu bị từ chối, vòng lặp chạy tới ((attempt++)) và job chết luôn. Nói cách khác, push_with_retry cũ không bao giờ đi tới lần thử thứ hai.

Kiểm chứng riêng bộ đếm bằng một pipeline thử chỉ có vòng lặp, không đụng Git. Cách attempt=$attempt+1 còn sai theo kiểu khác, vì bash nối chuỗi thay vì cộng số:

$ bash -c 'set -e; attempt=0; attempt=$attempt+1; echo "attempt=$attempt"; [ $attempt -lt 3 ]'
attempt=0+1
bash: line 0: [: 0+1: integer expression expected

$ bash -c 'set -e; attempt=0; attempt=$((attempt+1)); echo "attempt=$attempt"'
attempt=1

Một chi tiết đáng nhớ: chạy cùng vòng lặp có ((attempt++)) trên bash 3.2 mặc định của macOS, vòng lặp có thể chạy đủ số lần mà không dừng. Kiểm thử script CI trên máy cá nhân có thể cho kết quả khác hẳn trên runner. Hành vi của set -e có trong tài liệu The Set Builtin của bash.

Bước 5: tái hiện bằng hai pipeline song song

Cách kiểm chứng đáng tin nhất là chạy hai pipeline tạo site thử cùng lúc với hai domain thử, lặp lại vài lần sau mỗi lần sửa. Nếu lịch sử repo có các cặp commit chỉ cách nhau vài giây mà cả hai pipeline đều xanh, push_with_retry đã làm đúng việc.

Nguyên nhân gốc

  • Nhiều bên cùng ghi một nhánh Git mà không có cơ chế xếp hàng.
  • push_with_retry không lấy bản mới từ remote: git merge trên tham chiếu cũ là thao tác rỗng.
  • Bộ đếm ((attempt++)) dưới set -e làm job dừng ở lần thất bại đầu tiên.
  • Rollback không phân biệt mức độ lỗi: lỗi ở bước lưu values được xử lý giống lỗi helm install, dẫn tới xoá site đang chạy.

Khắc phục: viết lại push_with_retry

Bản push_with_retry nên làm theo kiểu "huỷ commit, lấy bản mới, commit lại", add đúng đường dẫn, có giãn cách và báo lỗi rõ khi hết lượt:

function push_with_retry() {
  local path="$1" msg="$2" max_attempts=5 attempt=0
  while [ "$attempt" -lt "$max_attempts" ]; do
    git pull --ff-only origin "$BRANCH"        # thay đổi chưa commit nên pull luôn sạch
    git add -- "$path"
    git commit -m "$msg"
    if git push origin "$BRANCH"; then
      echo "Push thành công"; return 0
    fi
    git reset --mixed HEAD~1                    # bỏ commit vừa tạo, giữ nguyên file
    attempt=$((attempt+1))
    sleep $((attempt * 2 + RANDOM % 3))         # giãn cách tăng dần, có jitter
  done
  echo "Hết $max_attempts lần thử, values chưa lên repo" >&2
  return 1
}

push_with_retry "sites/$DOMAIN" "update values $DOMAIN"

Vì commit cục bộ bị huỷ trước mỗi lần thử, git pull --ff-only luôn là fast-forward, lịch sử repo vẫn thẳng và không sinh commit merge. Khoảng nghỉ có jitter giúp hai pipeline đang tranh nhau không thử lại đúng cùng một khoảnh khắc. Cùng một hàm nên được dùng chung cho mọi pipeline ghi vào repo, kể cả pipeline xoá site.

Phòng ngừa

  • Xếp hàng thay vì tranh nhau: đặt bước gọi push_with_retry vào một resource_group của GitLab CI để mỗi lúc chỉ một job ghi repo; push_with_retry vẫn giữ làm lớp bảo vệ thứ hai.
  • Thất bại phải lộ ra: hết lượt thử thì trả lỗi, nhưng ở một job riêng không kéo theo rollback site, kèm thông báo để người trực đẩy values bằng tay. Job xanh trong khi values không lên repo còn nguy hiểm hơn job đỏ.
  • Tách rollback theo mức độ: chỉ gỡ site khi chính site hỏng (helm install, kiểm tra HTTP), không gỡ khi chỉ có bước lưu trạng thái thất bại.
  • Add đúng đường dẫn thay cho git add *, để không commit nhầm file tạm trong thư mục làm việc.
  • Kiểm thử script CI trên runner thật, với đúng image và đúng set -e.
  • Thử cả hai nhánh của mọi hàm thử lại: nhánh thành công ngay và nhánh bị từ chối.

Bài học

GitOps chỉ đáng tin khi Git là nguồn sự thật duy nhất, và điều đó đòi hỏi việc ghi vào Git phải có kỷ luật. Một repo Helm vừa là nơi lưu cấu hình, vừa là nhật ký của mọi pipeline, nên nó mang đủ vấn đề của một cơ sở dữ liệu nhiều người cùng ghi: tranh chấp, thử lại, và trạng thái lệch khi một lần ghi thất bại. push_with_retry giải quyết phần tranh chấp. Phần còn lại là xếp hàng, báo lỗi đúng chỗ và không để một bước phụ quyết định số phận của một site đang chạy.

Bài học nhỏ hơn nhưng tốn thời gian hơn: một dòng ((attempt++)) có thể vô hiệu hoá cả một cơ chế thử lại mà không để lại dấu vết rõ ràng trong log. Phương pháp điều tra chung có ở bài Điều tra sự cố nhiều tầng: từ balancer tới database và Kubernetes; các bài khác nằm trong chuyên mục blog kỹ thuật của Sudo.

Bài tiếp theo

helm upgrade không đổi biến môi trường: thiếu checksum annotation. Vì sao đổi một biến trong values, helm upgrade báo thành công, ConfigMap đã mang giá trị mới, mà pod vẫn có thể chạy với giá trị cũ.