Azure Container Registry から Azure Container Apps に安全にイメージを pull する方法

# はじめに

Azure Container Apps で System Assigned Managed Identity を使用してコンテナイメージをプルしようとすると、「鶏が先か、卵が先か」のような問題に直面します。

- アプリを作成するには、コンテナイメージをプルできる必要がある（Azure Container Apps は作成前にイメージの検証を行う）
- 一方、システム割り当てマネージド ID を使ってコンテナイメージをプルするには、アプリが存在している必要がある（アプリが作成されるまでシステム割り当てマネージド ID は発行されない）

この問題をどのように解決すればよいのでしょうか？本記事では、その具体的な方法を解説します。

なお、サンプルコードは以下のリポジトリにて公開されています。

https://github.com/azureossd/Container-Apps/tree/master/ManagedIdentity

# リソース一覧

まずは、今回のテーマに登場する Azure リソースを簡単に紹介します。

## Azure Container Apps 

Container Apps は、フルマネージドのサーバーレスコンテナサービスです。開発者がアプリケーション開発 (=ビジネスの差別化要因) に集中できることを目指して作られたサービスとなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/c1af3224-e278-f0f8-1299-be7bf4aa2ab6.png)

## Azure Container Registry

Azure Container Registry は、マネージドなプライベート Docker レジストリ サービスです。コンテナイメージのビルド機能も備えており、ビルドからデプロイまでを一元管理できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/ff8ddbf4-d739-3a7b-fe8e-928f8cec3d56.png)

## マネージド ID

マネージド ID（Managed Identity）は、Azure が提供する ID 管理の仕組みで、Azure サービスへの安全なアクセスを可能にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/3674bbde-d52e-49ab-8fa5-cc4270b6d9b9.png)

### マネージド ID の種類

**1. ユーザー割り当てマネージド ID**
- 手動で作成・管理可能（複数リソース間で共用可能）
- リソース削除後も ID は残る
- 用途: 共通 ID を使う場合（例: 複数アプリが同じ Key Vault にアクセス）

**2. システム割り当てマネージド ID**
- リソースごとに自動作成・割り当て
- リソース削除時に ID も削除
- 用途: 個々のリソースが Azure サービスへアクセスする場合

# Azure Container Apps に安全にイメージを pull する方法

さて、本題に入ります。

Azure Container Registry では、管理者ユーザーを有効化できますが、これはテスト用途向けであり、本番環境では推奨されません。

Docs にも以下のような記載があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/9166e9c9-841b-4518-acdf-e12a0b400967.png)

https://learn.microsoft.com/ja-jp/azure/container-registry/container-registry-faq#--------------------------------------

そのため、以下の 2 つの方法を紹介します。

## ユーザー割り当てマネージド ID を使った方法（推奨）

Azure 公式ドキュメントでは、可能な限りユーザー割り当てマネージド ID を使用することが推奨されています。

>When possible, you should use a user-assigned managed identity to pull images.
（イメージのプルには可能な限りユーザー割り当てマネージド ID を使用するべきです。）

https://learn.microsoft.com/ja-jp/azure/container-apps/managed-identity-image-pull?tabs=bash&pivots=bicep

ユーザー割り当てマネージド ID を使用すると、Bicep や Terraform などの IaC を利用し、1 ステップでコンテナイメージのプルとデプロイが可能になります。

後述する「Container Apps のシステム割り当てマネージド ID を使った方法」と比べてみると一目瞭然でしょう。

ポイントとなる Bicep の箇所を記載します。
明示的にユーザー割り当てマネージド ID を作成し、Azure Container Registry からイメージを pull するロールを割り当てます。

```terraform:
// ユーザー割り当てマネージド ID
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31-preview' = {
  name: userAssignedIdentityName
  location: location 
}

// ロール割り当て
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullTestUserAssigned')
  properties: {
    principalId: identity.properties.principalId  
    principalType: 'ServicePrincipal'
    // acrPullDefinitionId has a value of 7f951dda-4ed3-4680-a7ca-43fe172d538d
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}

// ・・・
```

マネージド ID に割り当て可能なロール一覧は、以下の Docs をご参照ください。

https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#acrpull


<details><summary>Bicep を使ったコード例</summary>

```terraform:container-apps-with-user-assigned-mid.bicep
param environmentName string 
param logAnalyticsWorkspaceName string
param appInsightsName string
param containerAppName string 
param azureContainerRegistry string
param azureContainerRegistryImage string 
param azureContainerRegistryImageTag string
param acrPullDefinitionId string
param userAssignedIdentityName string
param location string = resourceGroup().location

// ユーザー割り当てマネージド ID
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31-preview' = {
  name: userAssignedIdentityName
  location: location 
}

// ロール割り当て
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullTestUserAssigned')
  properties: {
    principalId: identity.properties.principalId  
    principalType: 'ServicePrincipal'
    // acrPullDefinitionId has a value of 7f951dda-4ed3-4680-a7ca-43fe172d538d
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}

// Container Apps の Log Analytics ワークスペース
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspace.id
  }
}

// Container Apps 環境
resource appEnvironment 'Microsoft.App/managedEnvironments@2024-02-02-preview' = {
  name: environmentName
  location: location
  properties: {
    daprAIInstrumentationKey: appInsights.properties.InstrumentationKey
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
  }
}

// Container Apps
resource containerApp 'Microsoft.App/containerApps@2022-06-01-preview' = {
  name: containerAppName
  location: location
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${identity.id}': {}
    }
  }
  properties: {
    environmentId: appEnvironment.id
    configuration: {
      ingress: {
        targetPort: 8080
        external: true
      }
      registries: [
        {
          server: '${azureContainerRegistry}.azurecr.io'
          identity: identity.id
        }
      ]
    }
    template: {
      containers: [
        {
          image: '${azureContainerRegistry}.azurecr.io/${azureContainerRegistryImage}:${azureContainerRegistryImageTag}'
          name: 'dockercontainersshexamples-dotnet-alpine'
          resources: {
            cpu: 1
            memory: '2Gi'
          }
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 1
      }
    }
  }
}
```

</details>


## システム割り当てマネージド ID を使った方法

システム割り当てマネージド ID を使用する場合、以下の 2 ステップが必要です。

1. パブリックイメージ（ダミーコンテナ）を使用してアプリを作成
2. 作成したアプリに AcrPull ロールを付与し、プライベートイメージを設定

システム割り当てマネージド ID は、アプリ作成後に発行されるため、まずは認証不要なパブリックイメージを使用し、その後適切な権限を付与することでプライベートレジストリからの pull を可能にします。

ポイントとなる Bicep の箇所を記載します。

まず、ダミーのコンテナイメージとしてパブリックイメージを使用してアプリを作成します。

```terraform
// パブリックイメージを使用した Container Apps
resource containerApp 'Microsoft.App/containerApps@2024-02-02-preview' = {
  ...
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    ...
    template: {
      containers: [
        {
          name: 'main'
          image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
          resources: {
            cpu: json('0.5')
            memory: '1Gi'
          }
        }
      ]
    }
    ...    
}

// ロール割り当て
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullSystemAssigned')
  scope: containerApp
  properties: {
    principalId: containerApp.identity.principalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}
```

次に、Container Apps で展開済みのアプリを入れ替えるために、プライベートイメージを指定します。

```diff_terraform
// パブリックイメージを使用した Container Apps
resource containerApp 'Microsoft.App/containerApps@2024-02-02-preview' = {
  ...
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    ...
    template: {
      containers: [
        {
          name: 'main'
-          image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
+          image: '${azureContainerRegistry.properties.loginServer}/private-image:latest'
          resources: {
            cpu: json('0.5')
            memory: '1Gi'
          }
        }
      ]
    }
    ...    
}

// ロール割り当て
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullSystemAssigned')
  scope: containerApp
  properties: {
    principalId: containerApp.identity.principalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}
```

<details><summary>Bicep を使ったコード例（ステップ１）</summary>
param environmentName string 
param logAnalyticsWorkspaceName string
param appInsightsName string
param containerAppName string 
param azureContainerRegistry string
param acrPullDefinitionId string
param location string = resourceGroup().location

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspace.id
  }
}

resource appEnvironment 'Microsoft.App/managedEnvironments@2022-06-01-preview' = {
  name: environmentName
  location: location
  properties: {
    daprAIInstrumentationKey: appInsights.properties.InstrumentationKey
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
  }
}

resource containerApp 'Microsoft.App/containerApps@2022-06-01-preview' = {
  name: containerAppName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    environmentId: appEnvironment.id
    configuration: {
      ingress: {
        targetPort: 80
        external: true
      }
    }
    template: {
      containers: [
        {
          image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
          name: 'dotnet'
          resources: {
            cpu: 1
            memory: '2Gi'
          }
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 1
      }
    }
  }
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullSystemAssigned')
  scope: containerApp
  properties: {
    principalId: containerApp.identity.principalId
    principalType: 'ServicePrincipal'
    // acrPullDefinitionId has a value of 7f951dda-4ed3-4680-a7ca-43fe172d538d
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}
</details>

<details><summary>Bicep を使ったコード例（ステップ２）</summary>
param environmentName string 
param logAnalyticsWorkspaceName string
param appInsightsName string
param containerAppName string 
param azureContainerRegistry string
param acrPullDefinitionId string
param location string = resourceGroup().location

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspace.id
  }
}

resource appEnvironment 'Microsoft.App/managedEnvironments@2022-06-01-preview' = {
  name: environmentName
  location: location
  properties: {
    daprAIInstrumentationKey: appInsights.properties.InstrumentationKey
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
  }
}

resource containerApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
  name: containerAppName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    environmentId: appEnvironment.id
    configuration: {
      ingress: {
        targetPort: 80
        external: true
      }
    }
    template: {
      containers: [
        {
          image: '${azureContainerRegistry.properties.loginServer}/private-image:latest'
          name: 'dotnet'
          resources: {
            cpu: 1
            memory: '2Gi'
          }
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 1
      }
    }
  }
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, azureContainerRegistry, 'AcrPullSystemAssigned')
  scope: containerApp
  properties: {
    principalId: containerApp.identity.principalId
    principalType: 'ServicePrincipal'
    // acrPullDefinitionId has a value of 7f951dda-4ed3-4680-a7ca-43fe172d538d
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', acrPullDefinitionId)
  }
}
</details>


# マネージド ID のライフサイクルを活用して最小権限のセキュリティ原則に従う

今回のシナリオのように、コンテナイメージの pull のみであれば、マネージド ID のライフサイクルを指定して、必要最小限の使用に制御することができます。

## 利用可能なオプション

以下のオプションを使用して、どのフェーズでマネージド ID を有効にするかを制御できます。

| オプション | 説明 | 主な用途 |
|:-:|:-|:-|
| Init  |  init コンテナーでのみ使用可能。メインコンテナーでは使用不可。ワークロードプロファイル従量課金環境でのみサポート。 | 初期化時のみマネージド ID が必要な場合（例: Key Vault からシークレットを取得後、不要になるケース）。|
|  Main |  メインコンテナーでのみ使用可能。init コンテナーでは使用不可。 | init コンテナーにマネージド ID が不要な場合。|
| All	| すべてのコンテナー（init と main の両方）で使用可能。| マネージド ID をフル活用する場合（デフォルト）。|
|None | どのコンテナーのコードからでも使用不可。ただし、ACR のイメージプルやスケールルール、Key Vault シークレット参照には利用可能。| 実行中のコンテナーにマネージド ID を不要にし、より厳密なアクセス制御を実現する場合。|

例えば、本記事のシナリオの場合は `None` を指定することで、最小権限のセキュリティ原則に従うことができます。

```terraform
resource containerApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
  name: appName
  location: resourceGroup().location
  properties: {
  // ・・・
      registries: [
        {
          server: acr.properties.loginServer
          identity: 'system'
        }
      ]
      identitySettings: [
        {
          identity: 'system'
          lifecycle: 'None'
        }
      ]  
    }
  // ・・・
```


https://learn.microsoft.com/ja-jp/azure/container-apps/managed-identity?tabs=portal%2Cdotnet#control-managed-identity-availability


# おわりに

今回紹介した Azure Container Apps のマネージド ID により、最小権限の原則（Least Privilege Principle） に従い、より安全かつ柔軟なアクセス制御が可能になります。
特に、Init フェーズ と Main フェーズ でのマネージド ID の使用を分離できることで、セキュリティリスクを低減し、不要な権限を排除できる点が大きな利点です。

また、ACR のイメージプルや Key Vault シークレットの取得にのみマネージド ID を使用する設定（`None` オプション） を活用することで、アプリケーション内部での ID 使用を完全に排除し、より強固なセキュリティを確保することもできます。

適切なマネージド ID の設定を行うことで、不要なアクセス権を削減し、よりセキュアな Azure 環境を構築できるので、用途に応じた設定をぜひ活用してみてください。