Azure Functions 環境を Terraform で構築する
はじめに
はじめまして。株式会社ビットキー 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 に則っています。
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
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 を出力しています。上手く行かないときの参考情報となるので出力しています。
provider "azurerm" {
features {}
}
Azure Provider の設定です。特別な設定は現状していません。
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 化の方法が確定しておらずまだ行えていない
- フェデレーションクレデンシャル
- GitHub Actions → Azure OIDC 用アプリケーション
# 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 は安全のため手動実行します。
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
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 ブランチにマージされるとデプロイされることを想定しています。
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 で構築し、デプロイ環境を整えるところまでご紹介しました。
記事中で気になった点が有る方はお気軽にコメントください。
Discussion