Git workflow cho team 3–4 người, 3 môi trường

Tài liệu chọn git workflow (quy trình dùng branch/tag để quản lý code) cho bối cảnh: team 3–4 dev, 3 môi trường test → staging → production chạy trên hạ tầng riêng biệt, production deploy bằng blue-green, test và staging deploy bằng recreate.

Phần 1–4 là workflow đề xuất. Phần 5 so sánh với cách "mỗi môi trường là một branch" — cách phổ biến nhất ở team nhỏ và cũng là cách gây đau nhất về sau.

Nền tảng chiến lược deploy: chien-luoc-deployment.md. Pipeline và deploy.sh cụ thể: laravel-gitlab-cicd.md.

Giả định: mỗi môi trường có hạ tầng riêng, không dùng chung database. Git workflow không phụ thuộc vào việc mỗi môi trường là 1 server hay 3 server — chỗ đó chỉ đổi deploy.sh, không đổi cách dùng branch.


Từ điển nhanh

Từ Nghĩa
trunk Nhánh chính duy nhất mà mọi người merge vào (main)
trunk-based development Kiểu làm việc: branch phụ sống ngắn, merge về trunk liên tục
artifact Sản phẩm build ra được (ở đây: Docker image), thứ thật sự chạy trên server
promote Đẩy đúng một artifact lên môi trường cao hơn, không build lại
drift Các branch lệch nội dung nhau dần theo thời gian
cherry-pick Bê một commit lẻ từ branch này sang branch khác (tạo commit mới, hash khác)
fast-forward Merge không tạo commit mới, chỉ tua con trỏ branch tới trước
protected branch Branch bị GitLab chặn push thẳng, bắt buộc qua merge request

1. Workflow đề xuất: trunk-based + promote artifact bằng tag

1.1 Branch

Branch Ai tạo Sống bao lâu Ghi chú
main vĩnh viễn Trunk. Protected. Luôn ở trạng thái deploy được.
feature/<ticket>-<mô-tả> dev 1–3 ngày Merge request (MR) + 1 review, merge xong xoá
fix/<ticket>-<mô-tả> dev < 1 ngày Như trên
hotfix/<ticket> dev vài giờ Chỉ khi production đang lỗi. Cắt từ tag đang chạy prod.

Hết. Không có develop, không có release/*, không có branch riêng cho từng môi trường.

Vì sao branch phải sống ngắn: branch sống 2 tuần thì lúc merge, code đã lệch xa main → conflict lớn, review khó, và bug chỉ lộ ra ở phút cuối. Với 3–4 người, quy tắc thực dụng là: branch nào quá 3 ngày thì chia nhỏ task, hoặc merge phần đã xong vào main sau feature flag (cờ bật/tắt tính năng — Laravel Pennant) để code có mặt trên main nhưng chưa bật cho user.

1.2 Luồng chính

flowchart LR
  F["feature/x"] --> M["main"]
  X["fix/y"] --> M
  M -->|"tự động"| T["TEST<br/>recreate"]
  M -->|"bấm nút thủ công"| S["STAGING<br/>recreate"]
  M -->|"tag v1.2.3 + approve"| P["PROD<br/>blue-green"]
  H["hotfix/z<br/>cắt từ tag v1.2.2"] --> M
  P -.->|"tag v1.2.4"| P

1.3 Quy tắc quan trọng nhất: build một lần, promote cùng một artifact

Khi merge vào main, CI build Docker image một lần và gắn tag image bằng git SHA (mã băm của commit), ví dụ registry/app:a1b2c3d.

Test, staging, production đều chạy đúng image đó. Khác nhau chỉ nằm ở file .env trên từng server (DB host, APP_URL, key của dịch vụ ngoài).

Không build lại ở mỗi môi trường. Build lại nghĩa là mỗi môi trường chạy một binary khác nhau — dependency có thể đã đổi version, asset build khác, và bug kiểu "chỉ xảy ra trên production" sinh ra từ đây. Đây cũng là điều kiện để câu "đã test kỹ trên staging rồi" có nghĩa.

Hệ quả thực tế: từ docker ps trên bất kỳ server nào, đọc tag image là biết chính xác commit nào đang chạy — không cần đoán, không cần so branch.

1.4 Từ commit tới production — ai bấm gì

Môi trường Trigger Ai Cách deploy Rollback
test merge vào main → chạy tự động không ai recreate deploy lại image SHA cũ
staging job thủ công trên pipeline của một commit main dev recreate như trên
production push tag v* + manual approval lead / PO blue-green switch Nginx về màu cũ (giây)

Tag v1.2.3 chỉ là cái nhãn dán lên commit đã qua staging. Nó không tạo code mới, không đổi nội dung — nên không có chỗ nào để code "lệch" đi.

Rollback production không đụng tới git: blue-green vẫn giữ màu cũ đang chạy, đổi một dòng config Nginx rồi reload là xong (xem laravel-gitlab-cicd.md mục 4). Git chỉ dùng để tạo bản vá tiếp theo.


2. Cấu hình GitLab CI khớp workflow này

Mở rộng từ .gitlab-ci.ymllaravel-gitlab-cicd.md mục 2:

stages: [test, build, deploy]

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

test:
  stage: test
  script:
    - composer install --no-interaction
    - php artisan test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

build:
  stage: build
  image: docker:24
  services: [docker:24-dind]
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  rules:
    - if: $CI_COMMIT_BRANCH == "main"    # CHỈ build ở main — một commit, một image

.deploy_template: &deploy
  stage: deploy
  image: alpine:3.20
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh && ssh-keyscan -H $SERVER_HOST >> ~/.ssh/known_hosts

deploy:test:
  <<: *deploy
  script:
    - ssh $SERVER_USER@$TEST_HOST "/srv/app/deploy.sh $IMAGE_TAG"
  environment:
    name: test
  rules:
    - if: $CI_COMMIT_BRANCH == "main"    # tự động, không cần bấm

deploy:staging:
  <<: *deploy
  script:
    - ssh $SERVER_USER@$STAGING_HOST "/srv/app/deploy.sh $IMAGE_TAG"
  environment:
    name: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual                       # dev tự bấm khi muốn PO xem

deploy:production:
  <<: *deploy
  script:
    # Image phải đã tồn tại — chặn trường hợp tag nhầm vào commit chưa qua CI.
    - docker manifest inspect $IMAGE_TAG > /dev/null
    - ssh $SERVER_USER@$PROD_HOST "/srv/app/deploy.sh $IMAGE_TAG"
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: manual                       # + Protected environment: chỉ lead approve được

Vài chỗ đáng chú ý:


3. Hotfix production

flowchart LR
  TAG["tag v1.2.2<br/>(đang chạy prod)"] --> HF["hotfix/PAY-91"]
  HF --> MR["MR vào main"] --> MAIN["main"]
  MAIN -->|"auto"| TEST["TEST"]
  MAIN -->|"bấm nút"| STG["STAGING<br/>smoke test nhanh"]
  MAIN -->|"tag v1.2.3"| PROD["PROD"]

Quan trọng: hotfix cắt từ tag đang chạy prod, không cắt từ main. Vì main lúc đó có thể đã chứa tính năng chưa được duyệt — nếu cắt từ main, bản vá sẽ kéo theo cả những thứ đó lên production.

Nhưng sau khi vá xong thì merge vào main, không merge thẳng lên production. Chỉ có một đường vào production: qua main rồi tag. Không có ngoại lệ, kể cả lúc 3 giờ sáng — ngoại lệ là chỗ code bị mất ở lần deploy kế tiếp.

Nếu main đã đi quá xa và hotfix conflict nặng khi merge ngược: đó là dấu hiệu đang giữ code chưa release quá lâu trên main, không phải dấu hiệu cần thêm branch. Cách chữa là release nhỏ và thường xuyên hơn.


4. Vì sao không dùng GitFlow

GitFlow có 5 loại branch (main, develop, feature/*, release/*, hotfix/*) và quy tắc merge hai chiều giữa chúng. Nó được thiết kế cho phần mềm phát hành theo phiên bản đóng gói (desktop app, thư viện, sản phẩm có nhiều version chạy song song ở khách hàng).

Với web app deploy liên tục lên hạ tầng mình tự quản, developrelease/* chỉ nhân đôi việc merge mà không thêm an toàn nào — cái an toàn đó đã nằm ở staging + manual approval rồi. Team 3–4 người mà nuôi 5 loại branch thì chi phí merge lớn hơn giá trị thu được.


5. So sánh: mỗi môi trường một branch

Đây là cách người dùng hay nghĩ tới đầu tiên, nên phần này đi kỹ.

5.1 Cách đó hoạt động thế nào

Ba branch dài hạn, mỗi branch gắn cứng với một môi trường:

flowchart LR
  F["feature/x"] --> T["branch test"] --> S["branch staging"] --> P["branch production"]
  T --> ST["server TEST"]
  S --> SS["server STAGING"]
  P --> SP["server PROD"]

Deploy = merge lên branch kế tiếp. CI chỉ cần rule đơn giản: push vào staging thì deploy staging, push vào production thì deploy production.

5.2 Ưu điểm (có thật, không nên bỏ qua)

5.3 Nhược điểm — theo thứ tự mức độ đau

a. Drift: ba branch lệch nhau dần

Hotfix vá thẳng lên production cho nhanh. Ai đó quên merge ngược về stagingtest.

Bản thân git merge staging không làm mất bản vá. Merge là 3-way merge (so cả hai nhánh với merge-base — commit tổ tiên chung), nên commit hotfix đã nằm trong lịch sử production được giữ lại; nếu staging sửa đúng dòng đó thì git báo CONFLICT và dừng, chứ không im lặng ghi đè. Git còn theo được cả rename: staging đổi tên Pay.phpPaymentService.php thì hotfix tự chuyển sang file mới. Nguy hiểm nằm ở chỗ khác:

Và thiệt hại chắc chắn xảy ra kể cả khi mọi thứ đúng: teststaging không có bản vá. QA gặp lại bug, dev vá lần hai theo cách khác → hai bản vá khác nhau cho cùng một bug, và lần merge sau conflict nặng.

Đây là nhóm lỗi khó phát hiện, vì phần lớn không có gì báo lỗi cả: merge thành công, pipeline xanh, chỉ có bug lặng lẽ tái xuất hiện. Với trunk + tag, cả nhóm này không thể xảy ra vì chỉ có một branch chứa code.

b. Cherry-pick phá vỡ ý nghĩa của việc test

PO chỉ duyệt tính năng A, nhưng test đang có A + B + C. Muốn đưa riêng A lên staging thì phải cherry-pick.

Cherry-pick tạo commit mới với hash khác. Nghĩa là cây code trên staging không còn giống cây code đã test trên test — nó là một tổ hợp chưa ai chạy bao giờ. Câu "đã test rồi" mất giá trị đúng lúc cần nó nhất.

Càng làm nhiều cherry-pick, ba branch càng lệch, và conflict lúc merge càng nặng — vì git thấy cùng một thay đổi ở hai hash khác nhau.

c. Build lại ở mỗi môi trường — mâu thuẫn trực tiếp với setup blue-green

Mỗi branch push là một pipeline, một image mới. Ba branch → ba image build từ ba cây source khác nhau, ba thời điểm khác nhau (composer install giữa hai lần build có thể kéo patch version khác nếu lock file không chặt tuyệt đối).

Bạn đang có Docker registry và blue-green — tức là đã có sẵn artifact có thể promote. Env-branch vứt bỏ lợi thế đó và quay lại kiểu "mỗi môi trường một bản build riêng".

d. Cùng một conflict giải ba lần

Merge teststaging conflict, giải. Merge stagingproduction conflict tương tự, giải lại. Mỗi lần giải tay là một cơ hội giải sai, và lần giải sai đó chỉ lộ ra ở môi trường cuối cùng.

e. Không trả lời chắc chắn được "production đang chạy commit nào"

Branch là con trỏ di động — nó bị đẩy tới lui, force-push, revert. Tag thì bất biến. Lúc sự cố, câu hỏi đầu tiên luôn là "bản đang chạy là bản nào", và git log --oneline production không đáng tin bằng tag v1.2.3 + image SHA đang chạy trong docker ps.

f. Rollback chậm hơn

Env-branch coi rollback là thao tác git: revert commit, push, chờ pipeline build + deploy — vài phút, trong lúc production đang lỗi. Blue-green rollback là switch Nginx: vài giây, và màu cũ vẫn đang chạy sẵn.

5.4 Bảng so sánh

Tiêu chí Trunk + tag (đề xuất) Mỗi env một branch
Số branch dài hạn 1 3
Code chạy trên 3 env Cùng một artifact Ba lần build khác nhau
Nguy cơ drift giữa các env Không có (một branch) Cao, tăng theo thời gian
Release chọn lọc từng tính năng Feature flag Cherry-pick (dễ sai)
Biết prod đang chạy gì Tag + image SHA, bất biến Đọc branch, con trỏ di động
Rollback prod Switch màu blue-green, vài giây Revert + build + deploy, vài phút
Merge overhead 1 lần (MR vào main) 3 lần + conflict lặp
Độ dễ hiểu cho người mới Trung bình (phải hiểu tag) Cao
Hợp khi deploy bằng git pull Không
Hợp khi có Docker registry Lãng phí

5.5 Khi nào env-branch vẫn là lựa chọn đúng

Không phải lúc nào nó cũng sai. Nó đúng khi:

Bối cảnh của bạn — Docker image, GitLab CI/CD, blue-green — không rơi vào ba trường hợp này.

5.6 Biến thể lai: main + một branch production chỉ để ghi lại

Có team giữ main làm trunk, thêm branch production chỉ được fast-forward tới commit đang chạy prod, để "nhìn phát biết ngay".

Cách này tránh được drift (vì fast-forward only, không bao giờ có commit riêng), nhưng nó chỉ làm lại đúng việc mà tag đã làm — và thêm một con trỏ nữa phải nhớ cập nhật. Nếu muốn cái nhìn nhanh "prod đang chạy gì", dùng GitLab Environments: nó hiển thị sẵn commit + thời điểm deploy của từng môi trường.


6. Ràng buộc kèm theo workflow này

7. Case thực tế có thể gặp và cách xử lý

Case 1 — Production đang lỗi, nhưng main đã có 3 tính năng chưa release. Không được deploy main để sửa 1 dòng. Xử lý: git checkout -b hotfix/x v1.4.2 (cắt từ tag đang chạy prod, không phải từ main), sửa, tag v1.4.3, deploy. Xong thì merge hotfix về main ngay trong ngày — quên bước này là bug quay lại ở release sau. Ngăn lần sau: để bước "merge back" thành một MR bắt buộc trong quy trình hotfix, đừng để nó là việc ai đó nhớ thì làm.

Case 2 — Feature branch sống 2 tuần, merge ra 40 file conflict. Xử lý ngay: rebase lên main theo từng chặng, không giải một lần; nếu quá rối thì bỏ branch, chia lại thành 3 MR nhỏ dựa trên main mới. Ngăn lần sau: branch sống tối đa 1–2 ngày. Phần chưa xong giấu sau feature flag rồi merge vào main. Trunk-based đổi độ phức tạp từ git (nơi không test được) sang code (nơi test được).

Case 3 — Rollback prod về tag cũ, app chạy lại nhưng dữ liệu sai. Release lỗi đã chạy migration DROP COLUMN và đã ghi vào bảng mới. Xử lý ngay: rollback code về tag trước là bước 1; bước 2 là đánh giá dữ liệu đã ghi sai — thường phải viết script sửa, không có nút undo. Ngăn lần sau: release có DROP/RENAME phải tách 2 lần deploy. Git rollback chỉ nhanh khi schema còn tương thích với code cũ.

Case 4 — Một MR đã merge vào main bị phát hiện sai sau khi lên test. Xử lý: git revert <merge-commit> -m 1 rồi merge như MR bình thường. Không reset --hard main (nó là protected branch, và người khác đã pull rồi). Sửa xong thì mở MR mới, revert lại cái revert.

Case 5 — Staging xanh, production fail ngay khi deploy. Nguyên nhân gần như luôn là: hai môi trường chạy hai artifact khác nhau (build lại theo từng môi trường), hoặc thiếu biến env ở prod. Xử lý ngay: rollback về tag trước, so docker image digest giữa staging và prod, so danh sách biến env. Ngăn lần sau: build đúng một image trên main, promote cùng digest đó qua các môi trường bằng tag. Khác biệt duy nhất giữa môi trường là biến env lúc runtime.

Case 6 — Ai đó push thẳng vào main (hoặc force-push) làm mất commit. Xử lý ngay: git reflog trên máy người đó hoặc git fsck trên server để tìm commit mồ côi, tạo branch trỏ lại. Ngăn lần sau: main là protected branch — chặn push thẳng, chặn force push. Đây là cấu hình 30 giây, làm ngay ngày đầu.

Case 7 — Deploy nhầm tag cũ lên production. Xử lý ngay: deploy lại đúng tag. Nguy hiểm thật nằm ở migration: tag cũ + schema mới có chạy được không? Nếu migration là expand-only thì có. Ngăn lần sau: job deploy prod là manual + cần approve, và in rõ tag + digest ra log trước khi chạy.

Case 8 — Feature flag đẻ ra rác. Sau 6 tháng còn 20 flag, không ai biết cái nào còn dùng, code đầy if. Xử lý: mỗi flag có chủ và hạn xoá (ghi trong MR tạo ra nó). Flag đã bật 100% qua 2 release thì xoá cả flag lẫn nhánh code chết.

8. Câu hỏi phụ interviewer hay hỏi tiếp

"Trunk-based thì code chưa xong cũng nằm trên main à, không sợ lên prod?" Code chưa xong lên main nhưng tắt bằng feature flag, nên nó không chạy với user. Đổi lại: nó được build, test và merge liên tục, thay vì nằm một mình trong branch 2 tuần rồi vỡ lúc merge.

"Không có branch develop thì QA test ở đâu?" Môi trường test deploy tự động từ mọi commit trên main. staging deploy từ tag ứng viên (release candidate). Môi trường thay vai trò của branch — và môi trường thì chạy được, còn branch thì không.

"Team 3 người có cần review MR không, hay chậm quá?" Cần, nhưng nhẹ: 1 approval, MR nhỏ (dưới ~400 dòng), review trong ngày. Giá trị lớn nhất không phải bắt bug mà là chia sẻ hiểu biết — team 3 người mà 1 người nghỉ thì 2 người còn lại phải hiểu code đó.

"Dùng tag hay branch để đánh dấu cái đang chạy trên prod?" Tag. Branch là con trỏ thay đổi được — hôm nay production trỏ commit A, mai trỏ B, không tra lại được. Tag v1.4.2 (protected tag) là bất biến, map 1-1 với một image digest.

"GitFlow sai ở đâu mà nhiều người bỏ?" GitFlow thiết kế cho phần mềm phát hành nhiều version song song và phải hỗ trợ version cũ (app desktop, thư viện). Web app chỉ có một version đang chạydevelop + release/* chỉ thêm bước merge mà không thêm thông tin gì.

"Squash merge hay merge commit?" Squash cho feature branch: main có lịch sử sạch, mỗi commit = một thay đổi hoàn chỉnh, git revert một phát là gọn. Merge commit chỉ giữ khi cần lịch sử chi tiết của branch đó (hiếm với branch sống 1–2 ngày).

"Nếu công ty bắt release theo lịch 2 tuần một lần thì sao?" Vẫn trunk-based, thêm branch release/1.5 ngắn hạn cắt từ main khi đóng sổ; chỉ nhận bug fix, không nhận tính năng; tag từ đó. Branch này sống vài ngày rồi xoá, khác hẳn branch môi trường sống mãi mãi.

"Build một lần thì làm sao mỗi môi trường có cấu hình khác nhau?" Cấu hình đi vào biến env lúc runtime, không build vào image. Image chỉ chứa code + dependency. Nếu phải build lại mới đổi được cấu hình thì đó là dấu hiệu cấu hình đang bị nhét sai chỗ.

"Ai được deploy production?" Đặt production là protected environment trong GitLab: job deploy là manual và cần Maintainer approve. Không phải vì không tin đồng đội, mà để có một chỗ dừng có chủ ý trước bước không hoàn tác được, và có log ai bấm lúc nào.

Checklist thiết lập lần đầu

Tóm tắt để trả lời phỏng vấn

"Với team 3–4 người và 3 môi trường, tôi dùng trunk-based: một main protected, branch tính năng sống 1–2 ngày, build một image trên main rồi promote chính image đó qua test → staging → production bằng tag. Cái tôi tránh là mỗi môi trường một branch: nó bắt build lại theo từng môi trường (nên thứ test ở staging không phải thứ chạy ở prod), gây drift khi hotfix quên merge ngược, và con trỏ branch thì thay đổi được nên không tra lại được prod đang chạy commit nào. Tính năng chưa xong thì giấu sau feature flag chứ không giấu bằng branch sống lâu, và hotfix luôn cắt từ tag đang chạy production rồi merge ngược về main ngay trong ngày."