Azure Functions: .NET 9.0 Isolated デプロイの落とし穴と解決策
C# 9.0の dotnet-isolated で作成したプロジェクトをBicepとGitHub Actionsを使用してAzure Functionにデプロイする
はじめに
最近、Bicep(Azure用の宣言型インフラストラクチャコードの言語)を使ったクラウドデプロイを学習しています。GitHub CopilotのAgentモードやClineを活用してコードを生成させ、それを検証・実行することで、再現性の高いインフラ構築が可能になることがわかりました。
Bicepによる自動化の成果
Bicepを使用して以下のインフラストラクチャコンポーネントのデプロイを自動化することに成功しました:
- App Service環境の自動プロビジョニングとWeb APIデプロイパイプライン
- Next.jsアプリケーション用のホスティング環境とBFF (Backend for Frontend)構成の自動デプロイ
- Azure Entra ID(旧称Azure AD)認証連携の自動セットアップとリソース間の権限設定
- Key Vaultとの連携による秘匿情報の自動管理と環境変数への安全な受け渡し
- VNETによるIP制限設定の自動適用(内部ネットワークからのみアクセス可能な構成)
Bicepのデプロイワークフローについて
Bicepを理解する上で重要なのは、Bicepファイル自体はインフラストラクチャの定義に過ぎず、実際のリソース作成には実行ツールが必要だということです。主に以下の2つの方法があります:
-
Azure CLI (az) - 従来の方法
-
az deployment group create
コマンドでBicepテンプレートを実行 - リソースの作成とアプリケーションコードのデプロイが明確に分離される
- カスタマイズ性が高く、デプロイプロセスの各段階を細かく制御可能
-
-
Azure Developer CLI (azd) - 比較的新しいアプローチ
- より開発者向けに設計された統合ツール
- インフラストラクチャのプロビジョニングとアプリケーションのデプロイを一度の操作で実行可能
- プロジェクトテンプレートとの統合が優れている
今回のプロジェクトでは、Azure CLI (az) を選択しました。その理由は:
- 開発環境でインフラストラクチャをプロビジョニングし、その後GitHub Actionsを使ってコードをデプロイするワークフローに適していた
- CICDパイプラインとの統合が容易で、段階的なデプロイプロセスの制御が可能だった
- 複数環境(開発・ステージング・本番)間で一貫したリソース構成を維持しやすかった
Azure Functionsでのハマりポイント
プランの選択に関する問題
-
Flex Consumption:最近リリースされた高速でスケーラブルなプラン
- 残念ながらJapan Eastリージョンでは利用できず
-
Consumptionプラン:Linux + Japan Eastの組み合わせでエラーが発生
- 結局、App Serviceプランを採用
Bicepでの実装における課題
-
AzureWebJobsStorageにKey Vaultリファレンスを設定するとエラー発生
- 解決策:AzureWebJobsStorageにはストレージアカウント接続文字列を直接設定
デプロイの問題
最も苦労したのが、Bicepでリソース作成後の関数デプロイでした。
- デプロイしたAzure Functionsリソースが関数を認識しない
- リソース作成はBicepで成功しているのに、コードデプロイしても、関数が認識されない
- Macから
az deploy
コマンドで試行錯誤するも解決せず - Visual Studioからの直接デプロイでは成功する現象
原因究明と解決策
Google Gemini ディープサーチを使って以下の情報源を発見:
問題の本質は、GitHub Actionsのupload-artifact
アクションの仕様変更 でした。バージョン3以降では、非表示(ドットで始まる)ディレクトリが自動的に除外されるようになりました。Azure Functionsのデプロイには.azurefunctions
ディレクトリが必要なため、これが含まれないとデプロイに失敗します。
解決策
GitHub Actionsワークフローで**include-hidden-files: true
**オプションを追加する必要があります:
- name: Publish Artifacts
uses: actions/upload-artifact@v4.4.3
with:
name: ${{ inputs.app-name }}
include-hidden-files: true # この設定が重要
path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
Azure Function デプロイの実装例
実際に動作したGitHub Actionsワークフロー、Bicepファイル、デプロイスクリプトなどの例を以下に示します。これらを参考に、独自の.NET 9.0 Isolated Azure Functionsデプロイパイプラインを構築できます。
GitHub Actionsワークフロー
name: Deploy any Functions
on:
workflow_call:
inputs:
project-path:
required: true
type: string
app-name:
required: true
type: string
secrets:
azure-credentials:
required: true
env:
DOTNET_VERSION: 9.0.x
OUTPUT_PATH: output
CONFIGURATION: Release
AZURE_FUNCTIONAPP_PACKAGE_PATH: ./published
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore "${{ inputs.project-path }}"
- name: Build
run: dotnet build "${{ inputs.project-path }}" --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Publish
run: dotnet publish "${{ inputs.project-path }}" --configuration ${{ env.CONFIGURATION }} --no-build --output "${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}"
- name: Publish Artifacts
uses: actions/upload-artifact@v4.4.3
with:
name: ${{ inputs.app-name }}
include-hidden-files: true
path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: ${{ inputs.app-name }}
path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
- name: Azure Login
uses: azure/login@v2
with:
creds: ${{ secrets.azure-credentials }}
- name: Deploy to Azure Function App
uses: Azure/functions-action@v1
with:
app-name: ${{ inputs.app-name }}
package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
ステージング環境用のデプロイワークフロー
name: "Staging: Deploy Functions"
on:
workflow_dispatch:
push:
branches:
- release-staging
paths:
- "src/YourSolution/**"
jobs:
deploy-function-yourfunctionname:
uses: ./.github/workflows/general-functions.yml
with:
app-name: func-staging-yourfunctionname
project-path: ./src/YourSolution/YourProjectFolder
secrets:
azure-credentials: ${{ secrets.YOUR_AZURE_CREDENSIALS }}
Bicepファイル
@description('The environment name suffix to add to resource names')
param environmentName string
@description('The Azure region for deploying resources')
param location string = resourceGroup().location
@description('Key Vault name')
param keyVaultName string = 'kv-sample-functions-${environmentName}'
@description('The name of the Function App')
param functionAppName string = 'func-${environmentName}-sample'
@description('The name of the secret in KeyVault that contains the Cosmos DB connection string')
param cosmosDbConnectionStringSecretName string = 'SampleCosmosDbConnectionString'
@description('The name of the secret in KeyVault that contains the Blob storage connection string')
param blobConnectionStringSecretName string = 'SampleBlobConnectionString'
@description('The name of the secret in KeyVault that contains the Queue storage connection string')
param queueConnectionStringSecretName string = 'SampleQueueConnectionString'
param storageAccountName string = 'storagesample${environmentName}'
param infrastructureType string = 'Cosmos'
param customVisionEndpointSecretName string = 'CustomVisionEndpoint'
param customVisionPredictionKeySecretName string = 'CustomVisionPredictionKey'
param customVisionProjectIdSecretName string = 'CustomVisionProjectId'
param customVisionModelNameSecretName string = 'CustomVisionModelName'
param mapApiKeySecretName string = 'MapApiKey'
// Reference to existing Key Vault
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
name: keyVaultName
}
// Reference to existing Storage Account
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' existing = {
name: storageAccountName
}
// Construct the connection string for the storage account
var storageConnectionString = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
// Create Application Insights for the Function App
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
name: 'ai-func-${environmentName}'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
Request_Source: 'rest'
IngestionMode: 'ApplicationInsights'
publicNetworkAccessForIngestion: 'Enabled'
publicNetworkAccessForQuery: 'Enabled'
}
}
// Create Log Analytics Workspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
name: 'law-func-${environmentName}'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
features: {
enableLogAccessUsingOnlyResourcePermissions: true
}
}
}
// Create App Service Plan (B1) for Linux Function App
resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = {
name: 'asp-sample-func-${environmentName}'
location: location
sku: {
name: 'B1' // Basic B1 plan
tier: 'Basic'
}
kind: 'linux' // Explicitly set to Linux
properties: {
reserved: true // Linux hosting
}
}
// Sample Function App
resource functionApp 'Microsoft.Web/sites@2022-09-01' = {
name: functionAppName
location: location
kind: 'functionapp,linux'
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true // Added for security
reserved: true // Required for Linux
siteConfig: {
linuxFxVersion: 'DOTNET-ISOLATED|9.0' // Set to .NET 9 Isolated
ftpsState: 'Disabled'
minTlsVersion: '1.2'
alwaysOn: true // Enabled since we're using Basic plan
connectionStrings: [
{
name: 'CosmosDb'
connectionString: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${cosmosDbConnectionStringSecretName}/)'
type: 'Custom'
}
{
name: 'BlobStorage'
connectionString: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${blobConnectionStringSecretName}/)'
type: 'Custom'
}
{
name: 'QueueStorage'
connectionString: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${queueConnectionStringSecretName}/)'
type: 'Custom'
}
]
appSettings: [
{
name: 'FUNCTIONS_EXTENSION_VERSION'
value: '~4'
}
{
name: 'FUNCTIONS_WORKER_RUNTIME'
value: 'dotnet-isolated'
}
{
name: 'AzureWebJobsStorage'
value: storageConnectionString
}
{
name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
value: storageConnectionString
}
{
name: 'WEBSITE_CONTENTSHARE'
value: toLower('SampleFunction')
}
{
name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
value: applicationInsights.properties.InstrumentationKey
}
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: applicationInsights.properties.ConnectionString
}
{
name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
value: '~3'
}
{
name: 'Infrastructure__Type'
value: infrastructureType
}
{
name: 'CustomVision__Endpoint'
value: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${customVisionEndpointSecretName}/)'
}
{
name: 'CustomVision__PredictionKey'
value: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${customVisionPredictionKeySecretName}/)'
}
{
name: 'CustomVision__ProjectId'
value: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${customVisionProjectIdSecretName}/)'
}
{
name: 'CustomVision__ModelName'
value: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${customVisionModelNameSecretName}/)'
}
{
name: 'Map__ApiKey'
value: '@Microsoft.KeyVault(SecretUri=https://${keyVaultName}.vault.azure.net/secrets/${mapApiKeySecretName}/)'
}
]
}
}
identity: {
type: 'SystemAssigned'
}
}
// Diagnostic settings for Function App
resource functionAppDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
name: 'diag-${functionAppName}'
scope: functionApp
properties: {
workspaceId: logAnalyticsWorkspace.id
logs: [
{
category: 'FunctionAppLogs'
enabled: true
}
]
metrics: [
{
category: 'AllMetrics'
enabled: true
}
]
}
}
// Grant the Function App access to Key Vault secrets
resource keyVaultAccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = {
parent: keyVault
name: 'add'
properties: {
accessPolicies: [
{
tenantId: functionApp.identity.tenantId
objectId: functionApp.identity.principalId
permissions: {
secrets: [
'get'
'list'
]
}
}
]
}
}
// Outputs
output functionAppName string = functionApp.name
output applicationInsightsName string = applicationInsights.name
output keyVaultName string = keyVaultName
output storageAccountName string = storageAccountName
デプロイスクリプト
#!/bin/bash
# Usage function to display available options
function show_usage() {
echo "Usage: $0 [environment]"
echo " environment - Optional environment name (e.g., dev, staging, prod) - Default is dev"
exit 1
}
# Get environment name (default to dev if not specified)
ENV_NAME=${1:-"dev"}
# Set variables
RESOURCE_GROUP="rg-sample-func-${ENV_NAME}"
LOCATION="japaneast"
DEPLOYMENT_NAME="deploy-functions"
# Get the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Function to create Azure resources
function create_resources() {
# Check for environment-specific parameters file
PARAMS_FILE="$SCRIPT_DIR/${ENV_NAME}.parameters.json"
# Check if parameters file exists, if not create a default one
if [ ! -f "$PARAMS_FILE" ]; then
echo "Parameters file '$PARAMS_FILE' not found. Creating a default one..."
cat > "$PARAMS_FILE" << EOL
{
"\$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environmentName": {
"value": "${ENV_NAME}"
},
"location": {
"value": "${LOCATION}"
},
"keyVaultName": {
"value": "kv-sample-functions-${ENV_NAME}"
},
"cosmosDbConnectionStringSecretName": {
"value": "SampleCosmosDbConnectionString"
},
"blobConnectionStringSecretName": {
"value": "SampleBlobConnectionString"
},
"queueConnectionStringSecretName": {
"value": "SampleQueueConnectionString"
},
"functionAppName": {
"value": "func-${ENV_NAME}-sample"
}
}
}
EOL
fi
echo "Using parameters file: $PARAMS_FILE"
# Create the resource group if it doesn't exist
echo "Checking resource group..."
az group show --name $RESOURCE_GROUP &> /dev/null
if [ $? -ne 0 ]; then
echo "Creating resource group '$RESOURCE_GROUP'..."
az group create --name $RESOURCE_GROUP --location $LOCATION
else
echo "Resource group '$RESOURCE_GROUP' already exists"
fi
# Deploy the Function App using Bicep
echo "Deploying Azure Functions infrastructure for environment: ${ENV_NAME}..."
az deployment group create \
--resource-group $RESOURCE_GROUP \
--name $DEPLOYMENT_NAME \
--template-file "$SCRIPT_DIR/deploy-functions.bicep" \
--parameters @$PARAMS_FILE
DEPLOYMENT_RESULT=$?
if [ $DEPLOYMENT_RESULT -eq 0 ]; then
echo "Azure Functions infrastructure deployment completed successfully."
# Display outputs
echo "Azure Functions deployment outputs:"
az deployment group show \
--resource-group $RESOURCE_GROUP \
--name $DEPLOYMENT_NAME \
--query "properties.outputs" \
--output json
# Get storage account name from the outputs
STORAGE_ACCOUNT_NAME=$(az deployment group show \
--resource-group $RESOURCE_GROUP \
--name $DEPLOYMENT_NAME \
--query "properties.outputs.storageAccountName.value" \
--output tsv)
if [ ! -z "$STORAGE_ACCOUNT_NAME" ]; then
echo "Waiting for storage account '$STORAGE_ACCOUNT_NAME' to be fully provisioned..."
# Wait for storage account to be fully provisioned and accessible
# This helps prevent "CouldNotAccessStorageAccount" errors
sleep 30
# Verify storage account is accessible
az storage account show \
--name $STORAGE_ACCOUNT_NAME \
--resource-group $RESOURCE_GROUP \
--query "statusOfPrimary" \
--output tsv
if [ $? -eq 0 ]; then
echo "Storage account is ready."
else
echo "Warning: Could not verify storage account status. Proceeding anyway, but deployment might fail."
fi
fi
else
echo "Azure Functions infrastructure deployment failed."
exit 1
fi
}
# Create the resources
create_resources
echo "Processing completed"
ステージング環境用のパラメータファイル
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environmentName": {
"value": "staging"
},
"location": {
"value": "japaneast"
},
"keyVaultName": {
"value": "kv-sample-functions-staging"
},
"functionAppName": {
"value": "func-staging-sample"
},
"cosmosDbConnectionStringSecretName": {
"value": "SampleCosmosDbConnectionString"
},
"blobConnectionStringSecretName": {
"value": "SampleBlobConnectionString"
},
"queueConnectionStringSecretName": {
"value": "SampleQueueConnectionString"
},
"storageAccountName": {
"value": "storagesamplestaging"
}
}
}
まとめ
IaC(Infrastructure as Code)を実践してみて、自分の書いたものではないプログラムのリリースの難しさを実感し、インフラエンジニアの仕事の価値を再認識しました。
Azure Portalでの手動設定(ポチポチ)と、コードによる自動化にはそれぞれメリットがあります。Azureのリソースグループにはbicepファイルの書き出し機能もあるため、まず手動で作成してからコード化する方法も有効です。
いずれにせよ、Bicepを使って最初からしっかりとリソースを定義することで、プロジェクトの再現性が高まり、学習する価値は十分にありました。今後はコンテナやKubernetesを活用したIaCにも挑戦していきたいと思います。
Discussion