Engineering Practice · CI/CD 細節文件版號 v2026100501
SRE · Engineering Practice · 工程實作

CI/CD 細節

文件版號 v2026100501(討論與引用請註明版號)。英文版為正本(English · SSOT),本文為中文版,內容與同版號的英文版一致。給各部門工程師與 SRE。上版約定與分支範本講「做什麼」,這份講「怎麼做」。標 約定 的章節說明 SRE 怎麼處理交付,所有人適用;標 範本 的章節是建議的 app repo pipeline,可以採用或自行替換。

1. 一頁看懂

SRE 部署的是 tag + commit hash

交付就是每個 repo 一個 tag 與它的完整 commit hash。SRE 核對兩者一致,就部署那一組。

build 一次

artifact 由交付的 tag build,以 commit 識別。灰度與 production 跑同一個 artifact。

環境設定在部署時注入

build 時 CI 沒有任何環境的設定,image 裡不能有 domain、API 位址、開關值。

核准在 GitLab,部署由平台接手

SRE 在 GitLab 上核准上版申請;之後 K8s 由 GitOps 同步、VM 由部署 job 執行,兩者對申請人都不可見。

2. 從交付到部署(SRE 端) 約定

  1. 前一工作日Release Manager 每個 repo 開一份上版申請:tag、完整 commit hash、release plan、退版方式。
  2. 核對SRE(或 job)在 app repo 解析 tag,與交付的 hash 比對(git rev-parse <tag>^{commit})。對不上就不上,直到 Release Manager 確認更正後的組合。
  3. 灰度Release day:SRE 核准整組的灰度變更;平台部署;SRE 觀察真實流量。灰度時段 11:00 到 14:00,可以 n 次;任一 repo 有問題 → 整組從灰度撤回、重新交付。14:00 灰度終止;沒通過就排下一個 release day。
  4. Production整組通過灰度:SRE 依 release plan 順序核准每個 repo 的 production 變更,artifact digest 與灰度相同。
  5. 退版依 release plan 反向,每個 repo 回到上一版;或用只改設定的變更把開關關掉。

環境 repo是記錄「每個環境現在跑哪個版本、用什麼設定」的 git repo(公司目前是 fleet-delivery)。GitOps 工具(Argo CD)自動把環境同步成 repo 上寫的樣子。目前涵蓋 K8s 上的服務,VM 服務之後納入。上版申請就是對它開的 MR:SRE 核准就是上版的決定,合併就是部署。

環境 repo 路徑內容誰核核准後
workloads/<svc>/<自驗環境>/渲染後的 K8s manifests自動合併GitOps 同步
workloads/<svc>/gray/同上,版本 = 交付的 tag(灰度,真實流量)SRE(CODEOWNERS)GitOps 同步
workloads/<svc>/production-<cluster>/同上,版本 = 通過灰度的 tagSRE(CODEOWNERS)GitOps 同步
workloads/<svc>/production-vm/version.yaml(tag)與設定檔SRE部署 job 在 on-prem runner 對 VM 做 git checkout <tag> 與重啟
同路徑,只改設定值功能開關、環境變數;artifact 不變,標 config-onlySRE,依 Release Manager 的申請同上;讀 env 的服務 rollout restart
退版SRE 依 release plan 反向,對每個 repo 部署上一版。K8s 服務:把 production 版本改回上一版(部署 MR 頁的 Revert 按鈕,或改回上一版 tag 的新 MR);VM 服務:目前用既有部署腳本,之後納入環境 repo。可做成手動 job rollback-release:輸入 release plan,依反向順序逐一退版SRE 宣告並執行逐一回到上一版
核准點為什麼在 GitLab

Argo CD 是自動同步、沒有核准概念;多數部門也不在 K8s 上。把核准放在 GitLab 的 MR,K8s 與 VM 就走同一道門。

VM 與 K8s 長得一樣

SRE 看到的都是「某 service 的 production 版本從 A 改成 B」。服務從 VM 搬到 K8s,是同一個目錄換內容,流程不變。

平台端的前置作業

環境 repo 的 production 目錄尚未接到 production 叢集;ApplicationSet 的目標叢集要依目錄決定;main 要加 CODEOWNERS 與 code owner approval;tag ↔ hash 核對與 rollback-release job 尚未實作。以上由 SRE 完成。

3. pipeline(app repo) 範本

配合分支範本的共用 CI/CD component,各 repo 只提供測試指令、Dockerfile 和 service 名。三條路徑:一般 MR / push、rc tag、合進 master 之後的正式 tag。

MR / PUSH
test

各 stack 的測試指令

手動
deploy-dev

部到自驗環境

MR → MASTER
check-merge

內容 == production 上的 rc

RC TAG
tag-verify

rc 在 release/* 或 hotfix/* 上

RC TAG
build

build 一次,記錄 digest

RC TAG
request-deploy

開上版申請:tag、hash、digest

正式 TAG
tag-verify

== master tip,內容 == production 上的 rc

正式 TAG
retag

同一個 image 加別名 X.Y.Z

正式 TAG
release-notes

Release 補 digest、MR 清單

正式 TAG
cleanup

刪 release 與已合併 feat

圖 1 — 上:每次 MR / push。中:Release Manager 打 rc tag;build 一次並開上版申請(之後見第 2 節)。下:production 驗證後,Release Manager 合進 master 並打正式 tag;正式 tag 不 build、不部署。金色是守門。
job何時跑做什麼
testMR、branch push各 stack 的測試指令(第 5 節)。
deploy-dev手動build 當下 commit 的 image,部到自驗環境,由開發人員選環境。
check-mergeMR → master守門,見第 4 節。純 git,不呼叫 API。
tag-verifytag pipeline 第一步守門,見第 4 節。不成立後面都不跑。
buildrc tagbuild image,registry tag = commit,輸出 digest。唯一會 build 給灰度 / production 的時機。
request-deployrc tag對環境 repo 開(或更新)上版申請,帶 tag、完整 commit hash 與 digest;Release Manager 補上 release plan 與退版方式。之後由 SRE 接手(第 2 節)。
retag正式 tagcrane tag / skopeo copy 把 production 上那個 rc 的 image 加別名 X.Y.Z;不重新部署。
release-notes正式 tag更新 GitLab Release:digest、對應的 rc、Milestone 的 MR 清單。
cleanup正式 tag刪除 release/X.Y.Z(或 hotfix/*)與已合併的 feat/*。

4. 守門 job(app repo) 範本

兩個 job 讓範本的承諾不靠人記:「production 跑的就是交付的 tag」「master 只收驗證過的東西」。它們跑在團隊自己的 repo,保護的是團隊;SRE 自己的檢查是第 2 節的 tag ↔ hash 核對。

守門條件不成立時
check-merge
MR → master
① 來源是 release/* 或 hotfix/*;② 來源的最新 commit 正好是一個 rc tag(production 上的那個,之後不能再有 commit);③ master 是這個 commit 的祖先。①:不該進 master 的分支。②:production 之後又推了東西,要交付新的 rc。③:期間有別的東西進了 master,先把 master 合進來、打新的 rc。
tag-verify
tag pipeline
rc tag:commit 在 release/* 或 hotfix/* 上。正式 tag:commit 是 master 最新,檔案樹與某個 rc 完全相同(git diff --quiet rc final),且該 rc 是它的祖先。tag 建錯位置。交付前刪掉重建即可,沒有任何東西被部署。
為什麼比對檔案樹而不是 commit:release 合進 master 用 merge commit,master 最新 commit 不等於 rc 的 commit,但內容相同。正式 tag 直接沿用 production 上那個 rc 的 artifact。

5. 三個技術棧 範本

不論用哪種 pipeline,各 stack 差的是四件事:怎麼測、怎麼 build、artifact 是什麼、設定怎麼進去。「設定在部署時注入」屬於約定(build 一次);其餘是建議。

Next.jsJava Spring BootLaravel
testnpm ci && npm test、npm run lint、tsc --noEmit./mvnw -B verify 或 ./gradlew testcomposer install、vendor/bin/phpunit
buildmulti-stage:npm ci → next build(output: 'standalone')→ runtime image 只帶 .next/standalonemulti-stage:mvnw package → JRE image 帶 jar;或 buildpacks / jibmulti-stage:composer install --no-dev、config:cache → php-fpm + nginx image
artifactcontainer image;共用元件發 npm packagecontainer image;共用元件發 jarcontainer image;共用元件發 composer package
設定注入server side 執行期讀 env。client side 不能用 NEXT_PUBLIC_* 放環境值(build 時烙進 bundle):改用同源相對路徑、server 提供 /api/config、或容器啟動時產生 env.js執行期 env → application.yaml placeholder,或 ConfigMap 掛成 application-prod.yaml執行期 env → config/*.php(config:cache 後只認 config)
功能開關server side 讀 env,或 client 從 /api/config 拉;OpenFeature JS SDK 可統一執行期用 Environment 讀,或 Togglz / OpenFeature;@ConditionalOnProperty 只在啟動時判斷config('features.x') 從 env 來;用套件時 driver 指到 env,不是 DB
改設定要不要重啟讀 env:要;讀掛載檔:不用要(預設);Spring Cloud Kubernetes reload:不用要(config:cache)
health自訂 /api/healthActuator /actuator/health/readinessLaravel 11 /up
DB migration通常沒有;Prisma / Drizzle 同右Flyway / Liquibase,expand / contractartisan migrate 在 Job / init container;不退 migration
版本可觀測image label org.opencontainers.image.revision=<commit>;app 露出 /version 回 commit,SRE 可在灰度與 production 確認跑的就是交付的 hash。
真的只能在 build 時決定的值(例如 Next.js 的 NEXT_PUBLIC_*),視為程式碼而不是環境設定:它跟著 commit 走,不會因環境而異。

6. GitLab 設定 範本

app repo(範本:建議的 group 層級預設)

設定值
Protected branchesmaster、release/*:push = No one,merge = Maintainers。
Merge methodmerge commit。分支之間用 MR 合併整條分支,不用 cherry-pick。
Squashproject 設定「允許」、預設不勾(GitLab 只能 project 層級設定,不能依目標分支)。只用在 feat/*、hotfix/* → release/*;release/* → master 勾了會被 check-merge / tag-verify 擋下。feat squash 後不會被 git 認成已合併,清分支改靠「merge 後刪 source branch」。
Protected tagsv*:只有 Maintainer(Release Manager)可建。
Push rules分支名 ^(feat|hotfix|release)/;commit message 含票號 #\d+;author email @fmt.com.tw(既有)。
MRmerge 後自動刪 source branch;pipeline 必須成功;目標為 master 與 release/* 需要 approval。
Milestone / Release每版一個 vX.Y.Z;MR 掛 Milestone;Release 由 Release Manager 建立,notes 自動產生。
Registryimmutable tags;保留策略只清 commit tag,不清版號 tag。
Runner要呼叫 GitLab API 的 job 用公司的 group runner(tags:);純 git 檢查用 GIT_FETCH_EXTRA_FLAGS。

環境 repo(SRE,全公司一個) 約定

設定值
Protected branchmain:push = No one;merge = Maintainers + 各課的 bot;Code owner approval required。
CODEOWNERS/workloads/*/production*/、/workloads/*/gray/ → sre group。只用 CODEOWNERS,不另加全 main 的 approval rule。
Merge methodmerge commit(Revert 按鈕退整個部署)。
Bot每課一個 service account(Developer + 可 merge main),token 放課的 group CI variable;以 git commit + push 提交,committer 用 <svc>-gitops-bot@fmt.com.tw。
Deploy freezeGitLab deploy freeze;凍結期間申請照開、不核准。

7. 常見問題

我們不用範本 pipeline,需要做什麼?

只需要交付:每個 repo 前一工作日交一個 tag + 完整 commit hash、artifact 由那個 commit build 且設定在部署時注入、加上上版申請(第 2 節)。怎麼產生由你們決定。

job 裡呼叫 GitLab API 回 403 / 404?

頂層 group 有 IP allowlist,gitlab.com 的 SaaS runner 不在名單內。把 job 指到公司的 group runner(tags:);純 git 的檢查不需要 API,用 GIT_FETCH_EXTRA_FLAGS 讓 runner 自己帶 refs。

bot 用 commits API 寫環境 repo 回 400?

push rule 要求 author email 是 @fmt.com.tw,bot 帳號的 email 不是。改用 git commit + push,committer 設成 <svc>-gitops-bot@fmt.com.tw。

Run pipeline 帶變數被拒絕?

新 project 預設 ci_pipeline_variables_minimum_override_role = no_one_allowed。需要時設成 maintainer,或改用 pipeline inputs。

剛建的 repo 為什麼 Maintainer 可以直接 push master?

GitLab 自動保護 default branch 時把 push 給了 Maintainers。建 repo 後要手動改成 No one。

protected tag 真的刪不掉嗎?

擋得住 git push --delete;Maintainer 從 UI / API 仍可刪。「tag 不可變」靠流程,不靠這個設定。

退版的 revert 用 API 回 400?

commits /revert API 不接受 merge commit。用 MR 頁的 Revert 按鈕,或 git revert -m 1。

SRE 自己開的退版 MR 不用第二人核?

code owner 自己開的 MR 視為已滿足 code owner 規則,退版因此很快。要求兩人才能動 production 時,另加一條 approval rule。