iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 10

Day 10:把憑證擋在版控之外 —— Sealed Secrets 的加密、綁定與離線復原

  • 分享至 

  • xImage
  •  

今日目的:在 CRC 上部署 Sealed Secrets Controller,並以三項判定確認「密文可以進版控」這件事真的成立。
先備知識:已能以 oc 連上 CRC 叢集,並具備 cluster-admin 權限用於初始設定。


開場對照表

直覺作法 實測結果
chart 有寫死的 UID,授 anyuid 就好 授權生效,Pod 依然建不出來。anyuidrestricted-v2 各滿足一半
helm template 看渲染結果 看不到叢集,OpenShift 偵測不成立,判斷會錯
helm uninstall 之後就乾淨了 CRD 留著,下一輪的檢核變成恆真
Pod 沒起來就查 Pod SCC 在准入階段拒絕時,Pod 物件從未建立,查不到任何東西
解得開就代表機制正確 只有正證無法排除「不靠這個機制也會成立」

實測環境

項目 版本
CRC / OpenShift 2.61.0 / 4.21.14
Chart / app sealed-secrets 2.19.1 / 0.38.4
kubeseal 0.38.4
helm v4.2.0(非 v3,兩者在 CRD 處理與 --dry-run=server 上有差異)
PowerShell 5.1(Windows 內建)

快樂路徑實跑五輪,以下輸出取自實測。換版本或換機器不保證重現。


一、要證明什麼

Sealed Secrets 的用法是:用公鑰把 Secret 加密成 SealedSecret,密文進版控,Controller 在叢集內用私鑰解回 Secret。要讓這個用法站得住,得證三件事:

  1. 鏈路 —— 一份 SealedSecret 從加密、套用到 Controller 解密,解出的內容與原文逐 key 相符
  2. 綁定 —— 同一份密文套到另一個 namespace,解不開
  3. 復原 —— 離開叢集之後,憑備份的金鑰仍解得回明文

第二項存在的理由是:只有第一項的話,無法排除「綁定沒生效,只是恰好一致」。第三項的理由是:私鑰在 Controller 首次啟動時於叢集內生成,叢集重建就會產生新的金鑰對,進了版控的密文會全數作廢。

判定二與判定三各自配一個反證。反證不是形式,它排除的是具體的替代解釋——「不靠這個機制也會成立」。

變數

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kubeconfig = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$apiServer  = "https://api.crc.testing:6443"

$ssNs         = "sealed-secrets"
$ssRelease    = "sealed-secrets"
$ssDeployerSa = "ss-deployer"
$nsA          = "ss-demo-a"     # 加密目標
$nsB          = "ss-demo-b"     # 反證用,刻意不一致

$ssWorkDir = Join-Path "$env:USERPROFILE\gitea-run" ("d10-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
New-Item -ItemType Directory -Path $ssWorkDir -Force | Out-Null
$pubKey  = Join-Path $ssWorkDir "sealed-secrets-public.pem"
$toolDir = "$env:USERPROFILE\gitea-run\tools"
New-Item -ItemType Directory -Path $toolDir -Force | Out-Null
$kubeseal = Join-Path $toolDir "kubeseal.exe"

這台機器上有兩支 oc.exe(cache\crc_hyperv_*\.crc\bin\oc\),上面的搜尋順序固定取 cache 那支,全程用同一支。$pubKey 放在無空白的路徑下,省去每次引用都要加引號。


二、清場

helm uninstall 不會帶走三樣東西:SCC 授權、CRD、以及節點上的殘留(本 chart 無 PVC,無此項)。

先查 SealedSecret,再刪任何東西。 刪 CRD 會連同該類型的所有密文一起刪掉,刪 namespace 也會帶走裡面的密文——兩者都會讓這個檢查失去意義,所以它排在最前面,而且要真的擋住,只印出來不擋等於沒查。

$ss = & $OC --kubeconfig $kubeconfig get sealedsecret -A --no-headers 2>$null
if ($ss) { $ss; throw "尚有 SealedSecret,繼續會一併刪除,請先確認" }
"既有 SealedSecret = 0"
helm uninstall sealed-secrets -n sealed-secrets --kubeconfig $kubeconfig

& $OC --kubeconfig $kubeconfig adm policy remove-scc-from-user anyuid -z sealed-secrets -n sealed-secrets
"remove-scc exit = $LASTEXITCODE"

& $OC --kubeconfig $kubeconfig delete namespace sealed-secrets ss-demo-a ss-demo-b --ignore-not-found
& $OC --kubeconfig $kubeconfig delete crd sealedsecrets.bitnami.com --ignore-not-found

release "sealed-secrets" uninstalled
未曾授予 anyuid 時回 rolebindings ... not found,remove-scc exit = 1,屬正常
CRD 刪除或 not found

adm policy remove-scc-from-user 沒有 --ignore-not-found,不像 delete 系列。把這一步包成腳本時,別用 exit code 判斷這一行的成敗。

CRD 為什麼要另外刪

Helm 官方文件明載:不支援用 Helm 升級或刪除 CRD,這是為避免誤刪造成資料遺失的設計決定。實測 helm uninstall 之後,ClusterRole 與 ClusterRoleBinding 都刪掉了,CRD 留下,CREATED 仍是上一輪的時間。

後果是第五節的檢核「CRD 存在」會被上一輪的殘留滿足,無論本輪安裝成功與否都通過。清場時另外刪掉,並把 CREATED 時間戳納入預期輸出,那個檢核才是真實量測。


三、部署身分與 SCC 策略

3.1 建 namespace 與部署帳號

& $OC --kubeconfig $kubeconfig create namespace $ssNs
& $OC --kubeconfig $kubeconfig create sa $ssDeployerSa -n $ssNs
& $OC --kubeconfig $kubeconfig adm policy add-role-to-user edit -z $ssDeployerSa -n $ssNs

$b = & $OC --kubeconfig $kubeconfig get rolebinding -n $ssNs -o json | ConvertFrom-Json
"SCC rolebinding 數量 = $(@($b.items | Where-Object { $_.roleRef.name -like 'system:openshift:scc:*' }).Count)"

SCC rolebinding 數量 = 0

不授權任何 SCC。3.3 會把 chart 調成 restricted-v2 收得下的形狀,第五節與收工時各驗一次。

3.2 選版本、切身分

helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm repo update
helm search repo sealed-secrets/sealed-secrets --versions | Select-Object -First 6

$ssChartVer = "2.19.1"
$ssAppVer   = "0.38.4"      # 第六節的 kubeseal 取同一版
$ssToken = & $OC --kubeconfig $kubeconfig create token $ssDeployerSa -n $ssNs --duration=8h
& $OC login --token=$ssToken --server=$apiServer --insecure-skip-tls-verify=true
& $OC whoami
& $OC auth can-i create deployment -n $ssNs
& $OC auth can-i create clusterrole

system:serviceaccount:sealed-secrets:ss-deployer
yes / no

Controller 需要叢集層級的 ClusterRole——它要 watch 所有 namespace 的 SealedSecret 並寫入 Secret——而部署帳號只有 edit。第二個 can-ino,所以接下來的 helm 指令都要帶 --kubeconfig。不先查的話,會在下一步得到一個 0 bytes 的渲染檔而不自知。

3.3 為什麼不授 anyuid

chart 渲染出三個 securityContext 欄位:seccompProfile: RuntimeDefaultfsGroup: 65534runAsUser: 1001

seccompProfile: RuntimeDefault fsGroup: 65534 / runAsUser: 1001
restricted-v2 允許 擋下
anyuid 擋下 允許

兩個 SCC 各滿足一半,沒有任何一個能同時通過。授予 anyuid 之後 Pod 依然建不出來,只是錯誤訊息換了一句:

授權前  provider "anyuid": Forbidden: not usable by user or serviceaccount
        provider restricted-v2: .spec.securityContext.fsGroup: Invalid value: [65534]
        provider restricted-v2: .containers[0].runAsUser: Invalid value: 1001

授權後  pod.metadata.annotations[seccomp.security.alpha.kubernetes.io/pod]: Forbidden: seccomp may not be set
        provider restricted-v2: .spec.securityContext.fsGroup: Invalid value: [65534]
        provider restricted-v2: .containers[0].runAsUser: Invalid value: 1001

anyuid 已經不在「not usable」清單裡,改由 seccomp may not be set 表達它的否決意見。授權生效了,是 anyuid 自己拒絕這個 Pod。

anyuid.seccompProfiles        = <none>              ← 欄位不存在,實測為不允許任何 profile
restricted-v2.seccompProfiles = [runtime/default]

SCC 不是一條由鬆到緊的直線。anyuid 允許任意 UID,卻在 seccomp 上比 restricted-v2 更嚴格——換到「更寬的 SCC」不保證原本能過的欄位還能過。

另外,anyuidpriority10,是預設 SCC 中唯一有值的。OpenShift 先按 priority 再按嚴格程度挑選,所以授予它會改變選擇順序,而不只是多一個選項:即使不撞 seccomp,原本走 restricted-v2 的 Pod 也可能改走 anyuid,fsGroup 策略隨之從 MustRunAs 變成 RunAsAny。這是不授它的第二個理由。

正確作法是縮小 chart 的要求,而不是放寬 SCC。這也是 chart README 對 OpenShift 的建議。

3.4 渲染對照

helm install $ssRelease sealed-secrets/sealed-secrets -n $ssNs --version $ssChartVer `
    --kubeconfig $kubeconfig --dry-run=server |
    Out-File (Join-Path $ssWorkDir "rendered-default.yaml")

helm install $ssRelease sealed-secrets/sealed-secrets -n $ssNs --version $ssChartVer `
    --kubeconfig $kubeconfig --dry-run=server `
    --set podSecurityContext.fsGroup=null `
    --set containerSecurityContext.runAsUser=null |
    Out-File (Join-Path $ssWorkDir "rendered-fit.yaml")

foreach ($f in "rendered-default.yaml", "rendered-fit.yaml") {
    $p = Join-Path $ssWorkDir $f
    "$f = $([System.IO.File]::ReadAllBytes($p).Length) bytes"
    Select-String -Path $p -Pattern "runAsUser|fsGroup|seccompProfile|runAsNonRoot"
}

rendered-default.yaml = 10736,命中 fsGroup: 65534 / seccompProfile / runAsNonRoot: true / runAsUser: 1001
rendered-fit.yaml = 10683,只剩 seccompProfile / runAsNonRoot: true

少的 53 bytes 就是被移除的兩行。seccompProfilerunAsNonRoot: true 保留。

3.5 離線渲染看不到叢集

上面這個判定必須用線上渲染。helm templatehelm install --dry-run=client 都不查叢集,.Capabilities.APIVersions 不含 security.openshift.io/v1,Bitnami 系列 chart 的 OpenShift 偵測不成立,渲染結果會與實際部署的不同。

helm install --dry-run=server 必須帶 --kubeconfig。部署帳號只有 edit,讀不到 ClusterRoleBinding,--dry-run=server 會在取得叢集資訊時失敗——而失敗的表現是渲染檔為 0 bytes,不是明顯的錯誤訊息。

helm template --dry-run=server 未測。上游回報該旗標在 helm template 上只影響 lookup 函式,對 .Capabilities 無效,行為可能隨 helm 版本而異。


四、安裝與就緒判定

$t0 = Get-Date
helm install $ssRelease sealed-secrets/sealed-secrets -n $ssNs --version $ssChartVer `
    --kubeconfig $kubeconfig `
    --set podSecurityContext.fsGroup=null `
    --set containerSecurityContext.runAsUser=null

STATUS: deployed,REVISION: 1

$deadline = (Get-Date).AddMinutes(10)
$last = ""
while ($true) {
    $raw = & $OC get pod -n $ssNs -o json 2>$null
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) {
        "[{0:HH:mm:ss}] (查詢失敗,重試)" -f (Get-Date)
    } else {
        $pods = ($raw | ConvertFrom-Json).items
        if ($pods.Count -eq 0) {
            $snap = "(namespace 內尚無 Pod)"; $allReady = $false
        } else {
            $snap = ($pods | ForEach-Object {
                $r = @($_.status.containerStatuses | Where-Object { $_.ready }).Count
                $t = @($_.status.containerStatuses).Count
                "$($_.metadata.name) $r/$t $($_.status.phase)"
            }) -join " | "
            $allReady = -not ($pods | Where-Object {
                -not ($_.status.conditions | Where-Object { $_.type -eq 'Ready' -and $_.status -eq 'True' })
            })
        }
        if ($snap -ne $last) { "[{0:HH:mm:ss}] {1}" -f (Get-Date), $snap; $last = $snap }
        if ($allReady) { "ALL READY"; break }
    }
    if ((Get-Date) -gt $deadline) {
        # Pod 從未出現時,問題在 admission,不在 Pod
        & $OC get deploy,rs -n $ssNs
        & $OC get rs -n $ssNs -o json | ConvertFrom-Json |
            ForEach-Object { $_.items.status.conditions } | Format-List
        throw "等待 Pod 就緒逾時(10 分鐘)"
    }
    Start-Sleep -Seconds 10
}
"全就緒耗時 = $([int]((Get-Date) - $t0).TotalSeconds) 秒"

0/1 Pending1/1 Running。首次含拉取映像約 20 秒;映像已快取時實測介於 2–12 秒,不具重現性,勿當基準值引用。

Pod 從未出現時,錯誤不在 Pod 上

SCC 在准入階段就拒絕,Pod 物件從未建立。所以這種查法會回報「無異常」:

oc get pod -A --field-selector=status.phase!=Running,status.phase!=Succeeded
# → No resources found

而 Deployment 當下正卡在 0/1。任何以 Pod 為對象的查詢都看不到這類故障。

迴圈一直印「(namespace 內尚無 Pod)」就是這個狀況,和拉映像慢的症狀不同——後者會看到 Pod 停在 ContainerCreating。錯誤只在 ReplicaSet 的 status.conditions 上,上面的逾時分支已經把這個查詢寫進去,不必事後才想起要查哪裡。


五、檢核

& $OC get pod -n $ssNs -o custom-columns=NAME:.metadata.name,SCC:.metadata.annotations."openshift\.io/scc"

# 映像無 shell 也無 id,從 Pod spec 讀。三個欄位分屬兩層:
#   runAsUser      → container 層
#   fsGroup        → pod 層
#   seccompProfile → pod 層
$pod = (& $OC get pod -n $ssNs -o json | ConvertFrom-Json).items[0]
"runAsUser      = $($pod.spec.containers[0].securityContext.runAsUser)"
"fsGroup        = $($pod.spec.securityContext.fsGroup)"
"seccompProfile = $($pod.spec.securityContext.seccompProfile.type)"
"uid-range      = $(& $OC --kubeconfig $kubeconfig get ns $ssNs -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}')"

SCC 為 restricted-v2,全程未授任何 SCC
runAsUserfsGroup 皆落在同時印出的 uid-range
seccompProfile = RuntimeDefault

移除欄位不等於沒有設定

3.4 把 runAsUserfsGroup 從 manifest 移除了,但實際 Pod spec 裡它們又出現:

containers[0].securityContext.runAsUser = 1000790000
spec.securityContext.fsGroup            = 1000790000
namespace uid-range                     = 1000790000/10000
spec.securityContext.seccompProfile     = RuntimeDefault   ← chart 指定值原樣保留

SCC 做的是兩件不同的事:對已指定的欄位做驗證(RuntimeDefault 在允許清單內,放行),對未指定的欄位做填值(從 namespace 區間配發)。

「把欄位拿掉」不是「沒有這個設定」,而是「交給叢集決定」。這正是授 anyuid 為何失敗、移除欄位為何成功的道理。

uid-range 每次重建 namespace 都不同(四輪實測為 1000690000100073000010007600001000790000),不可寫死,與 namespace 的 annotation 比對即可。取欄位時注意兩層之分,取錯層會印出空值。

CRD 與金鑰對

& $OC --kubeconfig $kubeconfig get crd sealedsecrets.bitnami.com `
    -o custom-columns=NAME:.metadata.name,CREATED:.metadata.creationTimestamp
& $OC get secret -n $ssNs -l sealedsecrets.bitnami.com/sealed-secrets-key `
    -o custom-columns=NAME:.metadata.name,TYPE:.type,CREATED:.metadata.creationTimestamp

CRD 存在,CREATED 為本輪的時間——第二節刪過才有意義
一份 kubernetes.io/tls 類型的金鑰 Secret

金鑰更新週期

預設每 30 天產生一把新的 sealing key,附加到既有集合。最近產生的那把用於封裝新密文,也是 kubeseal --fetch-cert 會下載的那一把。舊金鑰不會被刪除,舊密文仍解得開。週期由 --key-renew-period 控制,Helm 值為 keyrenewperiod,設 0 停用。

兩個實務後果:第六節匯出的公鑰在 30 天後不再是最新的那把;第八節的備份必須在每次更新後重做,否則叢集在新金鑰產生後、備份前失效,期間封裝的密文救不回來。


六、kubeseal 與公鑰

$ksUrl = "https://github.com/bitnami/sealed-secrets/releases/download/v$ssAppVer/kubeseal-$ssAppVer-windows-amd64.tar.gz"
$ksTgz = Join-Path $toolDir "kubeseal-$ssAppVer-windows-amd64.tar.gz"
Invoke-WebRequest -Uri $ksUrl -OutFile $ksTgz -UseBasicParsing

tar -xzf $ksTgz -C $toolDir      # Windows 內建 tar
& $kubeseal --version

kubeseal version: 0.38.4

專案已從 bitnami-labs 更名為 bitnami。舊網址中 repo 與 release 會 301 轉址,GitHub Pages 不會,全文一律使用 bitnami。解出的 kubeseal.exe 不在 PATH 上,用絕對路徑呼叫。

kubeseal 預設找的 controller 名稱是 sealed-secrets-controller、namespace 是 kube-system,與 chart 的預設安裝都不符,所以每次呼叫都要顯式帶這兩個參數。

$cert = & $kubeseal --fetch-cert `
    --controller-name=$ssRelease `
    --controller-namespace=$ssNs `
    --kubeconfig $kubeconfig | Out-String
[System.IO.File]::WriteAllText($pubKey, $cert, (New-Object System.Text.UTF8Encoding $false))

$pk = [System.IO.File]::ReadAllBytes($pubKey)
"bytes = $($pk.Length); first5 = $(($pk[0..4] | ForEach-Object { $_.ToString('X2') }) -join ' ')"

bytes = 1752;first5 = 2D 2D 2D 2D 2D(-----BEGIN CERTIFICATE-----)

不要用 > 重導向。PowerShell 5.1 的 > 會產生帶 BOM 的檔案,污染 PEM;PowerShell 7 預設無 BOM,不會踩到。first5 若是 EF BB BFFF FE,就是這裡出的問題。

公鑰無解密能力,可以進版控。


七、加密與套用

--dry-run=client 產生明文 YAML 後直接 pipe 給 kubeseal,明文只存在於管線中,不落盤。

& $OC --kubeconfig $kubeconfig create namespace $nsA
& $OC --kubeconfig $kubeconfig create namespace $nsB

$plainUser = "demo-user"
$plainPass = [System.Guid]::NewGuid().ToString()

$sealedPath = Join-Path $ssWorkDir "demo-cred.sealed.yaml"
$sealed = & $OC --kubeconfig $kubeconfig create secret generic demo-cred `
    --dry-run=client `
    --from-literal=username=$plainUser `
    --from-literal=password=$plainPass `
    -n $nsA -o yaml |
    & $kubeseal --cert $pubKey --format yaml | Out-String
[System.IO.File]::WriteAllText($sealedPath, $sealed, (New-Object System.Text.UTF8Encoding $false))
$sealedText = [System.IO.File]::ReadAllText($sealedPath)
"含明文密碼 = $($sealedText.Contains($plainPass))"
"含明文帳號 = $($sealedText.Contains($plainUser))"
"含 base64 密碼 = $($sealedText.Contains([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($plainPass))))"

三者皆為 False

第三行是刻意加的:base64 不是加密,只檢查明文不足以說明密文安全。這三項只排除「明文」與「單純 base64」,不構成密碼學層面的驗證。

& $OC --kubeconfig $kubeconfig apply -f $sealedPath
& $OC --kubeconfig $kubeconfig get sealedsecret demo-cred -n $nsA

sealedsecret.bitnami.com/demo-cred created;SYNCED = True

apply 成功只代表 CRD 被接受,不代表解密發生。


八、三項判定

判定一:鏈路

get secret 抓得到只證明有東西被寫出來,內容對不對是另一件事。所以另發一次讀取,把解出的值逐 key 與加密前的原文比對。

$deadline = (Get-Date).AddMinutes(2)
while ($true) {
    & $OC --kubeconfig $kubeconfig get secret demo-cred -n $nsA -o name 2>$null | Out-Null
    if ($LASTEXITCODE -eq 0) { break }
    if ((Get-Date) -gt $deadline) {
        & $OC --kubeconfig $kubeconfig logs deployment/$ssRelease -n $ssNs --tail=30
        throw "等待 Controller 解密逾時"
    }
    Start-Sleep -Seconds 3
}

$gotUser = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC --kubeconfig $kubeconfig get secret demo-cred -n $nsA -o jsonpath='{.data.username}')))
$gotPass = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC --kubeconfig $kubeconfig get secret demo-cred -n $nsA -o jsonpath='{.data.password}')))

"username match = $($gotUser -eq $plainUser)"
"password match = $($gotPass -eq $plainPass)"

兩個 match 皆為 True;SealedSecret 的 conditions 為 type: Synced / status: True

判定二:綁定

改掉密文中的 namespace 後 apply,看它解不開。

$crossPath = Join-Path $ssWorkDir "demo-cred.cross.yaml"
$cross = (Get-Content $sealedPath -Raw).Replace("namespace: $nsA", "namespace: $nsB")
[System.IO.File]::WriteAllText($crossPath, $cross, (New-Object System.Text.UTF8Encoding $false))

& $OC --kubeconfig $kubeconfig apply -f $crossPath
Start-Sleep -Seconds 5
& $OC --kubeconfig $kubeconfig get sealedsecret demo-cred -n $nsB
& $OC --kubeconfig $kubeconfig get secret demo-cred -n $nsB
"get secret exit = $LASTEXITCODE"
& $OC --kubeconfig $kubeconfig logs deployment/$ssRelease -n $ssNs --tail=30 | Select-String "decrypt|giving up"

apply 被接受(created,exit 0)
STATUSno key could decrypt secret,SYNCED = False
get secretNotFound,exit = 1
Controller log 有 will retry ×5 → Error updating, giving up

三個訊號要一致。只有其中一個成立,表示綁定的行為與預期不同,照實記錄。

Start-Sleep 5 綽綽有餘:實測從第一次失敗到放棄僅 217 毫秒。log 中 (password, username)(username, password) 兩種順序交錯出現,是 map 迭代順序不定,不是兩種不同的錯誤。

這一步排除的替代解釋是:綁定沒生效,只是恰好一致。

判定三:離線復原

私鑰在 Controller 首次啟動時於叢集內生成,叢集重建就會產生新的金鑰對。備份是這個工具最常被提醒的操作要求,而它是否有效可以當場驗,不必真的毀掉叢集。

$keyBackup = Join-Path $ssWorkDir "sealing-keys-backup.yaml"
$kb = & $OC --kubeconfig $kubeconfig get secret -n $ssNs `
    -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml | Out-String
[System.IO.File]::WriteAllText($keyBackup, $kb, (New-Object System.Text.UTF8Encoding $false))

backup bytes = 7109(單把金鑰時);頂層為 kind: List,內含一份 Secret,label 值為 active

label 不要加 =active。備份檔中該 label 的值正是 active,只寫 label 名稱會撈到全部(含輪替後保留的舊金鑰),加了 =active 就只剩最新那把,舊密文救不回來。這是這一步最容易寫錯的地方。

kind: Listoc get -o yaml 撈多筆的固定結構,--recovery-private-key 明確支援該格式。

這個檔案能解開所有的 SealedSecret,不可進版控。公鑰進版控、私鑰進密碼管理器或加密磁碟,兩者不可混淆。本文為求可核對而寫在工作目錄,實務上應移到加密儲存。

正證:憑備份解得開

$recovered = & $kubeseal --recovery-unseal --recovery-private-key $keyBackup -f $sealedPath -o yaml | Out-String
"recovery exit = $LASTEXITCODE"

$rec = $recovered -split "`n"
$recUser = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (($rec | Select-String "^\s+username:").ToString() -split ":\s*")[-1].Trim()))
$recPass = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (($rec | Select-String "^\s+password:").ToString() -split ":\s*")[-1].Trim()))

"offline username match = $($recUser -eq $plainUser)"
"offline password match = $($recPass -eq $plainPass)"
Remove-Variable recovered, rec, recUser, recPass

recovery exit = 0;兩個 offline ... match 皆為 True

--help 寫的是從 stdin 取得,實測 -f 也接受。解出的明文不落盤,比對完即清掉變數。

反證:沒有備份就解不開

& $kubeseal --recovery-unseal -f $sealedPath -o yaml
"no-key exit = $LASTEXITCODE"

error: no key could decrypt secret (username, password),no-key exit = 1

排除的替代解釋是:其實不靠那份備份也解得開。

錯誤訊息與判定二 Controller 吐的是同一句——同一份程式碼、同一個判斷,只是一個在叢集端、一個在離線端。

這一步完全不經過叢集

$saved = $env:KUBECONFIG
$env:KUBECONFIG = "C:\nonexistent\no-such-kubeconfig.yaml"
& $kubeseal --recovery-unseal --recovery-private-key $keyBackup -f $sealedPath -o yaml | Out-Null
"offline-with-no-cluster exit = $LASTEXITCODE"
$env:KUBECONFIG = $saved

offline-with-no-cluster exit = 0

叢集完全不可達時仍照常運作,這正是災難復原情境要的性質。判定一證明鏈路可用,這一步證明鏈路可還原,兩者是不同的事。


收工前檢查清單

  • [ ] 清場先查 SealedSecret:刪 CRD 與刪 namespace 都會帶走密文,這個檢查要排在最前面而且要真的擋住。
  • [ ] CRD 另外刪:helm uninstall 不刪 CRD,不刪的話「CRD 存在」的檢核恆真。
  • [ ] 不授 SCC:改用兩個 --set 移除 fsGrouprunAsUser,收工時確認 SCC rolebinding 數量仍為 0。
  • [ ] 渲染用 --dry-run=server 且帶 --kubeconfig:渲染檔 0 bytes 就是漏了 --kubeconfig
  • [ ] 等待迴圈的逾時分支要查 ReplicaSet:Pod 從未建立時,查 Pod 什麼都看不到。
  • [ ] 公鑰用 WriteAllText 寫檔:PowerShell 5.1 的 > 會加 BOM 污染 PEM。
  • [ ] kubeseal 每次都帶 --controller-name--controller-namespace:預設值與 chart 安裝結果不符。
  • [ ] 備份 label 不加 =active:加了就只剩最新一把,舊密文救不回來。
  • [ ] 備份檔移出工作目錄:它能解開所有密文,不可進版控。
  • [ ] 判定要配反證:綁定與復原各有一個,只有正證無法排除替代解釋。

收工時保留 Controller、金鑰對與公鑰,後續會用到;ss-demo-a / ss-demo-b 兩個 demo namespace 刪除。

& $OC --kubeconfig $kubeconfig delete namespace $nsA $nsB
& $OC --kubeconfig $kubeconfig get rolebinding -n $ssNs -o json | ConvertFrom-Json |
    ForEach-Object { $_.items } | Where-Object { $_.roleRef.name -like "system:openshift:scc:*" } |
    Select-Object @{n='role';e={$_.roleRef.name}}

SCC rolebinding 無輸出


未驗證項目

  • 叢集重建後的實際還原未測。 本文證明的是「離線解得開」,不是「還原到新叢集後 Controller 認得」。已知兩個要點:Controller 在啟動時讀取金鑰,還原後必須重啟,順序是先 apply 金鑰再讓它起來——找不到既有金鑰時它會自行生成一把新的並設為 active;備份檔帶著舊叢集的 uidresourceVersion,跨叢集還原的通用慣例是先刪掉這兩個欄位。
  • 自帶金鑰(BYOK)未評估。 本文採「Controller 自行生成 + 事後備份」。另一種作法是自行產生金鑰對、私鑰保存在叢集外,只在部署新 Controller 時餵入一次,如此金鑰不綁定特定叢集,代價是自行保管的責任。必須在安裝時決定,事後改要重封所有密文。
  • 金鑰更新未實測。 30 天週期、舊金鑰保留、--key-renew-period 皆為官方說明,本文只觀測到首次生成的一把。
  • anyuidseccompProfiles 語意未查到規範。 欄位不存在時的行為由實測與第三方回報佐證,非文件化語意。
  • 就緒耗時的變異未查。 同為「映像已快取」,三輪實測為 1、2、12 秒。未重複測量,也未檢視 readinessProbe 設定。

參考文件

規格與說明:

第三方回報(本文據以佐證,非本文實測):


收尾

這一天的成果是三件事:密文可以進版控、密文換了 namespace 就解不開、金鑰備份離開叢集仍然有效。

過程中比較花時間的不是部署本身,而是 SCC。「有寫死的 UID 就授 anyuid」這個規則本身沒錯,但不完整——anyuidrestricted-v2 在不同欄位上各自嚴格,升級到更寬的 SCC 不保證原本能過的欄位還能過。正確的方向是縮小 chart 的要求,讓 restricted-v2 收得下。

下一步是把真正的憑證封進去。目前用的是拋棄式的 demo-cred,要把 Gitea 的管理帳號加密進版控,得先決定它落在哪個 namespace——因為 namespace 是綁定的一部分,決定錯了就要重封。


上一篇
Day 9 中繼驗證:Gitea webhook 送達叢集內的私有 ClusterIP
下一篇
Day 11:裝好 Tekton Pipelines 之後,叢集多了一個能變成任何人的 ServiceAccount
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言