🐕

Terraformを利用してFastAPIサーバをCloud Runへデプロイ

に公開

今回はTerraformを利用してFastAPIサーバをCloud Runへデプロイしてみます。Terraformについて現在勉強中なので、まずは最低限の構成で試してみます。

今回作るシステム

今回は以下の条件で検証します。

  • IaCにはTerraformを採用
  • FastAPIを利用してアプリケーションサーバを実装
  • Google Cloud
    • Artifact RegistryにFastAPIのDockerイメージを格納
    • Cloud Runにサーバをデプロイ

それでは実装してみる

FastAPIサーバの実装

まずはFastAPIサーバを実装します。

最初にuvを使って環境を構築します。

uv init fastapi_cloudrun -p 3.12
cd fastapi_cloudrun
uv add fastapi uvicorn

次に、以下のような最低限の構成でサーバを実装します。

main.py
from fastapi import FastAPI
from datetime import datetime

app = FastAPI()

@app.get("/")
async def root():
      return {
            "message": "Hello from FastAPI on Cloud Run!",
            "timestamp": datetime.now().isoformat()
        }

最後に、Dockerfileを以下のように実装します。

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim

WORKDIR /app
COPY . /app

EXPOSE 8080
RUN uv sync

CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Terraform環境の構築

それではTerraform環境を実装してみます。

まずはmain.tfを以下のように実装します。

main.tf
terraform {
  required_version = ">= 1.0"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

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

# Artifact Registry for Docker images
resource "google_artifact_registry_repository" "fastapi_repo" {
  location      = var.region
  repository_id = "fastapi-images"
  description   = "Docker repository for FastAPI images"
  format        = "DOCKER"
}

# Cloud Run Service - 条件付きでデプロイ
resource "google_cloud_run_service" "fastapi_service" {
  count    = var.deploy_cloud_run ? 1 : 0
  name     = "fastapi-service"
  location = var.region

  template {
    spec {
      containers {
        image = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.fastapi_repo.repository_id}/fastapi-app:latest"
        ports {
          container_port = 8080
        }
        resources {
          limits = {
            cpu    = "1"
            memory = "512Mi"
          }
        }
      }
    }
  }

  traffic {
    percent         = 100
    latest_revision = true
  }
}

# IAM policy to allow public access
resource "google_cloud_run_service_iam_member" "public_access" {
  count    = var.deploy_cloud_run ? 1 : 0
  service  = google_cloud_run_service.fastapi_service[0].name
  location = google_cloud_run_service.fastapi_service[0].location
  role     = "roles/run.invoker"
  member   = "allUsers"
}

ここで、google_cloud_run_servicegoogle_cloud_run_service_iam_membercountを指定している理由ですが、今回の構成では以下の手順でデプロイすることを考えておりました。

  1. Artifact Registryを作成する
  2. FastAPIサーバをビルドしてArtifact Registryにpushする
  3. Cloud Runサーバを立ち上げる

なので、全てのリソースを同時に作った場合存在しないイメージを参照しようとして結果としてエラーになると思いました。そこで調べたところ、countという属性を指定すると条件付きデプロイができるということで、今回は最初の構築時はvar.deploy_cloud_runをfalseに設定してCloud Runの作成はせず、FastAPIサーバをpushしてからフラグをtrueにしてリソース作成をするようにしました。

次にvariables.tfを以下のようにしました。先ほど言及したように最初のapplyではCloud Runを作成したくないので、制御のためにdeploy_cloud_runを定義しており、デフォルトをfalseにしています。

variables.tf
variable "project_id" {
  description = "The GCP project ID"
  type        = string
}

variable "region" {
  description = "The GCP region"
  type        = string
  default     = "asia-northeast1"
}

variable "deploy_cloud_run" {
  description = "Whether to deploy Cloud Run service (set to true after pushing Docker image)"
  type        = bool
  default     = false
}

最後にoutputs.tfに出力として表示させたい内容を以下のように定義しました。

outputs.tf
output "service_url" {
  description = "URL of the Cloud Run service"
  value       = var.deploy_cloud_run ? google_cloud_run_service.fastapi_service[0].status[0].url : "Not deployed yet"
}

output "artifact_registry_url" {
  description = "URL of the Artifact Registry repository"
  value       = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.fastapi_repo.repository_id}"
}

Artifact Registryの作成

それでは先ほど定義したArtifact Registryリソースを作成してみます。以下のように実行します。

export PROHECT_ID=...
terraform apply -var="project_id=$PROJECT_ID"

実行ログは以下のようになり、Artifact Registryが無事作成されたことが確認できます。Google Cloudコンソールからも作成されたことが確認できます。

実行ログ
[0m[1mgoogle_artifact_registry_repository.fastapi_repo: Refreshing state... [id=projects/project-id/locations/asia-northeast1/repositories/fastapi-images][0m

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  [32m+[0m create[0m

Terraform will perform the following actions:

[1m  # google_artifact_registry_repository.fastapi_repo[0m will be created
[0m  [32m+[0m[0m resource "google_artifact_registry_repository" "fastapi_repo" {
      [32m+[0m[0m create_time      = (known after apply)
      [32m+[0m[0m description      = "Docker repository for FastAPI images"
      [32m+[0m[0m effective_labels = (known after apply)
      [32m+[0m[0m format           = "DOCKER"
      [32m+[0m[0m id               = (known after apply)
      [32m+[0m[0m location         = "asia-northeast1"
      [32m+[0m[0m mode             = "STANDARD_REPOSITORY"
      [32m+[0m[0m name             = (known after apply)
      [32m+[0m[0m project          = "project-id"
      [32m+[0m[0m repository_id    = "fastapi-images"
      [32m+[0m[0m terraform_labels = (known after apply)
      [32m+[0m[0m update_time      = (known after apply)
    }

[1mPlan:[0m 1 to add, 0 to change, 0 to destroy.
[0m[0m[1mgoogle_artifact_registry_repository.fastapi_repo: Creating...[0m[0m
[0m[1mgoogle_artifact_registry_repository.fastapi_repo: Still creating... [10s elapsed][0m[0m
[0m[1mgoogle_artifact_registry_repository.fastapi_repo: Creation complete after 12s [id=projects/project-id/locations/asia-northeast1/repositories/fastapi-images][0m
[0m[1m[32m
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
[0m[0m[1m[32m
Outputs:

[0martifact_registry_url = "asia-northeast1-docker.pkg.dev/project-id/fastapi-images"
service_url = "Not deployed yet"
]

Artifact Registryへのpush

最初に実装したFastAPIサーバをArtifact Registryにpushします。以下のようにすることで対応できます。

# Artifact Registryへの認証
gcloud auth configure-docker asia-northeast1-docker.pkg.dev

# イメージのビルド
docker build -t asia-northeast1-docker.pkg.dev/$PROJECT_ID/fastapi-images/fastapi-app:latest .

# イメージのプッシュ
docker push asia-northeast1-docker.pkg.dev/$PROJECT_ID/fastapi-images/fastapi-app:latest

Cloud Runへのデプロイ

それではCloud Runへデプロイしてみましょう。Artifact Registryにはすでにイメージをpushしているので、deploy_cloud_runフラグを立てた状態でapplyします。

terraform apply -var="project_id=$PROJECT_ID" -var="deploy_cloud_run=true"

実行ログは以下のようになっており、Cloud Runリソースが問題なく作成されていることがわかり、Google Cloudコンソールからもその結果が確認できます。

実行ログ
[0m[1mgoogle_artifact_registry_repository.fastapi_repo: Refreshing state... [id=projects/project-id/locations/asia-northeast1/repositories/fastapi-images][0m
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Refreshing state... [id=locations/asia-northeast1/namespaces/project-id/services/fastapi-service][0m

[1m[36mNote:[0m[1m Objects have changed outside of Terraform
[0m
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

[1m  # google_cloud_run_service.fastapi_service[0][0m has changed
[0m  [33m~[0m[0m resource "google_cloud_run_service" "fastapi_service" {
        id                         = "locations/asia-northeast1/namespaces/project-id/services/fastapi-service"
        name                       = "fastapi-service"
      [32m+[0m[0m status                     = [
          [32m+[0m[0m {
              [32m+[0m[0m conditions                   = [
                  [32m+[0m[0m {
                      [32m+[0m[0m message = "Revision 'fastapi-service-00001-scz' is not ready and cannot serve traffic. Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [32m+[0m[0m reason  = "RevisionFailed"
                      [32m+[0m[0m status  = "False"
                      [32m+[0m[0m type    = "Ready"
                    },
                  [32m+[0m[0m {
                      [32m+[0m[0m message = "Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [32m+[0m[0m status  = "True"
                      [32m+[0m[0m type    = "ConfigurationsReady"
                        [90m# (1 unchanged attribute hidden)[0m[0m
                    },
                  [32m+[0m[0m {
                      [32m+[0m[0m message = "Revision 'fastapi-service-00001-scz' is not ready and cannot serve traffic. Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [32m+[0m[0m reason  = "RevisionFailed"
                      [32m+[0m[0m status  = "False"
                      [32m+[0m[0m type    = "RoutesReady"
                    },
                ]
              [32m+[0m[0m latest_created_revision_name = "fastapi-service-00001-scz"
              [32m+[0m[0m observed_generation          = 1
              [32m+[0m[0m traffic                      = []
                [90m# (2 unchanged attributes hidden)[0m[0m
            },
        ]
        [90m# (3 unchanged attributes hidden)[0m[0m

        [90m# (2 unchanged blocks hidden)[0m[0m
    }


Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.
[90m
─────────────────────────────────────────────────────────────────────────────[0m

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  [32m+[0m create[0m
[31m-[0m/[32m+[0m destroy and then create replacement[0m

Terraform will perform the following actions:

[1m  # google_cloud_run_service.fastapi_service[0][0m is tainted, so must be [1m[31mreplaced[0m
[0m[31m-[0m/[32m+[0m[0m resource "google_cloud_run_service" "fastapi_service" {
      [33m~[0m[0m id                         = "locations/asia-northeast1/namespaces/project-id/services/fastapi-service" -> (known after apply)
        name                       = "fastapi-service"
      [33m~[0m[0m status                     = [
          [31m-[0m[0m {
              [31m-[0m[0m conditions                   = [
                  [31m-[0m[0m {
                      [31m-[0m[0m message = "Revision 'fastapi-service-00001-scz' is not ready and cannot serve traffic. Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [31m-[0m[0m reason  = "RevisionFailed"
                      [31m-[0m[0m status  = "False"
                      [31m-[0m[0m type    = "Ready"
                    },
                  [31m-[0m[0m {
                      [31m-[0m[0m message = "Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [31m-[0m[0m status  = "True"
                      [31m-[0m[0m type    = "ConfigurationsReady"
                        [90m# (1 unchanged attribute hidden)[0m[0m
                    },
                  [31m-[0m[0m {
                      [31m-[0m[0m message = "Revision 'fastapi-service-00001-scz' is not ready and cannot serve traffic. Image 'asia-northeast1-docker.pkg.dev/project-id/fastapi-images/fastapi-app:latest' not found."
                      [31m-[0m[0m reason  = "RevisionFailed"
                      [31m-[0m[0m status  = "False"
                      [31m-[0m[0m type    = "RoutesReady"
                    },
                ]
              [31m-[0m[0m latest_created_revision_name = "fastapi-service-00001-scz"
              [31m-[0m[0m observed_generation          = 1
              [31m-[0m[0m traffic                      = []
                [90m# (2 unchanged attributes hidden)[0m[0m
            },
        ] -> (known after apply)
        [90m# (3 unchanged attributes hidden)[0m[0m

      [33m~[0m[0m metadata (known after apply)
      [31m-[0m[0m metadata {
          [31m-[0m[0m annotations           = {} [90m-> null[0m[0m
          [31m-[0m[0m effective_annotations = {
              [31m-[0m[0m "run.googleapis.com/ingress"        = "all"
              [31m-[0m[0m "run.googleapis.com/ingress-status" = "all"
              [31m-[0m[0m "run.googleapis.com/operation-id"   = "8122e89f-2417-43ae-a6e5-fe0d9deaef66"
              [31m-[0m[0m "run.googleapis.com/urls"           = jsonencode(
                    [
                      [31m-[0m[0m "...",
                    ]
                )
              [31m-[0m[0m "serving.knative.dev/creator"       = "..."
              [31m-[0m[0m "serving.knative.dev/lastModifier"  = "..."
            } [90m-> null[0m[0m
          [31m-[0m[0m effective_labels      = {
              [31m-[0m[0m "cloud.googleapis.com/location" = "asia-northeast1"
            } [90m-> null[0m[0m
          [31m-[0m[0m generation            = 1 [90m-> null[0m[0m
          [31m-[0m[0m labels                = {} [90m-> null[0m[0m
          [31m-[0m[0m namespace             = "project-id" [90m-> null[0m[0m
          [31m-[0m[0m resource_version      = "AAY7XUH4PaU" [90m-> null[0m[0m
          [31m-[0m[0m self_link             = "/apis/serving.knative.dev/v1/namespaces/877765051213/services/fastapi-service" [90m-> null[0m[0m
          [31m-[0m[0m terraform_labels      = {} [90m-> null[0m[0m
          [31m-[0m[0m uid                   = "9abcd664-34e3-4111-8953-eb8dac9e691d" [90m-> null[0m[0m
        }

      [33m~[0m[0m template {
          [33m~[0m[0m metadata (known after apply)
          [31m-[0m[0m metadata {
              [31m-[0m[0m annotations      = {
                  [31m-[0m[0m "autoscaling.knative.dev/maxScale" = "20"
                } [90m-> null[0m[0m
              [31m-[0m[0m generation       = 0 [90m-> null[0m[0m
              [31m-[0m[0m labels           = {
                  [31m-[0m[0m "run.googleapis.com/startupProbeType" = "Default"
                } [90m-> null[0m[0m
                name             = [90mnull[0m[0m
                [90m# (4 unchanged attributes hidden)[0m[0m
            }
          [33m~[0m[0m spec {
              [33m~[0m[0m container_concurrency = 80 -> (known after apply)
              [33m~[0m[0m service_account_name  = "..." -> (known after apply)
              [32m+[0m[0m serving_state         = (known after apply)
              [33m~[0m[0m timeout_seconds       = 300 -> (known after apply)

              [33m~[0m[0m containers {
                  [31m-[0m[0m args        = [] [90m-> null[0m[0m
                  [31m-[0m[0m command     = [] [90m-> null[0m[0m
                  [32m+[0m[0m name        = (known after apply)
                    [90m# (2 unchanged attributes hidden)[0m[0m

                  [33m~[0m[0m ports {
                      [33m~[0m[0m name           = "http1" -> (known after apply)
                        [90m# (2 unchanged attributes hidden)[0m[0m
                    }

                  [33m~[0m[0m resources {
                      [31m-[0m[0m requests = {} [90m-> null[0m[0m
                        [90m# (1 unchanged attribute hidden)[0m[0m
                    }

                  [33m~[0m[0m startup_probe (known after apply)
                  [31m-[0m[0m startup_probe {
                      [31m-[0m[0m failure_threshold     = 1 [90m-> null[0m[0m
                      [31m-[0m[0m initial_delay_seconds = 0 [90m-> null[0m[0m
                      [31m-[0m[0m period_seconds        = 240 [90m-> null[0m[0m
                      [31m-[0m[0m timeout_seconds       = 240 [90m-> null[0m[0m

                      [31m-[0m[0m tcp_socket {
                          [31m-[0m[0m port = 8080 [90m-> null[0m[0m
                        }
                    }
                }
            }
        }

      [33m~[0m[0m traffic {
          [32m+[0m[0m url             = (known after apply)
            [90m# (4 unchanged attributes hidden)[0m[0m
        }
    }

[1m  # google_cloud_run_service_iam_member.public_access[0][0m will be created
[0m  [32m+[0m[0m resource "google_cloud_run_service_iam_member" "public_access" {
      [32m+[0m[0m etag     = (known after apply)
      [32m+[0m[0m id       = (known after apply)
      [32m+[0m[0m location = "asia-northeast1"
      [32m+[0m[0m member   = "allUsers"
      [32m+[0m[0m project  = (known after apply)
      [32m+[0m[0m role     = "roles/run.invoker"
      [32m+[0m[0m service  = "fastapi-service"
    }

[1mPlan:[0m 2 to add, 0 to change, 1 to destroy.
[0m
Changes to Outputs:
  [33m~[0m[0m service_url           = "Not deployed yet" -> (known after apply)
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Destroying... [id=locations/asia-northeast1/namespaces/project-id/services/fastapi-service][0m[0m
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Destruction complete after 1s[0m
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Creating...[0m[0m
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Still creating... [10s elapsed][0m[0m
[0m[1mgoogle_cloud_run_service.fastapi_service[0]: Creation complete after 17s [id=locations/asia-northeast1/namespaces/project-id/services/fastapi-service][0m
[0m[1mgoogle_cloud_run_service_iam_member.public_access[0]: Creating...[0m[0m
[0m[1mgoogle_cloud_run_service_iam_member.public_access[0]: Creation complete after 5s [id=v1/projects/project-id/locations/asia-northeast1/services/fastapi-service/roles/run.invoker/allUsers][0m
[0m[1m[32m
Apply complete! Resources: 2 added, 0 changed, 1 destroyed.
[0m[0m[1m[32m
Outputs:

[0martifact_registry_url = "asia-northeast1-docker.pkg.dev/project-id/fastapi-images"
service_url = "..."
]

発行されたサービスURLにアクセスすると以下のように結果が確認できることも確認できたので、デプロイは成功しました!

リソースの削除

最後にリソースを削除します。

terraform destroy -var="project_id=$PROJECT_ID"

まとめ

今回はTerraformを利用してCloud RunへFastAPIサーバをデプロイしてみました。私はTerraformを最近結構触り始めましたが、条件付きデプロイとかを知らなかったのでとても勉強になりました。今後もっとTerraformを触っていければと思います。

Discussion