Skip to content

Azure Pipeline — Kubeconfig 隔離最佳實踐

在 Azure Pipeline self-hosted runner 上,以 Agent.TempDirectory + KUBECONFIG 環境變數取代 ~/.kube/config,避免多 job 並行時的 race condition 與憑證殘留。

概述

Azure DevOps Pipeline 搭配 self-hosted runner 部署 K8s 時,常見的做法是將 kubeconfig decode 後寫入 ~/.kube/config。問題在於 ~ 指向 runner host 的真實 home 目錄,屬於全域共用路徑:多條 pipeline 同時執行時,後寫入的 config 會覆蓋前一個(race condition);job 結束後憑證檔案仍殘留在 host(安全疑慮);下一個 job 可能誤用上一次留下的 cluster config(環境污染)。

解決方式是使用 $(Agent.TempDirectory) 下的唯一路徑,透過 KUBECONFIG 環境變數讓 kubectl 讀取,並在 job 結束時(含失敗情境)強制清理。此方案不修改任何 host 全域設定,天然隔離多個並行 job。

核心內容

Agent.TempDirectory 的隔離特性

Azure Pipeline 內建變數,指向 agent 為本次 job 配置的臨時目錄(例如 /home/azureuser/agent/_temp)。重要特性:

  • 每個 job 有獨立路徑,天然隔離
  • Job 結束後 agent 會自動清理目錄內容
  • 不影響 host 全域的 ~/.kube/config

$(Build.BuildId) 為檔名後綴可進一步確保唯一性,防止同一 agent 上兩個 job 在極端情況下撞到同一路徑。

四個關鍵技術細節

chmod 600:kubectl 在部分環境下若 kubeconfig 權限為 644(其他用戶可讀)會拒絕讀取或發出警告,設為 600 確保只有當前用戶可讀。

##vso[task.setvariable]:Azure Pipeline 的 shell 變數無法跨 step 存活。需用此指令將值寫入 pipeline 變數,後續 step 以 $(KUBECONFIG_PATH) 引用。

env: KUBECONFIG: $(KUBECONFIG_PATH):只在該 step 的 process 中有效,不影響 host 或其他 step。這是讓不同 job 使用不同 cluster config 的核心手段。

condition: always():確保即使 deploy step 失敗,cleanup step 仍然執行,避免憑證殘留。

與舊寫法的對比

舊寫法(~/.kube/config新寫法(temp path)
路徑Host 全域共用Job 專屬臨時路徑
並行安全❌ 會互相覆蓋✅ 以 BuildId 隔離
憑證殘留❌ 執行後仍存在condition: always() 清理
多 cluster 支援❌ 需手動切換 context✅ 每個 job 各自指定
Host 環境污染❌ 修改全域設定✅ 不影響 host

關鍵要點

  • ~/.kube/config 在 self-hosted runner 是全域共用路徑,多 pipeline 並行時必然衝突
  • $(Agent.TempDirectory)/kubeconfig-$(Build.BuildId) 提供天然的 job 層級隔離
  • env: 層級的 KUBECONFIG 變數只在該 step 有效,不污染環境
  • condition: always() 確保 cleanup 在任何情況(成功/失敗/取消)下都執行
  • Microsoft-hosted agent 每次都是乾淨環境,舊寫法也可接受;但統一採用本方案是更好的規範

實際應用

適用情境:

  • 長期常駐的 self-hosted runner(最需要隔離)
  • 多個 pipeline 共用同一台 runner
  • 同一台 runner 需要連接不同 K8s cluster

若使用 Microsoft-hosted agent 或每次 job 起一台全新 VM,~/.kube/config 的寫法也可接受,但仍建議採用本方案作為統一規範,避免日後切換到 self-hosted 時需要重構。

部署設定參考

以下為完整的 Azure Pipeline YAML 範例,供日後查詢與複製使用。

完整 Pipeline YAML

yaml
- stage: Deploy
  displayName: Deploy
  dependsOn: Build
  jobs:
    - job: DeployNorway
      displayName: Deploy Norway
      pool:
        name: Default
      steps:
        - task: KubectlInstaller@0
          inputs:
            kubectlVersion: 1.24.0
          displayName: "Install kubectl"

        - task: AzureKeyVault@2
          displayName: "Azure Key Vault: Get Secrets"
          inputs:
            azureSubscription: $(keyvaultServiceConnection)
            KeyVaultName: "bj0600"
            SecretsFilter: "*"
            RunAsPreJob: false

        # ✅ 寫入 temp 路徑,並透過 vso 指令傳遞給後續 steps
        - script: |
            KUBECONFIG_PATH="$(Agent.TempDirectory)/kubeconfig-$(Build.BuildId)"
            echo -n $(K8S-CONFIG-BASE64) | base64 -d > "$KUBECONFIG_PATH"
            chmod 600 "$KUBECONFIG_PATH"
            echo "##vso[task.setvariable variable=KUBECONFIG_PATH]$KUBECONFIG_PATH"
          displayName: "Decode kubeconfig to temp"

        # --- Deploy for norway-dev branch ---
        - ${{ if eq(variables['Build.SourceBranchName'], 'norway-dev') }}:
            - script: |
                kubectl create secret generic norway-dev-env \
                  --from-env-file=norway_server/.env.development \
                  -n default -o yaml --dry-run=client | kubectl apply -f -
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(serverImageName):$(tag)|g" \
                  norway_server/k8s/dev/server.yaml | kubectl apply -f -
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(serverImageName):$(tag)|g" \
                  norway_server/k8s/dev/syncer.yaml | kubectl apply -f -
              displayName: "Deploy norway-dev to k8s"
              env:
                KUBECONFIG: $(KUBECONFIG_PATH)   # ✅ 明確指定,不依賴預設路徑

            - script: |
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(webImageName):$(tag)|g" \
                  norway_server/k8s/dev/web.yaml | kubectl apply -f -
              displayName: "Deploy norway-dev web to k8s"
              env:
                KUBECONFIG: $(KUBECONFIG_PATH)

        # --- Deploy for norway branch ---
        - ${{ if eq(variables['Build.SourceBranchName'], 'norway') }}:
            - script: |
                kubectl create secret generic norway-env \
                  --from-env-file=norway_server/.env.production \
                  -n default -o yaml --dry-run=client | kubectl apply -f -
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(serverImageName):$(tag)|g" \
                  norway_server/k8s/prd/server.yaml | kubectl apply -f -
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(serverImageName):$(tag)|g" \
                  norway_server/k8s/prd/syncer.yaml | kubectl apply -f -
              displayName: "Deploy norway-prd to k8s"
              env:
                KUBECONFIG: $(KUBECONFIG_PATH)

            - script: |
                sed -e "s|AZURE_IMAGE|$(containerRegistry)/$(webImageName):$(tag)|g" \
                  norway_server/k8s/prd/web.yaml | kubectl apply -f -
              displayName: "Deploy norway-prd web to k8s"
              env:
                KUBECONFIG: $(KUBECONFIG_PATH)

        # ✅ 無論成功或失敗都清理憑證
        - script: |
            rm -f "$(KUBECONFIG_PATH)"
            echo "kubeconfig cleaned up."
          displayName: "Cleanup kubeconfig"
          condition: always()

Decode 步驟說明

bash
# kubeconfig 以 Base64 形式儲存在 Azure Key Vault,取出後解碼
KUBECONFIG_PATH="$(Agent.TempDirectory)/kubeconfig-$(Build.BuildId)"
echo -n $(K8S-CONFIG-BASE64) | base64 -d > "$KUBECONFIG_PATH"
chmod 600 "$KUBECONFIG_PATH"
echo "##vso[task.setvariable variable=KUBECONFIG_PATH]$KUBECONFIG_PATH"

echo -n 去掉末尾換行符,避免 base64 解碼出錯。$(K8S-CONFIG-BASE64) 來自 AzureKeyVault task 取回的 Secret。

相關概念

來源