在 fleet-console 的服務列表上,每個服務右邊都有一顆「發版」按鈕。 點下去會跳出三個選項——修補、小改版、大改版——每個選項旁邊直接顯示算好的版本號。
從使用者的角度,這是一次點擊。從系統的角度,這次點擊會依序穿過三個 repo, 而每個 repo 都刻意不知道另外兩個在做什麼。這篇文章要拆開的就是這條路徑。
fleet-console(發版 UI):只送出「要往哪個方向加」,不碰版本號。clelereve_ops_backend(部署後端):讀 git tag 自動加一、build image、push registry、打 tag。clelereve_blog_front):在 Dockerfile 用 ARG 被動接收版本號。前端那顆按鈕背後的程式碼短得有點反高潮:
async function doRelease(bump: 'major' | 'minor' | 'patch') {
const r = await fetch(`/api/services/${releaseSvc.id}/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bump, by: me?.name }),
})
const { id } = await r.json()
setLogId(id ?? null)
}
整個 request body 只有兩個欄位:bump 說要往哪個位數加,by 記錄是誰按的。
沒有版本號。
這是刻意的。前端算不出正確的版本號——它不知道 registry 上最新的 tag 是什麼, 也不知道剛才有沒有人從別的瀏覽器發過版。如果讓前端算,兩個人同時開著頁面時就會算出同一個號碼。 版本號的唯一真相來源是 git tag,而 git tag 只有部署後端摸得到。
clelereve_ops_backend 收到 bump 之後,第一件事是去讀最新的 git tag,
解析出 x.y.z,然後照 semver 的規則加一:
const bumpVer = ([a, b, c]: [number, number, number], bump: Bump): string =>
bump === 'major' ? `${a + 1}.0.0`
: bump === 'minor' ? `${a}.${b + 1}.0`
: `${a}.${b}.${c + 1}`
注意 major 會把後兩位歸零、minor 會把最後一位歸零。
這條規則寫在後端的一行三元運算式裡,而不是散在前端的三顆按鈕上。
算出版本號之後,跑一次 docker build,把版本號當作 build argument 灌進去:
const buildArgs = [
'build', ...df,
'--build-arg', `APP_VERSION=${ver}`,
'-t', imageVer,
...(latest ? ['-t', `${spec.image}:latest`] : []),
spec.buildContext,
]
這裡有個容易被忽略的細節:--build-arg 是無條件帶的。
如果目標服務的 Dockerfile 沒有宣告 ARG APP_VERSION,Docker 只會忽略它,不會報錯。
這讓部署後端可以用同一套指令對付所有服務,不必為「這個服務要不要版本號」寫分支。
build 成功才 push,push 成功才打 tag。順序不能顛倒——先打 tag 再 build, build 失敗你就得到一個指向不存在 image 的 tag:
if (await run('docker', ['push', imageVer], onChunk) !== 0) {
onChunk?.('✗ push 失敗\n')
return { ok: false, version: tag }
}
// 4. git tag + push(都成功才打 tag)
if (await run('git', ['-C', spec.gitDir, 'tag', '-a', tag, '-m', tag], onChunk) !== 0) {
onChunk?.('✗ git tag 失敗(可能已存在)\n')
return { ok: false, version: tag }
}
第三個 repo 是被發版的對象。它要做的只有一件事:在 Dockerfile 裡宣告自己接受這個參數。
ARG APP_VERSION=dev
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
RUN npm run build
這裡的行號順序是有意義的。Next.js 的 NEXT_PUBLIC_* 是
build 時被 inline 進 bundle 的,所以 ENV 必須寫在
RUN npm run build 之前,否則 build 當下讀不到,版本號永遠是 dev。
Django 那邊就寬鬆得多,因為它是 runtime 讀環境變數:
APP_VERSION = os.environ.get("APP_VERSION", "dev")
位置不拘,甚至可以在 docker run -e APP_VERSION=1.2.3 時當場覆蓋。
同一個 --build-arg,在前後端的行為完全不同——這件事值得單獨寫一篇。
這是我自己一開始沒分清楚、後來吃過虧的地方。兩個按鈕長得很像,做的事天差地遠:
docker build → docker push → git tag。
產生一個新的 image 版本,會動到 registry 和 git。docker pull → docker rm -f → docker run -d。
拉一個已經存在的 tag 起容器,完全不碰 registry 內容。部署那條路徑的核心其實只有一行:
docker pull ${ref} && (docker rm -f ${container} || true) && \
docker run -d --name ${container} ${runArgs} ${envFlags} ${ref}
rm -f 後面掛 || true,是因為第一次部署時容器根本不存在,
rm 會回非零值把整條指令中斷掉。
分清楚之後,很多決策就自然了:改了程式碼要「發版」;只是想回滾到舊版本, 或者換個環境變數重啟,那是「重新部署」,選一個既有的 tag 就好,不需要浪費一次 build。
你當然可以把這三件事塞進一個 script。我一開始就是這樣。問題在於權責邊界會跟著消失:
現在的切法讓每個 repo 都只依賴一個很窄的介面:前端依賴
{ bump } 這個字串、目標服務依賴 ARG APP_VERSION 這個名字。
介面窄,就代表你可以單獨換掉任何一邊。
image 都 push 上去了,容器也 docker run 起來了,
docker ps 看起來一切正常。然後我打開瀏覽器,
前端完全連不到 API——而錯誤訊息說的是 CORS。
問題其實出在請求根本沒走到容器裡。下一篇談 nginx 怎麼在 Docker 網路裡找到你的容器。




