<-- mermaid -->

Hybrid NEGを利用してアプリケーションをGCLBで公開するためのTerraform Moduleを実装する

こんにちは、SaaS SREチームの八代です。

私たちのチームで開発しているSPEEDAというSaaSプロダクトは、オンプレミスとGCPで構成されたハイブリッドクラウド環境上に構築されており、私たちはGoogle Anthosや、Direct Connectなどのサービスを利用し、ハイブリッドクラウド環境での運用を改善する取り組みを続けてきています。

そんな中で、2021年末ごろ、 Hybrid NEGという新しい技術が発表され、GCLBがオンプレミス環境あるいは他のパブリッククラウドに拡張することができるようになりました。

cloud.google.com

今回は、このHybrid NEGという技術を取り入れ、オンプレミス上に構築したアプリケーションをGCLBで公開できるようにしたので、それについて記事を書こうと思います。

はじめに

この記事は、以下のような人に向けて書きます。

  • GCLBを使って、いい感じにオンプレのアプリケーションを公開したい
  • 冗長化のため、マルチクラウドでアプリケーションを稼働させたい
  • Hybrid NEGリソースをTerraformで管理したい

GCLBを利用してアプリケーションを公開することで、以下のような機能が利用できるようになります。

  • Cloud Armorを利用してアクセス制限 / 攻撃からの保護を実施する
  • Identity Aware Proxy (IAP)を利用して、OAuthで認証する
  • CDNを利用して、コンテンツ配信を高速化する
  • etc...

私たちのチームでは、Cloud Armorを利用したIPベースのアクセス制限やWAFの適用、IAPでの社内システムの公開などをよく行なっているのですが、非常に便利に使わせてもらっています。

今回、オンプレのアプリケーションでも同様の仕組みが利用できるようになったことで、運用コストの削減が実現できると感じているので、興味がある方はぜひ読んでみてください。

GCLBのアーキテクチャ

GCPの公式ドキュメントに記載されている、GCLBのアーキテクチャは以下のようになっています。

GCLBのアーキテクチャ(2022/05 GCP公式ドキュメントより引用)

図を見ると、今回Backend serviceに紐づけるbackendにNON_GCP_PRIVATE_IP_PORTのエンドポイントを指定できるようになっています。

詳しいアーキテクチャや、gcloudを利用したGCLBの構築手順などは、以下のブログ記事で詳しく記載されているので、参考にしてみてください。

medium.com

次の項からは、Terraformを利用してHybrid NEGおよびGCLBを構築するための内容を記載していきます。

Terraformを使ってGCLBを構築する

前提

今回は、以下の要件を満たすGCLBを構築しました。

  1. Cloud Armorを利用して、サービスの保護ができるようにする
  2. コスト最適化のため、単一のGCLBで複数種類のアプリケーションを公開できるようにする
  3. Hybrid NEGは3ゾーンで冗長化する
  4. マネージドSSL証明書を利用する

GCLBのアーキテクチャ図からも読み取れますが、GCLBを構成するために必要なGCPリソースの数が多く、ベタ書きだとかなり冗長になるため、moduleに切り出して作成していくこととします。

また、GCLBの作成にあたり必要な以下のリソースは本筋と離れるため、作成済みであるものとします。

  • パブリックIP
  • Cloud Armorによるセキュリティポリシー

moduleの呼び出し

まず、全体像のイメージを持っていただくため、moduleの呼び出し側を記載します。

module "test_hybrid_gclb" {
  name           = "test-hybrid-gclb"
  source         = "<module path>"

  certificates    = ["hoge-service-ub-speeda-com", "fuga-service-ub-speeda-com"]
  global_address = google_compute_global_address.test_hybrid_gclb.address

  default_backend_service = "hoge_service"
  service = {
    hoge_service = {
      name              = "hoge-service"
      host              = "hoge-service.ub-speeda.com"
      backend_endpoints = ["xxx.xxx.xxx.xxx"]
      port              = "80"
      security_policy   = ""
      health_check_port = "80"
      health_check_path = "/"
    },
    fuga_service = {
      name              = "fuga-service"
      host              = "fuga-service.ub-speeda.com"
      backend_endpoints = ["yyy.yyy.yyy.yyy"]
      port              = "80"
      security_policy   = ""
      health_check_port = "80"
      health_check_path = "/"
    }
  }
}

service の中がMapになっており、各バックエンドサービスへの接続情報を持った構造体が並んでいるところがポイントです。

この中で、ループを回すような処理を行い、実際のリソースを作成していきます。

moduleの実装

ループを回すためのlocal変数定義

今回は、GCPのリソース種によっては、最大3つの要素をループで回す必要が出てきます。

例えば、NEGに実際のエンドポイントを追加するためのリソース(google_compute_network_endpoint)を追加するには、以下の3要素でループを回す必要があります。

  • 公開したいバックエンドサービス(hoge-service, fuga-serviceに相当)
  • ゾーン3つ
  • バックエンドのIP / ポート

terraformでは、for_eachを利用した多重ループは単純にはできないので、今回はflatten関数を利用し、多重ループ相当の処理を行いました。

flatten関数を利用すると、複数の配列でループを回し、ループ中の要素を取り出して新しい構造体のMapを得ることができます。

詳しくは公式ドキュメントを参照してみてください。

www.terraform.io

今回は、ループを回すために3つのlocal変数を定義しました。

locals {
  # ゾーンのループを回すための定義
  zones = {
    asia-northeast1-a = {
      name        = "asia-northeast1-a"
      displayName = "ane1a"
    }
    asia-northeast1-b = {
      name        = "asia-northeast1-b"
      displayName = "ane1b"
    }
    asia-northeast1-c = {
      name        = "asia-northeast1-c"
      displayName = "ane1c"
    }
  }

  # ゾーン、バックエンドサービスのループを回すための定義
  negs = flatten([
    for zone_key, zone in local.zones : [
      for service_key, service in var.service : {
        service = service
        zone    = zone
      }
    ]
  ])

  # ゾーン、バックエンドサービス、エンドポイントのループを回すための定義
  backends = flatten([
    for zone_key, zone in local.zones : [
      for service_key, service in var.service : [
        for backend_endpoint in service.backend_endpoints : {
          service          = service
          backend_endpoint = backend_endpoint
          zone             = zone
        }
      ]
  ]])
}

上記の定義をすることで、リソース作成の記述で多重ループを実現することができます。

Hybrid NEGの作成

Hybrid NEGの作成は以下のように記述します。

resource "google_compute_network_endpoint_group" "neg" {
  for_each = {
    for neg in local.negs : "${neg.service.name}.${neg.zone.displayName}" => neg
  }
  provider = google-beta


  network               = "projects/<project_id>/global/networks/<your_vpc>"
  name                  = "${each.value.service.name}-neg-${each.value.zone.displayName}"
  default_port          = each.value.service.health_check_port
  zone                  = each.value.zone.name
  network_endpoint_type = "NON_GCP_PRIVATE_IP_PORT"
}

こちらでは、先ほど定義した local変数の negsを使って、 for_eachでループを回すことで、ゾーン / バックエンドサービスごとにNEGを作成することができます。

Hybrid NEGを作成するため、network_endpoint_typeNON_GCP_PRIVATE_IP_PORTを指定しています。(2022年5月時点では、ベータ機能のため、providerには google-betaを指定しています)

negの要素には、each.value以下でアクセスすることができます。

for_each中の"${neg.service.name}.${neg.zone.displayName}" => negの部分については、次の項で説明します。

NEGにエンドポイントを追加

次に、先ほど作成したNEGにエンドポイントを追加する記述を記載します。

resource "google_compute_network_endpoint" "default-endpoint" {
  for_each = {
    for backend in local.backends : "${backend.service.name}.${backend.zone.name}.${backend.backend_endpoint}" => backend
  }
  provider = google-beta

  network_endpoint_group = google_compute_network_endpoint_group.neg["${each.value.service.name}.${each.value.zone.displayName}"].name
  port                   = google_compute_network_endpoint_group.neg["${each.value.service.name}.${each.value.zone.displayName}"].default_port
  ip_address             = each.value.backend_endpoint
}

先ほど作成したNEGのリソースの指定の仕方がポイントで、neg["${each.value.service.name}.${each.value.zone.displayName}"]のようにアクセスしています。

これは、前項で記述したfor_each部分で、リソースにアクセスするためのキーをこのように指定しているためです。

# ${neg.service.name}.${neg.zone.displayName} の部分でリソースにアクセスするためのキーを指定
  for_each = {
    for neg in local.negs : "${neg.service.name}.${neg.zone.displayName}" => neg
  }

以上で、NEGの作成が完了したので、実際にGCLBで利用できるよう記述していきます。

Backend Serviceの指定

Backend Serviceは以下のように記述しました。

resource "google_compute_backend_service" "backend_service" {
  for_each = var.service

  name            = "${var.name}-${each.value.name}-backend"
  port_name       = "http"
  protocol        = "HTTP"
  timeout_sec     = 10
  enable_cdn      = false
  security_policy = each.value.security_policy != "" ? each.value.security_policy : ""

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1a"].self_link
  }

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1b"].self_link
  }

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1c"].self_link
  }

  log_config {
    enable = true
  }

  health_checks = [google_compute_health_check.health_check[each.key].self_link]
}

以上で、Terraformを利用してHybrid NEGを作成し、GCLBで利用する記述を記載しました。

その他のリソース

直接Hybrid NEGに関わる部分だけを記載しましたが、他にも多くリソースを作成しているので、モジュールの中身を一式記載しておきますので、作成を考えている方は参考にしてみてください。

モジュール一式の内容を見る

##### variable zone #####

variable "name" {}

variable "certificates" {
  type = list(string)
}

variable "global_address" {}

variable "default_backend_service" {}

variable "service" {
  type = map(object({
    name              = string
    host              = string
    port              = string
    health_check_port = string
    health_check_path = string
    security_policy   = string
    backend_endpoints = list(string)
  }))
}

locals {
  zones = {
    asia-northeast1-a = {
      name        = "asia-northeast1-a"
      displayName = "ane1a"
    }
    asia-northeast1-b = {
      name        = "asia-northeast1-b"
      displayName = "ane1b"
    }
    asia-northeast1-c = {
      name        = "asia-northeast1-c"
      displayName = "ane1c"
    }
  }

  negs = flatten([
    for zone_key, zone in local.zones : [
      for service_key, service in var.service : {
        service = service
        zone    = zone
      }
    ]
  ])

  backends = flatten([
    for zone_key, zone in local.zones : [
      for service_key, service in var.service : [
        for backend_endpoint in service.backend_endpoints : {
          service          = service
          backend_endpoint = backend_endpoint
          zone             = zone
        }
      ]
  ]])
}

### NEG

resource "google_compute_network_endpoint_group" "neg" {
  for_each = {
    for neg in local.negs : "${neg.service.name}.${neg.zone.displayName}" => neg
  }
  provider = google-beta


  network               = "projects/<project_id>/global/networks/<your_vpc>"
  name                  = "${each.value.service.name}-neg-${each.value.zone.displayName}"
  default_port          = each.value.service.health_check_port
  zone                  = each.value.zone.name
  network_endpoint_type = "NON_GCP_PRIVATE_IP_PORT"
}

resource "google_compute_network_endpoint" "default-endpoint" {
  for_each = {
    for backend in local.backends : "${backend.service.name}.${backend.zone.name}.${backend.backend_endpoint}" => backend
  }
  provider = google-beta

  network_endpoint_group = google_compute_network_endpoint_group.neg["${each.value.service.name}.${each.value.zone.displayName}"].name
  port                   = google_compute_network_endpoint_group.neg["${each.value.service.name}.${each.value.zone.displayName}"].default_port
  ip_address             = each.value.backend_endpoint
}

##### resource zone #####

resource "google_compute_global_forwarding_rule" "https_forward_rule" {
  name       = "${var.name}-https"
  target     = google_compute_target_https_proxy.https_proxy.self_link
  ip_address = var.global_address
  port_range = "443"

}

resource "google_compute_global_forwarding_rule" "http_forward_rule" {
  name       = "${var.name}-http"
  target     = google_compute_target_http_proxy.http_proxy.self_link
  ip_address = var.global_address
  port_range = "80"

}

resource "google_compute_target_https_proxy" "https_proxy" {
  name             = "${var.name}-https-proxy"
  url_map          = google_compute_url_map.url_map.self_link
  ssl_certificates = var.certificates
  quic_override    = false
  ssl_policy       = google_compute_ssl_policy.ssl_policy.self_link
}

resource "google_compute_target_http_proxy" "http_proxy" {
  name    = "${var.name}-http-proxy"
  url_map = google_compute_url_map.https_redirect_url_map.self_link
}

resource "google_compute_ssl_policy" "ssl_policy" {
  name            = "${var.name}-ssl-policy"
  profile         = "MODERN"
  min_tls_version = "TLS_1_2"
}

resource "google_compute_url_map" "https_redirect_url_map" {
  name = "${var.name}-https-redirect-url-map"
  default_url_redirect {
    redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
    https_redirect         = true
    strip_query            = false
  }
}

resource "google_compute_url_map" "url_map" {
  name            = "${var.name}-url-map"
  default_service = google_compute_backend_service.backend_service[var.default_backend_service].self_link

  lifecycle {
    ignore_changes = [host_rule, path_matcher]
  }

  dynamic "host_rule" {
    for_each = var.service

    content {
      hosts = [
        host_rule.value.host,
      ]
      path_matcher = "path-matcher-${host_rule.value.name}"
    }
  }

  dynamic "path_matcher" {
    for_each = var.service

    content {
      default_service = google_compute_backend_service.backend_service[path_matcher.key].self_link
      name            = "path-matcher-${path_matcher.value.name}"
    }
  }
}

######################## Backend Service ##########################

resource "google_compute_backend_service" "backend_service" {
  for_each = var.service

  name            = "${var.name}-${each.value.name}-backend"
  port_name       = "http"
  protocol        = "HTTP"
  timeout_sec     = 10
  enable_cdn      = false
  security_policy = each.value.security_policy != "" ? each.value.security_policy : ""

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1a"].self_link
  }

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1b"].self_link
  }

  backend {
    balancing_mode        = "RATE"
    max_rate_per_endpoint = 6000
    group                 = google_compute_network_endpoint_group.neg["${each.value.name}.ane1c"].self_link
  }

  log_config {
    enable = true
  }

  health_checks = [google_compute_health_check.health_check[each.key].self_link]
}

######################## Health Check ##########################

resource "google_compute_health_check" "health_check" {
  for_each = var.service

  provider = google-beta
  name     = "${var.name}-${each.value.name}-health-check"

  http_health_check {
    host         = each.value.host
    port         = each.value.health_check_port
    request_path = each.value.health_check_path
  }

  log_config {
    enable = true
  }
}

私自身も、伸び代を感じている部分があるので、もしより良い実装方法があればコメントいただきたいです!

注意点

現時点では、Hybrid NEGをTerraformで作成する場合は、リソースを作成するProjectと同じVPCネットワークを指定する必要があります。(ネットワークをフルパスで記載すること自体は可能なのですが、処理中でProject IDが置換されてしまう実装になっています)

なので、もし共有VPCを利用しているなどで、他のプロジェクトのVPCに紐づけたい場合は gcloudコマンドを利用して作成すると良いと思います。

弊社でも共有VPCを別プロジェクトに作成している場合があるので、時間があるときにPRを投げたいなとは思っています。

おわりに

今回は、Hybrid NEGを利用してアプリケーションをGCLBで公開するためのterraform moduleを実装する手順を説明しました。

Hybrid NEGの登場によって、オンプレのアプリケーションでもGCPと同様のセキュリティ対策や運用改善ができるようになり、非常に助かるアップデートだと感じました。

個人的には、Terraformで多重ループが発生するなどやや複雑な処理をやってみて、学びがありました。

ユーザベース SaaS事業部のSREチームでは、GCP・オンプレミス・AWSなど複数のプラットフォームを利用して、さまざまなプロダクトを運用しているので、このようなハイブリッドクラウド環境の運用改善に携わる機会が多くあります。

エンジニアも積極的に募集しているので、ご興味をお持ちいただいた方はお話ししましょう! 以下からエントリーをお待ちしております。

apply.workable.com

Page top