
CI 已經把程式建好了,dist/ 就在 workspace 上。剩下的事聽起來很小:包成映像、推上去。
實際上花掉最多時間的不是 buildah 的設定——是三種完全不同的憑證問題,
而它們的成因彼此無關,只是都長得像「認證失敗」。
讀完你能做到:在 OpenShift 上用非特權 buildah 把既有產物封裝成映像、
推進私有 registry,並且建構期用得到的憑證不會留在成品裡。
① 一條 CI 管道,前面已經有 task 產出建置成果(本文是 dist/apps/web)
② OpenShift + OpenShift Pipelines Operator(內建 buildah Task)
③ 一個私有 registry,叢集內可達(本文用 Nexus)
④ 應用是 Node SSR —— 這決定了基底映像的選擇,見 §2
⑤ 一個跨 task 共用的 workspace(本文叫 shared-workspace)
⑥ 若用 Tekton Triggers 自動觸發,你會有一個 TriggerTemplate —— §3.1 會用到
本文的管道長這樣(封裝是第 14 個 task,接在最右邊):
git-clone ──┬─► workspace-probe ─┐
├─► gitleaks-scan ───┤
├─► semgrep-scan ────┼─► npm-install ─┬─► typecheck ─► nx-build ──┐
├─► sca-scan ────────┘ ├─► eslint-check ───────────┼─► build-push
└─► generate-sbom ─► upload-sbom └─► unit-test ──────────────┘
finally: cleanup-workspace
後面出現的 task 名稱都是這張圖上的。 你的管道形狀不同的話,
要照應的是位置不是名字:build-push 接在「所有關卡都過了」之後,
而 git-clone 只是「那個提供 commit SHA 的 task」。
環境:OpenShift 4.21.14、Nexus 3.93.0、基底 node:22-alpine(Alpine 3.24)。
數字都是 2026-08-22 跑出來的。
§4 綁 OpenShift 版本,4.15 以前的行為不同,那一節會標。
① Dockerfile 只做封裝 → 不要重跑 build,那會繞過前面整條防線
② 先 SKIP_PUSH: "true" → 把「建得起來」和「推得上去」分開驗
③ 推送用 registry robot account → ⚠️ 不要快照 SA 的 dockercfg,它 1 小時就過期
④ 建構期憑證用 --secret 掛 → 而且要在同一個 RUN 裡改回上游位址
五個會咬人的地方,理由都在 Part 2:
imagePullSecrets 到不了 buildah 手上 |
基底映像是 buildah 在容器裡面拉的(§8) |
USER 1001 那行形同虛設 |
restricted-v2 會重新指派 UID(§9) |
| 量到的 UID 取決於你怎麼建 pod | 用 oc create -f pod.yaml 會得到錯的答案(§9.1) |
apk upgrade 破壞可重現性 |
同一個 commit,不同時間建出來不一樣(§12) |
| 驗收只看合併後的檔案系統會漏 | 被蓋掉的檔案還在 blob 裡,要逐層掃(§6.1) |
FROM nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
COPY keys/key-615c8a8f.rsa.pub /etc/apk/keys/key-615c8a8f.rsa.pub
RUN --mount=type=secret,id=apkauth \
NEXUS=nexus-nexus3.nexus-proxy.svc.cluster.local:8081 && \
V=$(cut -d. -f1,2 /etc/alpine-release) && \
AUTH=$(cat /run/secrets/apkauth) && \
printf 'http://%s@%s/repository/alpine-proxy/v%s/main\nhttp://%s@%s/repository/alpine-proxy/v%s/community\n' \
"$AUTH" "$NEXUS" "$V" "$AUTH" "$NEXUS" "$V" > /etc/apk/repositories && \
apk upgrade --no-cache && \
printf 'https://dl-cdn.alpinelinux.org/alpine/v%s/main\nhttps://dl-cdn.alpinelinux.org/alpine/v%s/community\n' \
"$V" "$V" > /etc/apk/repositories
WORKDIR /app
COPY dist/apps/web ./dist/apps/web
RUN chgrp -R 0 /app && chmod -R g=u /app
EXPOSE 4000
USER 1001
CMD ["node", "dist/apps/web/server/server.mjs"]
沒有 npm ci、沒有 build、沒有 multi-stage。
那些前面的 task 已經跑過了。在 Dockerfile 裡重做一遍不只浪費,
還會繞過 typecheck、單元測試、所有掃描那整條防線——那些關卡的意義就是
「先確認沒問題才建構」。在 Dockerfile 裡重建,等於從沒被檢查過的原始碼再產一次。
三件事值得說明:
COPY keys/...:如果你的套件來源是自架 proxy 而它重簽了索引,客戶端要有對應公鑰。檔名不能自己取,但可以算——見 §11。RUN --mount=type=secret:建構期憑證,不落層。見 §5。chgrp 0 + chmod g=u:真正讓映像在 OpenShift 跑得起來的是這一行,不是 USER。見 §9。SSR 應用的產出分兩塊:
dist/apps/web/
├── browser/ 292K ← 靜態
└── server/ 1.8M ← SSR
用 nginx 等於把 server/ 丟掉。而且 SSR server 自己就在服務 browser/:
app.use(express.static(browserDistFolder, { maxAge: '1y', ... }));
app.use('/**', (req, res, next) => angularApp.handle(req)...);
再擺一個 nginx 是重複的。
實測 SSR bundle 是自包的——映像裡沒有 node_modules,server.mjs 照樣跑。
成品 57.7 MB / 5 層,其中 51.9 MB 是基底,dist 只有 2.2 MB。
buildah 要一個乾淨的 context。這件事併進前一個 build task 當第二個 step 就好,
不必拆獨立 Task——它只是複製檔案,拆出去只是多一顆 pod 的啟動時間。
test -f Dockerfile || { echo "找不到 Dockerfile —— 是不是還沒 commit?"; exit 1; }
CTX=docker-build-context
rm -rf "$CTX"
mkdir -p "$CTX/dist/apps/web"
cp Dockerfile "$CTX/"
cp -r dist/apps/web/. "$CTX/dist/apps/web/"
cp -r keys "$CTX/"
第一行防呆是必要的。 CI 只讀 commit 過的版本——本機新建但沒 push 的檔案,
在 clone 下來的副本裡根本不存在,而 No such file or directory 不會告訴你原因。
- name: build-push
runAfter: [nx-build, eslint-check, unit-test]
taskRef:
resolver: cluster
params:
- { name: kind, value: task }
- { name: name, value: buildah }
- { name: namespace, value: openshift-pipelines }
params:
- { name: IMAGE, value: "<registry>/docker-hosted/web:$(tasks.git-clone.results.COMMIT)" }
- { name: DOCKERFILE, value: docker-build-context/Dockerfile }
- { name: CONTEXT, value: docker-build-context }
- { name: STORAGE_DRIVER, value: vfs }
- { name: TLS_VERIFY, value: "false" }
- { name: SKIP_PUSH, value: "true" } # ← 這一步只驗建構
workspaces:
- { name: source, workspace: shared-workspace }
- { name: dockerconfig, workspace: dockerconfig }
⚠️ SKIP_PUSH 這一步不要省。 一次到位的話,拿到的錯誤分不出是
storage driver、拉基底映像、還是推送認證——三件事的錯誤訊息都像「認證失敗」。
runAfter 只列直接上游,不要列一長串。
對照前提那張圖:四個掃描已經是 npm-install 的上游,npm-install 又是那三個的上游——再列一次只會讓 DAG 難讀。
寫成 runAfter: [nx-build, eslint-check, unit-test] 就等於「所有關卡都過了才封裝」。
STEP 1/9: FROM <registry>/docker-proxy/library/node:22-alpine
Error: creating build container: unable to copy from source docker://...:
Requesting bearer token: received unexpected HTTP status: 403 Forbidden
它已經走到 STEP 1/9——storage 初始化過了,掛在拉基底映像。
Pod 明明有 imagePullSecrets,為什麼還 403?
imagePullSecrets是給 kubelet 拉 task 自己的映像用的。
buildah 在容器裡面跑,自己發 HTTP 去拉基底映像,那份憑證不會傳進去。
修法是 buildah task 的 optional workspace:
spec:
workspaces:
- name: dockerconfig # PipelineRun 綁 registry 憑證 secret
TriggerTemplate 也要改。 webhook 建的 PipelineRun 得綁同一個 workspace,
否則手動跑得過、自動觸發的一樣 403。
它只吃
config.json或.dockerconfigjson這兩個檔名。這個限制在下一步會變成問題。
拿掉 SKIP_PUSH。如果你推的是 OpenShift 內部 registry,會遇到這個:
第一次推 成功
25 分鐘後 Error: unable to retrieve auth token: invalid username/password
⚠️ 不要把 ServiceAccount 的 dockercfg 快照複製到別的 secret 裡。
SA 現在的 token = 01d0a7130eb4224e
你烘進去的 = 2fc648bea71557e4 ← 不一樣了
token claims:
iat = 2026-08-22T09:36:27Z
exp = 2026-08-22T10:36:27Z ← 1 小時
那個 token 只活 1 小時,而且 secret 本身會被重新產生。
複製它的快照本質上就是壞的——第一次能推只是因為還沒過期。
這一段綁版本。 imageRegistryAuthTokenType 在 4.15 只能是 Legacy(不過期),
4.16 起預設 Bound。停在 4.14 / 4.15 的話快照憑證不會過期,這整段的前提就不成立。
| 合規性 | 麻煩度 | |
|---|---|---|
| A. 手動建長效 SA token | ❌ | 低 |
B. 執行期從 pod 的 projected token 產 config.json |
✅ 最正統 | 高 |
| C. 推到私有 registry,用 registry robot account | ✅ | 最低 |
A 不要做,理由見 §10。
B 是教科書答案,但 cluster 的 buildah task 加不了 step——config.json 只能由前一個 task 產出、透過 workspace 傳過去,而跨 task 的 workspace 就得走 PVC。
結果是為了避免憑證進 secret,反而把它寫到每個 task 都掛著的共用 PVC 上。
安全性沒有比較好,複雜度是實打實的。
環境裡沒有其他 registry、非推內部不可的話,B 就是該做的。
C:如果你的 registry 本來就有一個機器帳號,讓它同時涵蓋拉和推:
nx-repository-view-docker-docker-proxy-browse ← 拉基底映像
nx-repository-view-docker-docker-proxy-read
nx-repository-view-docker-docker-hosted-add ← 推成品
nx-repository-view-docker-docker-hosted-edit
一份憑證涵蓋兩邊,因為兩者在同一台 registry。
專用帳號、最小權限、由 registry 管理輪替、不涉及 Kubernetes 身分。
- IMAGE: image-registry.openshift-image-registry.svc:5000/ci/web:$(commit)
+ IMAGE: <registry>/docker-hosted/web:$(commit)
代價:映像不在內部 registry,部署時 pod 要 imagePullSecret——但那份憑證本來就掛在 SA 上了。
apk upgrade 要抓系統套件修補。如果你的套件來源需要認證,apk 吃的是 URL 裡的 basic auth:
http://<user>:<pass>@nexus.../repository/alpine-proxy/v3.24/main
而那行會被寫進 /etc/apk/repositories,那個檔案會被烘進映像層。
四段防線,缺一不可:
① 憑證不放進 build context
把 .apkauth 寫在 workspace 根目錄,不在 docker-build-context/ 裡。
Dockerfile 裡任何 COPY 都碰不到。
② buildah 的 secret mount
- { name: BUILD_EXTRA_ARGS, value: "--secret=id=apkauth,src=/workspace/source/.apkauth" }
RUN --mount=type=secret,id=apkauth \
AUTH=$(cat /run/secrets/apkauth) && ...
只在那個 RUN 期間存在於 /run/secrets/,不寫進任何一層。
③ 最後把設定檔改回上游位址——而且要在同一個 RUN 裡
⚠️ 兩個條件缺一不可,第二個常被漏掉:
RUN 裡改——拆成兩個 RUN 的話,帶帳密的那份會單獨成一層,④ 清掉 PVC 上那份
for D in node_modules .nx .angular dist coverage docker-build-context .apkauth; do
放 finally,前面紅燈時也會跑。
不要只信設計。 起一個容器進去查:
$ cat /etc/apk/repositories
https://dl-cdn.alpinelinux.org/alpine/v3.24/main
https://dl-cdn.alpinelinux.org/alpine/v3.24/community
$ grep -rl "<你的帳號名>" / --exclude-dir=proc --exclude-dir=sys --exclude-dir=run
(命中檔案數 = 0)
$ apk version apk-tools
apk-tools-3.0.7-r0 = 3.0.7-r0 ← upgrade 確實進了映像層
build history 也要查。 docker history 會顯示每層的指令原文:
history 裡出現 <帳號名> = 0 次
history 裡出現 @<registry> = 0 次
RUN 那層記的是 AUTH=$(cat /run/secrets/apkauth)——變數參照,不是值。
| 檢查 | 實際看的是 |
|---|---|
cat /etc/apk/repositories |
合併後的檔案系統 |
在跑起來的容器裡 grep -rl |
合併後的檔案系統 |
docker history |
指令原文,不是層內容 |
被後面的層覆蓋掉的檔案,這三個都看不到——但它還在 blob 裡。
任何人 pull 下來解開就拿得到。
假設有人把 §5 那個 RUN 拆開——很自然的重構,為了可讀性、
或中間要插一段別的:
RUN --mount=type=secret,id=apkauth ... > /etc/apk/repositories && apk upgrade --no-cache
RUN printf 'https://dl-cdn.alpinelinux.org/...' > /etc/apk/repositories # ← 第二個 RUN
第一層躺著帶帳密的 /etc/apk/repositories,第二層把它蓋掉。然後:
cat /etc/apk/repositories → 乾淨的上游位址 ✅
grep -rl "<帳號名>" / → 0 ✅
docker history → 0 次 ✅
⚠️ 三個檢查全過,憑證照樣發佈出去了。
§2 那份 Dockerfile 是安全的,但那是因為整段在同一個 RUN 裡——
是結構在保護你,不是驗收在保護你。
skopeo copy --src-tls-verify=false \
docker://<registry>/docker-hosted/web:<tag> dir:./img
for f in img/*; do
gzip -dc "$f" 2>/dev/null | grep -qa "<帳號名>" && echo "LEAK in $f"
done
echo "掃完"
⚠️ 一定要配一個正面對照。「全部乾淨」跟「掃描器沒在動」長得一模一樣。
拿一個你確定在映像裡的字串再掃一次,確認它會命中:
for f in img/*; do
gzip -dc "$f" 2>/dev/null | grep -qa "dl-cdn.alpinelinux.org" && echo "命中 $f"
done
實測(本文這份映像,5 層):
憑證字串 5 層全部 0 命中 ✅
已知字串 5 層中的 4 層命中 ← 偵測器確實有反應
兩行都跑了,「乾淨」才是一個結果。
容器建構要疊層檔案系統。預設是 overlay,在一般 Linux 主機上沒問題。
但 buildah 是跑在一個容器裡,兩件事同時成立:
| 實測 | |
|---|---|
掛載 overlay 需要 CAP_SYS_ADMIN,這個容器沒有 |
CapEff = 0x800005fb —— CHOWN / DAC_OVERRIDE / FOWNER / FSETID / KILL / SETGID / SETUID / SETPCAP / NET_BIND_SERVICE / SETFCAP,沒有 SYS_ADMIN |
| overlay 疊在 overlay 上不支援 | 容器 rootfs 本身就是 overlay:lowerdir=/var/lib/containers/storage/overlay/l/... |
vfs 不需要任何掛載權限,代價是每層完整複製、不做共用。
這跟「跑在虛擬機裡」無關。 裸機的單節點 OpenShift 一樣會撞到——
SCC 給的 capability 集合和容器 rootfs 的型別都一樣。
$ oc get task buildah -n openshift-pipelines -o json \
| jq '.spec.params[] | select(.name=="STORAGE_DRIVER")'
default = "vfs"
所以寫 STORAGE_DRIVER: vfs 是宣告意圖,不是修復。
照樣明寫,因為換成自己包的 Task 或別家的 buildah 封裝,預設不保證一樣。
實測 22~26 秒建完 9 個 STEP。vfs 慢的代價在 2.2 MB 的 context 上看不出來;
專案大了會有差。
| # | 症狀 | 成因 | 修法 |
|---|---|---|---|
| 1 | 拉基底映像 403 | pod 的 imagePullSecrets 不會傳進容器 |
dockerconfig workspace |
| 2 | 推送撐不過一小時 | SA token 只活 1 小時,快照必然過期 | 換 registry robot account |
| 3 | 憑證留在成品裡 | /etc/apk/repositories 會被烘進映像層 |
--secret + 改回上游位址 |
三個都長得像「認證失敗」,但沒有一個修法能套用到另外兩個。
共通點是:把所有來源收進一台私有 registry 之後,「誰去拉」變重要了。
基底映像 由 buildah 在容器裡拉 → imagePullSecrets 幫不上忙
推送目標 憑證有壽命 → 快照必然過期
套件來源 憑證要進建構、不能進成品 → 要三段防線
每個取用路徑各自解決認證,它們不共用同一套機制。
這就是為什麼要分階段——一次全開,這三個會混在一起。
USER 1001 其實沒用,chgrp 0 才是關鍵OpenShift 的 restricted-v2 SCC 會重新指派 UID:
$ oc get ns ci -o jsonpath='{.metadata.annotations}'
openshift.io/sa.scc.uid-range: 1000860000/10000
runAsUser.type 是 MustRunAsRange——映像裡寫的 USER 1001 會被無視,
換成範圍的最小值。但那個 UID 一定屬於 group 0。
所以:把 group 設成 0、group 權限對齊 owner,不管拿到哪個 UID 都讀得到。
不必為了「不知道會拿到哪個」而放寬成 chmod 777。
$ oc get pod -o jsonpath='{.spec.containers[0].securityContext}'
{"runAsNonRoot":true,"runAsUser":1000860000, ...} ← 不是 1001
$ oc exec <pod> -- id
uid=1000860000 gid=0(root) groups=0(root),1000860000
USER 1001 那一行留著只是給非 OpenShift 環境一個非 root 的預設值。
用 oc create -f pod.yaml 直接建 pod,會拿到 uid=1001——看起來 USER 生效了。
那是假的:
RunAsAny 的 SCC(會尊重映像的 USER)⚠️ 要看真實行為,用 Deployment——pod 由 ReplicaSet 控制器以 SA 身分建立,
准入才會照實際部署時的權限判斷。
手動建立的 kubernetes.io/service-account-token 產出的 token
沒有 exp claim——永不過期。
那正是 Kubernetes 這幾年一路在淘汰的東西。
為了繞過「1 小時太短」的不方便,把安全屬性整個拆掉,不划算。
而且它不會出錯、不會過期、不會提醒你它在那裡——
這種東西一旦建了就會留很久。
alpine這個 repo 格式是 Nexus 3.93 才加入的(2026-07)。舊版做法完全不同。
Nexus 的 alpine proxy 不直通上游簽章——建 repo 時簽章金鑰是必填,
它會用那把金鑰重簽索引。所以客戶端要有對應的公鑰,否則:
WARNING: ... APKINDEX.tar.gz: UNTRUSTED signature
檔名不能自己取,但可以算。 規則是 key-<hash>.rsa.pub,<hash> 是私鑰 base64 body(去掉 header/footer 與換行)的 SHA-256,
取十六進位摘要的第 56~63 個字元:
const body = pem.split(/\r?\n/).filter(l => l && !l.startsWith("-----")).join("");
const hash = crypto.createHash("sha256").update(body, "utf8").digest("hex").substring(56, 64);
// → key-<hash>.rsa.pub
實測吻合:
SHA-256 = b1ea86ba6e90755f7add8b2b3b8b834f435253d1e58e9183c7ff1df9615c8a8f
chars 56..63 = 615c8a8f
Nexus 用的 = key-615c8a8f.rsa.pub ★
所以建 repo 之前就能算出檔名,不必等 repo 建好再去解 APKINDEX 反查。
換行會影響結果。 金鑰檔在 Windows 上是 CRLF,切行時只當成 LF 處理的話,
每行結尾的 CR 會留在字串裡、一起算進雜湊,算出來的檔名就是錯的。
換簽章金鑰 → 檔名跟著變 → Dockerfile 要改。 因為檔名是私鑰的函數。
apk upgrade 破壞可重現性這篇同時主張了兩件不能並存的事:
tag 綁 commit SHA → 每次部署都能回溯到特定版本的原始碼
apk upgrade --no-cache→ 即時抓最新的系統套件修補
同一個 commit、同一個基底 digest、同一份 Dockerfile,
不同時間建出來的映像內容不一樣。
| 可重現性 | 安全修補 | |
|---|---|---|
| 只釘 digest | ✅ | ❌ 基底多久沒更新就落後多久 |
釘 digest + apk upgrade |
❌ | ✅ |
定期更新基底 digest,不 apk upgrade |
✅ | ✅ 但更新頻率由人決定 |
回溯得到的是原始碼,不是 bit-for-bit 相同的映像。
這句話要對使用的人講清楚,不要讓他們以為 tag 綁 commit 就等於映像可重現。
映像建好、推上去、pod 起來、log 也正常:
Node Express server listening on http://localhost:4000
但每一個請求都 400:
ERROR: Bad Request ("http://localhost:4000/").
Header "host" with value "localhost:4000" is not allowed.
這是 Angular SSR 的 host 驗證(CVE-2026-27739 的修補,回溯進 19 / 20 / 21 三條線)。
修法是在 build options 設 security.allowedHosts。
但重點不是這個 CVE。 重點是:
build 綠、typecheck 綠、單元測試綠、buildah 綠、push 綠——
錯誤只出現在容器起來之後的 server log 裡。
⚠️ 沒有任何一道 CI 關卡會擋這種東西。 封裝這一步的驗收,
不能停在「映像建出來了」,要停在「用它起一顆 pod,打得到內容」。
更陰的是:在某些版本上,未設定不會 400,而是悄悄降級成客戶端渲染,
只在 server log 留一行警告。頁面照樣看得到,SEO 和首屏全部失效,監控一切正常。
apk upgrade 這條路。config.json,本文選了 C 沒做。STORAGE_DRIVER 預設就是 vfs
'overlay' is not supported over overlayfs
imageRegistryAuthTokenType 4.15 只能 Legacy,4.16 起預設 Bound
/etc/apk/repositories 的 URL 格式、公鑰要放 /etc/apk/keys/
alpine 格式加入的那一版security.allowedHosts 的設定路徑與比對規則