💭

【DevOps実践】RustでAPIサーバーを構築してみた③: クラウド構築編

に公開

前置き

【DevOps実践】RustでAPIサーバーを構築してみた①: 全体紹介編
【DevOps実践】RustでAPIサーバーを構築してみた②: Docker構築編

なにやってるかが分からない方は読んでみてください。

やるべきこと

いよいよクラウドの構築に入ります。
まずはやるべきことを整理してみましょう。

今回のAPIサーバ―では、リレーショナルデータベース、インメモリーデータベース、クラウドストレージを使用しているため、これらは必然としていります。
それに加えて、APIサーバ―を実行するCloudRun、DockerImageを保存するArtifactRegistryが必要です。

  • ArtifactRegistry(CloudRunで使用するイメージ保管庫)
  • CloudRun(APIサーバー実行環境)
  • CloudSQL(リレーショナルデータベース、PostgreSQL)
  • Memotystore for Redis(インメモリーデータベース)
  • CloudStorage(ストレージ)

これらは今回の場合、必須項目です。
また、CloudRunからRedisに接続するには、VPCとVPCコネクターが必要です。
ちなみに、CloudRunからCloudSQLにアクセスするときは、一応パブリックIP経由でアクセスできるけど、お勧めしませんよー(小声)
VPCコネクター使って、安全・安心にデータのやり取りしましょう!

  • VPC(仮想ネットワーク空間)
  • VPC Connector(VPC内にあるリソースにアクセスするためのブリッジ)
インターネット
       ↓
[ Cloud Run ]  (外部公開)
       ↓
   ❌ 直接プライベートIPへは接続不可
       ↓
┌────────────────────────────────────────────┐
│  Serverless VPC Access Connector(トンネル)│
└───────────────────▲────────────────────────┘
                    │
                    ▼
─────────────── VPC Network ───────────────
                     │
                     ├─ Cloud Memorystore(Redis)
                     │
                     ├─ Cloud SQL(Private IP)
                     │
                     └─ GCE VM / その他の内部リソース

これだけでは終われません。
よりセキュアで、セキュリティも考慮すると、サービスアカウント(SA)を作成して、適宜な権限を渡す必要があります。GithubActionsでデプロイの自動化をやるので、GithubActions用のSAも必要です。

CloudRunで使用するSAは以下の権限が必要です。

  • VPCコネクター使用権限
  • CloudSQL接続権限
  • Redis接続権限
  • CloudStorageに読み取り、書き込み権限
  • ログ出力権限(おまけ、あると便利)

GithubActionsで使用するSAは以下の権限が必要です。

  • ArtifactRegistry書き込み権限
  • CloudRun実行時、SAを指定できる権限(上記のSAを指定しないとCloudSQL,Redis,Storageにアクセスできない!)
  • CloudRun実行権限

GithubActionsからGCPにアクセスするときは、OIDC認証を使うため、以下の設定も合わせて必要です。

  • workload_identity_pool
  • workload_identity_pool_provider

やる事いっぱいですね、順番にやっていきましょう。

準備作業

  • GCP CLI インストール
  • gcloudログイン
  • dockerログイン
  • GCP Artifact Registoryでリポジトリを作成
  • デプロイするプロジェクトのイメージ化してArtifact Registoryリポジトリにプッシュする

以下の作業はホストPCで行います。(自分はWSLです。)

まずはGoogle公式ドキュメントを参考にホストPC(WSL)にGCP CLIをインストールします。

Step 1: 必要なモジュールをインストール

sudo apt-get update && sudo apt-get install apt-transport-https ca-certificates gnupg

Step 2: GoogleCloud公開鍵をインストール

curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg

Step 3: gcloudCLIの配布URIをパッケージソースとして追加

echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list

Step 4: gcloudCLIを更新してインストール

sudo apt-get update && sudo apt-get install google-cloud-cli

ログイン情報をセットします。

Step 5: gcloudログイン

# CLI用
gcloud auth login
# アプリケーション用(*任意ですが、Terraformで認証が必要なので、使う方はやっといてください。)
gcloud auth application-default login

Step 6: デフォルトでアクセスするプロジェクト(GCP)を設定する

*任意ですが、設定しないと都度指定する必要があります

gcloud config set project <プロジェクトID>

これで一旦準備作業が完了しました。
次から本題の【クラウド構築】に入ります。

クラウド構築

自分はデプロイする予定のプロジェクトに【infra】フォルダを作成し、Terraformの設定ファイルを追加してます。

ディレクトリ構造はこんな感じです。

infra
├─ main.tf
├─ backend.tf
├─ variables.tf
├─ terraform.tfvars
└─ modules
      ├─ iam
      │   ├─ main.tf
      │   ├─ variables.tf
      │   └─ output.tf
      ├─ redis
      │   ├─ main.tf
      │   ├─ variables.tf
      │   └─ output.tf
      ├─ sql
      │   ├─ main.tf
      │   ├─ variables.tf
      │   └─ output.tf
      ├─ run
      │   ├─ main.tf
      │   └─ variables.tf
      ├─ storage
      │   ├─ main.tf
      │   └─ variables.tf
      └─ vpc
          ├─ main.tf
          ├─ variables.tf
          └─ output.tf

Step 1: IAMモジュール

# modules/iam/variables.tf
# ------------------------------------------------------
# サービスアカウント作成時に使用する変数を定義
# ------------------------------------------------------
variable "region" {
  type = string
}

variable "project_id" {
  type = string
}

variable "project_number" {
  type = string
}

variable "cloud_run_sa_id" {
  description = "CloudRun Service Account ID"
  type = string
}

variable "github_actions_sa_id" {
  description = "Github Actions Service Account ID"
  type = string
}

variable "github_workload_pool_id" {
  description = "Github Workload Identity Pool ID"
  type = string
}

variable "github_actions_provider_id" {
  description = "Github Actions IDP Provider ID"
  type = string
}

variable "github_repository_name" {
  description = "Github Repository Name(owner/repository形式)"
  type = string
}
# modules/iam/main.tf
# ------------------------------------------------------
# CloudRun用サービスアカウント設定
# ------------------------------------------------------

resource "google_service_account" "cloud_run_sa" {
    account_id = var.cloud_run_sa_id
    display_name = "CloudRunで使用するサービスアカウント"
}

# VPC Connector使用権限
resource "google_project_iam_member" "vpc_access_user" {
    project = var.project_id
    role = "roles/vpcaccess.user"
    member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# CloudSQL接続権限
resource "google_project_iam_member" "rdb_client" {
    project = var.project_id
    role = "roles/cloudsql.client"
    member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# Redis情報閲覧権限
resource "google_project_iam_member" "redis_info_viewer" {
    project = var.project_id
    role = "roles/redis.viewer"
    member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# CloudStorage読み取り、書き込み権限
resource "google_project_iam_member" "storage_editor" {
    project = var.project_id
    role = "roles/storage.objectAdmin"
    member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# CloudRunログ出力権限
resource "google_project_iam_member" "log_writer" {
    project = var.project_id
    role = "roles/logging.logWriter"
    member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}

# ------------------------------------------------------
# GitHubActions用サービスアカウント設定
# ------------------------------------------------------

resource "google_service_account" "github_actions_sa" {
    account_id = var.github_actions_sa_id
    display_name = "GithubActionsで使用するサービスアカウント"
}

# Artifact Registry書き込み権限
resource "google_project_iam_member" "registry_writer" {
    project = var.project_id
    role = "roles/artifactregistry.writer"
    member = "serviceAccount:${google_service_account.github_actions_sa.email}"
}

# CloudRun実行時、SAを指定できる権限
resource "google_project_iam_member" "sa_can_be_specified" {
    project = var.project_id
    role = "roles/iam.serviceAccountUser"
    member = "serviceAccount:${google_service_account.github_actions_sa.email}"
}

# CloudRun実行権限
# 注意:このアカウントはGithubActionsで自動デプロイに使うため
# 通常の【run.invoker】だけでは権限不足です。
# デプロイはCloudRunインスタンスの作成・更新
# 環境変数の変更、トラフィック切り替えなど、さまざまの権限が必要です。
resource "google_project_iam_member" "run_admin" {
    project = var.project_id
    role = "roles/run.admin"
    member = "serviceAccount:${google_service_account.github_actions_sa.email}"
}

# ------------------------------------------------------
# OIDC認証
# ------------------------------------------------------

# workload_identity_pool設定
# workload_identity_pool: 外部環境からGCPのリソースに
# OIDCやSAMLを用いて、安全に認証・アクセスできる仕組み
resource "google_iam_workload_identity_pool" "github_workload_pool" {
    workload_identity_pool_id = var.github_workload_pool_id
    display_name = "GitHub Workload Identity Pool"

    # terraform destroyすると
    # GCP側の復元システムによりゴミ箱に移動されるため
    # 復元可能期間内では同じIDで再作成不可となる
    # destroy時にエラーで弾くように設定します。
    lifecycle {
        prevent_destroy = true
    }
}

# workload_identity_pool_provider設定
# 特定の外部 IdP との連携設定を定義するリソース
# 外部 IdP から発行されたトークンを属性マッピング (Attribute Mapping) や
# 属性条件 (Attribute Condition) を用いて検証する
resource "google_iam_workload_identity_pool_provider" "github_actions_provider" {
    workload_identity_pool_id = google_iam_workload_identity_pool.github_workload_pool.workload_identity_pool_id
    workload_identity_pool_provider_id = var.github_actions_provider_id
    display_name = "GithubActions OIDC認証プロバイダー"

    # GithubIdPから渡ってくる認証情報(右側)をGoogle(左側)の属性にマッピング
    attribute_mapping = {
      "google.subject" = "assertion.sub"
      "attribute.repository" = "assertion.repository"
    }

    # 重要:セキュリティ高めるためには
    # 指定のリポジトリからのみ、アクセスできるようにしましょう!
    attribute_condition = "attribute.repository == \"${var.github_repository_name}\""

    oidc {
        issuer_uri = "https://token.actions.githubusercontent.com"
    }

    lifecycle {
        prevent_destroy = true
    }
}

# Githubリポジトリからのアクセスを許可
resource "google_service_account_iam_binding" "github_sa_binding" {
    # 注意:Emailではなく、Name
    service_account_id = google_service_account.github_actions_sa.name
    role = "roles/iam.workloadIdentityUser"

    # principalSet://iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/<WORK_IDENTITY_POOL_ID>: このPoolを通じた外部IdP(OIDC)からのアクセスを許可する
    # attribute.repository/<GITHUB_REPOSITORY_NAME>: Github特定なリポジトリからのアクセスに限定する
    members = "principalSet://iam.googleapis.com/projects/${var.project_number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github_workload_pool.workload_identity_pool_id}/attribute.repository/${var.github_repository_name}"
}
# modules/iam/outputs.tf
# ほかのモジュールで使う項目をモジュール外に出しておきます。
output "cloud_run_sa_email" {
  value = google_service_account.cloud_run_sa.email
}

Step 2: VPCモジュール

# modules/vpc/variables.tf
variable "region" {
  type = stirng
}

variable "vpc_name" {
  type = string
}

variable "vpc_subnet_name" {
  type = string
}

variable "vpc_connector_name" {
  type = string
}
# modules/vpc/main.tf
# ------------------------------------------------------
# ネットワーク
# ------------------------------------------------------
resource "google_compute_network" "vpc" {
    name = var.vpc_name

    # 自動でサブネットを作らないように設定
    auto_create_subnetworks = false
}


# ------------------------------------------------------
# サブネット
# ------------------------------------------------------
resource "google_compute_subnetwork" "vpc_subnet" {
    name = var.vpc_subnet_name
    # なんでこの数字になったのかは、CIDRの知識なので、気になる方は調べてみてください。
    ip_cidr_range = "10.0.1.0/24"
    region = var.region
    network = google_compute_network.vpc.name
}

# ------------------------------------------------------
# VPCコネクター
# ------------------------------------------------------
resource "google_vpc_access_connector" "connector" {
    name = var.vpc_connector_name
    region = var.region
    network = google_compute_network.vpc.name
    ip_cidr_range = "10.8.0.0/28"
}
# modules/vpc/outputs.tf

output "vpc_id" {
    value = google_compute_network.vpc.id
}

output "connector_name" {
    value = google_vpc_access_connector.connector.name
}

Step 3: Redisモジュール

# modules/redis/variables.tf
variable "region" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "redis_name" {
  type = string
}

variable "tier" {
  description = "単一ノード構成。レプリカなし。"
  type = string
  default = "BASIC"
}

variable "memory_size" {
  description = "GB"
  type = number
  default = 1
}
# modules/redis/main.tf
# データの永続化はRDBでやるので、単一ノードで構成します。
resource "google_redis_instance" "redis" {
    region = var.region
    name = var.redis_name
    tier = var.tier
    memory_size_gb = var.memory_size
    authorized_network = var.vpc_id
}
# modules/redis/outputs.tf
output "host" {
    value = google_redis_instance.redis.host
}

output "port" {
    value = google_redis_instance.redis.port
}

Step 4: Sqlモジュール

# modules/sql/variables.tf
variable "sql_name" {
  type = string
}

variable "region" {
  type = string
}

variable "db_version" {
  type = string
  default = "POSTGRES_18"
}

variable "tier" {
    description = "vCPU 2 / memory 16GB"
    type = string
    default = "db-custom-2-16384"
}

variable "disk_type" {
    type = string
    default = "PD_SSD"
}

variable "disk_size" {
    type = number
    default = 50
}


variable "vpc_id" {
    type = string
}

variable "db_name" {
    type = string
}

variable "db_user" {
    type = string
}

variable "db_password" {
    type = string
}
# modules/sql/main.tf
# ------------------------------------------------------
# CloudSQLインスタンス作成
# ------------------------------------------------------
resource "google_sql_database_instance" "postgres" {
    name = var.sql_name
    region = var.region
    database_version = var.db_version

    settings {
        tier = var.tier

        disk_type = var.disk_type
        disk_size = var.disk_size

        ip_configuration {
            ipv4_enabled = false
            private_network = var.vpc_id
        }

        backup_configuration {
            enabled = true
            # バックアップ開始時刻(UTC)
            start_time = "17:00" # JST AM 2:00
        }
    }

    # 削除防止をOFFにする場合は false
    deletion_protection = true
}

# ------------------------------------------------------
# DB作成
# ------------------------------------------------------
resource "google_sql_database" "postgres_db" {
    instance = google_sql_database_instance.postgres.name
    name = var.db_name
}

# ------------------------------------------------------
# DBユーザー作成
# ------------------------------------------------------
resource "google_sql_user" "name" {
    instance = google_sql_database_instance.postgres.name
    name = var.db_user
    password = var.db_password
}
# modules/sql/outputs.tf
output "host" {
    value = google_sql_database_instance.postgres.private_ip_address
}

output "port" {
    description = "固定値"
    value = 5432
}

Step 5: Storageモジュール

# modules/storage/variables.tf
variable "region" {
    type = string
}

variable "bucket_name" {
    type = string
}

variable "storage_class" {
    description = "高頻度アクセス向け"
    type = string
    default = "STANDARD"
}
# modules/storage/main.tf
resource "google_storage_bucket" "storage" {
    location = var.region
    name = var.bucket_name
    storage_class = var.storage_class
    # バケット単位で IAM ポリシーに統一制御する
    uniform_bucket_level_access = true
}

Step 6: Runモジュール

# modules/run/variables.tf
variable "region" {
    type = string
}

variable "project_id" {
    type = string
}

variable "api_server_name" {
    type = string
}

variable "sa_email" {
    description = "CloudRunが使用するサービスアカウント"
    type = string
}

variable "artifact_registry_name" {
    type = string
}

variable "image_name" {
    type = string
}

variable "image_tag" {
    type = string
    default = "latest"
}

variable "api_server_host" {
    type = string
    default = "0.0.0.0"
}

variable "api_server_port" {
    type = number
    default = 8080
}

variable "db_host" {
    type = string
}

variable "db_port" {
    type = string
}

variable "db_name" {
    type = string
}

variable "db_user" {
    type = string
}

variable "db_password" {
    type = string
}

variable "redis_host" {
    type = string
}

variable "redis_port" {
    type = string
}

variable "vpc_connector_name" {
    type = string
}
# modules/run/main.tf
# cloud_run_v2 とv1では記述方法が異なるので注意!
# ------------------------------------------------------
# CloudRunインスタンス作成
# ------------------------------------------------------
resource "google_cloud_run_v2_service" "api_server" {
    project = var.project_id
    location = var.region
    name = var.api_server_name

    template {
        service_account = var.sa_email

        containers {
            # <RIGION>-docker.pkg.dev/<PROJECT_ID>/<ARTIFACT_REGISTRY_NAME>/<DOCKER_IMAGE_NAME>:<DOCKER_IMAGE_TAG>
            image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.artifact_registry_name}/${var.image_name}:${var.image_tag}"

            env {
                name = "HOST"
                value = var.api_server_host
            }

            env {
                name = "PORT"
                value = var.api_server_port
            }

            env {
                name = "DB_HOST"
                value = var.db_host
            }

            env {
                name = "DB_PORT"
                value = var.db_port
            }

            env {
                name = "DB_NAME"
                value = var.db_name
            }

            env {
                name = "DB_USER"
                value = var.db_user
            }

            env {
                name = "DB_PASSWORD"
                value = var.db_password
            }

            env {
                name = "REDIS_HOST"
                value = var.redis_host
            }

            env {
                name = "REDIS_PORT"
                value = var.redis_port
            }

            ports {
                container_port = var.api_server_port
            }
        }

        vpc_access {
            connector = "projects/${var.project_id}/locations/${var.region}/connectors/${var.vpc_connector_name}"
            egress = "ALL_TRAFFIC" # すべてのアウトバウンド通信が VPC 経由に変更
        }
    }
}

# ------------------------------------------------------
# CloudRunインスタンス公開設定
# ------------------------------------------------------
# この設定が無いと、CloudRunにデプロイしても403で弾かれます。
resource "google_cloud_run_v2_service_iam_member" "public_access" {
    project = var.project_id
    location = google_cloud_run_v2_service.api_server.location
    name = google_cloud_run_v2_service.api_server.name
    role = "roles/run.invoker"
    member = "allUsers"
}

Step 7: main.tf

モジュールの設定が終わって、いよいよ組み立てていきます。

# backend.tf
# TerraformのステートファイルをCloudStorageに保存します。
terraform {
    backend "gcs" {
        bucket = "あなたのバケット名を指定してください。"
        prefix = "sample/prod" # ここで設定したディレクトリがバケットに作成されます
    }
}
# variables.tf
variable "project_id" {
  description = "GCPプロジェクトID"
  type = string
}

variable "project_number" {
  description = "GCPプロジェクトナンバー"
  type = string
}

variable "region" {
  description = "GCPリージョン"
  type = string
  default = "asia-northeast1"
}

variable "repository_name" {
  description = "Github Repository Name (owner/repository)"
  type = string
}

variable "cloud_run_sa_id" {
  description = "CloudRunが使用するサービスアカウント"
  type = string
}

variable "github_actions_sa_id" {
  description = "GithubActionsが使用するサービスアカウント"
  type = string
}

variable "db_name" {
  type = string
}

variable "db_user" {
  type = string
}

variable "db_password" {
  type = string
}


variable "artifact_registry_name" {
  type = string
}

variable "image_name" {
  type = string
}

# main.tf
terraform {
  required_providers {
    google = {
        resource = "hashicorp/google"
        version = "~> 5.10"
    }
  }
}

provider "google" {
    project = var.project_id
    region = var.region
}

# -----------------------------
# IAMモジュール
# -----------------------------
module "user" {
  source = "./modules/iam"
  region = var.region
  project_id = var.project_id
  project_number = var.project_number
  github_repository_name = var.repository_name
  github_workload_pool_id = "github-actions-pool"
  github_actions_provider_id = "github-actions-pool-provider"
  cloud_run_sa_id = var.cloud_run_sa_id
  github_actions_sa_id = var.github_actions_sa_id
}

# -------------------
# VPC、コネクター設定
# -------------------
module "network" {
  source = "./modules/vpc"
  region = var.region
  vpc_name = "app-vpc"
  vpc_subnet_name = "app-vpc-subnet"
  vpc_connector_name = "app-vpc-connector"
}

# -------------------
# Redisモジュール
# -------------------
module "redis" {
  source = "./modules/redis"
  region = var.region
  vpc_id = module.network.vpc_id
  redis_name = "app-redis"
}

# -------------------
# Sqlモジュール
# -------------------
module "db" {
  source = "./modules/sql"
  region = var.region
  vpc_id = module.network.vpc_id
  sql_name = "app-db"
  db_name = var.db_name
  db_user = var.db_user
  db_password = var.db_password
}

# -------------------
# Storageモジュール
# -------------------
module "storage" {
  source = "./modules/storage"
  region = var.region
  bucket_name = "app-bucket"
}

# -------------------
# Runモジュール
# -------------------
module "api_server" {
  source = "./modules/run"
  region = var.region
  project_id = var.project_id

  sa_email = module.user.cloud_run_sa_email
  artifact_registry_name = var.artifact_registry_name
  image_name = var.image_name

  vpc_connector_name = module.network.connector_name

  api_server_name = "app-api-server"

  db_host = module.db.host
  db_port = module.db.port
  db_name = var.db_name
  db_user = var.db_user
  db_password = var.db_password

  redis_host = module.redis.host
  redis_port = module.redis.port

}
# terraform.tfvars
# いよいよ最後!変数を入れて設定完了です。

project_id = ""
project_number = ""
repository_name = ""
cloud_run_sa_id = ""
github_actions_sa_id = ""
db_name = ""
db_user = ""
db_password = ""
artifact_registry_name = ""
image_name = ""

お疲れ様でした。これでクラウドの設定は一旦完了となります。

Step 8: インスタンス作成

terraform apply

ネットワーク環境にもよりますが、10分~20分くらいかかる場合もあります。
コーヒー飲んでお待ちください。

Step 9: インスタンス削除

terraform destroy
# 今回はlifecycleで消せない項目とかもあるので、destroyではなく、
# いらなくなったモジュールをコメントアウトしてapplyしましょう!

次回

【DevOps実践】RustでAPIサーバーを構築してみた④: CICD編

Discussion