1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AKSのAGICでWAFを使いたい

Last updated at Posted at 2025-04-13

Azure Kubernetes Service(AKS)では、イングレスコントローラとしてNGINXイングレスコントローラベースのアプリケーションルーティングとAzure Application Gateway(AppGW)をインターフェースに使用できるApplication Gateway Ingress Controller(AGIC)が利用できます。

AppGWといえばL7ロードバランサであるとともにWeb Application Firewall(WAF)機能を搭載することができます。

セキュリティめちゃツヨAGICにするならAppGWにWAFを乗せたいところ。

普通にAKSをセットアップするとAppGWはStandard V2のSKUでセットアップされてしまいます。そこで、WAF v2のSKUでAppGWを構築しつつAKSと接続してセキュリティめちゃ強いAGICを利用できるようにします。

楽ちんに構築したいので、IaCツール OpenTofu を利用してセットアップしてみます。
OpenTofuはTerraformのOSSフォークです。今のところ大きな差はないのでTerraformでも同様のコードで動作できると思います。

TL;DR

  • AppGWとAKSは別々に作成して、紐づける形で連携させる
  • AGICのためのマネージドIDが生成されるので、ロール紐づけが必要
    • AppGWが属するリソースグループに対する Readerロール
    • AppGWに対する Contributerロール
    • AppGWが使用するサブネットに対する Network Contributer ロール
    • AppGWに設定しうるWAFポリシーに対する Network Contributer ロール

バージョン情報

ツール・プロバイダ バージョン
OpenTofu 1.9.0
hashicorp/azurerm 4.25.0

構築コード

早速コードです。ちょっと長いので折りたたみで。

色々と誤魔化したり調整しているので、サンプルコードの動作保証はしていないです。

サンプルコード
main.tf
terraform {
  required_version = ">=1.9"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.25.0"
    }
  }
}

# 引数定義
variable "tenant_id" {
  type        = string
  description = "テナントID"
}

variable "identity" {
  type        = string
  description = "リソースのID"
}

variable "location" {
  type        = string
  description = "リソースのロケーション"
}

variable "domain" {
  type        = string
  description = "使用するドメイン"
}

variable "azrbac_admin_group_object_ids" {
  type        = list(string)
  description = "AKS RBACで管理ロールとするEntra IDのオブジェクトIDリスト"
}


# リソース定義

## リソースグループ
resource "azurerm_resource_group" "rg" {
  name     = "rg-${var.identity}"
  location = var.location
}

## 仮想ネットワーク
locals {
  address_space_ipv4 = "172.16.0.0/12"
}
resource "azurerm_virtual_network" "vn" {
  name                = "vn-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  address_space = [
    local.address_space_ipv4,
  ]
}

resource "azurerm_subnet" "sub_default" {
  name                 = "vn-${var.identity}-default"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes     = [cidrsubnet(local.address_space_ipv4, 3, 0)]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_subnet" "sub_appgw" {
  name                 = "vn-${var.identity}-appgw"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes = [
    cidrsubnet(local.address_space_ipv4, 3, 1),
  ]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_subnet" "sub_aks_cls" {
  name                 = "vn-${var.identity}-aks-cls"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes     = [
    cidrsubnet(local.address_space_ipv4, 3, 2),
  ]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

## IPアドレス
resource "azurerm_public_ip" "ipv4" {
  name                = "publicip-v4-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  sku                 = "Standard"
  sku_tier            = "Regional"
  allocation_method   = "Static"
  ip_version          = "IPv4"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_dns_zone" "public_dns" {
  name                = var.domain
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_dns_a_record" "default" {
  name                = "@"
  resource_group_name = azurerm_resource_group.rg.name

  zone_name = azurerm_dns_zone.public_dns.name
  ttl       = 600
  records   = [azurerm_public_ip.ipv4.ip_address]

  depends_on = [
    azurerm_dns_zone.public_dns,
    azurerm_public_ip.ipv4,
  ]
}

## WAFポリシー
resource "azurerm_web_application_firewall_policy" "default" {
  name                = "waf-custom-rules-${var.identity}-default"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  policy_settings {
    enabled = true
    # mode    = "Prevention"
    mode = "Detection"
  }

  managed_rules {
    managed_rule_set {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
    }
  }

  custom_rules {
    name      = "AcmeHttp01Challenge"
    priority  = 10
    action    = "Allow"
    rule_type = "MatchRule"

    match_conditions {
      operator   = "BeginsWith"
      transforms = ["Trim", "Lowercase", "RemoveNulls", "UrlDecode"]

      match_variables {
        variable_name = "RequestUri"
      }

      match_values = [
        "/.well-known/acme-challenge/",
      ]
    }
  }
}

## Application Gateway
locals {
  backend_address_pool_name      = "${var.identity}-beap"
  frontend_port_name_http        = "${var.identity}-feport-http"
  frontend_port_name_https       = "${var.identity}-feport-https"
  frontend_ip_configuration_name = "${var.identity}-feip"
  http_setting_name              = "${var.identity}-be-htst"
  listener_name                  = "${var.identity}-httplstn"
  request_routing_rule_name      = "${var.identity}-rqrt"
}

resource "azurerm_application_gateway" "appgw_waf" {
  name                = "appgw-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  sku {
    name     = "WAF_v2"
    tier     = "WAF_v2"
    capacity = 1
  }

  enable_http2                      = false
  fips_enabled                      = false
  force_firewall_policy_association = false

  waf_configuration {
    enabled       = true
    firewall_mode = "Prevention"

    # 本当はMicrosoft_DefaultRuleSetにしたいがAPIがエラーするのでOWASPを使用
    rule_set_type    = "OWASP"
    rule_set_version = "3.2"

    file_upload_limit_mb = "500"
    request_body_check   = false
  }

  gateway_ip_configuration {
    name      = "appgw-ip-conf"
    subnet_id = azurerm_subnet.sub_appgw.id
  }

  frontend_port {
    name = local.frontend_port_name_http
    port = 80
  }

  frontend_port {
    name = local.frontend_port_name_https
    port = 443
  }

  frontend_ip_configuration {
    name                 = local.frontend_ip_configuration_name
    public_ip_address_id = azurerm_public_ip.ipv4.id
  }

  backend_address_pool {
    name = local.backend_address_pool_name
  }

  backend_http_settings {
    name                                = local.http_setting_name
    cookie_based_affinity               = "Disabled"
    port                                = 80
    protocol                            = "Http"
    request_timeout                     = 30
    pick_host_name_from_backend_address = false
  }

  http_listener {
    name                           = local.listener_name
    frontend_ip_configuration_name = local.frontend_ip_configuration_name
    frontend_port_name             = local.frontend_port_name_http
    protocol                       = "Http"
    require_sni                    = false

    firewall_policy_id = azurerm_web_application_firewall_policy.default.id
  }

  request_routing_rule {
    name                       = local.request_routing_rule_name
    rule_type                  = "Basic"
    http_listener_name         = local.listener_name
    backend_address_pool_name  = local.backend_address_pool_name
    backend_http_settings_name = local.http_setting_name

    priority = 10010
  }


  lifecycle {
    # デプロイ後はAKS AGICで変更されるのですべて無視
    ignore_changes = all
  }
}

resource "azurerm_kubernetes_cluster" "aks" {
  name                = "aks-${var.identity}"
  resource_group_name = azurerm_resource_group.name
  location            = azurerm_resource_group.rg.location

  sku_tier = "Standard"

  dns_prefix                = "aks-${var.identity}"
  automatic_upgrade_channel = "patch"
  kubernetes_version        = "1.30"
  private_cluster_enabled   = false

  cost_analysis_enabled = "true"


  oidc_issuer_enabled       = true
  workload_identity_enabled = true

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled     = true
    tenant_id              = var.tenant_id
    admin_group_object_ids = var.azrbac_admin_group_object_ids
  }

  ingress_application_gateway {
    gateway_id = azurerm_application_gateway.appgw_waf.id
  }

  network_profile {
    network_plugin    = "azure"
    network_policy    = "calico"
    outbound_type     = "loadBalancer"
    load_balancer_sku = "standard"
  }

  workload_autoscaler_profile {
    keda_enabled = true
  }

  default_node_pool {
    name                        = "default"
    vm_size                     = "Standard_D4as_v5"
    os_sku                      = "AzureLinux"
    temporary_name_for_rotation = "deftmp"

    type                 = "VirtualMachineScaleSets"
    auto_scaling_enabled = true
    node_count           = 1
    min_count            = 1
    max_count            = 10
    max_pods             = 100

    vnet_subnet_id = azurerm_subnet.sub_aks_cls.id
    zones = [
      "1",
      "2",
      "3",
    ]
  }


  lifecycle {
    ignore_changes = [
      default_node_pool[0].node_count
    ]
  }
}

resource "azurerm_role_assignment" "k8s_rg" {
  scope                = var.resource_group_id
  principal_id         = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id
  role_definition_name = "Reader"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
resource "azurerm_role_assignment" "k8s_gateway" {
  scope        = var.appgw_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_gateway_network" {
  scope        = var.appgw_subnet_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_waf_policy_default" {
  scope        = var.waf_policy_default_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

Azure Application Gatewayの構成

OpenTofuでAzure Application Gateway WAF v2を構成するためには2つのリソースが必要です。

  • azurerm_web_application_firewall_policy
  • azurerm_application_gateway

WAF v2でゲートウェイを構成する場合は1つ以上のhttp_listenerfirewall_policy_idの指定が必要なので何らかのWAFポリシーの作成が必要です。

また、AGICの制御によりリスナー〜バックエンドの設定はすべて書き換わってしまいます が、定義上必須なので、ダミーのbackend_address_pool backend_http_settings http_listener request_routing_rule を設定します。
当然、lifecycle.ignore_changesの設定も必須です。

terraform; main.tf
resource "azurerm_web_application_firewall_policy" "default" {
  name                = "waf-custom-rules-${var.identity}-default"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  policy_settings {
    enabled = true
    # mode    = "Prevention"
    mode = "Detection"
  }

  managed_rules {
    managed_rule_set {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
    }
  }

  custom_rules {
    name      = "AcmeHttp01Challenge"
    priority  = 10
    action    = "Allow"
    rule_type = "MatchRule"

    match_conditions {
      operator   = "BeginsWith"
      transforms = ["Trim", "Lowercase", "RemoveNulls", "UrlDecode"]

      match_variables {
        variable_name = "RequestUri"
      }

      match_values = [
        "/.well-known/acme-challenge/",
      ]
    }
  }
}

サンプルコードではマネージドルールセットとしてMDR 2.1を指定し、カスタムルールとして、ACMEにおけるHTTP01方式のアクセス許可を設定しています。

terraform; main.tf
locals {
  backend_address_pool_name      = "${var.identity}-beap"
  frontend_port_name_http        = "${var.identity}-feport-http"
  frontend_port_name_https       = "${var.identity}-feport-https"
  frontend_ip_configuration_name = "${var.identity}-feip"
  http_setting_name              = "${var.identity}-be-htst"
  listener_name                  = "${var.identity}-httplstn"
  request_routing_rule_name      = "${var.identity}-rqrt"
}

resource "azurerm_application_gateway" "appgw_waf" {
  name                = "appgw-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  sku {
    name     = "WAF_v2"
    tier     = "WAF_v2"
    capacity = 1
  }

  enable_http2                      = false
  fips_enabled                      = false
  force_firewall_policy_association = false

  waf_configuration {
    enabled       = true
    firewall_mode = "Prevention"

    # 本当はMicrosoft_DefaultRuleSetにしたいがAPIがエラーするのでOWASPを使用
    rule_set_type    = "OWASP"
    rule_set_version = "3.2"

    file_upload_limit_mb = "500"
    request_body_check   = false
  }

  gateway_ip_configuration {
    name      = "appgw-ip-conf"
    subnet_id = azurerm_subnet.sub_appgw.id
  }

  frontend_port {
    name = local.frontend_port_name_http
    port = 80
  }

  frontend_port {
    name = local.frontend_port_name_https
    port = 443
  }

  frontend_ip_configuration {
    name                 = local.frontend_ip_configuration_name
    public_ip_address_id = azurerm_public_ip.ipv4.id
  }

  backend_address_pool {
    name = local.backend_address_pool_name
  }

  backend_http_settings {
    name                                = local.http_setting_name
    cookie_based_affinity               = "Disabled"
    port                                = 80
    protocol                            = "Http"
    request_timeout                     = 30
    pick_host_name_from_backend_address = false
  }

  http_listener {
    name                           = local.listener_name
    frontend_ip_configuration_name = local.frontend_ip_configuration_name
    frontend_port_name             = local.frontend_port_name_http
    protocol                       = "Http"
    require_sni                    = false

    firewall_policy_id = azurerm_web_application_firewall_policy.default.id
  }

  request_routing_rule {
    name                       = local.request_routing_rule_name
    rule_type                  = "Basic"
    http_listener_name         = local.listener_name
    backend_address_pool_name  = local.backend_address_pool_name
    backend_http_settings_name = local.http_setting_name

    priority = 10010
  }


  lifecycle {
    # デプロイ後はAKS AGICで変更されるのですべて無視
    ignore_changes = all
  }
}

SKUは当然WAF_v2です。
全体のWAFルールはMDR 2.1にしたかったのですが、どうもARM APIがエラーしてしまうので、OWASP 3.2にしています。
gateway_ip_configurationではあらかじめ構成したAppGW向けサブネットを紐づけます。frontend_portでは80と443を紐づけます。frontend_ip_configurationでパブリックIPを紐づけます。
backend_address_poolは名前だけ定義しておきます。
backend_address_pool backend_http_settings http_listener request_routing_ruleはどうせAGICによって書き換えが発生するのでそれっぽければOKです。ただし、http_listenerに限ってはfirewall_policy_idの設定が必要です。これがないと「WAFv2のSKUにできないよ!」と怒られます。
そして、ignore_changesで無視設定をちゃんと入れないと、AGICによる変更を壊してしまいますので設定必須です。どこが書き換わるかわからなければallを設定しておきましょう。

Azure Kubernetes Serviceの構成

main.tf
resource "azurerm_kubernetes_cluster" "aks" {
  name                = "aks-${var.identity}"
  resource_group_name = azurerm_resource_group.name
  location            = azurerm_resource_group.rg.location

  sku_tier = "Standard"

  dns_prefix                = "aks-${var.identity}"
  automatic_upgrade_channel = "patch"
  kubernetes_version        = "1.30"
  private_cluster_enabled   = false

  cost_analysis_enabled = "true"


  oidc_issuer_enabled       = true
  workload_identity_enabled = true

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled     = true
    tenant_id              = var.tenant_id
    admin_group_object_ids = var.azrbac_admin_group_object_ids
  }

  ingress_application_gateway {
    gateway_id = azurerm_application_gateway.appgw_waf.id
  }

  network_profile {
    network_plugin    = "azure"
    network_policy    = "calico"
    outbound_type     = "loadBalancer"
    load_balancer_sku = "standard"
  }

  workload_autoscaler_profile {
    keda_enabled = true
  }

  default_node_pool {
    name                        = "default"
    vm_size                     = "Standard_D4as_v5"
    os_sku                      = "AzureLinux"
    temporary_name_for_rotation = "deftmp"

    type                 = "VirtualMachineScaleSets"
    auto_scaling_enabled = true
    node_count           = 1
    min_count            = 1
    max_count            = 10
    max_pods             = 100

    vnet_subnet_id = azurerm_subnet.sub_aks_cls.id
    zones = [
      "1",
      "2",
      "3",
    ]
  }


  lifecycle {
    ignore_changes = [
      default_node_pool[0].node_count
    ]
  }
}

resource "azurerm_role_assignment" "k8s_rg" {
  scope                = var.resource_group_id
  principal_id         = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id
  role_definition_name = "Reader"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
resource "azurerm_role_assignment" "k8s_gateway" {
  scope        = var.appgw_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_gateway_network" {
  scope        = var.appgw_subnet_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_waf_policy_default" {
  scope        = var.waf_policy_default_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

ingress_application_gateway.gateway_id に構成したAppGWのIDを設定することで既存AppGWとの接続が可能です。

しかし!

紐づけただけでは全く動作しません。なぜかというと、権限が噛み合っていないからです。
AKSを構成したあとにAzure Portalを眺めるとわかるのですが、AKSのサービス自体は構成したリソースグループにいるのですが、MC_<aksリソースグループ名>_<AKS名>_<リージョン>といった名前のリソースグループが生えているかと思います。これはノードリソースグループといい、AKSを構成するコンポーネント(Azure VM Scale Setなど)が入っています。
このリソースグループの中にマネージドIDが生成されているのですが、これがAGICの制御に使用するマネージドIDです。
このマネージドIDがどれなのかというと、azurerm_kubernetes_cluster.<リソース名>.ingress_application_gateway[0].ingress_application_gateway_identity[0] で参照することができます。
このIDと各コンポーネントのロールを紐づける必要があります。

AGICを動作させるためには、以下のロール紐づけが必要です

対象リソース 必要なロール コード上での構成箇所
AppGW所属リソースグループ Reader  azurerm_role_assignment.k8s_rg
AppGW Contributor azurerm_role_assignment.k8s_gateway
AppGW使用のサブネット Network Contributor azurerm_role_assignment.k8s_gateway_network
WAFポリシー Network Contributor azurerm_role_assignment.k8s_waf_policy_default

ノードリソースグループ所属のマネージドIDなので、AppGWが所属しているリソースグループに対する閲覧者ロールが必要です。
関連コンポーネント(AppGW, サブネット、WAFポリシー)についてはくっつけたり外したりをするためのロールが必要です。

最後に

オフィシャルのドキュメントにはAKSとAppGWを別に作成して紐づける手順は記載されているののですが、ロール紐づけに関するところは以下の文言だけ……

AKS クラスター リソース グループとは異なるリソース グループのアプリケーション ゲートウェイを使う場合は、作成されるマネージド ID ingressapplicationgateway-{AKSNAME} に、アプリケーション ゲートウェイ リソース グループでのネットワーク共同作成者と閲覧者ロールを設定する必要があります。

これはわからないべ……

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?