Azure Container Apps 與 GitHub OIDC 無密碼部署實戰:新團隊安全上手指南

企業或專案引進「新開發團隊」接手部署時,常見的權限混亂、安全風險與網路連線問題。主要包含以下四大核心問題:

  • 權限與安全問題:避免為了部署而過度授予高風險權限(例如 Azure Owner 或 SQL 權限),並改用 GitHub Actions OIDC 取代長期保存的明文金鑰( secrets )。
  • 責任分工不明確:清楚劃分「平台管理者」與「開發團隊」的職責邊界,防止開發團隊誤動基礎架構(如 SQL Firewall、VNet )。
  • 網路連線與架構問題:解決 Container App 因未走正確的 VNet 與 Private Endpoint 導致無法連線至 Azure SQL Database 的問題。
  • 新手上手缺乏規範:提供明確的跨團隊開發指南、 CI/CD 自動化流程範本與開通檢查表,縮短團隊上手時間。

demoproject 與 ExampleOrg 只是示範名稱。正式環境使用前,必須由 平台管理者替換所有 <PLACEHOLDER> 與範例資源名稱,並確認權限範圍。

1. 先看結論

新加入的開發團隊平常只需要:

  1. 取得 GitHub repository 與必要的 Azure RBAC 權限。
  2. 使用平台管理者準備好的共用 Container Apps Environment。
  3. 透過 GitHub Actions OIDC 登入 Azure,不使用長期 client secret。
  4. Build Docker image、push 到 ACR、更新既有 Container App。
  5. 透過 revision 與 logs 查看部署結果。

新加入的開發團隊不需要

  • Azure Owner
  • SQL Resource Group 的 Contributor
  • 直接修改 SQL firewall、Private Endpoint、VNet 或 Private DNS。
  • 在 GitHub repository 中存放 SQL 密碼或應用程式 secrets。
  • 每次部署都執行完整的基礎資源建立流程。

應用程式連接 SQL 的權限不是由 GitHub Actions Service Principal 提供, 而是由 Backend Container App 自己的 Managed Identity 在資料庫中取得。

2. Azure 服務白話說明

Azure 服務或名詞白話說明DemoProject 用途
SubscriptionAzure 帳單與資源的管理範圍<SUBSCRIPTION_NAME>
Resource Group(RG)把相關 Azure 資源放在一起的資料夾App、ACR、CAE、SQL 分開管理
Azure Container Registry(ACR)存放 Docker image 的私有倉庫CI push image
Container Apps Environment(CAE)Container Apps 共用的執行環境與網路邊界Backend、Frontend 共用
Container App(CA)實際執行容器的 Azure 資源執行 DemoProject Backend/Frontend
Virtual Network(VNet)Azure 內部的私有網路連接 CAE 與 SQL Private Endpoint
Private Endpoint(PE)讓 Azure 服務在 VNet 內取得私有 IP將 SQL 接到 VNet
Private DNS讓 hostname 在 VNet 內解析到私有 IP解析 SQL hostname
Azure SQL Server / DatabaseSQL Server 與其中的資料庫存放 DemoProject 資料
Managed IdentityAzure 資源自己的身分,不需要存密碼App 登入 SQL、ACR、Key Vault
Key Vault集中存放 secret、憑證與金鑰Backend runtime 讀取 secrets
Azure RBAC管理 Azure 資源的控制權決定誰能部署或修改 App
SQL database role資料庫內的資料讀寫權限db_datareaderdb_datawriterdb_ddladmin
GitHub OIDCGitHub workflow 使用短期 token 登入 AzureCI 不存放 client secret

Azure RBAC 與 SQL 權限不是同一件事

  • Azure Contributor 可以管理 Azure Resource Manager 資源,不等於可以 查詢 SQL table。
  • SQL db_datareader 可以讀資料,不等於可以修改 Azure SQL Server。
  • SQL db_datawriter 可以寫資料。
  • SQL db_ddladmin 可以執行部分資料庫結構變更,應只授予需要執行 migration 的 Backend identity。
  • SQL firewall 與 Private Endpoint 決定「網路能不能到 SQL」;資料庫 roles 只有在網路已連通後才會生效。

3. DemoProject 資源地圖

以下名稱全部是示範值:

用途Resource Group資源名稱
Azure Container Registryrg-demoproject-stg-appacr-demoproject-stg.azurecr.io
Container Appsrg-demoproject-stg-appca-demoproject-backend-stgca-demoproject-frontend-stg
共用 Container Apps Environmentrg-demoproject-stg-caecae-demoproject-stg
SQL Server / Databaserg-demoproject-stg-datasql-demoproject-stg.database.windows.net / demoproject-dev
SQL Private Endpointrg-demoproject-stg-networkpe-demoproject-sql-stg
Key Vaultrg-demoproject-stg-appkv-demoproject-stg
VNetrg-demoproject-stg-networkvnet-demoproject-stg

3.1 建議的網路路徑

Backend Container App
    |
    |  共用 CAE 的 VNet 網路
    v
vnet-demoproject-stg
    |
    |  Private DNS 將 SQL hostname 解析到私有 IP
    v
SQL Private Endpoint pe-demoproject-sql-stg
    |
    v
sql-demoproject-stg / demoproject-dev

如果 Container App 位於沒有 VNet/private DNS 的獨立 CAE,它可能會透過 公網出口 IP 連線 SQL,並收到:

Client with IP address '...' is not allowed to access the server

遇到這個錯誤時,先確認 App 所在 CAE 與 DNS 路徑,不要立刻把大量公網 IP 加進 SQL firewall。

4. 權限模型

4.1 開發者與 CI 的建議權限

範圍建議角色/權限用途SQL data access
既有 Backend Container AppContributor 或專用 custom role更新 image/revision
既有 Frontend Container AppContributor 或專用 custom role更新 image/revision
ACRAcrPushDocker image push
共用 CAEReader讀取 CAE 設定
共用 CAEContainer Apps Environment Joiner使用指定 CAE
SQL Resource Group不授權SQL 集中由管理者控管
Backend Managed IdentitySQL contained user + SQL roles應用程式 runtime 連線 SQL

Container Apps Environment Joiner 應只包含:

Microsoft.App/managedEnvironments/join/action

不要為了讓團隊部署而授予共用 CAE Resource Group 的 Contributor

4.2 平台管理者與開發團隊分工

平台管理者一次性完成:

  1. 建立 App Registration 與 Service Principal。
  2. 建立指定 repository、branch 或 GitHub Environment 的 Federated Credential。
  3. 在 ACR scope 授予 AcrPush
  4. 在既有 Container App scope 授予更新權限。
  5. 在共用 CAE scope 授予 Reader 與 Container Apps Environment Joiner
  6. 確認 App runtime Managed Identity 具備 ACR、Key Vault 與 SQL 權限。
  7. 確認 App 位於正確的 VNet/private DNS CAE。

開發團隊負責:

  1. 維護 GitHub Actions workflow。
  2. Build Docker image。
  3. Push image 到 ACR。
  4. 更新既有 Container App。
  5. 查看 revision 與 logs。

開發團隊不應該:

  • 建立或刪除 Service Principal。
  • 修改 SQL firewall、Private Endpoint、VNet 或 Private DNS。
  • 將 App secrets 寫入 repository 或 workflow。
  • 執行 SQL CREATE USER
  • 修改共用 CAE 的網路設定。

5. GitHub Actions OIDC

5.1 OIDC 如何運作

GitHub push to main
        |
        | GitHub 發出短期 OIDC token
        v
GitHub Actions
        |
        | azure/login@v2
        v
Microsoft Entra App / Service Principal
        |
        | Federated Credential 比對 repo + branch/environment
        | Azure RBAC 比對允許的 scope
        v
ACR push -> Container App update

OIDC 不等於自動擁有 Azure 權限:

  • Federated Credential 決定「哪個 GitHub workflow 可以登入」。
  • Azure RBAC 決定「登入後可以做什麼」。

5.2 新團隊專用 Service Principal

建議每個團隊、每個環境使用不同 Service Principal。不要讓所有團隊共用 一個可寫入 Staging 的 identity。

以下是示範值:

SP display name: demoproject-team-a-staging-ci
Client ID:       <APP_CLIENT_ID>
Object ID:       <SERVICE_PRINCIPAL_OBJECT_ID>
Tenant ID:       <TENANT_ID>
Subscription ID: <SUBSCRIPTION_ID>

不需要建立 client secret。

5.3 角色配置範例

以下指令只展示格式,執行前請替換所有 placeholder:

set -euo pipefail

SUB_ID="<SUBSCRIPTION_ID>"
APP_RG="rg-demoproject-stg-app"
CAE_RG="rg-demoproject-stg-cae"
CAE_NAME="cae-demoproject-stg"
ACR_NAME="acr-demoproject-stg"
SP_NAME="demoproject-team-a-staging-ci"

CAE_SCOPE="$(az containerapp env show \
  --subscription "$SUB_ID" \
  --resource-group "$CAE_RG" \
  --name "$CAE_NAME" \
  --query id -o tsv)"

ACR_SCOPE="$(az acr show \
  --subscription "$SUB_ID" \
  --resource-group "$APP_RG" \
  --name "$ACR_NAME" \
  --query id -o tsv)"

APP_ID="$(az ad app create \
  --display-name "$SP_NAME" \
  --query appId -o tsv)"

SP_OBJECT_ID="$(az ad sp create \
  --id "$APP_ID" \
  --query id -o tsv)"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "AcrPush" \
  --scope "$ACR_SCOPE"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope "$CAE_SCOPE"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Container Apps Environment Joiner" \
  --scope "$CAE_SCOPE"

若 CI 只更新既有 App,建議把寫入權限限制在 App resource scope:

BACKEND_SCOPE="$(az containerapp show \
  --subscription "$SUB_ID" \
  --resource-group "$APP_RG" \
  --name "ca-demoproject-backend-stg" \
  --query id -o tsv)"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "$BACKEND_SCOPE"

不要把 CI identity 授予:

rg-demoproject-stg-data
rg-demoproject-stg-cae 的 Contributor
demoproject-dev 的 SQL roles

5.4 Federated Credential

方案 A:只允許 main branch

適合單一 staging deploy workflow:

REPO="ExampleOrg/demoproject-backend"
SAFE_NAME="${REPO//\//-}-main"
SUBJECT="repo:${REPO}:ref:refs/heads/main"

PAYLOAD="$(jq -n \
  --arg name "$SAFE_NAME" \
  --arg subject "$SUBJECT" \
  '{
    name: $name,
    issuer: "https://token.actions.githubusercontent.com",
    subject: $subject,
    audiences: ["api://AzureADTokenExchange"]
  }')"

az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters "$PAYLOAD"

方案 B:使用受保護的 GitHub Environment

新團隊建議建立 GitHub Environment staging,設定:

  • Required reviewers。
  • 只允許 main branch 或指定 tag。
  • 將 Azure identifiers 放在 Environment variables。

Federated Credential 的 subject 改為:

repo:ExampleOrg/demoproject-backend:environment:staging

Workflow 的 deploy job 必須包含:

environment: staging

Branch subject 與 Environment subject 不要混用。Azure 會比對完整的 sub claim,字串不完全相同就會登入失敗。

PR 不可取得 write token

以下 subject 不可綁定到具有 ContributorAcrPush 或 App write 的 identity:

repo:ExampleOrg/demoproject-backend:pull_request

PR 程式碼尚未審核,可能修改 workflow 並呼叫 Azure CLI。PR workflow 應只 執行 lint、test、build;若真的需要查詢 Azure,另建只有 Reader 的 identity。

6. GitHub Actions Workflow 範本

以下範例使用受保護的 staging Environment。若使用 main branch subject, 請移除 environment: staging,或先建立相符的 Environment Federated Credential。

name: Deploy DemoProject Backend

on:
  push:
    branches:
      - main

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Login to Azure with OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Login to ACR
        run: az acr login --name acr-demoproject-stg

      - name: Build and push image
        env:
          IMAGE: acr-demoproject-stg.azurecr.io/demoproject-backend:${{ github.sha }}
        run: |
          docker build -t "$IMAGE" .
          docker push "$IMAGE"

      - name: Update existing Container App
        env:
          IMAGE: acr-demoproject-stg.azurecr.io/demoproject-backend:${{ github.sha }}
        run: |
          az containerapp update \
            --resource-group rg-demoproject-stg-app \
            --name ca-demoproject-backend-stg \
            --image "$IMAGE"

Frontend 只需要替換 image repository 與 Container App name。

6.1 PR Workflow

PR workflow 不應取得 Azure write token:

on:
  pull_request:
    branches:
      - main

permissions:
  contents: read

PR 可執行 lint、unit test、build 或不涉及 Staging 的 container build, 但不要:

  • id-token: write
  • azure/login
  • Push Staging ACR。
  • Update 或 delete Container App。

6.2 不要在 CI 執行完整部署腳本

完整部署腳本通常會包含 secrets、Key Vault policy、Managed Identity、 SQL 授權與首次建立資源的流程,不適合直接放進 GitHub Actions。

CI 建議只做:

OIDC login
  -> docker build
  -> ACR push
  -> containerapp update --image

既有的環境變數與 secrets 由平台管理者預先設定;App runtime 使用自己的 Managed Identity 連接 SQL 與 Key Vault。

7. SQL Database 與 Managed Identity

7.1 App runtime 才需要 SQL role

查詢 Backend App 的 Managed Identity:

az containerapp show \
  --subscription "<SUBSCRIPTION_ID>" \
  --resource-group "rg-demoproject-stg-app" \
  --name "ca-demoproject-backend-stg" \
  --query "{type:identity.type,principalId:identity.principalId}" \
  -o json

如果 Container App 被刪除後重新建立,system-assigned identity 可能改變。 管理者需要重新確認 ACR、Key Vault 與 SQL 權限。

7.2 建立 SQL contained user

以下 SQL 應由 SQL Entra administrator 或等效資料庫管理員,在 demoproject-dev 執行。將 App name 替換成實際值:

IF DATABASE_PRINCIPAL_ID(N'ca-demoproject-backend-stg') IS NULL
BEGIN
    CREATE USER [ca-demoproject-backend-stg] FROM EXTERNAL PROVIDER;
END;

ALTER ROLE [db_datareader]
ADD MEMBER [ca-demoproject-backend-stg];

ALTER ROLE [db_datawriter]
ADD MEMBER [ca-demoproject-backend-stg];

-- 只有 Backend migration 確實需要時才授予
ALTER ROLE [db_ddladmin]
ADD MEMBER [ca-demoproject-backend-stg];

CI Service Principal 不需要執行這段 SQL,也不應被加入這三個 database roles。

7.3 人員需要直接查詢資料庫

不要把 Azure Contributor 當成 SQL data access。若確實需要讓人員直接 查詢:

  1. 管理者建立 Microsoft Entra security group。
  2. 將需要查詢的人員加入該群組。
  3. 在 demoproject-dev 建立該群組的 contained user。
  4. 優先只授予 db_datareader
  5. 確認使用者有 VNet、VPN 或其他 Private Endpoint 網路路徑。

8. Private Endpoint 與 SQL Firewall

8.1 為什麼不能只新增一個 IP?

獨立的 Container Apps Environment 可能使用公網出口 IP,而且出口 IP 可能有很多個或因環境變更而改變。錯誤訊息中的 IP 只代表當次連線, 不一定是永久 IP。

使用共用 CAE 時,應該走:

Container App
  -> VNet
  -> Private DNS
  -> SQL Private Endpoint
  -> SQL Database

不要用大量公網 IP firewall 規則取代正確的網路拓撲,也不要未經核准就 建立:

start-ip-address = 0.0.0.0
end-ip-address   = 0.0.0.0

這代表允許大範圍 Azure services 存取,不是只允許本團隊。

8.2 確認 App 使用正確 CAE

$expected = "/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/rg-demoproject-stg-cae/providers/Microsoft.App/managedEnvironments/cae-demoproject-stg"

az containerapp show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query properties.managedEnvironmentId `
  -o tsv

$expected

兩個輸出必須相同。如果 App 位於沒有 VNet/private DNS 的 CAE,先請平台 管理者確認網路路徑,不要先修改 SQL firewall。

8.3 常見錯誤

錯誤常見原因正確處理
Client with IP address ... is not allowedApp 走公網,SQL firewall 沒放行確認 App 是否位於共用 VNet CAE
Login failedApp identity 未建立或沒有 SQL role查 App principal ID,由 DB 管理者處理
Cannot resolve hostPrivate DNS 或 VNet 路徑錯誤檢查 PE、DNS zone link、CAE subnet
AuthorizationFailedCI identity 缺少對應 scope 的 role由平台管理者補上 App、ACR 或 CAE role

9. 部署後驗證

9.1 查看 App 所在 CAE 與 revision

az containerapp show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query "{environment:properties.managedEnvironmentId,latest:properties.latestRevisionName,ready:properties.latestReadyRevisionName}" `
  -o table
az containerapp revision list `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query "[].{name:name,health:properties.healthState,running:properties.runningState,traffic:properties.trafficWeight}" `
  -o table

9.2 查看 logs

az containerapp logs show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --type console `
  --tail 100

9.3 驗證 CI 身分

az ad app federated-credential list \
  --id "$APP_ID" \
  --query "[].{name:name,subject:subject,issuer:issuer,audiences:audiences}" \
  -o table
az role assignment list \
  --assignee-object-id "$SP_OBJECT_ID" \
  --all \
  --query "[].{role:roleDefinitionName,scope:scope}" \
  -o table

應確認:

  • issuer 是 https://token.actions.githubusercontent.com
  • audience 是 api://AzureADTokenExchange
  • subject 只有指定 repo 與指定 main/environment。
  • 沒有 write identity 的 pull_request subject。
  • 沒有 SQL Resource Group 或共用 CAE Resource Group 的 Contributor

10. 首次 bootstrap 與日常部署

10.1 首次 bootstrap

以下工作由平台管理者完成一次:

  • 建立 ACR、CAE、Container App 或確認資源已存在。
  • 建立 App Registration、Service Principal 與 Federated Credentials。
  • 授予 CI 的 ACR/App/CAE 角色。
  • 啟用 App system-assigned Managed Identity。
  • 授予 App identity AcrPull、Key Vault get/list 與 SQL roles。
  • 設定 Private Endpoint、Private DNS、custom domain 與憑證。

如果共用 CAE 暫時不存在,不要讓腳本建立沒有 VNet/private endpoint 的替代 環境;應停止流程並通知平台管理者。

10.2 日常部署

日常 CI 只需:

main push
  -> GitHub OIDC login
  -> build image
  -> push ACR
  -> update existing Container App
  -> check revision and logs

如果 App 被刪除並重新建立,請重新取得 identity,重新處理 ACR、Key Vault 與 SQL 授權。

11. 撤權與團隊交接

11.1 移除某個 repository 的信任

az ad app federated-credential list \
  --id "$APP_ID" \
  --query "[].{id:id,name:name,subject:subject}" \
  -o table
az ad app federated-credential delete \
  --id "$APP_ID" \
  --federated-credential-id "<FEDERATED_CREDENTIAL_ID>"

11.2 移除 Azure role

az role assignment delete \
  --assignee-object-id "$SP_OBJECT_ID" \
  --role "AcrPush" \
  --scope "$ACR_SCOPE"

團隊撤換時同時確認:

  1. GitHub Environment/repository variables 已移除。
  2. 該 repository 的 Federated Credentials 已刪除。
  3. App、ACR、CAE scope 的 role assignments 已移除。
  4. Container App runtime Managed Identity 沒有被誤刪。
  5. Audit log 與變更紀錄已保存。

12. 新團隊開通檢查表

平台管理者

  • [ ] 所有資源名稱、Subscription、tenant 與 repo 已確認。
  • [ ] 每個團隊建立獨立 Service Principal。
  • [ ] 只建立 main 或受保護 Environment 的 Federated Credential。
  • [ ] 沒有建立 write identity 的 pull_request credential。
  • [ ] ACR scope 有 AcrPush
  • [ ] 既有 App scope 有更新權限。
  • [ ] 共用 CAE scope 有 Reader + Container Apps Environment Joiner
  • [ ] 沒有授予 SQL Resource Group 或共用 CAE Resource Group Contributor。
  • [ ] App runtime Managed Identity 有 SQL、Key Vault、ACR 權限。
  • [ ] GitHub Environment 的 reviewers 與 branch restriction 已設定。

開發團隊

  • [ ] Workflow 有 permissions: id-token: write
  • [ ] environment 或 branch subject 與 Azure FIC 完全一致。
  • [ ] 使用 azure/login@v2,沒有 client secret。
  • [ ] Docker image 使用不可變的 commit SHA tag。
  • [ ] Push 到正確 ACR repository。
  • [ ] 只更新指定 Container App。
  • [ ] PR workflow 沒有 Azure write token。
  • [ ] 部署後確認 revision、logs 與功能。

13. 示範值替換清單

正式使用前,至少替換以下項目:

示範值正式值
DemoProject / demoproject正式專案名稱
ExampleOrgGitHub organization
<SUBSCRIPTION_ID>正式 Subscription ID
<TENANT_ID>正式 Entra tenant ID
rg-demoproject-*正式 Resource Groups
acr-demoproject-stg正式 ACR
cae-demoproject-stg正式 CAE
ca-demoproject-*-stg正式 Container Apps
sql-demoproject-stg.database.windows.net正式 SQL hostname
demoproject-dev正式 Database
C:\work\demoproject-staging團隊實際工作目錄
ExampleOrg/demoproject-*正式 repositories

替換後請再次搜尋以下字串,確認沒有殘留示範 placeholder 或錯誤環境:

<SUBSCRIPTION_ID>
<TENANT_ID>
ExampleOrg
demoproject

14. 安全規則

  1. 不要把 client secret、SQL password、JWT secret 或 storage connection string commit 到 Git。
  2. 不要把 secrets 貼到 PR、Issue、聊天工具或 deployment log。
  3. 不要為了快速測試把 SQL firewall 設成 0.0.0.0 - 0.0.0.0
  4. 不要把 SQL Resource Group 的 Contributor 當成 SQL data access。
  5. 不要把 write-capable Azure identity 授予 pull_request
  6. 不要自行刪除共用 CAE、Private Endpoint、Private DNS 或 SQL Server。
  7. 遇到錯誤時,先記錄 repository、workflow run、revision 與錯誤時間, 再請平台管理者檢查權限與網路。

15. 官方參考文件

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *