💜

Azure Functions 環境を Terraform で構築する

2024/11/19に公開

はじめに

はじめまして。株式会社ビットキー Cross Incubator チームの酒井です。

Cross Incubator チームは開発チームを横断して SRE・Backend・Frontend の垣根なく拾いきれなかった課題を解決するために組織され、高度な雑用係(!)として日々タスクをこなしています。

その中で Azure Functions 環境を一から構築する機会があり、得られた知見を共有します。

Terraform に関連する記事が少なく構築時の情報を集めるのに苦労したので、本記事では Terraform にフォーカスし、ほぼそのまま利用できる Terraform コードを記載する事で同様の環境を構築する方の一助となればと思っています。

概要

  • Azure Functions 環境を Terraform で構築する方法を解説⁠⁠
  • ディレクトリ構造と実際のコードを紹介
  • GitHub Actions + OIDC を使用したデプロイ環境の整備方法を説明⁠⁠
  • ローカルデプロイやスクリプト実装には踏み込まない

Azure Functions

サーバレスソリューションと呼ばれ、対応したプログラミング言語で書いた関数を処理します。
サーバレスと言いつつ Stop/Start のような VM を意識させる概念があったり、Cloud Run や Cloud Run functions(旧 Cloud Functions)、 AWS Lambda を想像すると最初は少し戸惑うかもしれません。

使用する背景

Azure で管理している証明書やシークレットの有効期限を監視したいという要件があり、その実装を置く場所として同じ Azure 上であることが好ましいという判断から、監視スクリプトの実行基盤として Azure Functions を選択しました。

構成概要

以下のような構成で構築しました。

Azure Functions 環境を Terraform で構築して GitHub Actions で terraform plan/apply できるようにし、関数となるスクリプトも専用のリポジトリで管理して GitHub Actions 経由で Azure Functions 上にデプロイしています。スクリプトの結果は Slack に通知します。

Timer trigger for Azure Functionsという機能を使い、スクリプト内に cron のような記述をする事で Scheduler 等は不要で単体でスケジュール実行可能になっています。

Terraform ディレクトリ構造

ディレクトリ構造は以下の形を取りました。

azure/
└── tenants
    ├──example-tenant-1
    │  ├── users
    │  └── subscriptions
    │      ├──modules # サブスクリプション間で共通のリソースは module に切り出す想定。本記事のスコープ外
    │      └──subscription-1
    │          └── azure-functions-for-notice # Azure Functions 環境を構築
    │              ├── README.md
    │              ├── backend.tf
    │              ├── main.tf
    │              ├── outputs.tf
    │              ├── providers.tf
    │              └── terraform.tf
    └──example-tenant-2
       ├── users
       └── subscriptions

テナント → テナントに紐付くサブスクリプション → リソース という構造を取っており、各サブスクリプション配下で作成した Azure Storage に State を持ちます。

トップディレクトリを azure とし、他のクラウド環境(Google Cloud、AWS)と分けます。

ここまで書いて、 tenants 配下に modules を置いて全体で共通のリソースを切り出しても良いのかなとも思いました。この辺りは運用しながら定めていきます。

Terraform コードの紹介

azure-functions-for-notice 配下のコードをそれぞれ紹介していきます。
ファイル名と役割は公式の Style Guide に則っています。

backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "subscription-1-tfstate"
    storage_account_name = "tfstate"
    container_name       = "subscription-1-tfstate"
    key                  = "subscription-1/terraform.tfstate"
  }
}

State 保存先として同じサブスクリプション内の Azure Storage を指定しています。保存先は先に作成する必要があるので、先行して手動で作成しています。

作成手順
#!/usr/bin/env bash
set -eux

RESOURCE_GROUP_NAME=subscription-1-tfstate
# Storage account name はグローバルで一意でかつ3〜24文字までの小文字英数字という制約がある
STORAGE_ACCOUNT_NAME=tfstate
CONTAINER_NAME=subscription-1-tfstate

az account set --subscription "subscription-1"

# Create resource group
az group create --name $RESOURCE_GROUP_NAME --location japaneast

# Create storage account
az storage account create --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ACCOUNT_NAME --sku Standard_LRS --encryption-services blob -l japaneast

# 'NoneType' object is not iterable というエラーが発生する場合、以下を実施したところ解消
# python -m pip install azure-multiapi-storage

# Create storage container
az storage container create --name $CONTAINER_NAME --account-name $STORAGE_ACCOUNT_NAME
outputs.tf
output "client_id" {
  value = data.azurerm_client_config.current.client_id
}

output "object_id" {
  value = data.azurerm_client_config.current.object_id
}

output "subscription_id" {
  value = data.azurerm_client_config.current.subscription_id
}

output "tenant_id" {
  value = data.azurerm_client_config.current.tenant_id
}

Terraform 実行ユーザーに紐付く各種 ID を出力しています。上手く行かないときの参考情報となるので出力しています。

providers.tf
provider "azurerm" {
  features {}
}

Azure Provider の設定です。特別な設定は現状していません。

terraform.tf
terraform {
  required_version = "1.9.8"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.9.0"
    }
  }
}

Terraform と Azure Provider のバージョンをここで指定しています。

main.tf

Azure Functions 環境のメインのコードを集約しています。
以下のリソースを作成しています。

  • Azure Functions 本体
    • リソースグループ
    • ストレージアカウント
    • サービスプラン
    • 本体
  • Azure Functions 環境変数用
    • Key Vault
    • Key Vault アクセスポリシー
    • Key Vault secrets
  • Azure Functions デプロイ用
    • GitHub Actions → Azure OIDC 用アプリケーション
      • デプロイ権限を与えるため、別途アプリケーションに対して Azure Functions 用のリソースグループで Contributor 権限を与えている
      • 手動で実施しており、Terraform 化の方法が確定しておらずまだ行えていない
    • フェデレーションクレデンシャル
main.tf
# outputs.tf で使用していたデータソース
data "azurerm_client_config" "current" {}

# Azure Functions 環境用リソースグループ
resource "azurerm_resource_group" "azure_functions_for_notice_rg" {
  name = "azure-functions-for-notice-rg"
  location = "Japan East"
}

# GitHub Actions から Azure Functions をデプロイする際に使用する OIDC 認証用のアプリケーション
resource "azuread_application" "for_github_actions_oidc" {
  display_name = "for-github-actions-oidc"
  owners = [
    "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
  ]
  sign_in_audience = "AzureADMyOrg"
  required_resource_access {
    # サービスプリンシパルである Microsoft Graph の固定 Application ID
    # https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in#application-ids-of-commonly-used-microsoft-applications
    resource_app_id = "00000003-0000-0000-c000-000000000000"
    resource_access {
      # アプリケーション作成時にデフォルト設定される Microsoft Graph 向け User.Read 権限の固定 Permission ID
      # https://learn.microsoft.com/en-us/graph/migrate-azure-ad-graph-permissions-differences#userread
      id   = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"
      type = "Scope"
    }
  }
}

# Terraform リポジトリの main ブランチから GitHub Actions で Azure へのリソースデプロイを許可するフェデレーションクレデンシャル
resource "azuread_application_federated_identity_credential" "federated_credential_for_terraform_main_branch" {
  application_id = "/applications/${azuread_application.for_github_actions_oidc.object_id}"
  display_name   = "FederatedCredentialForTerraformMainBranch"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:example-org/terraform:ref:refs/heads/main"
}

# Terraform リポジトリで Pull Request 作成時に GitHub Actions から Azure へのリソースデプロイを許可する(terraform plan 用)フェデレーションクレデンシャル
resource "azuread_application_federated_identity_credential" "federated_credential_for_terraform_pull_request" {
  application_id = "/applications/${azuread_application.for_github_actions_oidc.object_id}"
  display_name   = "FederatedCredentialForTerraformPullRequest"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:example-org/terraform:pull_request"
}

# azure-functions-for-notice リポジトリの main ブランチから GitHub Actions で Azure へのリソースデプロイを許可するフェデレーションクレデンシャル
resource "azuread_application_federated_identity_credential" "federated_credential_for_azure_functions_for_notice_main_branch" {
  application_id = "/applications/${azuread_application.for_github_actions_oidc.object_id}"
  display_name   = "FederatedCredentialForAzureFunctionsForNotice"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:example-org/azure-functions-for-notice:ref:refs/heads/main"
}

# シークレット格納用 Key Vault
resource "azurerm_key_vault" "azure_functions_for_notice_key_vault" {
  name                = "for-notice"
  resource_group_name = azurerm_resource_group.azure_functions_for_notice_rg.name
  location            = azurerm_resource_group.azure_functions_for_notice_rg.location
  sku_name            = "standard"
  tenant_id           = data.azurerm_client_config.current.tenant_id
}

# User Assigned Identity を作成して各リソースが Key Vault にアクセスできるようにする
resource "azurerm_user_assigned_identity
" "azure_functions_for_notice_identity" {
  name                = "azure-functions-for-notice-identity"
  resource_group_name = azurerm_resource_group.azure_functions_for_notice_rg.name
  location            = azurerm_resource_group.azure_functions_for_notice_rg.location
}

# Key Vault へのアクセスポリシーを定義。 User Assigned Identity からのアクセスを許可する
# azurerm_key_vault 内で設定も可能だが循環参照エラーを引き起こすため別で定義
resource "azurerm_key_vault_access_policy" "azure_functions_for_notice_key_vault_access_policy" {
  key_vault_id = azurerm_key_vault.azure_functions_for_notice_key_vault.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = azurerm_user_assigned_identity.azure_functions_for_notice_identity.principal_id

  secret_permissions = [
    "Get", "List"
  ]
}

# 初回構築時のみ使用するアクセスポリシー。初回のみローカルから terraform apply する時などに使用
# Key Vault secrets 作成前にこのポリシーを作成しておかないと権限が無くて失敗するため
# resource "azurerm_key_vault_access_policy" "terraform" {
#   key_vault_id = azurerm_key_vault.azure_functions_for_notice_key_vault.id
#   tenant_id    = data.azurerm_client_config.current.tenant_id
#   object_id = "your user's object id"

#   secret_permissions = [
#     "Get", "List", "Set", "Delete", "Recover", "Backup", "Restore", "Purge"
#   ]
# }

# スクリプトが使用するシークレットを作成
resource "azurerm_key_vault_secret" "secrets" {
  for_each = {
    "client-id" = {
      value = "replace me"
    },
    "client-secret" = {
      value = "replace me"
    },
    "tenant-id" = {
      value = "replace me"
    },
    "slack-webhook-url" = {
      value = "replace me"
    }
    # for-github-actions-oidc の object id を設定
    # Enterprise Application -> for-github-actions-oidc から確認できる object id を設定すること
    # 同じアプリながら Application object と Service principal object という2つの扱われ方があり、 object id が異なる
    # Key Valut のアクセスポリシーに指定する想定で、特定のテナント配下で使用される Service principal 側を指定しないとうまくいかない
    # https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser#relationship-between-application-objects-and-service-principals
    "for-github-actions-oidc-object-id" = {
      value = "replace me"
    }
  }

  name         = each.key
  value        = each.value.value
  key_vault_id = azurerm_key_vault.azure_functions_for_notice_key_vault.id

  lifecycle {
	  # 値は手動で入力して変化するので ignore
    ignore_changes = [value]
  }
}

# GitHub Actions が アプリ for-github-actions-oidc 経由で Key Vault を操作できるようにする
resource "azurerm_key_vault_access_policy" "for_github_actions_oidc" {
  key_vault_id = azurerm_key_vault.azure_functions_for_notice_key_vault.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = azurerm_key_vault_secret.secrets["for-github-actions-oidc-object-id"].value

  secret_permissions = [
    "Get", "List", "Set", "Delete", "Recover", "Backup", "Restore", "Purge"
  ]
}

# Azure Functions 向けストレージアカウント
resource "azurerm_storage_account" "azure_functions_for_notice_storage_account" {
  name                     = "forazurefunctions"
  resource_group_name      = azurerm_resource_group.azure_functions_for_notice_rg.name
  location                 = azurerm_resource_group.azure_functions_for_notice_rg.location
  account_kind             = "StorageV2"
  account_tier             = "Standard"
  account_replication_type = "LRS"
  identity {
    type = "UserAssigned"
    identity_ids = [
      azurerm_user_assigned_identity.azure_functions_for_notice_identity.id,
    ]
  }
}

# Azure Functions 向けサービスプラン
resource "azurerm_service_plan" "azure_functions_for_notice_service_plan" {
  name                = "azure-functions-for-notice-service-plan"
  resource_group_name = azurerm_resource_group.azure_functions_for_notice_rg.name
  location            = azurerm_resource_group.azure_functions_for_notice_rg.location
  os_type             = "Linux"
  # 従量課金プラン用 SKU
  sku_name = "Y1"
}

# ログなどを見たいので Azure Functions で  Application Insights を有効化
resource "azurerm_application_insights" "azure_functions_for_notice_application_insights" {
  application_type    = "Node.JS"
  name                = "azure-functions-for-notice"
  resource_group_name = azurerm_resource_group.azure_functions_for_notice_rg.name
  location            = azurerm_resource_group.azure_functions_for_notice_rg.location
  sampling_percentage = 0
}

# Azure Functions の本体。環境変数は Key Vault を指定
resource "azurerm_linux_function_app" "azure_functions_for_notice" {
  app_settings = {
	  # スクリプト向け
    CLIENT_ID                       = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.azure_functions_for_notice_key_vault.name};SecretName=${azurerm_key_vault_secret.secrets["client-id"].name})"
    CLIENT_SECRET                   = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.azure_functions_for_notice_key_vault.name};SecretName=${azurerm_key_vault_secret.secrets["client-secret"].name})"
    TENANT_ID                       = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.azure_functions_for_notice_key_vault.name};SecretName=${azurerm_key_vault_secret.secrets["tenant-id"].name})"
    SLACK_WEBHOOK_URL               = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.azure_functions_for_notice_key_vault.name};SecretName=${azurerm_key_vault_secret.secrets["slack-webhook-url"].name})"
    # デフォルトで設定され差分が出るためここで明示
    WEBSITE_ENABLE_SYNC_UPDATE_SITE = "true"
  }
  builtin_logging_enabled    = true
  client_certificate_mode    = "Required"
  https_only                 = true
  name                       = "azure-functions-for-notice"
  resource_group_name        = azurerm_resource_group.azure_functions_for_notice_rg.name
  location                   = azurerm_resource_group.azure_functions_for_notice_rg.location
  service_plan_id            = azurerm_service_plan.azure_functions_for_notice_service_plan.id
  storage_account_name       = azurerm_storage_account.azure_functions_for_notice_storage_account.name
  storage_account_access_key = azurerm_storage_account.azure_functions_for_notice_storage_account.primary_access_key

  identity {
    type = "UserAssigned"
    identity_ids = [
      azurerm_user_assigned_identity.azure_functions_for_notice_identity.id,
    ]
  }
  key_vault_reference_identity_id = azurerm_user_assigned_identity.azure_functions_for_notice_identity.id

  site_config {
    application_stack {
      node_version = "20"
    }
    cors {
      allowed_origins = ["https://portal.azure.com"]
    }
    application_insights_key               = azurerm_application_insights.azure_functions_for_notice_application_insights.instrumentation_key
    application_insights_connection_string = azurerm_application_insights.azure_functions_for_notice_application_insights.connection_string
  }

  lifecycle {
    ignore_changes = [
      # 関数デプロイ時に自動生成・変更されるため ignore
      app_settings["WEBSITE_RUN_FROM_PACKAGE"],
      # Application Insights 向けに自動で設定されるため ignore
      tags
    ]
  }
}

GitHub Actions

ここまでで Terraform の準備が終わったので、GitHub Actions を用いてデプロイ環境の整備をします。
ここの OIDC 認証を実現させるために Azure でアプリケーションとフェデレーションクレデンシャルの作成をしていました。

Terraform 用リポジトリの .github/workflows ディレクトリに terraform plan/apply 用の設定ファイルを配置します。

plan 結果はプルリクエストに出力し、 apply は安全のため手動実行します。

azure-plan.yml
name: Azure Plan

on:
  pull_request:
    paths:
      - 'azure/tenants/example-tenant-1/subscriptions/subscription-1/azure-functions-for-notice/**'
      - '.github/workflows/azure-plan.yml'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read
  id-token: write
  issues: write
  pull-requests: write

env:
  TF_ACTIONS_WORKING_DIR: 'azure/tenants/example-tenant-1/subscriptions/subscription-1/azure-functions-for-notice'
  # ARM_xxx 環境変数は terraform 実行時に Azure Provider に認証情報として利用される
  ARM_USE_OIDC: true
  ARM_CLIENT_ID: ${{ vars.CLIENT_ID }}
  ARM_TENANT_ID: ${{ vars.TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.SUBSCRIPTION_1 }}
jobs:
  run:
    name: Run
    runs-on: ubuntu-24.04-2cores-arm64
    timeout-minutes: 30
    defaults:
      run:
        working-directory: ${{ env.TF_ACTIONS_WORKING_DIR }}
    steps:
        - name: Checkout
          uses: actions/checkout@v4

        - name: Setup tfcmt
          uses: shmokmt/actions-setup-tfcmt@v2

        - name: Az CLI login
          uses: azure/login@v2
          with:
            client-id: ${{ env.ARM_CLIENT_ID }}
            tenant-id: ${{ env.ARM_TENANT_ID }}
            subscription-id: ${{ env.ARM_SUBSCRIPTION_ID}}

        - name: Set up Terraform
          uses: hashicorp/setup-terraform@v3

        - name: Terraform Init
          id: init
          run: terraform init -reconfigure

        - name: Terraform Plan
          id: plan
          env:
            GITHUB_TOKEN: ${{ github.token }}
            TFCMT: ${{ github.event_name == 'pull_request' }}
          run: |
            if [ "${TFCMT}" = 'true' ]; then
              tfcmt plan --patch -- \
              terraform plan
            else
              terraform plan
            fi

        - name: Output Summary
          if: always()
          run: |
            cat - << 'EOS' | sed -r 's/[[:cntrl:]]\[[0-9]{1,3}m//g' >> "${GITHUB_STEP_SUMMARY}"
              # Terraform Plan: `${{ steps.plan.outcome }}`
              ```hcl
              ${{ steps.plan.outputs.stdout }}
              ${{ steps.plan.outputs.stderr }}
              ```
            EOS
azure-apply.yml
name: Azure Apply

on:
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: read
  id-token: write
  issues: write
  pull-requests: write

env:
  TF_ACTIONS_WORKING_DIR: 'azure/tenants/example-tenant-1/subscriptions/subscription-1/azure-functions-for-notice'
  ARM_USE_OIDC: true
  ARM_CLIENT_ID: ${{ vars.CLIENT_ID }}
  ARM_TENANT_ID: ${{ vars.TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.SUBSCRIPTION_1 }}

jobs:
  run:
    if: ${{ inputs.apply }}
    name: Run
    runs-on: ubuntu-24.04-2cores-arm64
    timeout-minutes: 30
    defaults:
      run:
        working-directory: ${{ env.TF_ACTIONS_WORKING_DIR }}
    steps:
        - name: Checkout
          uses: actions/checkout@v4

        - name: Setup tfcmt
          uses: shmokmt/actions-setup-tfcmt@v2

        - name: Az CLI login
          uses: azure/login@v2
          with:
            client-id: ${{ env.ARM_CLIENT_ID }}
            tenant-id: ${{ env.ARM_TENANT_ID }}
            subscription-id: ${{ env.ARM_SUBSCRIPTION_ID}}

        - name: Set up Terraform
          uses: hashicorp/setup-terraform@v3

        - name: Terraform Init
          id: init
          run: terraform init -reconfigure

        - name: Terraform Apply
          id: apply
          env:
            GITHUB_TOKEN: ${{ github.token }}
          run: |
              tfcmt apply -- \
              terraform apply -auto-approve

        - name: Output Summary
          if: always()
          run: |
            cat - << 'EOS' | sed -r 's/[[:cntrl:]]\[[0-9]{1,3}m//g' >> "${GITHUB_STEP_SUMMARY}"
              # Terraform Apply: `${{ steps.apply.outcome }}`
              ```hcl
              ${{ steps.apply.outputs.stdout }}
              ${{ steps.apply.outputs.stderr }}
              ```
            EOS

スクリプトのデプロイ

スクリプトのデプロイも GitHub Actions を用いて行います。
スクリプト用リポジトリの .github/workflows に同じくデプロイ用の設定ファイルを配置します。
プルリクエストが main ブランチにマージされるとデプロイされることを想定しています。

deploy-function-app.yml
name: Deploy Node.js project to Azure Function App
on:
  push:
    branches:
      - main

permissions:
      id-token: write
      contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  AZURE_FUNCTIONAPP_NAME: 'azure-functions-for-notice'

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

    - name:HAz CLI login
      uses: azure/login@v2
      with:
        client-id: ${{ vars.CLIENT_ID }}
        tenant-id: ${{ vars.TENANT_ID }}
        subscription-id: ${{ vars.SUBSCRIPTION_1 }}

    - name: Setup Node Environment
      uses: actions/setup-node@v4
      with:
        node-version-file: '.nvmrc'
 
    - name: 'Resolve Project Dependencies Using Npm'
      shell: bash
      run: |
        npm ci
        npm run build

    - name: 'Run Azure Functions action'
      uses: Azure/functions-action@v1
      with:
        app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}

まとめ

Azure Functions 環境を Terraform で構築し、デプロイ環境を整えるところまでご紹介しました。

記事中で気になった点が有る方はお気軽にコメントください。

Bitkey Developers

Discussion