今日目的:掌握 Helm Chart 在 OpenShift 上真正能跑起來所需要的授權順序,Chart 內建的「OpenShift 相容開關」實際做了什麼,以及換到
anyuid之後要跟著付出的代價。
先備知識:本篇銜接 Day 5 的 SCC 授權機制,這裡把它實際套用到 Helm 部署上。
helm install 在標準 Kubernetes 上能跑,搬到 OpenShift 上不一定能跑。多數開源 Helm Chart 的預設值是為通用 Kubernetes 設計的,套在 OpenShift 更嚴格的存取控制下,表現常常跟預期不一樣。
這篇要處理的 Nexus3 Chart 剛好把三種不一樣的落差都示範了一遍:
| 落差類型 | 具體例子 | 為什麼值得注意 |
|---|---|---|
| 直接失敗 | Chart 宣告 runAsUser: 200(比照 Nexus 官方映像),撞上 restricted-v2 的 uid-range |
訊號很明顯,部署當下就會發現 |
| 不報錯,但資料留不住 | 沒設定 persistence,Nexus 資料掛在 emptyDir 上 | 最危險:Pod 正常 Running,直到重啟才發現東西都沒了 |
| 舊說法被抄成通則,適用範圍卻不見了 | 網路教學說 OpenShift 的 SCC 一律擋 seccomp 設定 | 最耗神:它有時對、有時錯,取決於你落在哪個 SCC |
下面依實際處理順序一一說明,中間會順手處理一個不算 OpenShift 特有、但一樣值得修的預設值陷阱:admin 密碼別讓它躺在 Pod 檔案系統裡等人尋寶。
這個叢集上實測的環境版本是 OpenShift 4.21.14、Kubernetes 1.34.6,跟 Day 5 同一個叢集。這篇全篇的行為都依賴 stevehipwell/nexus3 這個 Chart:
PS> helm list -n nexus
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
nexus nexus 4 2026-07-25 11:31:21.3040929 +0800 CST deployed nexus3-5.24.0 3.94.0
下面所有 Chart 預設值的查驗都是針對 5.24.0 這一版。ArtifactHub 上當前最新版本是 5.24.1,換一個版本,persistence、rootPassword 這些欄位的預設值要自己重新核對一次,不能直接沿用這篇的結論。(提醒:文中各段輸出的 AGE/時間戳擷取自不同時間點,不是同一次查詢的快照,不要拿來互相推算時間軸。)
體例跟 Day 5 一致:文中的引用區塊都把結論寫在標題上,讀完標題就能決定要不要展開,跳過不影響後文。**【坑】是照直覺寫會中招的地方,【延伸】是查證推導與機制細節,【範圍】**標示沒實測到、只能算推論,或本篇刻意不處理的部分。
評估一個 Helm Chart 能不能直接部署到 OpenShift,第一步是看它有沒有內建的相容開關。有的話,部署時直接打開就好,省去手動授權 anyuid SCC 的步驟。
這裡要先澄清一個常見的誤解:沒有一個叫做 openshift.enabled 的通用欄位。 網路教學常直接寫 --set openshift.enabled=true,好像這是 Helm 的標準參數,但它不是——每個 Chart 自己取名,路徑跟型別都不一樣。抄一個名字去套別的 Chart,helm install 不會報錯,那個值只會被靜靜忽略。
以 Bitnami 系列為例,欄位名稱是 global.compatibility.openshift.adaptSecurityContext,值不是布林,是三選一的字串:
--set global.compatibility.openshift.adaptSecurityContext=auto
bitnami/postgresql、bitnami/keycloak、bitnami/wordpress、bitnami/nginx、bitnami/redis、bitnami/rabbitmq、bitnami/mysql、bitnami/kafka、bitnami/mongodb 逐一查過 values,都有這個欄位。
實作邏輯在共用的 bitnami/common library chart 的 templates/_compatibility.tpl(以 bitnami/postgresql 拉下來查):
{{- define "common.compatibility.renderSecurityContext" -}}
{{- $adaptedContext := .secContext -}}
{{- if (((.context.Values.global).compatibility).openshift) -}}
{{- if or (eq .context.Values.global.compatibility.openshift.adaptSecurityContext "force") (and (eq .context.Values.global.compatibility.openshift.adaptSecurityContext "auto") (include "common.compatibility.isOpenshift" .context)) -}}
{{/* Remove incompatible user/group values that do not work in Openshift out of the box */}}
{{- $adaptedContext = omit $adaptedContext "fsGroup" "runAsUser" "runAsGroup" -}}
{{- if not .secContext.seLinuxOptions -}}
{{- $adaptedContext = omit $adaptedContext "seLinuxOptions" -}}
{{- end -}}
(節錄,if 區塊尚未結束;下略)
關鍵是 omit 那一行:開關生效時,它把 fsGroup、runAsUser、runAsGroup 從 securityContext 裡拿掉。
拿掉之後,Pod 沒有指定要用哪個 UID 跑,平台就會照 Day 5 講的機制指派 uid-range 起始值的 UID(映像裡不存在)。這正好繞開 restricted-v2 的檢查——不是說服它放行某個固定 UID,而是根本不提出要求。
auto、force、disabled 的差別在偵測:auto 會透過另一個 helper common.compatibility.isOpenshift 檢查 .Capabilities.APIVersions.Has "security.openshift.io/v1",只有跑在 OpenShift 上才啟用;force 則不管平台一律套用;disabled 兩個條件都不觸發,omit 不執行,securityContext 維持原樣。
需要注意的是,這類開關的做法是「把固定 UID 的要求拿掉」,前提是映像本身能跑在任意 UID 下。
Nexus 這種官方映像把 UID 200 寫死在映像裡、而且檔案權限也綁在 200 上,就算 Chart 幫你拿掉 runAsUser,容器照樣起不來。這也是為什麼下面要走手動授權 anyuid 的路線:Nexus3 這個 Chart 沒有相容開關,而且就算有,對它也不管用。
針對沒有相容開關的通用 Chart(例如這裡用的 Nexus3 Chart),有兩個預設值必須在 helm install 之前就改掉——一個決定資料留不留得住,一個決定 admin 密碼落在哪裡。兩者都不會報錯,這也是它們危險的地方。
在動手 Helm 部署之前,先確認一個容易被預設值影響、後果卻不小的設定項:儲存持久化。
persistence 這一段的 Chart 預設值不用猜,查得到:
helm show values stevehipwell/nexus3 --version 5.24.0
persistence:
# -- If `true`, persistence should be enabled for the `StatefulSet`.
enabled: false
# -- Annotations for the `PersistentVolumeClaim`.
annotations: {}
# -- Access mode for the `PersistentVolumeClaim`.
accessMode: ReadWriteOnce
# -- Storage class for the `PersistentVolumeClaim`, if not set the default will be used.
storageClass:
# -- Size of the `PersistentVolumeClaim`.
size: 8Gi
# -- If `true`, keep `PersistentVolumeClaims` when the `StatefulSet` is deleted.
retainDeleted: true
# -- If `true`, keep `PersistentVolumeClaim` when the `StatefulSet` is scaled down.
retainScaled: true
enabled 預設是 false。這個開關實際做了什麼,查 Chart 本身的樣板邏輯就能確認(helm pull stevehipwell/nexus3 --version 5.24.0 --untar 之後看 templates/statefulset.yaml):
nexus3\templates\statefulset.yaml:374: {{- if not .Values.persistence.enabled }}
nexus3\templates\statefulset.yaml:375: - name: data
nexus3\templates\statefulset.yaml:376: emptyDir: {}
nexus3\templates\statefulset.yaml:377: {{- end }}
沒開 persistence.enabled,掛到 /nexus-data 的 data 這個 volume 直接就是 emptyDir: {}。
emptyDir 的生命週期綁定 Pod。只要 Pod 被重建(節點重啟、節點驅逐、rollout restart、Pod 被刪除重建),整個資產庫的套件快取、blob store 與設定就會全部消失。
對一個要擔任「中央 Proxy Cache」的 Nexus 而言,這代表每次重啟都得重新從外網拉一遍;在離線/管制環境更是直接失效。實際運作的效果是一個重啟即歸零的資產庫,稱不上真正持久化。
這正是「抓出不適用於本情境的預設值」的實戰。部署前務必在 values.yaml 明確啟用持久化 PVC,不要沿用會落到 emptyDir 的預設:
persistence:
enabled: true
storageClass: crc-csi-hostpath-provisioner # 依實際 StorageClass 調整
size: 20Gi
順帶一提,Chart 預設的 size 是 8Gi,這裡的 20Gi 是刻意放大的設定值,不是照抄預設。
部署完成後,第一件事就是驗證持久化真的生效。Pod 顯示 Running,不代表資料真的落在持久化儲存上:
(下文開始出現的 $OC/$kubeconfig,定義見〈三、Namespace 預建與 SCC 授權時序〉的標準程序腳本,這裡先當作已經設好的變數使用。)
& $OC get pvc -n nexus # 必須看到一條 STATUS 為 Bound 的 PVC
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-nexus-nexus3-0 Bound pvc-ae6bae5e-ac46-4c00-8500-1a7c63fb714f 59Gi RWO crc-csi-hostpath-provisioner 9d
& $OC get statefulset nexus-nexus3 -n nexus -o jsonpath='{.spec.volumeClaimTemplates[*].metadata.name}'
data
CAPACITY 欄位顯示 59Gi,但這不代表 values.yaml 的 size: 20Gi 沒生效。查 spec.resources.requests.storage 仍是宣告值:
& $OC get pvc data-nexus-nexus3-0 -n nexus -o jsonpath='{.spec.resources.requests.storage}'
20Gi
59Gi 是 crc-csi-hostpath-provisioner 這個 CSI 驅動綁定 PV 時回報的容量,跟宣告值無關。把全叢集的 PVC 一起列出來就很清楚:
& $OC get pvc --all-namespaces -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,REQ:.spec.resources.requests.storage,CAP:.status.capacity.storage
NS NAME REQ CAP
ci pipeline-source-pvc 2Gi 59Gi
gitea data-gitea-postgresql-ha-postgresql-0 10Gi 59Gi
nexus data-nexus-nexus3-0 20Gi 59Gi
openshift-image-registry crc-image-registry-storage 20Gi 30Gi
(節錄,完整輸出共 11 條)
宣告 2Gi 的、10Gi 的、20Gi 的,只要走這個 provisioner,CAP 一律回報 59Gi。全叢集 11 條 PVC 裡有 10 條是這個樣子,唯一的例外是 openshift-image-registry 那條,走的是不同的儲存機制。
所以判斷持久化有沒有生效,看的是 Bound 狀態,不是 CAPACITY 這欄的數字。
若 oc get pvc 是空的、或 volumeClaimTemplates 沒有內容,代表資料正掛在 emptyDir 上。此時必須回頭修正 values 並重新部署,別把後面辛苦配置的 Proxy repository 建在流沙上。
Nexus3 Chart 的 rootPassword 預設是空的:
rootPassword:
# -- (string) Name of the secret containing the root password.
secret:
# -- Key in the secret containing the root password.
key: password
而樣板裡這個值決定了一個二選一的分支(templates/statefulset.yaml):
nexus3\templates\statefulset.yaml:221: {{- if .Values.rootPassword.secret }}
nexus3\templates\statefulset.yaml:222: - name: NEXUS_SECURITY_RANDOMPASSWORD
nexus3\templates\statefulset.yaml:223: value: "false"
nexus3\templates\statefulset.yaml:224: - name: NEXUS_SECURITY_INITIAL_PASSWORD
nexus3\templates\statefulset.yaml:225: valueFrom:
nexus3\templates\statefulset.yaml:226: secretKeyRef:
nexus3\templates\statefulset.yaml:227: name: {{ .Values.rootPassword.secret }}
nexus3\templates\statefulset.yaml:228: key: {{ .Values.rootPassword.key }}
nexus3\templates\statefulset.yaml:229: {{- else }}
nexus3\templates\statefulset.yaml:230: - name: NEXUS_SECURITY_RANDOMPASSWORD
nexus3\templates\statefulset.yaml:231: value: "true"
nexus3\templates\statefulset.yaml:232: {{- end }}
沒設定 rootPassword.secret 就落到 else 分支:NEXUS_SECURITY_RANDOMPASSWORD 被設成 true,每次全新部署都會隨機產生一組 admin 密碼寫進 Pod 內的 /nexus-data/admin.password,得靠 oc exec 鑽進 Pod 去撈這個檔案才能拿到。
這是一種「機密落地」的反模式:密碼多繞了 Pod 檔案系統這個不必要的中繼點,也不方便後續自動化腳本取用。
設定 rootPassword.secret,把密碼來源換成一個事先建好的 K8s Secret:
rootPassword:
secret: nexus-admin # 需事先手動建立,Chart 只負責引用,不負責生成
key: password
這個 Secret 必須在 helm install 之前手動建立好:
$randomPass = [System.Guid]::NewGuid().ToString()
$b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($randomPass))
# 以 stdin 建立,避免明碼出現在指令列參數
@"
apiVersion: v1
kind: Secret
metadata:
name: nexus-admin
namespace: nexus
type: Opaque
data:
password: $b64
"@ | & $OC apply -f -
【範圍】UUID 的隨機性夠,複雜度不一定夠
[System.Guid]::NewGuid()產生的字串全是十六進位數字加連字號,沒有大寫字母或符號。如果 Nexus 或其他下游系統對密碼有複雜度要求(例如必須含大小寫、符號),這組值不一定過得了。我沒有實測 Nexus 是否強制密碼複雜度,這裡只是提醒 UUID 不等於「安全的隨機密碼」——隨機性夠,複雜度不一定夠,兩者是不同的屬性。
部署之後,可以直接查 StatefulSet 的環境變數確認走的是哪個分支:
PS> & $OC get secret nexus-admin -n nexus
NAME TYPE DATA AGE
nexus-admin Opaque 1 12d
# 查 container env 用 -o json | ConvertFrom-Json,不用 jsonpath——secretKeyRef 跟直接 value 是不同的欄位結構,jsonpath 選欄位容易漏或跳脫出錯
PS> $ss = & $OC get statefulset nexus-nexus3 -n nexus -o json | ConvertFrom-Json
PS> $container = $ss.spec.template.spec.containers | Where-Object { $_.name -eq 'nexus3' }
PS> $container.env | Where-Object { $_.name -like 'NEXUS_SECURITY_*' } | ForEach-Object {
if ($_.value) { "$($_.name)=$($_.value)" }
else { "$($_.name)=<valueFrom: $($_.valueFrom.secretKeyRef.name)/$($_.valueFrom.secretKeyRef.key)>" }
}
NEXUS_SECURITY_RANDOMPASSWORD=false
NEXUS_SECURITY_INITIAL_PASSWORD=<valueFrom: nexus-admin/password>
RANDOMPASSWORD=false 加上 INITIAL_PASSWORD 走 secretKeyRef,代表樣板確實走進了 if 那一邊。之後要取用 admin 密碼,直接 & $OC get secret nexus-admin -n nexus -o jsonpath='{.data.password}' 解 Base64 即可,不必再鑽進 Pod 撈 /nexus-data/admin.password(本系列後續會用到這組密碼呼叫 Nexus 的組態 API)。
Nexus 官方映像預設以固定的 UID 200 執行,這一點 Sonatype 自己的映像文件寫得很明白:/nexus-data 必須可被以 UID 200 執行的 Nexus 行程寫入。而這個 Chart 把同一件事照實寫進了自己的 values 預設值:
securityContext:
runAsUser: 200
runAsGroup: 200
這個區分值得講清楚:SCC 在 admission 階段看不到映像內容,它檢查的是 Pod spec 裡宣告出來的值。所以被擋下的直接原因是 Chart 宣告了 runAsUser: 200,不是映像裡有一行 UID 200 的 USER 指令。映像那邊的限制是後果不是原因——就算把宣告拿掉讓 admission 放行,/nexus-data 的檔案權限仍然綁在 200 上,容器一樣起不來。
而 Day 5 說過,restricted-v2 要求 UID 必須落在該 Namespace 的 uid-range 內:
& $OC get namespace nexus -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}'
1000670000/10000
也就是 1000670000 到 1000679999 這個區間。200 跟這個區間相差約五百萬倍(六、七個數量級),不可能落在裡面,所以被擋。
anyuid 解除的正是這條限制。但它不是「只解除一條限制」那麼單純——換到 anyuid 的同時,seccomp 那一格反而變嚴了,Chart 預設的 RuntimeDefault 必須一併關掉,這件事下一節會整節處理。
anyuid,不是別的解法在下授權指令之前,值得先講清楚這是一個選擇,不是唯一解。同一個「映像把 UID 寫死」的問題,至少有四條路可走:
| 解法 | 做法 | 放寬的範圍 | 代價 |
|---|---|---|---|
授權 anyuid(本篇) |
一行 add-scc-to-user |
任意 UID,含 root 0 | 比實際需要的鬆;而且它不收 seccomp,走它就得放棄 RuntimeDefault |
| 改 Namespace 的 uid-range | 把 openshift.io/sa.scc.uid-range 改成含 200 的區間,讓 restricted-v2 自己放行 |
整個 Namespace 的 UID 下限 | 影響同 Namespace 的所有 workload;這個 annotation 平常由叢集自動分配,手動改要自己承擔後續衝突。另外 supplemental-groups 是獨立分配的另一個 annotation,改了 uid-range 不會連動,fsGroup 仍會落在原本那組大數字 |
| 自訂 SCC | 複製一份 restricted-v2,只把 runAsUser 改成 MustRunAs 的 200,其餘照抄 |
只有 UID 這一格,seccompProfiles 原封不動留著 |
多一個 cluster-scoped 物件要跨版本維護 |
| 改映像 | Day 5 那條 chgrp -R 0 加 chmod -R g=u,讓 /nexus-data 在 group 0 底下可寫 |
不需要放寬任何 SCC | 要自己維護衍生映像 |
本篇走 anyuid,理由是它最短、零維護,而且如 Day 5 查過的,它的 HOSTDIR/HOSTNET 都是 false——放行任意 UID 的同時,宿主機那一側仍然是關的。在一個 CRC 單機開發叢集上,這個取捨站得住腳。
但它鬆在哪也要講明白。Nexus 要的只是 UID 200,anyuid 給的是任意 UID 含 root 0。哪天 Chart 升版改掉 runAsUser、或有人往這個 SA 底下加一顆用 root 跑的 sidecar,SCC 都不會攔——授權的當下就等於先簽了一張空白支票。
這裡我原本推論錯了一次,值得留在文章裡:Day 5 查過 anyuid 的 priority 是 10、restricted-v2 是 <nil>,我據此以為授權 anyuid 會「連坐」——這個 SA 底下每一顆 Pod 都被它接手,包括本來跑得動 restricted-v2 的。實測不成立。
在一個綁了 anyuid 的 SA 底下,送一顆不宣告 runAsUser、只帶 seccompProfile: RuntimeDefault 的普通 Pod:
PS> & $OC get pod exp2-pod -n anyuid-probe -o jsonpath='{.metadata.annotations.openshift\.io/scc}'
restricted-v2
PS> & $OC exec exp2-pod -n anyuid-probe -- id
uid=1000790000 gid=0(root) groups=0(root),1000790000
Pod 正常跑起來,套用的是 restricted-v2,UID 也是 range 起始值那個典型的大數字。所以 priority 決定的是嘗試順序,不是最終結果:anyuid 確實排在最前面先被試,也確實因為 seccompProfiles 是 nil 而拒絕了這顆帶 RuntimeDefault 的 Pod,但候選清單被拒之後 admission 會繼續往下走,restricted-v2 沒有理由擋它,就落在那裡了。授權一個高 priority 的 SCC,不等於把該 SA 底下所有 Pod 都拖過去。
(順帶一提,Day 5 那句「同時具備兩者時最終套用的一定是 anyuid」,也要拿掉那個「一定」——精確的說法是它會最先被嘗試。)
那 seccomp 那筆帳為什麼還是要付?因為 Nexus 那顆 Pod 沒有第二條路。它宣告了 runAsUser: 200,restricted-v2 一定會擋在 UID 上,能收它的只有 anyuid;而 anyuid 不收 seccomp。兩邊各擋一半的結果就是 Pod 進不去,只能把 RuntimeDefault 拿掉去遷就唯一收得下它的那顆 SCC。換句話說,代價不是「換 SCC」的必然結果,是「這顆 Pod 只剩 anyuid 一個選項」的結果——表格第三列的自訂 SCC 之所以是更貼身的做法,正是因為它把 UID 和 seccomp 同時收下,Pod 不必二選一。
【延伸】表格前三列的實測結果
上表除了「改映像」那一列(就是 Day 5 已經拆過的
chgrp -R 0做法),其餘三條都在這個叢集上跑過拋棄式測試。自訂 SCC 成立:複製一份
restricted-v2(它的seccompProfiles是["runtime/default"])建成nexus-uid200,只把runAsUser改成MustRunAs的 200,其餘照抄。送一顆同時帶runAsUser: 200和seccompProfile: RuntimeDefault的 Pod,openshift.io/scc顯示nexus-uid200,spec.securityContext.seccompProfile完整保留,容器內id回uid=200(nexus)。UID 和 seccomp 可以同時滿足。
anyuid的空白支票成立:同一個 SA 換送runAsUser: 0、不帶 seccomp 欄位的 Pod,scc=anyuid、id回uid=0(root)。授權當下確實沒有任何機制擋住 root。改 uid-range 成立,但別預期它會連動:把 Namespace 的
uid-range改成200/10000、不授權任何額外 SCC,runAsUser: 200的 Pod 就被restricted-v2收下了。我原本猜fsGroup會跟著落到 200 附近,實測是1000800000——supplemental-groups是另一個獨立分配的 annotation,改uid-range不會動到它。
授權指令需透過 ServiceAccount 名稱綁定(通常為 <release>-<chart-name> 格式):
& $OC --kubeconfig $kubeconfig adm policy add-scc-to-user anyuid -z nexus-nexus3 -n nexus
授權完成後,可以從兩個角度確認它真的生效。先看綁定關係:
$b = & $OC --kubeconfig $kubeconfig get rolebinding -n nexus -o json | ConvertFrom-Json
$b.items | Where-Object { $_.roleRef.name -like "system:openshift:scc:*" } |
Select-Object @{n='name';e={$_.metadata.name}}, @{n='role';e={$_.roleRef.name}}, @{n='subjects';e={($_.subjects | ForEach-Object { $_.name }) -join ','}}
name role subjects
---- ---- --------
system:openshift:scc:anyuid system:openshift:scc:anyuid nexus-nexus3
system:openshift:scc:privileged system:openshift:scc:privileged nexus-node-resolver
subject 是具名的 nexus-nexus3,不是 default,符合 Day 5 那條「別把 SCC 綁在 default 上」的提醒。
第二行的 nexus-node-resolver 不是這篇的授權對象,是同一個 Namespace 裡另一個 DaemonSet,解決 CRI-O 節點層級看不到叢集內部 DNS 的問題,跟 Nexus 本身無關。它綁的是 privileged 而不是 anyuid,查它的 spec 可以對上原因:hostNetwork: true 加 securityContext.privileged: true,這兩項都超出 anyuid 的授權範圍,必須用更高階的 privileged。這個元件本篇不展開。
(Select-Object name 直接寫會抓到空白,因為 RoleBinding 的名稱在 .metadata.name 而不是 .name。這是 PowerShell 屬性路徑的問題,不是叢集沒資料。)
再看 Pod 這一端的最終結果:
PS> & $OC get pod -n nexus -l app.kubernetes.io/name=nexus3 -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}'
anyuid
PS> & $OC exec -n nexus nexus-nexus3-0 -c nexus3 -- id
uid=200(nexus) gid=200(nexus) groups=200(nexus)
annotation 顯示實際套用的是 anyuid,容器裡的 id 顯示它真的以 UID 200 在跑。從授權到生效,這兩條輸出把整條鏈路串起來了。
【延伸】同一顆映像,GID 為什麼在兩個 SCC 底下不一樣
Day 5 用同一顆 Nexus 映像測
restricted-v2時,量到的是gid=0(root);這裡走anyuid的 Nexus,量到的是gid=200。差別在 UID 有沒有被換掉。
restricted-v2會把 UID 換成 range 起始值的 UID(映像裡不存在),映像帶的身分資訊跟著失效,GID 落到 0;anyuid不換,映像宣告的200:200就原封不動留著。實務上的意思是:Day 5 那條
chgrp -R 0加chmod -R g=u的設計,前提是 workload 跑在會換 UID 的 SCC 底下。像 Nexus 這種走anyuid的,容器根本不在 group 0 裡,那套權限布置對它沒有作用。
這裡還牽涉兩個不同物件的存在時機,值得分開講。
Namespace 必須先存在,這是真的會失敗的部分:
PS> & $OC --kubeconfig $kubeconfig adm policy add-scc-to-user anyuid -z somesa -n does-not-exist-demo
Error from server (NotFound): namespaces "does-not-exist-demo" not found
ServiceAccount 不需要先存在。 Day 5 講過原理:SCC 授權底層是標準 RoleBinding,Kubernetes 允許把權限綁定給一個尚不存在的 Subject。也就是說,可以在 Helm 還沒建立 nexus-nexus3 這個 SA 之前,就先送出這條綁定指令,只要 Namespace 本身先存在就行。
那如果反過來,先 helm install、後補 SCC 授權呢?
Day 5 用 Deployment/ReplicaSet 實測過 controller 會自己重試、自己救回來,但留了一個明確的缺口:那次的結論是從 controller 的通用行為類推的,沒有實際換一種資源類型測過。Nexus 部署出來的是 StatefulSet,補這一段測試:
# statefulset-scc-test.yaml,固定 UID 200,跟 Nexus 官方映像的限制一致
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: statefulset-scc-test
namespace: statefulset-scc-demo
spec:
serviceName: statefulset-scc-test
replicas: 1
selector:
matchLabels: { app: statefulset-scc-test }
template:
metadata:
labels: { app: statefulset-scc-test }
spec:
containers:
- name: statefulset-scc-test
image: nginx:latest
command: ["sleep", "3600"]
securityContext:
runAsUser: 200
不預先授權,直接部署:
& $OC apply -f statefulset-scc-test.yaml
& $OC get events -n statefulset-scc-demo
LAST SEEN TYPE REASON OBJECT MESSAGE
2s Warning FailedCreate statefulset/statefulset-scc-test create Pod statefulset-scc-test-0 in StatefulSet statefulset-scc-test failed error: pods "statefulset-scc-test-0" is forbidden: unable to validate against any security context constraint: [provider "anyuid": Forbidden: not usable by user or serviceaccount, provider "pipelines-scc": Forbidden: not usable by user or serviceaccount, provider restricted-v2: .containers[0].runAsUser: Invalid value: 200: must be in the ranges: [1000760000, 1000769999], provider restricted-v3: .spec.securityContext.hostUsers: Invalid value: null: Host Users must be set to false, provider restricted-v3: .containers[0].runAsUser: Invalid value: 200: must be in the ranges: [1000, 65534], provider "restricted": Forbidden: not usable by user or serviceaccount, provider "nested-container": Forbidden: not usable by user or serviceaccount, provider "nonroot-v2": Forbidden: not usable by user or serviceaccount, provider "nonroot": Forbidden: not usable by user or serviceaccount, provider "hostmount-anyuid": Forbidden: not usable by user or serviceaccount, provider "hostmount-anyuid-v2": Forbidden: not usable by user or serviceaccount, provider "machine-api-termination-handler": Forbidden: not usable by user or serviceaccount, provider "hostnetwork-v2": Forbidden: not usable by user or serviceaccount, provider "hostnetwork": Forbidden: not usable by user or serviceaccount, provider "hostaccess": Forbidden: not usable by user or serviceaccount, provider "hostpath-provisioner": Forbidden: not usable by user or serviceaccount, provider "privileged": Forbidden: not usable by user or serviceaccount]
跟 Day 5 一樣的檢核流程,這次卡在 restricted-v2/restricted-v3 的 UID 區間,跟 seccomp 無關。這個 Namespace 的區間是 [1000760000, 1000769999],跟 nexus 的 1000670000 起始值不同,再次印證每個 Namespace 的區間都是獨立分配的。
不刪除、不重建,只補一句授權:
PS> Get-Date
2026年8月4日 上午 04:22:17
PS> & $OC --kubeconfig $kubeconfig adm policy add-scc-to-user anyuid -z default -n statefulset-scc-demo
clusterrole.rbac.authorization.k8s.io/system:openshift:scc:anyuid added: "default"
這裡綁 default 是因為測試用的 statefulset-scc-test.yaml(見上方 spec)本身沒宣告 serviceAccountName,跟著落到 default;statefulset-scc-demo 也是拋棄式測試 Namespace,用完即刪。正式環境仍依收工清單那條,綁具名 SA(如本節前面的 nexus-nexus3),不要綁 default。
用 Day 5 修正過的輪詢寫法(Where-Object 檢查所有容器是否 ready,不對陣列直接做布林轉換)確認狀態,以上面 Get-Date 那個時間點為起點量測,得到授權後 19.0 秒 Pod 自己轉成 Running 且所有容器 ready。跟 Day 5 一樣,這是輪詢量到的上界而非精確值。
這 19.0 秒裡的絕大部分是映像拉取,事件訊息裡寫著 Successfully pulled image "nginx:latest" in 16.695s。真正屬於「controller 偵測到新授權、重新建立 Pod」的那一段遠小於拉取時間,跟 Day 5 的結論同一個量級。
(訊息內容有抄下來,events 的原始時間戳沒有——測試 Namespace 事後也刪掉了,所以無法像 Day 5 那樣把 controller 反應時間單獨拆出來報一個數字。這裡只能給出「遠小於拉取時間」這個量級判斷。)
結論是 Day 5 的行為可以推廣到 StatefulSet:不是只有 Deployment/ReplicaSet 的 controller 會自己重試,StatefulSet controller 一樣會。Nexus 這種用 StatefulSet 部署的服務,「先 helm install、後補 SCC 授權」不會讓部署永久失敗,只會讓 Pod 多卡一段 FailedCreate 的噪音期。
補的是資源類型這一半。這次測的是手寫的 StatefulSet 而不是 Helm 裝出來的,不過 Helm 只是產生同樣的 StatefulSet 物件,之後的行為由 controller 決定,所以差別應該不大——但這句仍然是推論,不是量到的。
標準建立程序如下。採這個順序的理由是把 FailedCreate 的噪音窗口降到最低,不是因為反過來做一定會失敗:
$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kubeconfig = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$env:KUBECONFIG = $kubeconfig # helm 沒有比照下面每個 oc 呼叫都帶 --kubeconfig,改讀這個環境變數;不設的話會落到預設 ~/.kube/config,可能指到別的 context
$ns = "nexus"
$sa = "nexus-nexus3"
# 1. 建立 Namespace(若不存在,用 $LASTEXITCODE 判斷,不是靠有沒有輸出)
& $OC --kubeconfig $kubeconfig get namespace $ns 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
& $OC --kubeconfig $kubeconfig create namespace $ns
}
# 2. 綁定 SCC 授權(這一步不需要等 SA 存在)
& $OC --kubeconfig $kubeconfig adm policy add-scc-to-user anyuid -z $sa -n $ns
# 3. 建立 nexus-admin Secret(見上一節,Chart 只引用不生成,必須先於 helm install 存在)
$randomPass = [System.Guid]::NewGuid().ToString()
$b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($randomPass))
@"
apiVersion: v1
kind: Secret
metadata:
name: nexus-admin
namespace: $ns
type: Opaque
data:
password: $b64
"@ | & $OC --kubeconfig $kubeconfig apply -f -
# 4. 執行 Helm 安裝(values.yaml 必須含上面的 persistence 與 rootPassword 設定;--version 鎖 5.24.0,換版本前先照開場那條提醒重新核對預設值)
helm repo add stevehipwell https://stevehipwell.github.io/helm-charts/
helm install nexus stevehipwell/nexus3 -n $ns -f values.yaml --version 5.24.0
# 5. 部署後立即驗證持久化真的生效——別只看 Pod 是否 Running
& $OC --kubeconfig $kubeconfig get pvc -n $ns # 必須有一條 STATUS 為 Bound 的 PVC
values.yaml 直接決定 Nexus 是真正持久化的資產庫、還是重啟即歸零的暫存狀態,也決定 admin 密碼是走 Secret 還是走隨機尋寶。它必須帶上 persistence.enabled: true、storageClass、rootPassword.secret: nexus-admin——這三項都是前兩節挑出來的。還差一項,是換到 anyuid 之後才冒出來的帳,下一節查清楚再補。
把這些寫進 values 而非事後手動 patch,也確保日後 helm upgrade 不會把設定打回原形。
常見的教學會說:OpenShift 的 restricted SCC 禁止容器套用 seccomp 設定,所以 Chart 裡的 podSecurityContext.seccompProfile 必須改成 null,否則會看到 seccomp may not be set,錯誤字樣如下:
pod is forbidden: unable to validate against any security context constraint:
[container.seccomp.security.alpha.kubernetes.io/...: Forbidden: seccomp may not be set]
這句話漏掉了一個決定性的限定詞:是哪一個 SCC。同一個叢集(OpenShift 4.21、Kubernetes 1.34)上跑三次測試,會拿到三個不同的答案,而上一節授權出來的 anyuid,剛好落在最不利的那一格。
測試一:restricted-v2 底下,RuntimeDefault 沒有被擋。 用一個拋棄式的 Deployment(避免動到正式運作中的 Nexus)驗證:
& $OC apply -f seccomp-test.yaml # securityContext.seccompProfile.type: RuntimeDefault
PS> & $OC get pod -n seccomp-demo -l app=seccomp-test -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}'
restricted-v2
PS> & $OC get pod -n seccomp-demo -l app=seccomp-test -o jsonpath='{.items[0].metadata.annotations.seccomp\.security\.alpha\.kubernetes\.io/pod}'
runtime/default
PS> & $OC get pod -n seccomp-demo -l app=seccomp-test -o jsonpath='{.items[0].spec.securityContext}'
{"fsGroup":1000780000,"seLinuxOptions":{"level":"s0:c28,c12"},"seccompProfile":{"type":"RuntimeDefault"}}
Pod 正常排程、以 restricted-v2 執行,effective SecurityContext 直接顯示 seccompProfile: {type: RuntimeDefault},SCC 沒有任何抱怨。
【坑】對整包 annotations 下 jsonpath,讀到的是一團噪音
直接對整個
.metadata.annotations下 jsonpath 查詢(-o jsonpath='{.items[0].metadata.annotations}'),拿到的是一整包單行 JSON map,裡面混著 OVN 網路的內部 annotation,讀起來很亂。針對想看的 key 直接查到底(如上面兩條),才是乾淨的做法,跟 Day 4 講過的「查特定欄位,不要對整包資料做文字比對」是同一個道理。
這個 Pod 確實 crash 了,但原因跟 seccomp 無關:
2026/08/03 16:31:47 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)
nginx 想寫入 /var/cache/nginx 而失敗,是 Day 5 講過的 UID 問題:restricted-v2 指派了 uid-range 起始值的 UID(映像裡不存在),而映像的檔案權限沒有為此準備。SCC 沒有擋下這個 Pod,但它讓容器跑不起來——這是兩件不同的事,也正是 Day 5 那條 chgrp -R 0 加 chmod -R g=u 的設計要處理的問題。
測試二:restricted-v2 底下,真正會被擋的是 Localhost 加自訂 profile。 把同一個測試改成:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: my-custom-profile.json
這次拿到完整的 FailedCreate:
Error creating: pods "seccomp-test-localhost-5bb8784ccc-" is forbidden: unable to validate against any security context constraint: [provider "anyuid": Forbidden: not usable by user or serviceaccount, provider "pipelines-scc": Forbidden: not usable by user or serviceaccount, pod.metadata.annotations[container.seccomp.security.alpha.kubernetes.io/c]: Forbidden: localhost/my-custom-profile.json is not an allowed seccomp profile. Valid values are [runtime/default], provider restricted-v3: .spec.securityContext.hostUsers: Invalid value: null: Host Users must be set to false, provider "restricted": Forbidden: not usable by user or serviceaccount, provider "nested-container": Forbidden: not usable by user or serviceaccount, provider "nonroot-v2": Forbidden: not usable by user or serviceaccount, provider "nonroot": Forbidden: not usable by user or serviceaccount, provider "hostmount-anyuid": Forbidden: not usable by user or serviceaccount, provider "hostmount-anyuid-v2": Forbidden: not usable by user or serviceaccount, provider "machine-api-termination-handler": Forbidden: not usable by user or serviceaccount, provider "hostnetwork-v2": Forbidden: not usable by user or serviceaccount, provider "hostnetwork": Forbidden: not usable by user or serviceaccount, provider "hostaccess": Forbidden: not usable by user or serviceaccount, provider "privileged": Forbidden: not usable by user or serviceaccount]
關鍵句是這一段:localhost/my-custom-profile.json is not an allowed seccomp profile. Valid values are [runtime/default]。
訊息本身就講明了規則:runtime/default 是被允許的,被擋的只有自訂 profile。到這裡為止,舊說法看起來確實被推翻了。
測試三:換成 anyuid,舊說法完全成立。 前兩個測試都是在 restricted-v2 底下量的,而上一節授權完的 Nexus 走的是 anyuid。先查這個 SCC 允許哪些 profile:
& $OC get scc anyuid -o jsonpath='{.seccompProfiles}'
指令成功執行,但什麼都沒印出來。這裡有個判讀陷阱:畫面空白有兩種可能,一種是欄位存在但值為空,一種是欄位根本不在這個物件上。要分辨,改查物件實際有哪些屬性:
PS> $obj = & $OC get scc anyuid -o json | ConvertFrom-Json
PS> $obj.PSObject.Properties.Name | Sort-Object
allowedCapabilities
allowHostDirVolumePlugin
allowHostIPC
allowHostNetwork
allowHostPID
allowHostPorts
allowPrivilegedContainer
allowPrivilegeEscalation
apiVersion
defaultAddCapabilities
fsGroup
groups
kind
metadata
priority
readOnlyRootFilesystem
requiredDropCapabilities
runAsUser
seLinuxContext
supplementalGroups
userNamespaceLevel
users
volumes
anyuid 這個 SCC 物件總共 23 個欄位,seccompProfiles 不在其中,確認是後者。
欄位不在物件上,不代表 schema 沒有這個欄位。測試二那句 Valid values are [runtime/default],就是 admission 讀某個 SCC 的 seccompProfiles 讀出來的,restricted-v2 上明明有值。只是在 anyuid 這個物件上它是 nil,而序列化時 nil 欄位會被整個省略,查起來才跟「空的」長得一模一樣。
nil 在這裡的語意不是「不限制」,而是「一個都不允許」。實際送一個 runAsUser: 0、帶 RuntimeDefault 的 Pod 進去:
pods "seccomp-demo-pod" is forbidden: unable to validate against any security context constraint:
[pod.metadata.annotations[seccomp.security.alpha.kubernetes.io/pod]: Forbidden: seccomp may not be set,
pod.metadata.annotations[container.seccomp.security.alpha.kubernetes.io/test]: Forbidden: seccomp may not be set,
provider "pipelines-scc": Forbidden: not usable by user or serviceaccount,
provider restricted-v2: .containers[0].runAsUser: Invalid value: 0: must be in the ranges: [1000810000, 1000819999],
provider restricted-v3: .spec.securityContext.hostUsers: Invalid value: null: Host Users must be set to false,
provider restricted-v3: .containers[0].runAsUser: Invalid value: 0: must be in the ranges: [1000, 65534],
provider "restricted": Forbidden: not usable by user or serviceaccount, ...]
(這則訊息是後續驗證時在另一個同模式的 namespace seccomp-demo-verify 重新產生的,不是測試一那個 seccomp-demo 本身,所以這裡的 uid-range 號碼跟測試一量到的 fsGroup:1000780000 對不起來——兩者是不同 namespace 各自分配到的區塊,不是同一個 namespace 前後兩次量到的數字。)
前兩行沒有 provider "X": 前綴,是 anyuid 留下的拒絕紀錄——它 priority 最高(10),所以最先被嘗試,這就是開頭那句舊說法描述的錯誤,一字不差。但清單裡不是每一行都代表「沒授權」:restricted-v2/restricted-v3 那三行給的是具體的 UID range 拒絕理由(provider restricted-v2: 這種寫法名稱沒有引號,跟後面 provider "restricted": 這種有引號包住的不一樣),代表它們確實被拿去評估過,只是被 runAsUser: 0 擋下,不是這個 SA 沒有存取權——這兩個 SCC 預設就對所有已認證身分開放,不需要個別授權,跟前一節 exp2-pod 那顆落到 restricted-v2 的普通 Pod 是同一件事的兩面。真正標著 Forbidden: not usable by user or serviceaccount(名稱有引號包住)的那些,才是純粹沒被授權,跟 seccomp、UID 都無關。這個 SA 明確被授權的只有 anyuid,但它不是清單裡唯一「被評估」的 SCC。另外值得一提,送出的是新版的 spec.securityContext.seccompProfile 欄位,但拒絕訊息引用的仍是舊版 annotation 的措辭。
【坑】用管理員身分測 SCC,量到的是管理員的候選清單
測試三的前兩次嘗試,我用 kubeconfig 的預設身分(
system:admin)直接oc apply,候選清單裡混進了管理員自己能用的所有 SCC,Pod 分別被nonroot-v2(UID 200 時)和privileged(UID 0 時)接手,anyuid怎麼判定完全被蓋掉。改成--as=system:serviceaccount:seccomp-demo:seccomp-demo-sa以 SA 身分送出,才把anyuid單獨隔離出來。機制 Day 5 已經拆過:發起請求的身分會蓋過 Pod 宣告的 ServiceAccount,而 controller 建立的 Pod 不吃這一套。所以這個陷阱只在直接送出 Pod 時存在——測試一、二那種包成 Deployment 的測法,不加
--as也量得準。用管理員身分做 SCC 測試,量到的是管理員的候選清單,不是 workload 實際會走的那一條。
實務上該怎麼做:這題沒有一體適用的答案,要看 workload 落在哪一個 SCC 上。
restricted-v2(多數 workload):seccompProfile: RuntimeDefault 不需要改成 null,該檢查的是 Chart 有沒有用到 Localhost 加自訂 profile。anyuid(像本篇的 Nexus):Chart 預設的 RuntimeDefault 必須改成 null,否則 Pod 根本進不去。上一節結尾欠的那一項,就是這個。這個叢集上正式部署的 values 帶著這一段(helm get values -n nexus nexus 查得到):
podSecurityContext:
seccompProfile: null
加上這一行,values.yaml 才是完整的:兩項來自〈二〉的預設值陷阱,一項來自〈三〉的 SCC 選擇。
上一節手動授權 anyuid 解決了 UID 200 的問題,就得連帶接受 anyuid 在 seccomp 這一格比 restricted-v2 更嚴。選 SCC 是整組換掉,不是只換你想換的那一條規則——上一節那張表裡「自訂 SCC」之所以值得考慮,理由就在這裡:它把 UID 和 seccomp 一起收下,實測過的 nexus-uid200 就是這樣同時吃下 runAsUser: 200 和 RuntimeDefault。
至於那些舊教學為什麼會那樣寫:OpenShift 4.10 以前的預設 SCC 叫 restricted,它同樣禁止帶 RuntimeDefault 的 Pod,4.11 起新增的 restricted-v2 才開始允許。那些說法在當年幾乎是全域成立的,只是後來預設 SCC 換掉了,而它們沒有跟著更新。(由舊版升級上來的叢集要留意:舊的 restricted 仍然存在,RoleBinding 沒換過去的話,實際套用到的可能還是它。)
這已經是連續第二篇遇到類似狀況,但性質跟 Day 5 不同。Day 5 推翻的是「不預先綁定 SCC 就會部署失敗」,那句話本身是錯的;今天這句「seccomp 一律不准設」並不錯,它只是漏了適用範圍,被抄成了通則。後者其實更難察覺——去實測,一半的情況會驗證它、另一半會反駁它,端看你挑在哪個 SCC 底下測。所以該養成的習慣不是「不信網路說法」,而是先問這個結論的成立條件是什麼,再對照自己的 workload 落在哪一格。
Nexus3 Chart 預設會生出兩個叢集內部 Service——一個一般的 ClusterIP,一個 StatefulSet 需要的 headless Service(<release>-<chart-name>-hl,clusterIP: None,供 Pod 的穩定 DNS 名稱使用)——而且一般的那個預設只開一個 port:
service:
# -- Service type.
type: ClusterIP
# -- Service annotations.
annotations: {}
# -- Default port.
port: 8081
# -- Additional ports to expose.
# @default -- See _values.yaml_
additionalPorts: []
additionalPorts 預設是空陣列。Chart 的 values 檔案在下面用註解給了一組 docker-group/docker-hosted 的範例,這個叢集就是把那組範例照著啟用,所以實際查到三個 port:
& $OC get svc -n nexus -o custom-columns=NAME:.metadata.name,TYPE:.spec.type,PORTS:.spec.ports[*].port
NAME TYPE PORTS
nexus-nexus3 ClusterIP 8081,8082,8083
nexus-nexus3-hl ClusterIP 8081
不論開幾個 port,預設都不會建立任何對外 Route。叢集內的 CI Pod 直接用 nexus-nexus3.nexus.svc.cluster.local:8081 連線即可運作,拉映像則走 8082/8083。這種「預設不對外」本身就是符合最小暴露原則的設計,離線/管制環境維持這個內部封閉狀態完全沒問題。
因此,建立 Route 不是理所當然的安裝步驟,而是一個刻意的邊界暴露決策。
這個叢集做了這個決策,理由是要讓叢集外的自動化維運腳本能存取 Nexus——例如從 Windows 本機直接呼叫 Nexus REST/ExtDirect API 設定 Outbound Proxy、簽署 EULA,這是本系列後續會用到的能力:
# 做出暴露決策後,手動建立 edge TLS Route(外部 HTTPS,Router→Pod 走內部 HTTP)
& $OC create route edge nexus --service=nexus-nexus3 --port=8081 `
--insecure-policy=Redirect -n nexus
建立後的結果:
PS> & $OC get route -n nexus
NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD
nexus nexus-nexus.apps-crc.testing nexus-nexus3 8081 edge/Redirect None
TERMINATION 欄位的 edge/Redirect 就是這條 Route 的兩個關鍵設定:edge 終止讓外部流量走 HTTPS(Router 到內部 Pod 才走 HTTP),Redirect 則把 HTTP 請求強制導向 HTTPS。
關鍵在於「決策是誰做的、為什麼做」:這是為了明確的外部自動化需求主動開出來的,Helm 本身不會自動建立這個 Route。
【範圍】暴露之後的那些事,這篇不處理
一旦對外暴露,就要一併扛起隨之而來的責任:認證、TLS 憑證、來源網段限制。若沒有明確的外部需求,維持純 ClusterIP 內部存取才是更安全的預設,能不暴露就不暴露。這條 Route 實際怎麼保護,本系列後續會處理自簽憑證與憑證不落地的部分。
正因為前面正確啟用了 PVC 持久化,這一節的清理動作才有意義。這是持久化的另一面:資料留得住,所以「重置」時就得主動清。
這裡有一個容易講錯的地方,我原本也以為 Chart 的 persistence.retainDeleted 是疊在 Kubernetes 之外的一層額外保護。查樣板才發現不是:
nexus3\templates\statefulset.yaml:436: {{- if semverCompare ">= 1.27-0" .Capabilities.KubeVersion.Version }}
nexus3\templates\statefulset.yaml:437: persistentVolumeClaimRetentionPolicy:
nexus3\templates\statefulset.yaml:438: whenDeleted: {{ ternary "Retain" "Delete" .Values.persistence.retainDeleted }}
nexus3\templates\statefulset.yaml:439: whenScaled: {{ ternary "Retain" "Delete" .Values.persistence.retainScaled }}
nexus3\templates\statefulset.yaml:440: {{- end }}
retainDeleted 寫的就是 StatefulSet 的原生欄位 spec.persistentVolumeClaimRetentionPolicy.whenDeleted,沒有第二層機制。這個欄位在 Kubernetes 1.23 進入 alpha、1.27 beta、1.32 正式 GA,樣板用 semverCompare ">= 1.27-0" 判斷叢集版本夠不夠新才輸出它。
查這個叢集上的實際值:
& $OC get statefulset nexus-nexus3 -n nexus -o jsonpath='{.spec.persistentVolumeClaimRetentionPolicy}'
{"whenDeleted":"Retain","whenScaled":"Retain"}
跟 Chart 預設的 retainDeleted: true/retainScaled: true 對得上。
所以正確的說法是「一個機制被設成了安全的預設值」,而不是「兩層保護疊加」。這個區別有實際後果:如果有人把 retainDeleted 設成 false,whenDeleted 就會變成 Delete,helm uninstall 刪掉 StatefulSet 時,PVC 會被 Kubernetes 連坐刪除,資料直接沒了。 「PVC 不會消失」不是 StatefulSet 的天性,是這個欄位目前的值。
1.27 之前是 alpha 且 feature gate 預設關閉,實務上等同不存在,StatefulSet 產生的 PVC 本來就沒有任何自動清除路徑,行為等同硬編碼的 Retain。1.27 之後這件事變成可配置的,也就變成一個要確認的設定項。
若未將 PVC 一併清除即重新部署,新建立的 Pod 會直接掛載既有的舊儲存區,或引發 PVC 鎖定衝突。看起來是重新部署了一個乾淨環境,實際上仍沿用上一輪留下的資料。
【延伸】清理不掉,代表前面就沒設對
如果部署時不小心落入 emptyDir 的預設(
oc get pvc -n nexus空的),那確實沒有 PVC 需要清理,但這代表 Nexus 資料本來就「重啟即消失」,稱不上是好狀態。這種情況該做的是回到前面把持久化補上。能順利執行下面這段清理,本身就代表持久化設定是正確的。
完整卸載與狀態重置動作:
# 先查出實際的 PVC 名稱(volumeClaimTemplate 會生成 <template-name>-<statefulset-name>-<ordinal>,StatefulSet 名稱依 Helm 慣例是 <release>-<chart-name>,這個叢集上是 data-nexus-nexus3-0)
& $OC get pvc -n nexus
helm uninstall nexus -n nexus
& $OC delete pvc data-nexus-nexus3-0 -n nexus # 依實際查到的名稱替換
values.yaml 啟用 persistence(PVC),避免資料落在 emptyDir 上重啟即消失;部署後用 oc get pvc 確認有 Bound 的 PVC,不要看 CAPACITY 欄位的數字。nexus-admin Secret,並在 values.yaml 設定 rootPassword.secret,避免 admin 密碼落在 Pod 內的 /nexus-data/admin.password 尋寶檔。openshift.enabled——欄位名稱各家不同(Bitnami 是 global.compatibility.openshift.adaptSecurityContext),要去查該 Chart 自己的 values。restricted-v2 底下 RuntimeDefault 不必改成 null,要防的是 Localhost 加自訂 profile;anyuid 底下 seccompProfiles 是 nil,Chart 預設的 RuntimeDefault 必須設成 null。anyuid 開放的是任意 UID 含 root(實測放行 uid=0),而且它不收 seccomp;自訂 SCC 只換 UID 那一格、RuntimeDefault 保得住,是更貼身的做法。default;授權後查 Pod 的 openshift.io/scc annotation 確認實際套用的是哪一個。persistentVolumeClaimRetentionPolicy 是不是 Retain,這個欄位設成 Delete 的話 PVC 會跟著 helm uninstall 一起消失。這篇處理的三種落差——直接失敗、不報錯但資料留不住、舊說法漏了適用範圍——回頭看危險程度並不對稱。
直接失敗的那個最好處理,因為它自己會叫。資料留不住的那個最危險,因為它什麼都不說。舊說法那個最耗神,因為它半對半錯:實測會驗證它還是反駁它,取決於你挑在哪個 SCC 底下測,光是「我實際跑過」並不足以下結論。
三者的共同處理方法也一樣:查 Chart 自己的預設值和樣板邏輯,不要相信網路上流傳的說法或自己的直覺假設。retainDeleted 那一段是最好的例子——我原本以為它是 Chart 額外加的一層保護,打開樣板才發現它只是 Kubernetes 原生欄位的一個開關,而且可以被關掉。
有了 Helm 在 OpenShift 上能跑起來的完整流程,明天(Day 7)要正式把 Nexus 這個內部私有資產庫用起來,處理它的 Outbound Proxy 設定。
參考文件
OpenShift
restricted-v2 於 4.11 取代 restricted 成為新安裝叢集的預設 SCC;同一段也寫明 seccompProfile 未設定時會被預設成 runtime/default,而先前版本這個欄位必須留空,這就是舊說法的分界點,對應〈四、seccompProfile〉restricted 與 restricted-v2 會並存、且後者在 admission 排序中優先,對應〈四、seccompProfile〉結尾的但書anyuid 綁定後、Pod 設 seccompProfile 會被拒的官方紀錄,對應〈四、seccompProfile〉的測試三(Issue 與適用版本可公開閱讀,Resolution 需要 Red Hat 訂閱)restricted-v3 與 nested-container 兩個 SCC 也是這一版新增的,對應錯誤訊息裡出現的候選清單Kubernetes
persistentVolumeClaimRetentionPolicy 的兩個欄位語意與預設值,對應〈六、狀態清理〉semverCompare ">= 1.27-0" 判斷的原因RuntimeDefault 與 Localhost 兩種 profile 的差別,以及 localhostProfile 必須存在於節點上,對應〈四、seccompProfile〉的測試二Helm Chart
persistence、rootPassword、service、securityContext),實際查驗請用 helm show values --version 5.24.0 對照該版本global.compatibility.openshift.adaptSecurityContext 的參數說明與 auto/force/disabled 三個值,對應〈一、Chart 有沒有內建的 OpenShift 相容開關〉common.compatibility.renderSecurityContext 的實作,omit 掉 fsGroup/runAsUser/runAsGroup 的那一行就在這裡,對應〈這個開關實際做了什麼〉Sonatype
/nexus-data 必須可被以 UID 200 執行的 Nexus 行程寫入,對應〈三、Namespace 預建與 SCC 授權時序〉