【GKE/Terraform】外部ネットワークからの全てのアクセスを制限した限定公開クラスタを作成し、踏み台サーバーからkubectlする

11 min読了の目安(約10400字TECH技術記事

TL;DR

こんな人向け

  • 限定公開クラスタについて知りたい
  • GKEのセキュリティーを強化したい
  • TerraformでGCP・GKEさわりたい

解説

限定公開クラスタ(プライベートクラスタ)とは?

GKEは、主にコントロールプレーンノードから成り立っています。
詳しくはこちら -> https://zenn.dev/articles/6b5dffe3d563e1
限定公開クラスタにすると、ノードに使われるVMインスタンスに外部IPアドレスが割り振られなくなります。
すると、ノードに関しては外部からアクセスすることはできなくなります。

どうやって外部からのアクセスを制限するの?

ノードに関しては、限定公開クラスタにすれば、ノードに外部IPアドレスが付与されなくなるので、外部ネットワークからのアクセスを制限することができます。
限定公開クラスタにするには、enable_private_clusterというオプションを付与してGKEクラスタを作成すればOKです。

次に、コントロールプレーンへのアクセスも制限します。
enable_private_endpointというオプションを指定すると、コントロールプレーンがpublicなエンドポイントを持たなくなるので、外部ネットワークからのアクセスを制限することができます。

どうやってkubectlするの?

外部からのアクセスをできなくしていまうので、外部ネットワークから kubectlコマンドを実行することはできなくなります。
なので、GKEクラスタと同じネットワーク内に踏み台サーバーとなるVMインスタンスを起動します。

またVMインスタンスにSSH接続するのに今回はCloudIAPを用います。
CloudIAPについて詳しくはこちら -> https://zenn.dev/nekoshita/articles/6c8a3650db4489

イメージ図

  • VPCネットワークとsubnetを作成します
  • subnet内に限定公開クラスタを作成します
    • 各ノードが外部IPアドレスを持ちません
  • コントロールプレーンへのアクセス制限をかけ、全ての外部ネットワークからのアクセスを拒否します
    • 外部ネットワークからkubectlコマンドが使えなくなります
  • subnet内に踏み台サーバーとしてVMインスタンスを起動します
  • 踏み台サーバー踏み台サーバーにIAPを用いてSSH接続します
  • 踏み台サーバーから、GKEクラスタに対してkubectlコマンドを実行します

Terraform解説

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

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 = var.gcp_regions["tokyo"]

  # サブネットで使用したい内部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"
  }

  secondary_ip_range {
    range_name    = "my-subnetwork-for-pods"
    ip_cidr_range = "10.1.0.0/16"
  }

  secondary_ip_range {
    range_name    = "my-subnetwork-for-services"
    ip_cidr_range = "10.2.0.0/16"
  }

  private_ip_google_access = true
}

サブネット内に限定公開クラスタを作成する

enable_private_nodesenable_private_endpointのオプションを指定することが重要です。

resource "google_container_cluster" "my_cluster" {
  name     = "my-cluster"
  location = "asia-northeast1-a"
  project  = "あなたのGCPプロジェクト"

  network    = google_compute_network.my_network.name
  subnetwork = google_compute_subnetwork.my_subnetwork.name

  # デフォルトのノードプールがないクラスターを作成することはできないので、
  # クラスター作成直後にデフォルトのノードプールを削除する
  remove_default_node_pool = true
  initial_node_count       = 1

  # 限定公開クラスタにするための設定
  private_cluster_config {
    # プライベートノードにするかどうか(ノードに外部IPを付与しない)
    enable_private_nodes = true
    # kubectlコマンドのアクセスを内部IPアドレスからのみにするかどうか
    enable_private_endpoint = true
    # クラスターのmasterとILB VIPのためのプライベードIPアドレスを指定する必要がある
    #  /28 subnetでないといけない
    master_ipv4_cidr_block = "192.168.0.0/28"
  }
  # enable_private_endpointがtrueの場合はmaster_authorized_networks_configを設定する必要があります。
  # master_authorized_networks_configにはコントロール プレーンにアクセスできるIPアドレスを指定します。
  # enable_private_endpointがtrueの時には、master_authorized_networks_configで指定しなくても、以下の2種類のIPアドレスからコントロールプレーンにアクセス可能です
  # 1, GKEクラスタが所属するサブネットのプライマリ範囲
  # 2, GKEクラスタのポッドに使用されるセカンダリ範囲
  master_authorized_networks_config {}

  ip_allocation_policy {
    cluster_secondary_range_name  = google_compute_subnetwork.my_subnetwork.secondary_ip_range.0.range_name
    services_secondary_range_name = google_compute_subnetwork.my_subnetwork.secondary_ip_range.1.range_name
  }
}

resource "google_container_node_pool" "my_cluster_nodes" {
  name       = "my-node-pool"
  location   = "asia-northeast1-a"
  cluster    = google_container_cluster.my_cluster.name
  node_count = 1

  node_config {
    preemptible  = true
    machine_type = "n1-standard-1"

    service_account = google_service_account.my_cluster.email
    oauth_scopes = [
      "https://www.googleapis.com/auth/cloud-platform"
    ]
  }
}

# ノードとして起動されるVMインスタンスのためのサービスアカウント
resource "google_service_account" "my_cluster" {
  account_id   = "my-cluster"
  display_name = "My Service Account For My Cluster"
}

サブネット内に踏み台サーバーとしてVMインスタンスを作成する

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

  # Firewallでタグごとにルールを設定したいので、VMインスタンスにタグを設定しておく
  tags = ["bastion-tag"]

  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アドレスは割り振らないので、access_configはコメントアウトしておく
    # 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_service_account" "bastion" {
  account_id   = "bastion"
  display_name = "My Service Account For Bastion"
}

Cloud IAPでSSH接続するためのFirewallルールを設定する

Cloud IAPでSSH接続するには、Firewallルールを設定する必要があります。
詳しくはこちら -> https://zenn.dev/nekoshita/articles/6c8a3650db4489#firewallルールを設定する

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

  direction = "INGRESS"

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  # 対象のVMインスタンスのタグを指定する
  target_tags = ["bastion-tag"]
  # Cloud IAPのバックエンドIPアドレス範囲を指定する
  # https://cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule
  source_ranges = ["35.235.240.0/20"]

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

踏み台サーバーにSSH接続するユーザーに権限を付与する

今回はプロジェクトレベルIAMとリソースレベルIAMを用いて、最小限のカスタムロールを作成して、それをユーザーに紐づけます。
まずはロールを作成します。

# IAPを用いてSSH接続するのに必要な権限
# プロジェクトレベルで付与する必要がある
resource "google_project_iam_custom_role" "use_iap" {
  role_id = "UseIAP"
  title   = "use iap"

  permissions = [
    # プロジェクト内のリソースにアクセスするのに必要な権限
    "compute.projects.get",
    # 別のサービスアカウントにアクセスするために必要な権限
    # SSH接続するときはVMインスタンスのサービスアカウントにアクセスするので必要になる
    "iam.serviceAccounts.actAs",
    # IAPに必要な権限
    "iap.tunnelInstances.accessViaIAP",
  ]
}

# 踏み台サーバーにSSHするためのロール
resource "google_project_iam_custom_role" "ssh_to_vm_instance" {
  role_id = "SSHToVMInstance"
  title   = "SSH to VM Instance"

  permissions = [
    # VMインスタンスを取得するのに必要な権限
    "compute.instances.get",
    # メタ情報を付与するのに必要な権限
    # 初めてSSH接続する時、鍵を渡すために必要
    "compute.instances.setMetadata",
  ]
}

次に、ロールをユーザーに紐づけます

# アクセスを許可するユーザーにプロジェクトレベルで付与するロール
# このロールに含まれる権限はプロジェクトレベルで付与する必要がある
resource "google_project_iam_binding" "bind_use_iap" {
  role = google_project_iam_custom_role.use_iap.id

  members = [
    "user:${var.allowed_user_mail}",
  ]
}

# 踏み台サーバーにIAP経由でSSHするユーザーにリソースレベルのロールを付与する
resource "google_compute_instance_iam_binding" "enable_ssh_to_vm_instance_for_bastion" {
  instance_name = google_compute_instance.bastion.name
  zone          = var.gcp_zones["tokyo-a"]

  role = google_project_iam_custom_role.ssh_to_vm_instance.id

  members = [
    "user:${var.allowed_user_mail}",
  ]
}

踏み台サーバーからGKEへアクセスするための権限を付与する

踏み台サーバーのためのサービスアカウントはすでに作成してあるので、そのサービスアカウントにGKEへアクセスするための権限を付与します。
また、GKEへのアクセスするための権限はリソースレベルで付与することはできないので、プロジェクトレベルで付与します。
まずはカスタムロールを作成します。

# kubectlするためのロール
resource "google_project_iam_custom_role" "enable_kubectl" {
  role_id = "EnableKubectl"
  title   = "Enable Kubectl"

  permissions = [
    # クラスタの認証情報を取得するのに必要な権限
    "container.clusters.get",
    # podの一覧取得するのに必要な権限,
    "container.pods.list"
  ]
}

次にカスタムロールを踏み台サーバーのサービスアカウントに紐づけます

# プロジェクトレベルで踏み台サーバーにロールを付与する
# GKEはリソースレベルでのロールの付与ができない
resource "google_project_iam_binding" "bind_enable_kubectl" {
  role = google_project_iam_custom_role.enable_kubectl.id

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

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

準備

リソースを作成してkubectlしてみる

#-------------------
# リポジトリをクローンする
#-------------------
$ git clone https://github.com/nekoshita/gke-private-cluster-with-no-client-access-to-the-public-endpoint-example
$ cd gke-private-cluster-with-no-client-access-to-the-public-endpoint-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

#-------------------
# kubectlを実行する
#-------------------
$ export ZONE="asia-northeast1-a"
$ export VM_INSTANCE_NAME="bastion"
$ export GCP_PROJECT_ID="your-gcp-project-id"

# GCPのprojectを作成したgoogleアカウントでログインする(すでにログインしてる場合は不要)
$ gcloud auth login

# IAPを利用して踏み台サーバーにSSH接続する
$ gcloud beta compute ssh --zone $ZONE $VM_INSTANCE_NAME --project $GCP_PROJECT_ID --tunnel-through-iap

# kubectlコマンドをインストールする
$ sudo apt install kubectl

# クラスタの認証情報を取得する
$ gcloud container clusters get-credentials my-cluster --zone asia-northeast1-a

# podを取得できるかためしてみる
$ kubectl get pod --all-namespaces

#-------------------
# 最後にリソースの削除をお忘れなく!
#-------------------
$ 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

最後に

  • kubectlを外部ネットワークからできなくなるので、よりセキュアになるのは嬉しい
  • 踏み台サーバーに接続するのはめんどくさい
  • 外部のマネージドのCI系サービス(GitHubActions、CircleCIなど)からkubectlコマンド使えないのは不便かもしれない
  • ArgoCDとか使えば、kubectlコマンドでリソース管理することはなくなるので、そうすべきかもしれない
  • 間違ってたらご指摘いただけると嬉しいです!