Zenn
📚

Azure Functions: .NET 9.0 Isolated デプロイの落とし穴と解決策

2025/04/05に公開

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つの方法があります:

  1. Azure CLI (az) - 従来の方法

    • az deployment group createコマンドでBicepテンプレートを実行
    • リソースの作成とアプリケーションコードのデプロイが明確に分離される
    • カスタマイズ性が高く、デプロイプロセスの各段階を細かく制御可能
  2. 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

ログインするとコメントできます