iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

https://ithelp.ithome.com.tw/upload/images/20260824/20183337Y69pLIjAsU.png

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 以前的行為不同,那一節會標。


1. 三分鐘版

① 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)

Part 1 — 快樂路徑

2. 第 1 步:Dockerfile 只做封裝

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。

2.1 基底選 node 不選 nginx

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_modulesserver.mjs 照樣跑。
成品 57.7 MB / 5 層,其中 51.9 MB 是基底,dist 只有 2.2 MB。

2.2 build context 要自己準備

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 不會告訴你原因。

3. 第 2 步:接上 buildah,先只驗建得起來

- 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] 就等於「所有關卡都過了才封裝」。

3.1 第一次跑會撞到 403

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 這兩個檔名。這個限制在下一步會變成問題。

4. 第 3 步:開推送 —— 選對憑證

拿掉 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 的話快照憑證不會過期,這整段的前提就不成立。

4.1 三個做法,選最省的那個

合規性 麻煩度
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 上了。

5. 第 4 步:建構期憑證要進、不能留在成品

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

⚠️ 兩個條件缺一不可,第二個常被漏掉:

  1. 必須改回上游位址
  2. 必須在同一個 RUN 裡改——拆成兩個 RUN 的話,帶帳密的那份會單獨成一層,
    合併後的檔案系統看不到,但 blob 裡還在。理由見 §6.1。

④ 清掉 PVC 上那份

for D in node_modules .nx .angular dist coverage docker-build-context .apkauth; do

finally,前面紅燈時也會跑。

6. 驗收:進成品裡看

不要只信設計。 起一個容器進去查:

$ 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)——變數參照,不是值。

6.1 上面三個檢查都只看得到「合併後」的樣子

檢查 實際看的是
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——
是結構在保護你,不是驗收在保護你。

6.2 逐層掃

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 層命中        ← 偵測器確實有反應

兩行都跑了,「乾淨」才是一個結果。


Part 2 — 細節探討

7. storage driver 為什麼是 vfs

容器建構要疊層檔案系統。預設是 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 的型別都一樣。

7.1 而且那個 task 的預設就是 vfs

$ 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 上看不出來;
專案大了會有差。

8. 三種憑證問題,成因完全不同

# 症狀 成因 修法
1 拉基底映像 403 pod 的 imagePullSecrets 不會傳進容器 dockerconfig workspace
2 推送撐不過一小時 SA token 只活 1 小時,快照必然過期 換 registry robot account
3 憑證留在成品裡 /etc/apk/repositories 會被烘進映像層 --secret + 改回上游位址

三個都長得像「認證失敗」,但沒有一個修法能套用到另外兩個。

共通點是:把所有來源收進一台私有 registry 之後,「誰去拉」變重要了

基底映像    由 buildah 在容器裡拉      →  imagePullSecrets 幫不上忙
推送目標    憑證有壽命                →  快照必然過期
套件來源    憑證要進建構、不能進成品    →  要三段防線

每個取用路徑各自解決認證,它們不共用同一套機制。
這就是為什麼要分階段——一次全開,這三個會混在一起。

9. 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.typeMustRunAsRange——映像裡寫的 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 的預設值。

9.1 測試方式會影響你量到的答案

oc create -f pod.yaml 直接建 pod,會拿到 uid=1001——看起來 USER 生效了。

那是假的:

  • 那顆 pod 用的 SA 可能綁著 RunAsAny 的 SCC(會尊重映像的 USER
  • 而且 SCC 准入會連建立者的權限一起算——你以 cluster-admin 建立,判斷就不同

⚠️ 要看真實行為,用 Deployment——pod 由 ReplicaSet 控制器以 SA 身分建立,
准入才會照實際部署時的權限判斷。

10. 不要為了方便建永不過期的 token

手動建立的 kubernetes.io/service-account-token 產出的 token
沒有 exp claim——永不過期

那正是 Kubernetes 這幾年一路在淘汰的東西。
為了繞過「1 小時太短」的不方便,把安全屬性整個拆掉,不划算。

而且它不會出錯、不會過期、不會提醒你它在那裡——
這種東西一旦建了就會留很久

11. Nexus 重簽 APKINDEX:公鑰檔名可以算

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 要改。 因為檔名是私鑰的函數。

12. apk upgrade 破壞可重現性

這篇同時主張了兩件不能並存的事:

tag 綁 commit SHA → 每次部署都能回溯到特定版本的原始碼

apk upgrade --no-cache → 即時抓最新的系統套件修補

同一個 commit、同一個基底 digest、同一份 Dockerfile,
不同時間建出來的映像內容不一樣。

可重現性 安全修補
只釘 digest ❌ 基底多久沒更新就落後多久
釘 digest + apk upgrade
定期更新基底 digest,不 apk upgrade ✅ 但更新頻率由人決定

回溯得到的是原始碼,不是 bit-for-bit 相同的映像。
這句話要對使用的人講清楚,不要讓他們以為 tag 綁 commit 就等於映像可重現。

13. 映像建好不等於服務可用

映像建好、推上去、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 和首屏全部失效,監控一切正常。

14. 這篇沒涵蓋的

  • 多階段建構:本文刻意不用,理由見 §2。你的產物如果不是 CI 先建好的,情況不同。
  • 映像瘦身:57.7 MB 裡 51.9 MB 是基底。要再小得換 distroless 或 static,
    那會失去 apk upgrade 這條路。
  • 簽章與掃描:封裝完之後的事。
  • B 方案的完整實作:projected token 產 config.json,本文選了 C 沒做。

15. 參考文件

buildah 與 storage driver

OpenShift ServiceAccount 憑證

Alpine 與 apk

§13 那個 CVE


上一篇
Day 23:typecheck、generate-sbom、upload-sbom、cleanup-workspace
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言