AzureFunctionのストレージをアクセス制限してデプロイしようとしてハマった話

# この記事について
VNET統合して、バックエンドのストレージアカウントはネットワークアクセス制限するという条件でAzureFunctionをTerraformで作ろうとしたのですが、下記のエラーに悩まされました
```
Error: reading logs configuration for Windows Function App: (Site Name "azfvnettest001" /
Resource Group "rg-azf-test-001"):web.AppsClient#GetDiagnosticLogsConfiguration: 
Failure responding to request: StatusCode=503 
-- Original Error: autorest/azure: Service returned an error.
Status=503 Code="ServiceUnavailable" 
Message="The operation timed out and could not be completed. Please retry the action or try again later." 
Details=[
{"Message":"The operation timed out and could not be completed. 
Please retry the action or try again later."},
{"Code":"ServiceUnavailable"},
{"ErrorEntity":{"Code":"ServiceUnavailable",
"ExtendedCode":"51007",
"Message":"The operation timed out and could not be completed. Please retry the action or try again later.","MessageTemplate":"The operation timed out and could not be completed. Please retry the action or try again later."}}]
```
AzurePortalから確認するとAzureFunctionのリソースは出来上がっているのですが、URLにアクセスしても503エラーになってしまう状況です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3735414/a645d106-dedb-a14b-f9bf-4565347b89a7.png)
ちなみに、正しく作成ができている状態でURLにアクセスするとこんな画面になります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3735414/a09553c3-c70b-ee09-84fa-91e066186897.png)
しばらくはAzurePortalから手動で作成してからimportをしていたのですが、複数環境を作成するようになってきて、さすがに辛くなってきたので、解決策を検討することにしました。

## 最初に書いていたコード
あまり特別なことはしていないつもりで、次のことを実現したくて実装しました
- VNET統合をするためにサブネットの作成
- セキュリティを考慮してストレージアカウントはアクセス制限
- ElasticPremiumでよしなにスケーリング
```terraform
#リソースグループ
resource "azurerm_resource_group" "rg" {
  name     = "rg-azf-test-001"
  location = "japaneast"
}

#仮想ネットワークの作成
resource "azurerm_virtual_network" "vnet" {
  name                = "vnet-azf-test-001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  address_space       = ["10.0.0.0/16"]
}

#サブネットの作成
resource "azurerm_subnet" "subnet" {
  name                 = "snet-azf-test-001"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.0.0/24"]
  #AZFをVNET統合するためにサブネット委任を設定する
  delegation {
    name = "delegation"
    service_delegation {
      name    = "Microsoft.Web/serverFarms"
      actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
    }
  }
  #ストレージアカウントにアクセスできるようにサービスエンドポイントを設定
  service_endpoints = ["Microsoft.Storage"]
}

#AZFのログやインスタンス間のファイル共有を格納するためのストレージアカウント
resource "azurerm_storage_account" "st" {
  name                     = "stazftest001"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

#ストレージアクセス制限
resource "azurerm_storage_account_network_rules" "st" {
  storage_account_id         = azurerm_storage_account.st.id
  default_action             = "Deny"
  virtual_network_subnet_ids = [azurerm_subnet.subnet.id]
}

# AZFのPlan
resource "azurerm_service_plan" "plan" {
  name                = "plan-azf-test-001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  os_type             = "Windows"
  sku_name            = "EP1"
}

#AZF作成
resource "azurerm_windows_function_app" "func" {
  name                            = "azfvnettest001"
  resource_group_name             = azurerm_resource_group.rg.name
  location                        = azurerm_resource_group.rg.location
  service_plan_id                 = azurerm_service_plan.plan.id
  storage_account_name            = azurerm_storage_account.st.name
  storage_account_access_key      = azurerm_storage_account.st.primary_access_key
  virtual_network_subnet_id       = azurerm_subnet.subnet.id
  builtin_logging_enabled         = false
  https_only                      = true
  app_settings = {
    WEBSITE_ENABLE_SYNC_UPDATE_SITE = "true"
    WEBSITE_CONTENTOVERVNET         = "1"
    WEBSITE_CONTENTSHARE            = "azfvnettest001"
  }
  site_config {
    runtime_scale_monitoring_enabled = true
    use_32_bit_worker                = false
    application_stack {
      node_version = "~18"
    }
    vnet_route_all_enabled      = true
    remote_debugging_version    = "VS2019"
    ftps_state                  = "Disabled"
    scm_use_main_ip_restriction = true
  }
}
```

## 原因
結論、以下の条件で発生するということがわかりました
- Elastic Premiumプランを使用している
- 作成時から環境変数`WEBSITE_CONTENTOVERVNET`を`1`に指定している
    - この環境変数についての詳細は[こちら](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-app-settings#website_contentovervnet)

※色々苦労して検証したつもりですが、条件はこの限りではないかもしれません。

AzureFunctionのバックエンドにおいているストレージアカウントにはログや、インスタンス間で共有するファイルが保管されますが、ストレージアカウントにネットワークアクセス制限を設けたい場合は、`WEBSITE_CONTENTOVERVNET`を`1`にする必要があります。これは困った。

#### 所感
ちなみに、ストレージアカウントのアクセス制限をプライベートエンドポイントにした場合、サービスエンドポイントにした場合同様のエラーでした。
想像レベルでしかないですが、この環境変数が初期段階で有効になっていると、バックエンドのストレージにうまくアクセスできないのではないかと思います。実際にエラーが出ている時はストレージに何もコンテナが作成されなかったので・・・
GUIでAzureFunctionを作成する際に最初からストレージにアクセス制限するようなUIじゃないので、TerraformなどAPIで作ろうとするときにハマるのだと思われます
## 解決策
原因にも書いた通り**作成時**に上記の環境変数が設定されているとこのエラーに遭遇します。
なので、**AzureFunctionを作成した後にこの変数を追加すればいいのです**

とはいっても、宣言的な記述をするTerraformで手続的なコーディング方法がないかと模索してみました。

## Terraformでの実装
先に結論ですが、このような実装で「AZFの作成後に環境変数を追加する」ができました
※AzureFunctionを`AZF`と略しています
```terraform
#リソースグループ
resource "azurerm_resource_group" "rg" {
  name     = "rg-azf-test-001"
  location = "japaneast"
}

#仮想ネットワークの作成
resource "azurerm_virtual_network" "vnet" {
  name                = "vnet-azf-test-001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  address_space       = ["10.0.0.0/16"]
}

#サブネットの作成
resource "azurerm_subnet" "subnet" {
  name                 = "snet-azf-test-001"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.0.0/24"]
  #AZFをVNET統合するためにサブネット委任を設定する
  delegation {
    name = "delegation"
    service_delegation {
      name    = "Microsoft.Web/serverFarms"
      actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
    }
  }
  #ストレージアカウントにアクセスできるようにサービスエンドポイントを設定
  service_endpoints = ["Microsoft.Storage"]
}

#AZFのデータを格納するためのストレージアカウント
resource "azurerm_storage_account" "st" {
  name                     = "stazftest001"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_storage_account_network_rules" "st" {
  storage_account_id         = azurerm_storage_account.st.id
  default_action             = "Deny"
  virtual_network_subnet_ids = [azurerm_subnet.subnet.id]
  depends_on = [ azapi_update_resource.func_convert_overvnet ] //追加した設定
}

# AZF
resource "azurerm_service_plan" "plan" {
  name                = "plan-azf-test-001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  os_type             = "Windows"
  sku_name            = "EP1"
}

resource "azurerm_windows_function_app" "func" {
  name                            = "azfvnettest001"
  resource_group_name             = azurerm_resource_group.rg.name
  location                        = azurerm_resource_group.rg.location
  service_plan_id                 = azurerm_service_plan.plan.id
  storage_account_name            = azurerm_storage_account.st.name
  storage_account_access_key      = azurerm_storage_account.st.primary_access_key
  virtual_network_subnet_id       = azurerm_subnet.subnet.id
  builtin_logging_enabled         = false
  https_only                      = true
  app_settings = {  //WEBSITE_CONTENTOVERVNETの環境変数をここから削除
    WEBSITE_ENABLE_SYNC_UPDATE_SITE = "true"
    WEBSITE_CONTENTSHARE            = "azfvnettest001"
  }
  site_config {
    runtime_scale_monitoring_enabled = true
    use_32_bit_worker                = false
    application_stack {
      node_version = "~18"
    }
    vnet_route_all_enabled      = true
    remote_debugging_version    = "VS2019"
    ftps_state                  = "Disabled"
    scm_use_main_ip_restriction = true
  }
  lifecycle { //追加した設定
    ignore_changes = [ 
      app_settings["WEBSITE_CONTENTOVERVNET"]
    ]
  }
}

//追加した設定 ここから-----
#Functionの環境変数を読み取り
data "azapi_resource_action" "func_app_settings" {
  type                   = "Microsoft.Web/sites@2022-03-01"
  action                 = "config/appsettings/list"
  resource_id            = azurerm_windows_function_app.func.id
  response_export_values = ["properties"]
}

#呼び出したdataと追加したい環境変数をマージして設定する
resource "azapi_update_resource" "func_convert_overvnet" {
  type      = "Microsoft.Web/sites/config@2022-03-01"
  parent_id = azurerm_windows_function_app.func.id
  name      = "appsettings"
  body = jsonencode({
    properties = merge(
      jsondecode(data.azapi_resource_action.func_app_settings.output).properties,
      {
        WEBSITE_CONTENTOVERVNET = "1"
      }
    )
    kind = "string"
  })
  #stateファイルにbodyの全文が残らず、毎回planに表示されるため、bodyをignore_changeにする。
  lifecycle {
    ignore_changes = [
      body
    ]
  }
}
//追加した設定 ここまで-----
```
この顔を無事拝むことができてホッとしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3735414/0c03c4f2-3966-5795-5311-e4a11a3b7c58.png)
`WEBSITE_CONTENTOVERVNET`の値も無事設定できているようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3735414/3860b312-383c-2b6c-a012-b6e678336f87.png)
### 簡単な解説
ざっくりと、上記のコードの主要部分の流れを説明すると、次のとおりです
```mermaid
graph TD;
    ストレージアカウントを作成-->AZFを作成;
    AZFを作成-->AZFの環境変数を読み取り;
    AZFの環境変数を読み取り-->読み取ったデータと追加したい環境変数をマージしてAZFに設定;
    読み取ったデータと追加したい環境変数をマージしてAZFに設定-->ストレージアカウントのネットワークアクセス制限を設定;
```
- 作成時に`WEBSITE_CONTENTOVERVNET`を設定できないので、まずはネットワーク制限がないストレージを作成します
- AZF作成します
- 完成したAZFの環境変数を読み取ります
    - 完成したAZFの環境変数の画像でもわかるとおり、Terraformで設定していない組み込みの環境変数が多数設定されるため、完成したAZFから読み取るのが肝です
- 読み取ったデータと`WEBSITE_CONTENTOVERVNET`を環境変数をマージして設定します
    - 最初、読み取りとかマージとかをすっ飛ばして設定したら、環境変数が`WEBSITE_CONTENTOVERVNET`だけになってしまいましたので、大事なポイントです
- 環境変数が設定されてAZFがNWアクセス制限したストレージアカウントに接続する準備が整ったので、ストレージアカウントのネットワークアクセス制限を設定(これを最後に持ってくるのはdepends_onを使用して実現しています）

## 詳細な解説
主に追加している内容を詳細に解説していきます。
### AZFの環境変数を読み取り
上記のコードのこの部分で実現しています
```terraform
data "azapi_resource_action" "func_app_settings" {
  type                   = "Microsoft.Web/sites@2022-03-01"
  action                 = "config/appsettings/list"
  resource_id            = azurerm_windows_function_app.func.id
  response_export_values = ["properties"]
```
azapiについて解説を始めると書ききれないのでざっくりと。このリソースについてのドキュメントは[こちら](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/azapi_resource_action)をご覧ください。
- `type`ではAPIのタイプを指定します
    - AZFやAppServiceのAPIは`Microsoft.Web/sites@2022-03-01`を指定します
    - 他のAPIについても知りたかったら、[このページ](https://learn.microsoft.com/ja-jp/rest/api/azure/)を見ると色々詳細に書いてあるので眺めてみてください
- `action`ではリソースに対するアクションを設定できます
    - `config/appsettings/list`と指定することで、環境変数を設定値を取得できます
- `resource_id`はどのリソースからdata取得をするかどうかを指定します
- `response_export_values`では数あるレスポンスのうちどの値を戻り値として使用するかを指定することができます
    - `"*"`を指定して、「とりあえず全部」アウトプットすることも可能です

取得したdataを展開してみるとこんな感じの値が入っています
```bash
> data.azapi_resource_action.func_app_settings
{
  "action" = "config/appsettings/list"
  "body" = tostring(null)
  "id" = "/subscriptions/***/resourceGroups/rg-azf-test-001/providers/Microsoft.Web/sites/azfvnettest001/config/appsettings/list"
  "method" = "POST"
  "output" = "{\"properties\":{\"AzureWebJobsStorage\":\"DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net\",\"FUNCTIONS_EXTENSION_VERSION\":\"~4\",\"FUNCTIONS_WORKER_RUNTIME\":\"node\",\"WEBSITE_CONTENTAZUREFILECONNECTIONSTRING\":\"DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=****;EndpointSuffix=core.windows.net\",\"WEBSITE_CONTENTSHARE\":\"azfvnettest001\",\"WEBSITE_ENABLE_SYNC_UPDATE_SITE\":\"true\",\"WEBSITE_NODE_DEFAULT_VERSION\":\"~18\"}}"
  "resource_id" = "/subscriptions/***/resourceGroups/rg-azf-test-001/providers/Microsoft.Web/sites/azfvnettest001"
  "response_export_values" = tolist([
    "properties",
  ])
  "timeouts" = null /* object */
  "type" = "Microsoft.Web/sites@2022-03-01"
}
```
見てわかるように、`output`という属性の中に読み取られた環境変数がjson形式で格納されてれています。HCL形式のオブジェクトの方が整形や扱いが楽なので、`jsondecode`関数を使うことでうまく取り出すことができます。
```bash
> jsondecode(data.azapi_resource_action.func_app_settings.output)
{
  "properties" = {
    "AzureWebJobsStorage" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
    "FUNCTIONS_EXTENSION_VERSION" = "~4"
    "FUNCTIONS_WORKER_RUNTIME" = "node"
    "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
    "WEBSITE_CONTENTSHARE" = "azfvnettest001"
    "WEBSITE_ENABLE_SYNC_UPDATE_SITE" = "true"
    "WEBSITE_NODE_DEFAULT_VERSION" = "~18"
  }
}
#さらに.propertiesをつければ完全にkey/valueの形式になります
> jsondecode(data.azapi_resource_action.func_app_settings.output).properties
{
  "AzureWebJobsStorage" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
  "FUNCTIONS_EXTENSION_VERSION" = "~4"
  "FUNCTIONS_WORKER_RUNTIME" = "node"
  "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
  "WEBSITE_CONTENTSHARE" = "azfvnettest001"
  "WEBSITE_ENABLE_SYNC_UPDATE_SITE" = "true"
  "WEBSITE_NODE_DEFAULT_VERSION" = "~18"
}
```
これで作成後のAZFに設定されている環境変数を読み取ることができました

### 実際にAZFに環境変数を追加する
環境変数の追加はこの部分で実現しています
```terraform
resource "azapi_update_resource" "func_convert_overvnet" {
  type      = "Microsoft.Web/sites/config@2022-03-01"
  parent_id = azurerm_windows_function_app.func.id
  name      = "appsettings"
  body = jsonencode({
    properties = merge(
      jsondecode(data.azapi_resource_action.func_app_settings.output).properties,
      {
        WEBSITE_CONTENTOVERVNET = "1"
      }
    )
    kind = "string"
  })
  #stateファイルにbodyの全文が残らず、毎回planに表示されるため、bodyをignore_changeにする。
  lifecycle {
    ignore_changes = [
      body
    ]
  }
}
```
このリソースについてのドキュメントは[こちら](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_update_resource)をご覧ください。
- 一つ前のdataソースと引数は似たようなものなので、細かい解説は省きますがポイントとしては設定値はJSONで求められます。なので、`jsonencode`関数を使用して、値を入れています
- あとは、`merge`関数を利用して、先ほど取得してきた環境変数と`{WEBSITE_CONTENTOVERVNET = "1"}`を結合しています
戻り値としてはこんな感じになります
```bash
>  merge(jsondecode(data.azapi_resource_action.func_app_settings.output).properties,{WEBSITE_CONTENTOVERVNET = "1"})
{
  "AzureWebJobsStorage" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
  "FUNCTIONS_EXTENSION_VERSION" = "~4"
  "FUNCTIONS_WORKER_RUNTIME" = "node"
  "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING" = "DefaultEndpointsProtocol=https;AccountName=stazftest001;AccountKey=***;EndpointSuffix=core.windows.net"
  "WEBSITE_CONTENTOVERVNET" = "1"
  "WEBSITE_CONTENTSHARE" = "azfvnettest001"
  "WEBSITE_ENABLE_SYNC_UPDATE_SITE" = "true"
  "WEBSITE_NODE_DEFAULT_VERSION" = "~18"
}
```
- `ignore_changes`の箇所については最初は書かなかったんですが、毎回このリソースがPlanに上がってきてしまったため、tfstateファイルを確認したところ、`body`の値を何も保存できていなかったみたいです。そのせいで毎回差分を検知してPLanに上がってきてしまっていたため、このような実装にしました
### その他細かいところ
#### ネットワークアクセス制限に`depends_on`を追加しました
```terraform
resource "azurerm_storage_account_network_rules" "st" {
  storage_account_id         = azurerm_storage_account.st.id
  default_action             = "Deny"
  virtual_network_subnet_ids = [azurerm_subnet.subnet.id]
  depends_on = [ azapi_update_resource.func_convert_overvnet ]
}
```
これによって、**環境変数の追加が完了してからストレージアカウントにネットワークアクセス制限がかかります**


#### AzureFunctionに`ignore_changes`を追加しました
```terraform
resource "azurerm_windows_function_app" "func" {
  name                            = "azfvnettest001"
  resource_group_name             = azurerm_resource_group.rg.name
  location                        = azurerm_resource_group.rg.location
  service_plan_id                 = azurerm_service_plan.plan.id
  storage_account_name            = azurerm_storage_account.st.name
  storage_account_access_key      = azurerm_storage_account.st.primary_access_key
  virtual_network_subnet_id       = azurerm_subnet.subnet.id
  builtin_logging_enabled         = false
  https_only                      = true
  app_settings = {
    WEBSITE_ENABLE_SYNC_UPDATE_SITE = "true"
    WEBSITE_CONTENTSHARE            = "azfvnettest001"
  }
  site_config {
    runtime_scale_monitoring_enabled = true
    use_32_bit_worker                = false
    application_stack {
      node_version = "~18"
    }
    vnet_route_all_enabled      = true
    remote_debugging_version    = "VS2019"
    ftps_state                  = "Disabled"
    scm_use_main_ip_restriction = true
  }
  lifecycle {
    ignore_changes = [ 
      app_settings["WEBSITE_CONTENTOVERVNET"]
    ]
  }
}
```
`app_settings["WEBSITE_CONTENTOVERVNET"]`を`ignore_changes`（変更の無視）しています。
これをしておかないと、2回目以降にApplyを実行した際に`azapi_update_resource`によって追加された環境変数を差分としてトリガーしてしまい、環境変数を消そうとしてしまいます。
あとは、当たり前ですが、`app_settings`では`WEBSITE_CONTENTOVERVNET`の設定をしないようにしています

## 最後に
すこし複雑なコードになってしまいましたが、これでAzureFunctionが出来上がったあとに環境変数を追加するということができて、ストレージアクセス制限のデプロイでハマっていたところから抜け出せるかと思います
`azapi`を使用するとかゆいところに手が届くことが多いので、`azurerm`しか使用されていない方でどうしても実現できないことがある場合は少しかじってみてもいいかもしれません
どなたかの参考になれば幸いです