
一口氣多了不少TASK(汗)
環境:單節點 CRC 4.21.14 / Nx 23.1.1 / TypeScript 6.0.3 / Angular 22 / 只有
apps/web一個專案。
| Task | 做的事 | 為什麼要有 |
|---|---|---|
typecheck |
對 tsconfig.app.json 和 tsconfig.spec.json 各跑一次 tsc --noEmit |
測試檔的型別沒有任何關卡在看。 build 不編 spec、jest 走 transpile、lint 不管型別 |
generate-sbom |
用 cyclonedx-npm 讀 package-lock.json,產出一份 CycloneDX JSON |
記下這次建構到底裝了哪 1257 個套件。將來某個套件爆 CVE 時要靠它盤點 |
upload-sbom |
把那份 JSON PUT 到 Nexus 的 sbom-raw repo |
留在 workspace 裡的檔案下一輪就被刪了。沒存出去的 SBOM 等於沒產出 |
cleanup-workspace |
刪掉共用 PVC 上的 node_modules / .nx / .angular / dist / coverage |
這個不在原本計畫裡,是被逼出來的(第五節) |
原本的 npm-build 拆成 npm-install + nx-build,typecheck 插在中間。DAG 從 8 個 Task 變成 12 個 + 1 個 finally。
| 新 Task | 掛在哪 | 耗時 | 選這個位置的理由 |
|---|---|---|---|
typecheck |
npm-install 之後、nx-build 之前 |
11s | 先擋型別錯,nx-build 就不必為注定失敗的程式碼跑一次 |
generate-sbom |
git-clone 之後 |
18s | 不需要 node_modules,跟四個掃描並行 |
upload-sbom |
generate-sbom 之後 |
7s | 緊接產出 |
cleanup-workspace |
finally |
11s | 前面紅燈時也要跑 |
critical path 固定變長 11 秒。換到的是:型別錯的那次,nx-build 完全不用起跑。 這台觀測到 nx build 落在 21~488 秒之間。(註:直接把CRC記憶體調高nx-build就沒事了,記憶體不足的情況下npm install 會讓系統部分功能被砍掉)
git-clone ──┬─► workspace-probe ─┐
├─► gitleaks-scan ───┤
├─► semgrep-scan ────┤
├─► sca-scan ────────┼─► npm-install ─► typecheck ─► nx-build ─┬─► eslint-check
│ │ └─► unit-test
└─► generate-sbom ───┘
│
└────────────────────────────────────────────────► upload-sbom
finally: cleanup-workspace
git-clone 10s
gitleaks-scan 7s ─┐
sca-scan 12s │
semgrep-scan 16s ├ 並行
generate-sbom 18s │
workspace-probe 48s ─┘
upload-sbom 7s
npm-install 38s
typecheck 11s ← 卡在 critical path
nx-build 488s
eslint-check 85s ─┐ 並行
unit-test 89s ─┘
cleanup-workspace 11s (finally)
整條 11m41s
nx-build 一個人佔掉七成。三個新 Task(typecheck + generate-sbom + upload-sbom)加起來 36 秒,cleanup-workspace 再 11 秒。
前提是這台的資源狀況:
memory requests 10181Mi / 11781652Ki (88%)
餘裕約 1.5 GiB。跑一顆量測 Pod 就會讓 API server 出現 TLS handshake timeout,寫這篇的過程中發生了六次以上。
| 工作 | 耗時 | 峰值 RSS |
|---|---|---|
typecheck |
4s(tsc 本身) | 213 MB |
generate-sbom |
4~5s | 213 MB |
unit-test |
7~21s | 1441~1722 MB |
nx build |
21~488s | 2025 MB |
| Node 預設 heap 上限 | 2096 MB |
四個決定:
typecheck 放在 nx-build 前面。 這需要把原本的 npm-build(npm ci + nx build 兩件事塞在一個 step)拆成 npm-install 和 nx-build。拆完 npm-install 是 38 秒,跟拆之前的 npm ci 基準(31~32s)差不多,沒有付出額外的安裝成本。
typecheck 不當成 nx-build 的 step。 nx build 峰值 2025 MB 頂在 2096 MB 的上限上。Kubernetes 算 Pod 的 requests/limits 是把所有 container 加總的,不管 Tekton 其實是循序執行 step——多塞一個 step 進去就是把整顆 pod 的預算撐大。拆成獨立 Task,兩邊各自 213 MB。
generate-sbom 不排在 npm-install 後面。 它只讀 package-lock.json,實測在沒有 node_modules 的乾淨 clone 上一樣產出 1257 components。掛在 git-clone 後跟四個掃描並行,那段本來就在等最慢的掃描。
三個新 Task 都不設 memory request。 requests 已經 88%,設了排不進去會直接 Pending。不設反而是它跑得動的原因。
| 之前(typecheck 並行) | 之後(typecheck 串接) | |
|---|---|---|
| 固定成本 | 0 | +11 秒(含 pod 啟動,不只 tsc 的 4 秒) |
| 型別錯時省下 | 0 | 整段 nx-build,21~488 秒 |
| 型別錯時看得到什麼 | 四道關卡的結果都有 | 只有型別這一個,nx-build/eslint-check/unit-test 全被跳過 |
最後一列是真的損失。看第六節那次紅燈:同一份 log 裡看得到 unit-test 是綠的(4 passed)、nx-build 也過了,只有 typecheck 紅。「測試全過但型別是壞的」這個對照,是靠並行才看得到的。改成串接之後,那次 run 只會顯示 typecheck 紅、後面 skipped。
拿診斷資訊換速度。 在 nx build 要跑 4~8 分鐘的機器上,這個交換划算。要是你的 build 只有 30 秒,就不划算,維持並行比較好。
typecheck同一個型別錯誤放在不同位置,五道檢查各自的反應(修好 tsconfig.spec.json 之後量的):
| 錯誤在哪 | app.json |
spec.json |
nx build |
nx test |
nx lint |
|---|---|---|---|---|---|
app.ts(被 spec import) |
抓到 | 抓到 | 抓到 | 漏掉 | 漏掉 |
app.routes.ts(沒被 spec import) |
抓到 | 漏掉 | 抓到 | — | — |
| 孤兒檔(沒人 import) | 抓到 | 漏掉 | 抓到 | — | — |
*.spec.ts |
漏掉 | 抓到 | 漏掉 | 漏掉 | 漏掉 |
孤兒檔在這台抓得到,因為 tsconfig.app.json 的 include 是 glob:
"include": ["src/**/*.ts"]
不靠 import 鏈,src 底下所有 .ts 都納入。所以「沒被引用的死碼會漏掉」這個常見說法,在這種寫法下不成立。
看第 1~3 列:app.json 抓得到的,nx build 全都抓得到。 涵蓋範圍上那半是重複的。
但重複不等於沒用——重排之後,app.json 那半的價值從「多防一點」變成「早一點」:app.ts 的型別錯在第 11 秒被攔下,nx-build 那 488 秒完全不用起跑。附帶好處是錯誤訊息乾淨很多,typecheck 的 log 只有 252 bytes,npm-install 的有 5683 bytes(npm ci 的輸出混在裡面)。
看第 4 列:spec 檔的型別,五道裡只有 spec.json 看得見。 tsconfig.app.json 明確 exclude 掉 *.spec.ts,nx build 走的也是 app 這條線,jest-preset-angular 走 transpile 不做完整型別檢查。
spec.json 實際編 6 個檔案,其中只有 3 個是別人不看的:
| 檔案 | 怎麼進來的 | 別人有在看嗎 |
|---|---|---|
src/test-setup.ts |
files |
沒有 |
src/app/app.spec.ts |
include |
沒有 |
src/app/format-utils.spec.ts |
include |
沒有 |
src/app/app.ts |
被 spec import | nx build 也看 |
src/app/nx-welcome.ts |
被 spec import | nx build 也看 |
src/app/format-utils.ts |
被 spec import | nx build 也看 |
那 3 個檔案就是這道關卡唯一獨佔的守備範圍。聽起來很少,但測試檔正是 mock 和 fixture 住的地方——一份沒被型別檢查的測試,驗的是你手寫的那個形狀,不是產品程式碼真正的形狀。
順帶一提,
spec.json的include裡有一項"jest.config.ts"是死的——這個 repo 的檔名是jest.config.cts,一個檔案都沒 match 到。
steps:
- name: typecheck
image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
test -d node_modules || { echo "沒有 node_modules,npm-install 沒跑過"; exit 1; }
TSC=node_modules/.bin/tsc
echo "tsc $($TSC --version)"
FAILED=0
for TSCONFIG in \
$(params.APP_DIR)/tsconfig.app.json \
$(params.APP_DIR)/tsconfig.spec.json; do
echo "--- $TSCONFIG"
if $TSC --noEmit -p "$TSCONFIG"; then echo "OK"; else echo "FAILED"; FAILED=1; fi
done
[ $FAILED -eq 0 ] || { echo "TypeScript type check failed"; exit 1; }
echo "All type checks passed"
三個細節:
node_modules/.bin/tsc 精確路徑。 alpine 容器裡 tsc 不在 $PATH,npx tsc 的解析行為也不保證一致。FAILED=1 累積。 兩個 tsconfig 跑完再統一失敗,一次看到全貌。tsconfig.json。
apps/web/tsconfig.json → 檢查了 0 個檔案
apps/web/tsconfig.app.json → 10 個
apps/web/tsconfig.spec.json → 6 個
tsconfig.json 是 solution-style(files: []、include: []、只有 references)。tsc --noEmit -p 它會 exit 0,但什麼都沒檢查。跟 Day 22 那幾個陷阱同一族。
typecheck 寫好,還沒接進 DAG 就先在本機跑了一次:
apps/web/tsconfig.spec.json(11,25): error TS5107: Option 'moduleResolution=node10' is
deprecated and will stop functioning in TypeScript 7.0.
照它建議加 "ignoreDeprecations": "6.0",換成兩個新錯:
app.spec.ts(1,25): error TS2307: Cannot find module '@angular/core/testing'
test-setup.ts(4,3): error TS2353: 'errorOnUnknownElements' does not exist in type 'SetupOptions'
第二個很容易讓人以為 Angular 22 拿掉了那個 API。翻 .d.ts:
node_modules/@angular/core/types/testing.d.ts:330: errorOnUnknownElements?: boolean;
它好好的在那裡。TS2353 是 node10 解析不到正確 .d.ts 的症狀,跟 TS2307 同一個根因。
實測六種改法,只有兩種過:moduleResolution: "bundler",或整行拿掉繼承 tsconfig.base.json(它本來就是 bundler)。node16 / nodenext 還要一起改 module。
// apps/web/tsconfig.spec.json
- "moduleResolution": "node10"
+ "moduleResolution": "bundler"
改完 nx test / nx build / nx lint 都不受影響。
這個設定檔從專案建立那天起就沒被任何關卡碰過。 build 不看 spec 這條線、jest 走 transpile、lint 不管型別。所以它壞了多久沒人知道。
導入新關卡要把這段時間算進預算,寫完 YAML 不是結束。
generate-sbom + upload-sbom--package-lock-only 只讀 package-lock.json。實測:
有 node_modules: 1257 components,3 秒
沒有 node_modules: 1257 components,4 秒
一樣。所以掛在 git-clone 後面,跟掃描並行,對 critical path 的貢獻是 0。
① 鎖版本
npm install -g "@cyclonedx/cyclonedx-npm@$(params.CYCLONEDX_VERSION)" --prefer-offline --quiet
不鎖的話同一份 YAML 在不同時間裝到不同版本。SBOM 的產生器版本本身就是供應鏈資訊。
② 用 Tekton result 傳檔名
常見寫法是兩個 Task 各拼一次 sbom-$(git-revision).json。改了一邊沒改另一邊,Tekton 不會報錯,只會在執行期走進「檔案不存在」那條分支。
results:
- name: sbom-file
- name: component-count
- name: upload-sbom
params:
- name: SBOM_FILE
value: $(tasks.generate-sbom.results.sbom-file)
實跑的 result:
component-count "1257"
sbom-file sbom-7d9158a.json
component 數順便當 result,PipelineRun 上直接看得到。
③ 找不到檔案 exit 1,不是 exit 0
常見寫法是警告後放行。那條分支的語意不對稱:
| 情況 | 行為 | Pipeline |
|---|---|---|
| 找不到 SBOM | exit 0 |
全綠 |
| 上傳失敗 | exit 1 |
擋下 |
SBOM 缺一份、Pipeline 全綠,要等到真的要盤點某個 CVE 影響範圍時才會發現那次建構的成分表從來沒存在過。
--ignore-npm-errors很多教學說這個旗標是必要的,因為 cyclonedx-npm 內部的 npm ls 會因套件版本衝突中斷(exit 254)。
這台沒有那個問題:
$ npm ls --json --long --all --package-lock-only
exit=0,stderr 空的
留著是為了將來 npm ls 一有抱怨時不要整個 Task 掛掉。註解要寫實話,不要假裝踩過沒踩過的坑。
原本只有 Day 20 建的 semgrep-rules。不要沿用,保存語意相反:
| Semgrep 規則快照 | 每天被覆蓋成最新版 |
| SBOM | 每次建構各留一份,只增不減 |
照 Day 20 的形態另開一組,權限只涵蓋 sbom-raw:
repo sbom-raw raw hosted
role sbom-raw-writer browse / read / add / edit
user sbom-writer
secret sbom-writer-credentials(ci namespace)
上傳用 node:22-alpine 的內建 fetch,不另外拉 curl 映像。那個映像全線都在用、已經在節點上了。
cleanup-workspace這個不在原本的計畫裡,是被逼出來的。
本節的數字量於拆 Task 之前,那時
npm ci和nx build還合在一個叫npm-build的 Task 裡。
對應到現在就是npm-install+nx-build兩個加起來。
有一次 webhook 觸發的 run 掛了,REASON 是 PipelineRunTimeout,unit-test 是 TaskRunCancelled。
我第一眼的判斷是「git-clone 花了 9m46s」——那是錯的。oc get taskrun 的 STARTTIME 和 COMPLETIONTIME 兩欄都是「距今多久」,要相減才是耗時。
用時間戳重算:
npm-build 455s Succeeded ← 真正吃掉時間的
unit-test 71s TaskRunCancelled
eslint-check 70s TaskRunCancelled
git-clone 25s Succeeded ← 一點都不慢
npm-build 一個人吃掉 455 秒,10 分鐘的 timeouts.pipeline 見底,後面兩個被連坐取消。
TaskRunCancelled不代表那道關卡擋了東西,代表它根本沒跑完。StepFailed才是真的失敗。
785 MB / 53067 個檔案(node_modules 佔 774.9 MB)
git-clone 的清理邏輯(從 Task 的 base64 解出來):
clean_dir() {
rm -rfv ${_dir:?}/*
rm -rfv ${_dir}/.[!.]*
rm -rfv ${_dir}/..?*
}
...
clean_dir ${checkout_dir} || true
-v 把刪掉的每個 path 印一行。實測 git-clone 的 pod log 5.2 MB / 60594 行(對照:npm-build 的 log 只有 5.7 KB)。
|| true 把清理失敗吞掉,腳本繼續走到 git-init,然後噴出 cannot copy ... pre-push.sample: File exists。看起來像 git 壞了,實際是上一步沒清乾淨。
finally:
- name: cleanup-workspace
taskRef:
kind: Task
name: cleanup-workspace
workspaces:
- name: source
workspace: shared-workspace
#!/bin/sh
# 刻意不用 set -e:清理失敗不該把整條 pipeline 變紅
echo "=== 清理前 ==="
du -sh . ; echo "檔案數 = $(find . -type f | wc -l)"
for D in node_modules .nx .angular dist coverage; do
[ -e "$D" ] || continue
SIZE=$(du -sh "$D" | cut -f1)
rm -rf "$D" && echo "已刪除 $D($SIZE)" || echo "刪除 $D 失敗"
done
echo "=== 清理後 ==="
du -sh . ; echo "檔案數 = $(find . -type f | wc -l)"
exit 0
放 finally 不放 tasks。 runAfter 在前面紅燈時不會跑,而 workspace 最需要被清的時候正好就是失敗那次(5.1 那次就是這樣留下 787 MB 的)。
不用 rm -rfv。 -v 正是 git-clone 那 5.2 MB log 的來源。這裡只印摘要。
⚠️ 舊版 Tekton 有個 bug:pipeline timeout 時
finally會被直接跳過,v1.13.0 才修成PipelineRunTimeoutRunningFinally。
5.1 那次正好就是 timeout。我沒有查 CRC 4.21.14 帶的 OpenShift Pipelines 是哪一版,你自己的環境值得確認一次。
同一顆共用 PVC 連跑兩輪(run A 開始時是髒的,run B 開始時是乾淨的):
| run A | run B | |
|---|---|---|
git-clone 的 pod log |
5448035 bytes | 6705 bytes |
| 刪掉的 path 數 | 60594 | 89 |
git-clone 耗時 |
25s | 9s |
npm-build 耗時 |
365s | 229s |
| 整條 | 8m05s | 5m40s |
cleanup-workspace 的 log:
=== 清理前 ===
787.5M .
檔案數 = 53068
已刪除 node_modules(774.9M)
已刪除 .nx(4.4M)
已刪除 .angular(1.9M)
已刪除 dist(2.2M)
=== 清理後 ===
4.1M .
檔案數 = 68
這裡沒有多花的成本。 git-clone 的 DELETE_EXISTING 預設就是 true,本來每次就會把 node_modules 刪光,npm ci 本來就每次重裝。這只是把刪除從「下一輪的開頭」搬到「這一輪的結尾」。
git-clone 那兩欄是確定的:60594 → 89 個 path 不受叢集負載影響。
npm-build 的 365s → 229s 只有各一次樣本。這台的 npm-build 本來就在 253~455s 之間跳,229s 雖然低於先前所有觀測值,但單一樣本不足以宣稱 −37%。方向對,倍數不對。
229s 也仍然遠高於全新 volumeClaimTemplate PVC 的 61s。清理有幫助,沒有把差距補平。
$ oc get taskrun -n ci -l tekton.dev/pipelineRun=d23-order
git-clone / workspace-probe / gitleaks-scan / semgrep-scan / sca-scan
generate-sbom / upload-sbom / npm-install / typecheck / nx-build / eslint-check / unit-test
+ finally: cleanup-workspace
→ 全部 Succeeded,逐項耗時見第一節
上傳 sbom-7d9158a.json → .../repository/sbom-raw/web/sbom-7d9158a.json
HTTP 201
Nexus 上:/web/sbom-7d9158a.json 2752214 bytes。
在 spec 檔放一個執行期完全正常、只有型別錯的測試:
it('型別錯誤,但測試會過', () => {
const result: number = formatDisplayName('ada'); // 回傳 string
expect(result).toBe('ADA');
});
push 之後 webhook 自動觸發:
Tasks Completed: 11 (Failed: 1, Cancelled 0), Skipped: 0
npm-build True Succeeded
eslint-check True Succeeded
unit-test True Succeeded
typecheck False StepFailed ← 只有它
這次紅燈量於重排之前,所以四道關卡的結果都看得到。
改成typecheck → nx-build串接之後,同樣的錯誤只會顯示 typecheck 紅、nx-build/eslint-check/unit-test全部 skipped——這就是第一節「重排的代價」那一列講的損失。
typecheck:
--- apps/web/tsconfig.app.json
OK
--- apps/web/tsconfig.spec.json
format-utils.spec.ts(18,11): error TS2322: Type 'string' is not assignable to type 'number'.
FAILED
同一次 run 的 unit-test:
Test Suites: 2 passed, 2 total
Tests: 4 passed, 4 total
NX Successfully ran target test for project web
測試全過、build 過、lint 過,型別是壞的。 四道關卡只有一道看得到。
Day 22 是「測試全過,但覆蓋率退步」。這篇是它的鏡像。
| 現象 | 說明 |
|---|---|
oc 頻繁 TLS handshake timeout |
requests 88% / 92 個 running pod。nx-build 一跑起來 API server 就抖。建立物件要寫重試 |
MutatingAdmissionWebhook failed to complete mutation in 13s |
同一個成因,oc create 重試一次通常就過 |
| 讀 TaskRun 耗時 | STARTTIME / COMPLETIONTIME 兩欄都是「距今多久」。要用 .status.startTime / .status.completionTime 相減 |
| 測試時不要輪詢 | 每 20 秒一次 oc get 會讓已經吃緊的 API server 更糟。用 oc wait --for=jsonpath='{.status.completionTime}',單一 watch |
編排
generate-sbom 掛 git-clone 後,不是 npm-install 後typecheck 夾在 npm-install 和 nx-build 中間,是獨立 Task 不是 stepcleanup-workspace 放 finally,不是 runAfter
finally 在 timeout 時會跑typecheck
node_modules/.bin/tsc 精確路徑FAILED=1 累積tsconfig.json
SBOM
-f 和 -s 都檢查cyclonedx-npm 鎖版本exit 1
cleanup
rm -rfv
set -e,清理失敗不該讓 pipeline 變紅|| true 靜靜吞掉,要把結果印出來四個新 Task 加起來 47 秒,但真正動到 critical path 的只有 typecheck 那 11 秒——generate-sbom 和 upload-sbom 躲在掃描的並行段裡,cleanup-workspace 在 finally。
那 11 秒是刻意付的。 換到的是型別錯的那次 nx-build 不用起跑,代價是那次 run 只看得到型別這一項。build 慢的機器划算,build 快的機器不划算。
真正花掉時間的是兩件計畫外的事:tsconfig.spec.json 從專案建立那天起就沒被檢查過,所以關卡一開就是紅的;共用 PVC 累積的 53067 個檔案讓 git-clone 的 log 膨脹到 5.2 MB。
第一件是導入新關卡的固定成本,寫完 YAML 不是結束。第二件是誤診——我先把帳算在 git-clone 頭上,是因為把 oc get taskrun 的兩個 age 欄位讀成了「開始時間 / 持續時間」。
Day 24 進入映像檔封裝:Rootless Buildah,以及虛擬機環境下儲存驅動的設定限制。
參考文件
finally 與逾時(§一、§五)Finally to the Pipeline —— finally 的保證:「guaranteed to be executed in parallel after all PipelineTasks under tasks have completed regardless of success or error」。這就是 cleanup-workspace 放這裡而不是 runAfter 的依據finally 在 timeout 時會跑」的正解。三個欄位的語意不同:
timeouts.pipeline 到期 → 「any running child TaskRuns will be canceled, regardless of whether they are normal Tasks or finally Tasks」,finally 也會被砍timeouts.tasks 到期 → 「finally Tasks will run if timeouts.finally is specified」timeouts.tasks 和 timeouts.finally,不是只靠版本夠新tsc --noEmit -p tsconfig.json 檢查了 0 個檔案」的出處。文件描述的正是這種寫法:「a "solution" tsconfig.json file that simply has references to all of your leaf-node projects and sets files to an empty array」,而且明講:
「to preserve compatibility with existing build workflows,
tscwill not automatically build dependencies unless invoked with the--buildswitch」所以要嘛用
tsc -b,要嘛像本篇一樣直接指向葉子設定檔(tsconfig.app.json/tsconfig.spec.json)。用-p指 solution 檔會 exit 0 而什麼都沒做——不是 bug,是文件寫明的行為
files / include / references —— §2.1 那張表為什麼「孤兒檔抓得到」:include 是 glob,不靠 import 鏈@cyclonedx/cyclonedx-npm。三個本篇用到的旗標,官方原文:
--package-lock-only:「Whether to only use the lock file, ignoring "node_modules"」——§4.1 那個「有沒有 node_modules 都是 1257 components」的原因
--ignore-npm-errors:「Whether to ignore errors of NPM. This might be used, if "npm install" was run with --force or --legacy-peer-deps」——⚠️ 注意官方給的理由比「很多教學」講的窄,沒有提到 npm ls exit 254 或版本衝突。§4.3 那段的保留態度是有根據的--spec-version:預設 1.6
/repository/<repo-name>/<file-path>,上傳就是一個 HTTP PUT:
curl -v --user 'admin:admin123' --upload-file ./test.png \
http://localhost:8081/repository/documentation/test.png
這就是 upload-sbom 用 node:22-alpine 內建 fetch 就能做完、不必另外拉 curl 映像的原因oc wait --for=jsonpath='{.status.completionTime}' 開單一 watch,不要每 20 秒 oc get 一次