【GCP/Terraform】 外部IPアドレスを持たないVMインスタンスからCloud NAT経由でインターネットへアクセスする

公開:2021/02/11
更新:2021/02/11
10 min読了の目安(約9700字TECH技術記事

TL;DR

こんな人向け

  • Cloud NAT について知りたい&使ってみたい
  • GCPのVPCとかsubnetとかFirewallとかよくわかんない
  • terraformでGCPさわりたい

Cloud NAT について

Cloud NAT とは

  • NATを提供してくれるGoogleのマネージドサービスです
  • 外部IPアドレスを持たないVMインスタンスや限定公開のGKEクラスタからインターネットへアクセスできるようになります

Cloud NAT のメリット

  • 個々のVMに外部IPアドレスを割り振る必要がなくなりますのセキュアになる
  • 分散マネージドサービスなのでプロジェクト内のVMや単一の物理ゲートウェイデバイスには依存しない
  • NATのIPアドレスを自動でスケーリングできる
  • Cloud NATはVMごとのネットワーク帯域幅を縮小しない

解説

ネットワークの構成

  1. 外部IPアドレスを持つインスタンス bastion にSSH接続します
  2. basion から my-instance にSSH接続します
  3. my-instance から Cloud NAT 経由でインターネットに接続します

ネットワークとサブネットを作成する

VMインスタンスを設置するためのVPCネットワークとサブネットを作成します。

resource "google_compute_network" "my_network" {
  name                    = "my-network"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "my_subnetwork" {
  name   = "my-subnetwork"
  region = "asia-northeast1"

  # サブネットで使用したい内部IPアドレスの範囲を指定する
  ip_cidr_range = "10.0.0.0/16"
  network       = google_compute_network.my_network.self_link

  # CloudLoggingにFlowLogログを出力したい場合は設定する
  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
  
  private_ip_google_access = true
}

サブネット内にVMインスタンスを2台作成する

VMインスタンスに付与するservice accountを作成することをGoogleが推奨しているので、service accountも定義します。
bastionは踏み台として使うので、外部IPアドレスを付与し、my-instanceは外部IPアドレスを付与しません。
また、あとでFirewallルールを付与する必要があるので、 タグ も設定しておきます。

resource "google_service_account" "bastion" {
  account_id   = "bastion"
  display_name = "My Service Account For Bastion"
}

resource "google_service_account" "my_instance" {
  account_id   = "my-instance"
  display_name = "My Service Account For My Instance"
}
resource "google_compute_instance" "bastion" {
  name         = "bastion"
  machine_type = "f1-micro"
  zone         = var.gcp_zones["tokyo-a"]

  # Firewallルールやルートの設定のためのタグ名を付与する
  tags = ["enable-access-from-internet"]

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-10"
    }
  }

  network_interface {
    # my_subnetworkにインスタンスを起動する
    network    = google_compute_network.my_network.name
    subnetwork = google_compute_subnetwork.my_subnetwork.name
    # 踏み台として使うので外部IPアドレスを割り振る
    # 外部IPアドレスを割り振る方法は次の2種類ある。静的外部IPアドレス、エフェメラル外部IPアドレス
    # 今回はエフェメラル外部IPアドレス(インスタンス起動時に発行される外部IPアドレス)を割り振る
    access_config {}
  }

  service_account {
    # https://cloud.google.com/compute/docs/access/service-accounts#accesscopesiam
    # インスタンスで完全な cloud-platform アクセス スコープを設定すると、すべての Google Cloud リソースに対する完全アクセス権を付与できる
    # その後、IAM ロールを使用してサービス アカウントの API アクセス権を安全に制限することをGoogleは推奨している
    email  = google_service_account.bastion.email
    scopes = ["cloud-platform"]
  }

  scheduling {
    preemptible = true
    automatic_restart = false
  }
}

resource "google_compute_instance" "my_instance" {
  name         = "my-instance"
  machine_type = "f1-micro"
  zone         = var.gcp_zones["tokyo-a"]

  # Firewallルールやルートの設定のためのタグ名を付与する
  tags = ["enable-access-from-my-subnetwork"]

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-10"
    }
  }

  network_interface {
    # my_subnetworkにインスタンスを起動する
    network    = google_compute_network.my_network.name
    subnetwork = google_compute_subnetwork.my_subnetwork.name
    # このインスタンスは外部IPアドレスは割り振らないので、access_configはコメントアウトしておく
    # access_config {}
  }

  service_account {
    email  = google_service_account.my_instance.email
    scopes = ["cloud-platform"]
  }

  scheduling {
    preemptible = true
    automatic_restart = false
  }
}

Firewallルールを設定する

二つのFirewallルールを設定します。
1つは、外部のネットワークからbastionへのアクセスを許可するためのルールです。
もう一つは、サブネット内からアクセスを許可するためのルールです。
Firewallルールは指定したタグのあるVMインスタンスにのみ適用されるので、タグを設定するのを忘れないように気をつけてください。

resource "google_compute_firewall" "enable_access_from_internet" {
  name    = "enable-access-from-internet-firewall"
  network = google_compute_network.my_network.name

  direction = "INGRESS"

  # 通信を許可するprotocolとportを指定する
  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  # 対象のVMインスタンスのタグを指定する
  target_tags = ["enable-access-from-internet"]

  # アクセスを許可するIPアドレス範囲を指定する
  # とりあえず動けばいいので、全てのIPアドレス範囲からアクセスを許可することにする
  source_ranges = ["0.0.0.0/0"]

  # CloudLoggingにFlowLogログを出力したい場合は設定する
  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
}

resource "google_compute_firewall" "enable_access_from_my_subnetwork" {
  name    = "my-subnetwork-firewall"
  network = google_compute_network.my_network.name

  direction = "INGRESS"

  # 通信を許可するprotocolとportを指定する
  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  # 対象のVMインスタンスのタグを指定する
  target_tags = ["enable-access-from-my-subnetwork"]

  # アクセスを許可するIPアドレス範囲を指定する
  # 今回はmy_subnetwork内からのアクセスを許可したいので、my_subnetworkのIPアドレス範囲を指定する
  source_ranges = [google_compute_subnetwork.my_subnetwork.ip_cidr_range]

  # CloudLoggingにFlowLogログを出力したい場合は設定する
  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
}

Cloud NATを設定する

Cloud NATを設定するには、Cloud Routerも必要になります。
どちらもリージョンごとに作成するリソースで、対象とするサブネットやIPアドレス範囲を細かく指定することが可能です。
また、CloudNATが使う外部IPアドレスは、自動取得もできますし、静的IPアドレスを設定することも可能です。

resource "google_compute_address" "nat_gateway" {
  name   = "nat-gateway"
  region = "asia-northeast1"
}

resource "google_compute_router" "my_router" {
  name    = "my-router"
  region = "asia-northeast1"
  network = google_compute_network.my_network.id

  bgp {
    # ローカルASN番号を割り振る
    asn = 64514
    # ルーターの設定をカスタムすることもできるか今回はしない
  }
}

resource "google_compute_router_nat" "my-router-nat" {
  name   = "my-router-nat"
  router = google_compute_router.my_router.name
  region = "asia-northeast1"

  # 自動で外部IPアドレスを取得するモードか、自分で外部IPアドレスを設定するモードが選択する
  # NATの外部IPアドレスは、固定したい用途のが多いと思うので、今回はマニュアルで行う
  nat_ip_allocate_option = "MANUAL_ONLY"
  # 外部IPアドレスは複数設定できる
  # 1つの外部IPアドレスごとに、全ポート数65536から
  # well-knownポートの1024個を差し引いた64512個のポートを利用可能
  # min_ports_per_vmというオプションでVMインスタンスごとに割り振るポート数を指定できる
  nat_ips = [google_compute_address.nat_gateway.self_link]

  # どのサブネットに対して有効にするか設定できるが、
  # 今回はサブネットが1つしかないので全てのサブネットに対して有効にする
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  # CloudLoggingにログを出力したい場合は下のオプションを付与する
  log_config {
    enable = true
    filter = "ALL"
  }
}

SSH接続するユーザーにRoleを付与する

今回は2つのサービスアカウントにRoleを付与します。
1つは、ネットワークから踏み台サーバーへSSH接続するユーザーのためのRoleです。
もう一つは、踏み台サーバーがその他のサーバーへSSH接続するためのRoleです。

resource "google_project_iam_binding" "access_user" {
  role = google_project_iam_custom_role.my_custom_role.id

  members = [
    "user:踏み台サーバーへアクセス許可するユーザーのメールアドレス",
  ]
}

resource "google_project_iam_binding" "bastion" {
  role = google_project_iam_custom_role.bastion.id

  members = [
    "serviceAccount:${google_service_account.bastion.email}",
  ]
}

resource "google_project_iam_custom_role" "my_custom_role" {
  role_id = "MyCustomRole"
  title   = "My Custom Role"

  permissions = [
    # VMインスタンスにSSH接続するのに必要な権限
    "compute.projects.get",
    "compute.instances.get",
    "compute.instances.setMetadata",
    "iam.serviceAccounts.actAs",
  ]
}

resource "google_project_iam_custom_role" "bastion" {
  role_id = "BastionRole"
  title   = "My Custom Role"

  permissions = [
    # VMインスタンスにSSH接続するのに必要な権限
    "compute.projects.get",
    "compute.instances.get",
    "compute.instances.setMetadata",
    "iam.serviceAccounts.actAs",
  ]
}

ネットワークに接続できるか試す

$ export GCP_PROJECT_ID="your-gcp-project-id"
# 踏み台サーバーにSSH接続する
$ gcloud beta compute ssh --zone asia-northeast1-a bastion --project $GCP_PROJECT_ID
# 踏み台サーバーから外部IPを持たないVMインスタンスにSSH接続する
$ gcloud compute ssh --zone asia-northeast1-a my-instance --internal-ip
# グローバルIPアドレスを取得できるか確認する
$ sudo apt install curl
$ curl httpbin.org/ip

使われるネットワークの優先順位

CloudNATに紐づけたサブネット内のインスタンスでも、たとえば、外部IPアドレスを持つインスタンスからネットワークへアクセスする場合は、CloudNATを通過しません。
使われるネットワークの優先順位としては以下のようになります。

  1. ルーティングルール
  2. 外部IPアドレス
  3. Subnetに紐づくCloudNAt

実際にリソースを作ってみる

準備

作成してSSH接続してみる

#-------------------
# リポジトリをクローンする
#-------------------
$ git clone https://github.com/nekoshita/gcp-cloud-nat-example
$ cd gcp-cloud-nat-example

#-------------------
# terraform applyしてリソースを作成する
#-------------------
$ export GCP_PROJECT_ID="your-gcp-project-id"
$ export GCS_BUCKET_NAME="your-gcs-bucket-name"
$ export USER_MAIL="your-google-user-mail-to-allow-ssh-acccess@gmail.com"
$ bin/apply $GCP_PROJECT_ID $GCS_BUCKET_NAME $USER_MAIL

#-------------------
# ネットワークに接続できるか試す
#-------------------
# GCPのprojectを作成したgoogleアカウントでログインする(すでにログインしてる場合は不要)
$ gcloud auth login
$ gcloud beta compute ssh --zone asia-northeast1-a bastion --project $GCP_PROJECT_ID
# 踏み台サーバーから外部IPを持たないVMインスタンスにSSH接続する
$ gcloud compute ssh --zone asia-northeast1-a my-instance --internal-ip
# グローバルIPアドレスを取得できるか確認する
$ sudo apt install curl
$ curl httpbin.org/ip

#-------------------
# 最後にリソースの削除をお忘れなく!
#-------------------
$ export GCP_PROJECT_ID="your-gcp-project-id"
$ export GCS_BUCKET_NAME="your-gcs-bucket-name"
$ export USER_MAIL="your-google-user-mail-to-allow-ssh-acccess@gmail.com"
$ bin/destroy $GCP_PROJECT_ID $GCS_BUCKET_NAME $USER_MAIL

ログを見てみる

GCPコンソールのCloudNATを開き、作成したCloudNATを選択します。
LOGSのタブを開くと Stackdriver Logging へのリンクがあるので開くとログが見れます。

最後に

公式ドキュメントを元に理解したつもりですが、私の認識が間違っていたらご指摘いただけると嬉しいです!