上一篇讓 Pipeline 跨 Namespace 引用了 openshift-pipelines 裡的 git-clone。接下來的問題是:git-clone 把 repo 抓下來之後,下一個 Task 怎麼讀得到。
答案是 workspace。這篇處理的不是「怎麼掛」,而是名字有幾個位置、哪些必須相同、哪些可以自訂,以及固定 PVC 這條路徑實際跑起來的樣子。
ci namespace 與 pipeline ServiceAccount先給結論,其餘是驗證過程。
| 層 | 名稱 | 約束 |
|---|---|---|
PipelineRun spec.workspaces[].name |
shared-workspace |
必須等於下一列 |
Pipeline 頂層 spec.workspaces[].name |
shared-workspace |
必須等於上一列 |
| git-clone Task 內部 | output |
Task 作者定的,不能改 |
| 自訂 Task 內部 | repo |
自行決定 |
四個位置、三種字串。只有第 1、2 列必須對死,其餘各自獨立。
這篇的第一版草稿把每一層都取名 source,YAML 看起來整齊,但也讓映射關係看不出來。這一版刻意把三層取成不同的字。
本篇分成兩半。上半是快樂路徑:從建 PVC 到看到證據,一條線走完,過程中的行為不解釋。下半把值得說明的部分抽出來各自處理,上半會用〈A〉〈B〉這樣的標記指過去。最後是資料來源整理。
| 項目 | 值 |
|---|---|
| CRC | 2.61.0 |
| OpenShift | 4.21.14 |
| Kubernetes | v1.34.6 |
| OpenShift Pipelines Operator | 1.23.1(Pipelines controller v1.12.2) |
| 主機端 | Windows + PowerShell 5.1,oc 4.21.14(未安裝 kubectl) |
指令一律 & $OC --kubeconfig $kubeconfig ...。
三個影響寫法的旗標:
& $OC --kubeconfig $kubeconfig get cm feature-flags -n openshift-pipelines -o jsonpath='{.data.coschedule}'
# workspaces
& $OC --kubeconfig $kubeconfig get cm feature-flags -n openshift-pipelines -o jsonpath='{.data.enable-concise-resolver-syntax}'
# false → resolver 必須寫完整語法
& $OC --kubeconfig $kubeconfig get cm cluster-resolver-config -n openshift-pipelines -o jsonpath='{.data}'
# {"allowed-namespaces":"","blocked-namespaces":"","default-kind":"task","default-namespace":""}
# ↑ 空的,namespace param 不能省
StorageClass:
crc-csi-hostpath-provisioner (default) kubevirt.io.hostpath-provisioner
Retain WaitForFirstConsumer allowVolumeExpansion=false
Retain 與 WaitForFirstConsumer 這兩項後面都會用到。
Tekton 給 workspace 掛 PVC 有兩條路:volumeClaimTemplate(每跑一次自動建、跑完自動刪)與 persistentVolumeClaim(自行建立、自行管理)。上一篇走前者,這篇走後者,取捨見〈F〉。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pipeline-source-pvc
namespace: ci
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
& $OC --kubeconfig $kubeconfig apply -f 01-pvc.yaml
# persistentvolumeclaim/pipeline-source-pvc created
建立後的狀態:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
pipeline-source-pvc Pending crc-csi-hostpath-provisioner
是 Pending 而非 Bound。這是這台 StorageClass 的 volumeBindingMode 造成的預期行為,要等 PipelineRun 送出才會 Bound;停多久、以及對 CI 腳本的影響見〈A〉。
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: d15-happy-path
namespace: ci
spec:
workspaces:
- name: shared-workspace # 第 2 層:Pipeline 頂層宣告
tasks:
- name: git-clone
taskRef:
resolver: cluster # 簡寫語法已關閉,只能寫完整形式
params:
- name: kind
value: task
- name: name
value: git-clone
- name: namespace
value: openshift-pipelines
params:
- name: URL
value: http://gitea-http.gitea.svc.cluster.local:3000/gitea_admin/frontend-nx-mono.git
- name: REVISION
value: main
workspaces:
- name: output # 第 3 層:git-clone 自己定的
workspace: shared-workspace # 映射回第 2 層
- name: workspace-probe
runAfter:
- git-clone
taskRef:
name: workspace-probe
workspaces:
- name: repo # 第 4 層:自訂
workspace: shared-workspace # 一樣映射回第 2 層
workspaces 底下兩個欄位的意義相反:name 是 Task 內部的名字,workspace 是要對回 Pipeline 頂層的名字。這是最常寫反的位置。
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: workspace-probe
namespace: ci
spec:
workspaces:
- name: repo
steps:
- name: probe
image: registry.redhat.io/openshift-pipelines/pipelines-git-init-rhel9@sha256:d8de2ba8...
workingDir: $(workspaces.repo.path)
script: |
#!/usr/bin/env sh
set -e
echo "workspaces.repo.path = $(workspaces.repo.path)"
pwd
id
ls -ld "$(workspaces.repo.path)"
ls -la
for f in nx.json package.json; do
if [ -f "$f" ]; then echo "FOUND: $f"; else echo "MISSING: $f"; fi
done
image 直接用 git-clone 那顆 digest:節點上已有快取,不必走公網,而且確定有 git 與 bash。ubi9/ubi-minimal 沒有 git,配上 set -e 會讓 TaskRun 失敗在與本題無關的地方。
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: d15-happy-run-
namespace: ci
spec:
pipelineRef:
name: d15-happy-path
taskRunTemplate:
serviceAccountName: pipeline
workspaces:
- name: shared-workspace # 第 1 層:必須與 Pipeline 頂層相同
persistentVolumeClaim:
claimName: pipeline-source-pvc
PVC、Task、Pipeline 都用 oc apply。PipelineRun 帶 generateName,apply 會被擋(含 --dry-run=server),只能用 create——原因見〈B〉。
& $OC --kubeconfig $kubeconfig create -f 04-pipelinerun.yaml -o name
# pipelinerun.tekton.dev/d15-happy-run-hf78d
oc create 送出後立即返回,PipelineRun 非同步執行,前景輪詢即可。整條 53.6 秒。
送出後 0.325 秒的第一輪:
[17:28:44.511]
STS : affinity-assistant-5f0843cf5a 0/1 0s
POD : affinity-assistant-5f0843cf5a-0 Pending
d15-happy-run-hf78d-git-clone-pod Pending
PVC : pipeline-source-pvc Bound pvc-d9718706-... 179Gi
三件事同時成立:
affinity-assistant-* 的 StatefulSetBound——前兩分鐘還是 Pending
Pending
affinity assistant 是什麼、為什麼 PVC 在這一刻才 Bound,見〈D〉。
之後串接執行:
[17:28:51.207] git-clone Running
[17:28:55.580] git-clone Completed probe Init:1/3
[17:28:57.820] probe Running
[17:29:37.079] PipelineRun = True / Succeeded
STS : (無)
probe 的輸出:
workspaces.repo.path = /workspace/repo
--- pwd (workingDir) ---
/workspace/repo
=== IDENTITY ===
uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot),1000860000
=== WORKSPACE ROOT ls -ld ===
drwxrwsr-x. 5 root 1000860000 4096 /workspace/repo
=== ROOT LISTING ===
-rw-rw-r--. 1 nonroot 1000860000 1599 nx.json
-rw-rw-r--. 1 nonroot 1000860000 1681 package.json
drwxrwsr-x. 3 nonroot 1000860000 17 apps
drwxrwsr-x. 7 nonroot 1000860000 161 .git
...
FOUND: nx.json
FOUND: package.json
更直接的證據是兩顆 Pod 的 spec:
& $OC --kubeconfig $kubeconfig get pod -n ci -o json | ConvertFrom-Json
d15-happy-run-hf78d-git-clone-pod
volume 'ws-79a94' -> PVC pipeline-source-pvc
container step-prepare-and-run : ws-79a94 -> mountPath /workspace/output
d15-happy-run-hf78d-workspace-probe-pod
volume 'ws-d3b4c' -> PVC pipeline-source-pvc
container step-probe : ws-d3b4c -> mountPath /workspace/repo
同一顆 PVC,不同的 volume 名,不同的 mountPath。git-clone 寫入 /workspace/output,probe 從 /workspace/repo 讀取,因為那是同一塊儲存。
路徑的來源:依 Workspaces 文件,未指定 mountPath 時,workspace 會掛在 /workspace/ 加上該 workspace 名稱的位置。查過 git-clone 的四個 workspace,mountPath 全部未設定,因此走預設。而決定路徑的那個名稱是「Task 內部的 workspace 名」,與 PVC 無關,也與 Pipeline 頂層的名稱無關。
這就是四個名字裡只有 PipelineRun ↔ Pipeline 必須對死的原因:那一組是綁定,其餘兩個是映射。
兩個 Task 都以 image 內建的 nonroot 執行,全程沒有權限問題,也沒有調整任何 SCC;目錄權限的細節見〈C〉。
# 1. PipelineRun 成功
& $OC --kubeconfig $kubeconfig get pipelinerun -n ci
# 2. git-clone 抓到的 commit
$tr = (& $OC --kubeconfig $kubeconfig get taskrun <name> -n ci -o json | ConvertFrom-Json)
($tr.status.results | Where-Object { $_.name -eq 'COMMIT' }).value
# 3. PVC 仍為 Bound、未被刪除
& $OC --kubeconfig $kubeconfig get pvc pipeline-source-pvc -n ci
# 4. 沒有新增孤兒 PV
& $OC --kubeconfig $kubeconfig get pv --no-headers | Where-Object { $_ -match 'Released' }
# 5. affinity assistant 已清乾淨
& $OC --kubeconfig $kubeconfig get statefulset -n ci
預期:PipelineRun Succeeded、COMMIT 與遠端 HEAD 相同、PVC Bound、Released 數量與執行前相同、ci 沒有殘留的 StatefulSet。
上半沒有解釋的行為,在這裡各自處理。彼此獨立,可以只挑要看的。
Pending 兩分鐘接第一節建立 PVC 之後。oc describe 的 event:
Events:
Type Reason From Message
Normal WaitForFirstConsumer persistentvolume-controller waiting for first consumer to be created before binding
這是 volumeBindingMode: WaitForFirstConsumer 的預期行為。依 Kubernetes Storage Classes 文件,這個模式會把 PV 的綁定與動態配置延後到有 Pod 使用該 PVC 為止;未設定時的預設是 Immediate,PVC 一建立就配置。
這顆 PVC 停在 Pending 2 分 9 秒,直到 PipelineRun 送出。期間 PV 總數未變。
若 CI 腳本裡寫「建完 PVC 就等 Bound,逾時報錯」,在這種 StorageClass 上會永遠逾時。
generateName 不能用 applyPVC、Task、Pipeline 都用 oc apply。PipelineRun 照做會得到:
error: from d15-happy-run-: cannot use generate name with apply
--dry-run=server 同樣被擋。kubectl apply 的文件寫明 The resource name must be specified.——apply 需要靠名字比對既有物件,而 generateName 的名字要到伺服器端建立時才產生。Red Hat 的 KB 也記錄了這個錯誤,並提到 OCP 4.5 與 4.6 的訊息措辭不同。
所以:Task / Pipeline / PVC 用 apply,PipelineRun 用 create。
workspace 根目錄由 root 所有、帶 setgid、group 1000860000 可寫;兩個 Task 都以 image 內建的 nonroot(65532) 執行,附屬群組包含 1000860000。因此 nonroot 寫得進去、下一顆 Pod 也讀得到。全程沒有權限問題,也沒有調整任何 SCC。
執行期間出現、結束後消失的 affinity-assistant-5f0843cf5a,是 Tekton 用來讓共用同一顆 workspace 的 TaskRun 被排到同一個節點的機制。
它是一顆 pipelines-nop-* image 的 placeholder Pod,包在 StatefulSet 裡。原始實作的 commit 訊息記載了設計:TaskRun 的 Pod 被設上 podAffinity 指向這顆 assistant,assistant 之間彼此 podAntiAffinity(Best Effort),選用 StatefulSet 是為了 singleton 語意。
這裡的因果容易寫反:
RWO 確實只允許單一節點掛載。平行 Task 之所以能共用同一顆 PVC,是因為 affinity assistant 把它們排到同一個節點,不是 RWO 本身寬鬆。
在單節點的 CRC 上這兩種說法看起來相同。換到多節點叢集、或把 coschedule 關掉,寫反的那個會出問題。
跑完後 assistant 自動刪除,這一點 Affinity Assistants 文件有記載,實測時間戳落在同一秒:
18:32:39Z PipelineRun/d15-happy-run-hf78d Succeeded
18:32:39Z Pod/affinity-assistant-5f0843cf5a-0 Killing Stopping container affinity-assistant
WaitForFirstConsumer 要等第一個 consumer。是 affinity assistant 的 Pod,還是 git-clone 的 Pod?
實測的答案是:分不出來,因為兩者在競爭。同一個叢集秒內的事件:
18:31:43Z TaskRun/…-git-clone Pending pod status "PodScheduled":"False"
message: "running PreBind plugin \"VolumeBinding\"…
18:31:43Z PVC ExternalProvisioning → Provisioning → ProvisioningSucceeded
18:31:43Z Pod/…-git-clone-pod FailedScheduling
running PreBind plugin "VolumeBinding":
Operation cannot be fulfilled on persistentvolumeclaims…
18:31:43Z StatefulSet/affinity-assistant-5f0843cf5a SuccessfulCreate
18:31:44Z Pod/affinity-assistant-5f0843cf5a-0 Scheduled
18:31:45Z Pod/…-git-clone-pod Scheduled
能確定的有三點:
Scheduled 之前就配好並綁定VolumeBinding PreBind 的是 git-clone 的 Pod,而且撞上 Operation cannot be fulfilled——PVC 的樂觀鎖衝突,代表同一刻有另一個寫入者所以準確的說法是:WaitForFirstConsumer 的觸發者是這兩顆 Pod 的排程競爭。第 1 點與 WaitForFirstConsumer 的定義並不衝突——排程器的 PreBind 階段早於 Pod 被標記 Scheduled,綁定就是在那一步完成的。
整條 pipeline 沒有綁任何 git 憑證。git-clone 有三個 optional workspace(ssh-directory / basic-auth / ssl-ca-directory),一個都沒綁。
原因有兩層。
前提:repo 是 public,走的是叢集內的純 http Service DNS(gitea-http.gitea.svc.cluster.local:3000)。沒有 TLS 就不需要 CA,沒有權限控管就不需要帳密。
Task 本身處理過:git-clone 的 stepTemplate 帶著三個變數:
WORKSPACES_SSH_DIRECTORY_BOUND = $(workspaces.ssh-directory.bound)
WORKSPACES_BASIC_AUTH_BOUND = $(workspaces.basic-auth.bound)
WORKSPACES_SSL_CA_DIRECTORY_BOUND = $(workspaces.ssl-ca-directory.bound)
script 裡三處都是 if [[ "${..._BOUND}" == "true" ]] 的顯式分支。執行時的 log 印出走過的 phase,那三個都沒出現:
---> Phase: Preparing the filesystem before cloning the repository...
---> Phase: Deleting all contents of checkout-dir '/workspace/output/'...
---> Phase: Setting output workspace as safe directory ('/workspace/output')...
---> Phase: Cloning 'http://gitea-http.gitea.svc.cluster.local:3000/...' into '/workspace/output/'...
沒有 Configuring Git authentication with 'basic-auth' Workspace files,也沒有 Copying '.ssh' from ssh-directory workspace。
Tekton 的憑證機制是 annotation 驅動的。依 Authentication at Run Time,憑證用的 annotation key 必須以 tekton.dev/git- 或 tekton.dev/docker- 開頭,未正確標註的 Secret 不會被採用。這個 namespace 裡沒有任何帶 tekton.dev/git-* annotation 的 secret,因此不會產生 git 憑證檔。
Pod 上仍然掛著一個 Secret:pipeline-dockercfg-*,型別 kubernetes.io/dockercfg,是 ServiceAccount 的 image pull secret,由 Tekton 自動注入。所以準確的敘述是「不需要 git 憑證」,而不是「不需要任何憑證」。
這台的 StorageClass 是 reclaimPolicy: Retain。依 Persistent Volumes 文件,這個回收策略在 PVC 被刪除後不會回收 PV,PV 會停在 Released,底層資料保留。
用 volumeClaimTemplate 時 PVC 由 Tekton 建立、跑完即刪,於是每跑一次就留下一顆 Released 的孤兒 PV。而 oc get pvc -n ci 看不到——PV 是叢集層級物件,要改看:
& $OC --kubeconfig $kubeconfig get pv --no-headers | Where-Object { $_ -match 'Released' }
用固定 PVC 跑了兩次,收工時:
PV 總數 = 10 Released = 3 Bound = 7
那 3 顆 Released 是先前留下的,這次沒有新增。新增的是一顆 Bound:
pvc-d9718706-6f1c-4fa8-aef9-92390c363137 179Gi Retain Bound ci/pipeline-source-pvc
沒有產生孤兒 PV 的原因是 PVC 沒被刪。刪掉 pipeline-source-pvc 之後,Retain 一樣會留下一顆 Released。差別在於「每跑一次一顆」變成「整條路線一顆」。
同一條 PipelineRun 跑第二次時,git-clone 的 DELETE_EXISTING 預設是 true,內部用 rm -rfv,所以把刪掉的檔案逐行印出:
---> Phase: Deleting all contents of checkout-dir '/workspace/output/'...
removed '/workspace/output//Dockerfile'
removed '/workspace/output//README.md'
removed '/workspace/output//apps/web/src/app/app.ts'
removed directory '/workspace/output//apps/web/src/app'
...
removed '/workspace/output//.editorconfig'
removed '/workspace/output//.git/description'
...
三點:
.editorconfig、整個 .git(.git/ 底下就 120 行)/workspace/output//*。原始碼註解說明了理由:不能直接刪目錄,因為那可能是 / 或掛載點的根checkout_dir="${WORKSPACES_ROOT_PATH}/${PARAMS_SUBDIRECTORY}" 而 SUBDIRECTORY 為空,無影響所以固定 PVC 提供的是 PV 層面的穩定,不是內容的延續。要做快取得自行指定 SUBDIRECTORY,或把 DELETE_EXISTING 設成 false 再自己處理衝突。
設計平行掃描 Task 時會撞到這條規則。Workspaces 文件說明,在 coschedule 為 workspaces 或 disabled 時,不允許把多顆 PVC-backed workspace 綁到同一個 TaskRun,理由是可能的 Availability Zone 衝突。這兩個正好是兩個 stable 模式,另外兩個模式(pipelineruns、isolate-pipelinerun)是 alpha。
實測方式是把兩個 claimName 都填成不存在的 PVC。Tekton 只讀 WorkspaceBinding 裡的名稱、不檢查存在性,驗證會在任何東西被配置之前觸發,因此不會產生 PV。
webhook 不擋(--dry-run=server 會過),要實跑才看得到:
PipelineRun Failed
Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0
子 TaskRun TaskRunValidationFailed
[User error] more than one PersistentVolumeClaim is bound
失敗形狀分兩層:PipelineRun 只給 Failed 加一句 Tasks Completed: 1 (Failed: 1…),真正的原因要往子 TaskRun 看。這在 Tekton 是通例。
這個錯誤字串與 tektoncd/pipeline#3480 裡使用者回報的一致,該 issue 也記載了同樣的不對稱:單獨跑 TaskRun 沒事,走 PipelineRun 就失敗。
與 Tekton 無關,但這次都實際踩到。
-o jsonpath='{...}' 用單引號直傳,只在沒有內層雙引號時成立。一旦用到分隔符或 filter,PowerShell 會吃掉引號,而三種情境的錯誤訊息不同:
| 寫法 | 錯誤訊息 |
|---|---|
'{.a}{"|"}{.b}' |
unrecognized character in action: U+007C '|' |
'{.status.conditions[?(@.type=="Ready")].status}' |
unrecognized identifier Ready |
'{.a}{" x="}{.b}' |
unterminated quoted string |
第二種最容易誤判——訊息看起來像 jsonpath 語意寫錯,而不是引號被吃掉。
解法:內層雙引號寫成 \";或任何多欄位、帶 filter 的查詢一律走 -o json | ConvertFrom-Json 再 Where-Object。
oc auth can-i use scc/... 要帶 -n& $OC ... auth can-i use scc/pipelines-scc --as=system:serviceaccount:ci:pipeline
# Warning: resource 'securitycontextconstraints' is not namespace scoped
# no
& $OC ... auth can-i use scc/pipelines-scc --as=system:serviceaccount:ci:pipeline -n ci
# yes
SCC 物件是叢集層級的,但「能不能用」的授權來自 namespace 裡的 RoleBinding。那句 Warning 容易讀成「不用加 namespace」。
NAME STATUS CAPACITY ...
pipeline-source-pvc Bound 179Gi ← request 是 2Gi
kubevirt.io.hostpath-provisioner 回報的是配置當下節點檔案系統的容量。這是這個 provisioner 的實作行為,不是 CSI 通則;上游有對應的 issue(kubevirt/hostpath-provisioner#33,回報 request 4Gi、capacity 39Gi)。
在這種 provisioner 上,oc get pvc 的 CAPACITY 欄不能拿來做容量監控。
coschedule=disabled 下的多 PVC 限制:文件說同樣不允許,這次只實測了 workspaces 那一半。要驗得改全叢集的 feature-flags。pipeline-source-pvc 是否留下 Released PV:Retain 是 StorageClass 層的設定,理論上必然,但沒有為此製造第 4 顆孤兒 PV。workspace 有四個名字的位置,只有 PipelineRun ↔ Pipeline 那一組必須對死,另外兩個各自獨立。驗證方式是把三層取成不同的字,再去看兩顆 Pod 的 volumeMounts——/workspace/output 與 /workspace/repo 指向同一顆 PVC。
其餘是過程中確認的行為:WaitForFirstConsumer 讓 PVC 停在 Pending 是預期的、Retain 讓孤兒 PV 從「每跑一次一顆」變成「總共一顆」、固定 PVC 不等於保留狀態、generateName 不能 apply。
明天處理下一段:把抓下來的 repo 真的建起來。
每條標註本文用到它的位置。
| 來源 | 用在 | 取用的內容 |
|---|---|---|
| Using Workspaces in Pipelines | 五、〈G〉 | 未指定 mountPath 時的預設掛載路徑;多 PVC 限制與 AZ 理由 |
| Affinity Assistants | 〈D〉 | 四種 coschedule 模式、完成即刪除 |
| Authentication at Run Time | 〈E〉 | tekton.dev/git-* annotation 規則 |
| affinity assistant 原始實作 commit | 〈D〉 | placeholder pod、podAffinity、singleton 的設計意圖 |
| tektoncd/pipeline#3480 | 〈G〉 | more than one PersistentVolumeClaim is bound 的使用者回報 |
| 來源 | 用在 | 取用的內容 |
|---|---|---|
| Storage Classes | 〈A〉 | volumeBindingMode 與 WaitForFirstConsumer 的語意 |
| Persistent Volumes | 〈F〉 | reclaimPolicy: Retain 的回收語意 |
| kubevirt/hostpath-provisioner#33 | 〈H〉 | PV capacity 大於 PVC request 的回報 |
| 來源 | 用在 | 取用的內容 |
|---|---|---|
| kubectl apply 參考 | 〈B〉 | The resource name must be specified. |
| Red Hat KB 5969351 | 〈B〉 | cannot use generate name with apply |
上述 tekton.dev 連結描述的是上游現況,本文環境是 Red Hat 打包的 v1.12.2。文中所有非引用的數字與輸出,都來自「實測環境」那張表的版本組合,換版本請重驗。