GCS バケット更新をトリガーに Filestore へ書き込む Cloud Run 構築 (Terraform)

## GCS バケット更新をトリガーに Filestore へ書き込む Cloud Run 構築 (Terraform)

[前回](https://qiita.com/inuk4i/items/ca265559738bb32b761d)は、Cloud Run Functions で GCS バケットの更新をトリガーにして Filestore にファイルを保存しようとしたが、イメージのユーザ権限周りでうまくいかなかった。
Cloud Run Functions はデフォルトで root 権限がないユーザーで実行されるから、Filestore への書き込みに必要な権限が足りなかった。

今回は、Cloud Run Functions の代わりに、**カスタムイメージを使って Cloud Run をデプロイ**する方法で、この問題を解決する。
具体的には、GCS バケットに新しいファイルがアップロードされたら、それをトリガーにして、Cloud Run で動くコンテナが Filestore にファイルをコピーする、という仕組みを作る。

### 全体像

まず、今回構築するシステムの全体像はこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3550845/03fad9fc-270f-4c67-b632-4089ce0f807a.png)

1.  **GCS バケット:** ログファイルとか、Filestore に保存したいファイルがアップロードされる場所。
2.  **Eventarc トリガー:** GCS バケットで「ファイルが作成された」イベントを検知して、Cloud Run に伝える。
3.  **Cloud Run:**
    *   **カスタムコンテナイメージ:** Filestore に書き込むためのツール (例えば `mount.nfs` コマンド) とか、GCS からファイルをダウンロードするためのツール (gsutil) が入ってる。
    *   **VPC Direct Egress:** Cloud Run が Filestore と同じネットワーク内で通信できるようにする。
    *   **サービスアカウント:** Filestore への書き込み権限と、GCS からの読み取り権限を持ってる。
4.  **Filestore:** Cloud Run から書き込まれたファイルが保存される場所。

この仕組みを、今回は Terraform を使って構築していく。

### Terraform コード解説

今回使う Terraform コードの主要な部分を解説する。

#### 1. Artifact Registry (コンテナイメージの保管場所)

```terraform
resource "google_artifact_registry_repository" "repository" {
  project       = var.project_id
  location      = var.region
  repository_id = "cloud-run-images"
  format        = "DOCKER"
  description   = "Docker repository for Cloud Run images"
}
```
ここでは、google_artifact_registry_repository リソースを定義し、Artifact Registry リポジトリを作成している。このリポジトリに、Cloud Run で使うカスタムコンテナイメージを保存する。

#### 2. Docker イメージのビルドとプッシュ (null_resource)
```terraform
resource "null_resource" "docker_build_and_push" {
  depends_on = [google_artifact_registry_repository.repository]

  provisioner "local-exec" {
    command = <<EOT
      # 認証
       gcloud auth print-access-token | sudo docker login -u oauth2accesstoken --password-stdin https://asia-northeast1-docker.pkg.dev
      # terraformのmain.tfがあるパスからDockerfileがある相対パスを指定
      DOCKER_FILEPATH=""
      # Docker Build 
      sudo docker build -t $IMAGE_NAME $DOCKER_FILEPATH

      # Docker Push
      sudo docker push $IMAGE_NAME
    EOT
  }
}
```

null_resource は、Terraform のリソースとしては何も作成しないが、provisioner "local-exec" を使ってローカルでコマンドを実行できる。ここでは、以下の処理を実行してる。

- 認証: Google Cloud の認証情報を設定して、Artifact Registry にアクセスできるようにする。

- イメージ名の設定: ビルドする Docker イメージの名前を、Artifact Registry のリポジトリのパスを含む形式で設定する。

- Docker Build: docker build コマンドで、指定したパスにある Dockerfile からコンテナイメージをビルドする。

- Docker Push: docker push コマンドで、ビルドしたイメージを Artifact Registry にプッシュする。

#### 3. Cloud Run サービス

```terraform
resource "google_cloud_run_v2_service" "default" {
  project  = var.project_id
  name     = var.cloud_run_service_name
  location = var.region

  template {
    execution_environment = "EXECUTION_ENVIRONMENT_GEN2" # 第 2 世代の実行環境を指定
    containers {
      
      image = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.repository.repository_id}/${var.cloud_run_image_name}:latest" # 1.で作成したArtifact Registoryおよびpushしたimageを指定
       env {
         name  = "MOUNT_PATH"
         value = "/mnt/nfs" # Filestore のマウントポイント
       }
      volume_mounts {
        name       = "nfs" # ボリューム名
        mount_path = "/mnt/nfs" # マウントパス
      }
    }
    service_account = var.default_compute_service_account # 今回はデフォルトの Compute Engine サービスアカウントを利用する。
    vpc_access { # Direct VPC egress を設定
        network_interfaces {
          network    = var.network_self_link 
          subnetwork = var.subnet_self_link 
        }
    }

    volumes { 
      name = "nfs" # ボリューム名 (上記 volume_mounts.name と一致させる)
      nfs {
        server    = var.filestore_ip_address # Filestore インスタンスの IP アドレス
        path      = var.filestore_share_name    # Filestore の共有名
        read_only = false
      }
    }
  }
}
```

google_cloud_run_v2_service リソースで、Cloud Run サービスを定義している。各行の説明はコメントで記載。

#### 4. Eventarc トリガー

```terraform 
resource "google_eventarc_trigger" "trigger" {
  project  = var.project_id
  name     = "${var.cloud_run_service_name}-trigger"
  location = var.region
  service_account = var.default_compute_service_account

  matching_criteria {
    attribute = "type"
    value     = "google.cloud.storage.object.v1.finalized"
  }
  matching_criteria {
    attribute = "bucket"
    value     = var.gcs_bucket_name #GCSのバケット更新をトリガーにCloud runを実行する
  }
  destination {
    cloud_run_service {
      service = google_cloud_run_v2_service.default.name
      path    = "/"
      region  = var.region
    }
  }
}
```
google_eventarc_trigger リソースで、GCS バケットのイベントをトリガーにして Cloud Run サービスを起動するように設定してる。
type: google.cloud.storage.object.v1.finalized (オブジェクトの作成/更新完了) イベントをトリガーにする。

#### 5. IAM 権限
```terraform
resource "google_cloud_run_service_iam_member" "invoker" {
  location = google_cloud_run_v2_service.default.location
  project  = google_cloud_run_v2_service.default.project
  service  = google_cloud_run_v2_service.default.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com"
}
```

ここでは、Cloud Run サービスを実行するサービスアカウント (今回はデフォルトの Compute Engine サービスアカウント) に、必要な権限を付与してる。

roles/run.invoker: Cloud Run サービスを起動する権限 (Eventarc トリガーに必要)。

#### 補足：Dockerfile


```Dockerfile
FROM python:3.12-slim
# 環境変数を設定
ENV PORT=8080

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

# 依存関係をコピーしてインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Cloud Functions フレームワークをインストール
RUN pip install functions-framework

# アプリケーションコードをコピー
COPY main.py .

# Root ユーザーで実行
USER root

# Cloud Functions フレームワークを実行 --targetでpythonのエンドポイントととなる関数を指定。
CMD ["functions-framework", "--target=main", "--port=8080"]
```

- root ユーザーでの実行について
この Dockerfile では、USER root でコンテナ内のプロセスを root ユーザーで実行するように設定している。これは、Filestore をマウントするために root 権限が必要という制約があるため。

#### main.pyの例　

下記プログラムは、GCSバケットに更新もしくは作成されたファイルをFilestore上にコピーするもの。今回の記事で詳細な解説は割愛。

```python
import subprocess
import os
import functions_framework
from google.cloud import storage

# 環境変数から設定を取得
MOUNT_PATH = os.getenv("MOUNT_PATH")

# GCS クライアント
storage_client = storage.Client()

@functions_framework.cloud_event
def main(cloud_event):
    """GCS のファイル変更イベントを処理し、Filestore にコピー"""
    data = cloud_event.data

    file_name = data.get("name")
    source_bucket = data.get("bucket")

    file_path = f"gs://{source_bucket}/{file_name}"
    print(f"Processing file: {file_path}")

    if not source_bucket or not file_name:
        raise ValueError("Missing bucket or file name in event data")

    destination_path = os.path.join(MOUNT_PATH, file_name)
    print(f"Mounting Filestore: {destination_path}...")

    # ディレクトリが存在しない場合は作成
    filestore_dir = os.path.dirname(destination_path)
    if not os.path.exists(filestore_dir):
        try:
            os.makedirs(filestore_dir, exist_ok=True)
            print(f"Created directory: {filestore_dir}")
        except OSError as e:
            print(f"Error creating directory {filestore_dir}: {e}")
            return

    try:
        bucket = storage_client.bucket(source_bucket)
        blob = bucket.blob(file_name)

        # Cloud Filestore にコピー
        blob.download_to_filename(destination_path)

        print(f"Copied {file_name} to {destination_path}")
    except Exception as e:
        print(f"Error copying file: {str(e)}")
        raise
```