APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

Azure Key Vaultと連携してApp Service/Functionsのアプリ設定にシークレットな値をTerraformで格納する

ACS事業部の安藤です。

皆さん、データベースやストレージのパスワードや接続文字列ってどんな感じで管理されていますか?

私の場合はシステム開発の一環で必要になるので開発チーム等にも割と簡単に共有してしまいがちなんですが、
最小権限(特権)の原則に基づくなら開発チームに教えなくて済むならそうしたい、というニーズもあると思います。

そんな時に使えるのがAzure Key Vaultです。

シークレットや証明書などを管理できるKey Vaultですが、Azureのサービスによっては接続文字列等を直接入力するのではなく、
Key Vaultに格納されたシークレットを参照させる、ということも可能なものがあります。

また、Azureを始めとしたマネージドなリソースの接続文字列であればTerraformがパラメータとして持っていることが多いので、
Key Vaultへの格納からアプリ等での参照をTerraformで一気にできるとイイ感じじゃないかと思います。
意外とこの辺りの情報がまとまってる記事が見つからなかったので紹介したいと思います。

今回はその一例として、比較的使用頻度の高い Azure Functions でストレージアカウントの接続文字列をアプリ設定(環境変数)に格納してみたいと思います。

実践

設定方法の確認

まずはTerraformは抜きにして、App Service/Functionsでアプリ設定としてKey Vault参照する方法です。

こちらのドキュメントに解説されています。

learn.microsoft.com

簡単にまとめると

  • App Service/Functions(のManaged ID) にKey Vault Secretを参照する権限を与える
  • App Service/Functions のアプリ設定に値を @Microsoft.KeyVault(SecretUri=${SecretId}) という形式で登録する
    • SecretId は https://myvault.vault.azure.net/secrets/secretname/ のようなURL形式

の2ステップで完了です。

Terraform で実装

それでは周辺リソースの作成を含めてTerraformでやってみましょう。

全体のコードはこんな感じです。

provider "azurerm" {
  features {}
}

data "azurerm_resource_group" "main" {
  name = "ando-test"
}

resource "azurerm_storage_account" "main" {
  name                     = "kvtestblob"
  resource_group_name      = data.azurerm_resource_group.main.name
  location                 = data.azurerm_resource_group.main.location
  account_tier             = "Standard"
  account_replication_type = "GRS"
}

resource "azurerm_key_vault" "main" {
  name                        = "kv-test-apc"
  resource_group_name         = data.azurerm_resource_group.main.name
  location                    = data.azurerm_resource_group.main.location
  enabled_for_disk_encryption = true
  tenant_id                   = data.azurerm_client_config.current.tenant_id
  soft_delete_retention_days  = null
  purge_protection_enabled    = false
  enable_rbac_authorization   = true
  sku_name                    = "standard"
}

data "azurerm_client_config" "current" {}

resource "azurerm_role_assignment" "current" {
  role_definition_name = "Key Vault Administrator"
  scope                = azurerm_key_vault.main.id
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_secret" "blob" {
  name         = "BLOB"
  value        = azurerm_storage_account.main.primary_connection_string
  key_vault_id = azurerm_key_vault.main.id
  depends_on   = [azurerm_role_assignment.current]
}

resource "azurerm_service_plan" "main" {
  name                = "kv-test-plan"
  resource_group_name = data.azurerm_resource_group.main.name
  location            = data.azurerm_resource_group.main.location
  os_type             = "Linux"
  sku_name            = "Y1"
}

resource "azurerm_linux_function_app" "main" {
  name                       = "kv-test-apc"
  resource_group_name        = data.azurerm_resource_group.main.name
  location                   = data.azurerm_resource_group.main.location
  service_plan_id            = azurerm_service_plan.main.id
  storage_account_name       = azurerm_storage_account.main.name
  storage_account_access_key = azurerm_storage_account.main.primary_access_key
  https_only                 = true
  
  app_settings = {
    BLOB_CONNECTION_STRING = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.blob.versionless_id})"
  }

  identity {
    type = "SystemAssigned"
  }

  site_config {
    application_stack {
      python_version = "3.11"
    }
  }
}

resource "azurerm_role_assignment" "func" {
  role_definition_name = "Key Vault Secrets User"
  scope                = azurerm_key_vault.main.id
  principal_id         = azurerm_linux_function_app.main.identity[0].principal_id
}

Terraformコードの解説

説明しやすいような順番で記述しているので上から解説していきます。

azurerm_storage_account.main

今回はこちらのストレージアカウントの接続文字列をアプリに渡したいと思ってストレージを作成しています。
また若干ややこしいですが、Azure Functionsを作成するのにストレージアカウントが必要になるので、それにも利用します。

azurerm_key_vault.main

シークレットを格納するKey Vaultを作成しています。
特筆すべきパラメータとしては enable_rbac_authorization = true でRBACによるアクセス制御を有効化しています。
Key Vaultはデフォルトではアクセスポリシーという、シークレットのGetやSetなどアクション毎の可否を細く設定できるアクセス制御形式で設定するのですが、
AzureとしてはRBACが推奨となっています。

個人的には権限をズラっと書き並べるより、大枠で権限が把握しやすいロール名のほうが好みなので今回はRBACを採用しています。

data.azurerm_client_config.current

ここではTerraformを実行しているAzureアカウント、いわゆるAzure Entra IDの情報を取得しています。
Azureのユーザー権限で実行していればそのユーザー、Service Principalで実行していればそのアプリの情報になります。

azurerm_role_assignment.current

RBACの仕組みでTerraformの実行ユーザーにKey Vaultに対するKey Vault Administrator権限を付与しています。
アクセスポリシーを採用する場合はアクセスポリシーで必要な権限を付与してください。
Terraformといえど権限がなければKey Vaultにシークレットを作成したり読み取ったりはできないので必要な作業です。

Key Vaultに対するロールは以下のページを参考にしてください。

learn.microsoft.com

azurerm_key_vault_secret.blob

azurerm_storage_account.main の(プライマリ)接続文字列をKey Vaultのシークレットとして格納します。
↑の azurerm_role_assignment.current が設定された後でないとシークレットを作成する権限がなくて失敗するため、
depends_on = [azurerm_role_assignment.current] で明示的な依存関係を付けて作成順序を指定しています。

azurerm_service_plan.main

Azure Functions の コンピューティングリソースとしてApp Service Planを作成します。
検証用なので従量課金プランにするため sku_name = "Y1" と指定しています。

azurerm_linux_function_app.main

Azure Functionsを作成しています。
要素が多いので更に細かく見ていきます。

app_settings

Azure Functions のアプリ設定として、いわゆる環境変数を設定しています。
ここで冒頭に出たKey Vault参照の形式で値を設定しています。

BLOB_CONNECTION_STRING = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.blob.versionless_id})"

シークレットのIDはTerraformから取得することができます。
シークレットはバージョン管理することができ、バージョン毎にIDを持っていますが、
今回使用した versionless_id という形式の場合はアクティブなバージョンのIDを自動的に取得してくれます。

identity

Azure FunctionsからKey Vaultを参照する権限も必要になります。
Azure FunctionsはデフォルトではマネージドIDを持っていませんが、
identityブロックで type = "SystemAssigned" を指定することでリソース作成時にマネージドIDを自動生成して割り当てています。

azurerm_role_assignment.func

最後にAzure Functions(のマネージドID)にKey Vault Secrets User権限を付与します。
Azure Functionsのapp_settingsでシークレットのIDを参照した後に権限を付与するのは若干気持ち悪いですが、
権限は後付けでも問題なく動作します。

リソースを作って実物を確認

terraform applyを実行して実際のリソースを見てみましょう。

アプリケーションがアプリ設定を読み込めているか、コードをデプロイして確認してみます。

import azure.functions as func
import logging

import os

app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)

@app.route(route="http_trigger")
def http_trigger(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    value = ""
    for k in os.environ:
        value += f'{k}={os.environ[k]}\n'

    return func.HttpResponse(
        value,
        status_code=200
    )

環境変数を全て表示するという雑なコードですが、検証用ということでご容赦ください。
VSCodeのAzure拡張でデプロイしました(こちらの詳細は割愛)

URLを叩いて該当の環境変数を探してみると

BLOB_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=kvtestblob;AccountKey=***************;EndpointSuffix=core.windows.net

しっかり環境変数に組み込めてますね。

このようにKey Vaultのシークレットに秘匿情報を格納することで、必要な人に、必要な時だけしか秘匿情報にアクセスできないような制御ができるようになりました。

実はこの秘匿情報をいつでも見れてしまうところがある

Key Vault連携を祭り上げておいてなんですが、 実はこの秘匿情報をいつでも見れてしまうところがあります。

どこだかわかりますか?

そう、Terraformのステートファイルです!!

ここにはSensitiveな属性が付与されていようがお構いなしに平文で値が格納されています。
なのでステートファイルを配置する場所の管理はとても大事なんです。
リモートステートとしてAzure Blobなどに置く場合でも、
独立したリソースグループやサブスクリプションに配置して容易にRead/Write権限が当たらないようにしたり、
IP制限などネットワーク的な制御も視野にいれる必要があります。

その点、Terraform Cloudはユーザー/チーム単位で権限が管理でき、ステートへのアクセスレベルも制御できるので安心感がありますね(露骨な宣伝)

developer.hashicorp.com

その他Tips

複雑なシークレットの参照

今回は azurerm_storage_account.main.primary_connection_string の値そのままを格納したので簡単な記述で済みましたが、
データベースの接続文字列などのように一定のフォーマットに従いつつ、いくつかの値を組み合わせた文字列を渡したい場合もあると思います。

アプリ設定側で文字列とシークレットを組み合わせる、ということはできないようなので、
以下のようにシークレットの作成段階で組み合わせる必要があります。

resource "azurerm_key_vault_secret" "db" {
  name         = "DB_CONNECTION_STRING"
  key_vault_id = azurerm_key_vault.main.id
  value        = "Server=\"${azurerm_mysql_flexible_server.main.fqdn}\";UserID = \"${var.db_user}\";Password=\"${var.db_password}\";Database=\"${var.db_database}\";"
}

実行順序の制御(デッドロック対策)

Terraformは「手続き型ではなく宣言型」とはいいつつも作成順序を気にする必要がある場面もあります。
今回 depends_on を使って明示的な依存関係を記述したのもその一例ですが、
他にもリソース間が相互に依存しあうことでどちらのリソースも作り始めることができない、いわゆるデッドロックも発生することがあります。

今回使ったリソースを例に上げると、Key VaultにはRBACの他にアクセスポリシーでアクセス制御することができる、というお話をしました。
このアクセスポリシーにも2通りの書き方があって、RBACのようにazurerm_key_vaultとは別のリソースとして書く方法に加えて、以下のようにazurerm_key_vaultリソースの中に書く方法もあります。

resource "azurerm_key_vault" "example" {
  name                        = "examplekeyvault"

  ...
  ...

  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = data.azurerm_client_config.current.object_id

    secret_permissions = [
      "Get", "Set", "Delete"
    ]
  }

  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = azurerm_linux_function_app.main.identity[0].principal_id

    secret_permissions = [
      "Get",
    ]
  }
}

この書き方でAzure FunctionsのManaged IDに権限を付与しようとすると、Azure FunctionsとKey VaultとKey Vault Secretの間でデッドロックが発生します。

$ terraform plan
╷
│ Error: Cycle: azurerm_key_vault_secret.blob, azurerm_linux_function_app.main, azurerm_key_vault.main
│
│

回避方法としては、アクセスポリシーやRBACのリソースをazurerm_key_vaultとは独立したリソースとして記述することの他に、
今回FunctionsのマネージドIDをSystemAssignedで自動生成しましたが、user_assigned_identityというこれまた独立したリソースを作成し、
それをFunctionsに割り当てる、という方法もあります。

Terraformで管理するリソースが増えてくると、こういった方法を複数組み合わせてデッドロックを回避する必要が出てくるケースもあります。

Key Vaultと連携できるその他のサービス

今回はAzure Functionsを例にKey Vault連携の紹介をしましたが、App Serviceでも同様の方法で連携できます。
他にもCompute系は概ね対応しており、Azure Container AppsやLogic Appsでも対応しています。
AKSの場合はSecrets Store CSI Driverのような若干複雑な仕組みが必要ですがこちらも連携可能です。

最近使った変わり種だと、Azure Data FactoryというデータフローやETLができるサービスでも、Key Vaultとリンクサービスで接続することで、
シークレットにあるデータベースやストレージの接続文字列を読み込んでそれぞれのリンクサービスを構築するのに活用しました。

その他、対応していないサービスやAzure外であっても各種プログラミング言語のAzure SDKやAzure CLIでも連携できそうですね。

あとがき

今回はアプリケーションとシークレットの連携というテーマから、Terraformの実行順序などの動やセキュリティに関するお話をしました。

同じHashicorp関連の話題としては、最近GAされた Hashicorp Vault Enterprise 1.16 でSecrets Sync機能がリリースされました。

www.hashicorp.com

Secrets SyncではAzure Key VaultやAWS Secrets Manager 、GitHub Actionsなどのサードパティのシークレット管理サービスと連携できるようになりました。

各クラウドのマネージドサービスの中にはHashicorp Vaultとは直接連携できない/しにくいものもあると思いますが、
今回のような方法で各クラウドのシークレット管理サービスを介することで、Hashicorp Vaultでシークレットを中央集権的に管理しやすくなったと思います。

個人的な話をすると、最近Terraform Associate (003)という資格を取得しました。
実務経験も長いので内容的には特に問題なかったのですが、英語オンリーというのがしんどかったです。
オンライン試験を受けるのも久々だったのですが、ルール的にもなかなか厳しく、
英語がわからなくてブツブツの読み上げてたら真っ赤な警告画面が出てきて「次やったら失格な」的なことがここだけ日本語で出てきたのは心臓が止まりかけました。

合格ブログ書こうぜ、という話もありましたが、すでに同僚が書いているのでいいかなあ、というのと、
直近の5月から試験のバージョンが上がって試験内容が変わり、さらにオンライン試験の提供サービスも別のものに切り替わるとのことで、
残念なほどに利用価値の薄い合格ブログになってしまう…という事情もありました。

このオチを書きたいがためにこの記事を書き始めたまであるので、以上とさせていただきます。


【PR】
私達 ACS 事業部は Azure・AKS などのクラウドネイティブ技術を活用した内製化のご支援をしております。

www.ap-com.co.jp

一緒に働いていただける仲間も募集中です!
まだまだ組織規模拡大中なので、ご興味持っていただけましたらぜひお声がけください。
我々の事業部の CultureDeck はコチラ。

www.ap-com.co.jp

私達ACS事業部はAzure・AKSなどのクラウドネイティブ技術を活用した内製化のご支援をしております。

www.ap-com.co.jp

本記事の投稿者: 安藤 知樹
AzureとTerraformをメインにインフラ系のご支援を担当しています。