😸

【Terraform】 第2世代の Cloud Run を利用したセキュアなアーキテクチャの実現

2024/07/22に公開

【Terraform】 第2世代の Cloud Run を利用したセキュアなアーキテクチャの実現

Hogetic Lab でデータエンジニアをしている大橋と申します。

今回は、第2世代の Cloud Run を使用して、シンプルな構成ながらセキュアなアーキテクチャを実現する方法について紹介します。

はじめに

現代のソフトウェア開発では、技術スタックやマイクロサービス化の進展に伴い、様々なAPIと連携する必要があります。また、APIの開発時には認証などの複雑な要素を後回しにして、まずは素早く機能を実装したいというニーズがあります。開発環境自体もコンテナ化されていることが一般的となっており、効率的なデプロイとスケーリングが求められます。

こうした背景から、セキュリティを確保しながら、コンテナ化されたアプリケーションを 簡単にクラウド上にデプロイする方法を検討することが、本システム構築のモチベーションとなっています。

システム構成の概要

今回のシステム構成は以下の通りです:

  1. API:Cloud Run 上で稼働するコンテナ化された API サーバー。
  2. フロントエンド:Cloud Run 上で稼働し、APIと通信するウェブアプリケーション。
  3. 専用 VPC:Cloud Run サービスをセキュアに接続するための専用の Virtual Private Cloud。

構成図

前提条件

  • Terraform のインストール
  • デプロイ先のプロジェクトの作成
  • 必要な API の有効化

手順

1. プロジェクトのセットアップ

まず、GCP プロジェクトを作成し、必要な API を有効にします。
今回はテスト用のため、デプロイの際はオーナー権限を利用しています。

2. VPCの作成

専用の VPC を作成し、Cloud Runと接続するためのサブネットを設定します。

resource "google_compute_network" "demo" {
  name                    = "demo"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "demo" {
  name          = "demo"
  ip_cidr_range = "10.0.0.0/24"
  region        = "asia-northeast1"
  network       = google_compute_network.demo.id
}

4. Cloud Runサービスのデプロイ

内部 APIとフロントエンドのそれぞれに対して、Cloud Run サービスをデプロイします。ここでは例として、簡単な Python Flask アプリケーションを使用します。

API

今回作成した API サーバーは単純な JSON レスポンスを返すサーバーです。
社内でも PoC として開発している場合、認証などの機能は後回しにして機能開発を実施することがあるかと思います。
今回もそのような開発をイメージし、認証などの処理は行っていません。

サーバーコード
from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route('/', methods=['GET'])
def get_dummy_data():
    dummy_data = {
        "id": 1,
        "name": "John Doe",
        "email": "john.doe@example.com",
        "age": 30,
        "address": {
            "street": "123 Main St",
            "city": "Anytown",
            "state": "CA",
            "zip": "12345"
        }
    }
    return jsonify(dummy_data)

if __name__ == '__main__':
    app.run(debug=True, host="0.0.0.0", port=os.environ["PORT"])
Dockerfile
# ベースイメージとしてPythonを使用
FROM python:3.9.12

# 作業ディレクトリを作成
WORKDIR /app

# 必要なパッケージをインストール
COPY requirements.txt .
RUN pip install -r requirements.txt

ENV PYTHONUNBUFFERED True

# アプリケーションのソースコードをコピー
COPY . .


# アプリを起動
CMD ["python", "main.py"]
requirements.txt
flask

フロントエンド

サーバーコード
from flask import Flask, jsonify
import requests
import os

# API の URL を環境変数から取得
API_URL = os.environ["API_URL"]

app = Flask(__name__)

@app.route('/', methods=['GET'])
def get_external_data():
    try:
        response = requests.get(API_URL)
        response.raise_for_status()
        data = response.json()
        return jsonify(data)
    except requests.exceptions.RequestException as e:
        return jsonify({"error": str(e)}), 500

if __name__ == '__main__':
    app.run(debug=True, host="0.0.0.0", port=os.environ["PORT"])
Dockerfile
# ベースイメージとしてPythonを使用
FROM python:3.9.12

# 作業ディレクトリを作成
WORKDIR /app

# 必要なパッケージをインストール
COPY requirements.txt .
RUN pip install -r requirements.txt

ENV PYTHONUNBUFFERED True

# アプリケーションのソースコードをコピー
COPY . .


# アプリを起動
CMD ["python", "main.py"]
requirements.txt
flask
requests

イメージのビルドと Cloud Run の作成

null_resource を利用して、変数として指定したイメージのタグの変更をトリガーとして利用して、イメージのビルドとプッシュを行います。
そして、その後作成したイメージを Cloud Run としてデプロイします。
Cloud Run は作成した VPC に直接接続し、認証無しで内部からのみアクセス出来るように構成します。

resource "null_resource" "frontend" {
  triggers = {
    image_version = "${var.frontend_image_tag}"
  }

  provisioner "local-exec" {
    command = <<EOT
      docker build -t asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/frontend:${var.frontend_image_tag} \
      -f ./frontend/Dockerfile ./frontend/
      docker push asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/frontend:${var.frontend_image_tag}
    EOT
  }
}

resource "null_resource" "backend" {
  triggers = {
    image_version = "${var.backend_image_tag}"
  }

  provisioner "local-exec" {
    command = <<EOT
      docker build -t asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/backend:${var.backend_image_tag} \
      -f ./backend/Dockerfile ./backend
      docker push asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/backend:${var.backend_image_tag}
    EOT
  }
}

resource "google_service_account" "demo" {
  account_id = "demo-account"
}

resource "google_project_iam_member" "demo" {
  project = var.project
  role    = "roles/run.admin"
  member  = "serviceAccount:${google_service_account.demo.email}"
}

resource "google_cloud_run_v2_service" "frontend" {
  name     = "frontend"
  location = "asia-northeast1"

  template {
    service_account = google_service_account.demo.email
    containers {
      image = "asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/frontend:${var.frontend_image_tag}"
      resources {
        limits = {
          cpu    = "1"
          memory = "2Gi"
        }
      }

      env {
        name  = "API_URL"
        value = google_cloud_run_v2_service.backend.uri
      }
    }

    vpc_access {
      egress = "ALL_TRAFFIC"
      network_interfaces {
        network    = google_compute_network.demo.name
        subnetwork = google_compute_subnetwork.demo.name
      }
    }
  }

  traffic {
    percent = 100
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
  }

  ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  depends_on = [null_resource.frontend]
}

resource "google_cloud_run_service_iam_policy" "frontend" {
  location    = google_cloud_run_v2_service.frontend.location
  service     = google_cloud_run_v2_service.frontend.name
  policy_data = data.google_iam_policy.frontend.policy_data
}

data "google_iam_policy" "frontend" {
  binding {
    role = "roles/run.invoker"
    members = [
      "allUsers",
    ]
  }
}

resource "google_cloud_run_v2_service" "backend" {
  name     = "backend"
  location = "asia-northeast1"

  template {
    service_account = google_service_account.demo.email
    containers {
      image = "asia-northeast1-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.demo.repository_id}/backend:${var.backend_image_tag}"
    }

    vpc_access {
      egress = "ALL_TRAFFIC"
      network_interfaces {
        network    = google_compute_network.demo.name
        subnetwork = google_compute_subnetwork.demo.name
      }
    }
  }

  traffic {
    percent = 100
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
  }

  ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  depends_on = [null_resource.backend]
}

resource "google_cloud_run_service_iam_policy" "backend" {
  location    = google_cloud_run_v2_service.backend.location
  service     = google_cloud_run_v2_service.backend.name
  policy_data = data.google_iam_policy.backend.policy_data
}

data "google_iam_policy" "backend" {
  binding {
    role = "roles/run.invoker"
    members = [
      "allUsers",
    ]
  }
}

5. Load Balancer の作成

作成した Cloud Run を Load Balancer に組み込み、フロントエンドのみ外部からアクセス出来るようにします。
フロントエンドと API の通信は、内部での通信となります。

resource "google_compute_region_network_endpoint_group" "frontend" {
  name                  = "frontend"
  network_endpoint_type = "SERVERLESS"
  region                = "asia-northeast1"
  cloud_run {
    service = google_cloud_run_v2_service.frontend.name
  }
}

resource "google_compute_region_network_endpoint_group" "backend" {
  name                  = "backend"
  network_endpoint_type = "SERVERLESS"
  region                = "asia-northeast1"
  cloud_run {
    service = google_cloud_run_v2_service.backend.name
  }
}

resource "google_compute_backend_service" "frontend" {
  name                  = "frontend"
  load_balancing_scheme = "EXTERNAL"
  protocol              = "HTTP"
  backend {
    group = google_compute_region_network_endpoint_group.frontend.id
  }

  lifecycle {
    ignore_changes = [
      iap,
    ]
  }
}

resource "google_compute_backend_service" "backend" {
  name                  = "backend"
  load_balancing_scheme = "EXTERNAL"
  protocol              = "HTTP"
  backend {
    group = google_compute_region_network_endpoint_group.backend.id
  }
}

resource "google_compute_url_map" "demo" {
  name = "demo"

  default_service = google_compute_backend_service.frontend.id
}

resource "google_compute_global_address" "demo" {
  name = "demo"
}

resource "google_compute_managed_ssl_certificate" "demo" {
  provider = google-beta

  name = "demo"
  managed {
    domains = [
      "${var.doman_name}",
    ]
  }
}

resource "google_compute_target_https_proxy" "demo" {
  name             = "demo"
  url_map          = google_compute_url_map.demo.self_link
  ssl_certificates = [google_compute_managed_ssl_certificate.demo.id]
}

resource "google_compute_global_forwarding_rule" "demo" {
  name       = "demo"
  target     = google_compute_target_https_proxy.demo.self_link
  port_range = "443"
  ip_address = google_compute_global_address.demo.address
}

5. Terraformによる一括デプロイ

作成した Terraform を実行して、インフラを一括デプロイします。
コード全体は以下のリポジトリにあります。

今回のサンプルコード全体

デプロイ完了後にアクセスしたキャプチャが以下です。

ウェブ画面

6. オプション設定

必要に応じて以下を実施します。

Cloud Identity-Aware-Proxy の設定

フロントエンドに認証機能がない場合、Cloud Identity-Aware-Proxy(iap) を有効化して、外部からのアクセス時に Google 認証を強制することができます。
IAP for Cloud Run の有効化

なお、今回作成した Terrafrom では iap の有効化は無視するようにしています。
iap は手動でしか設定できない箇所があったり、シークレットのような機微情報を Terraform に記載する必要があるため、
後から手動で追加できるようにしました。

resource "google_compute_backend_service" "frontend" {
  name                  = "frontend"
  load_balancing_scheme = "EXTERNAL"
  protocol              = "HTTP"
  backend {
    group = google_compute_region_network_endpoint_group.frontend.id
  }

  lifecycle {
    ignore_changes = [
      iap,
    ]
  }
}

おわりに

第2世代の Cloud Run では直接 VPC に接続できるようになったことで、よりシンプルな形でセキュアなネットワーク構成を実現できます。
また、Load Balancer の下に組み込むことで Cloud Identity-Aware-Proxy や Cloud Armor を利用することで更なるセキュリティを実現できます。

※ データ分析、データ基盤構築、及び AI 活用に関するご相談は、以下よりお気軽にお問い合わせください。
お問い合わせフォーム

Hogetic Lab

Discussion