EXPLAZA Tech Blog

株式会社エクスプラザのテックブログです

Terraform + Cloud Runを使ったDifyの環境構築と運用

はじめに

こんにちは、@_mkazutakaです。

今回は、Terraform + Cloud Runを使ったDifyの環境構築と運用についてTerraformのコード等を交えて紹介します。 Cloud Runを採用することで、土日や深夜帯などアクセスがない時間帯のコストを最小限に抑えれたり、VM上で運用するより管理コストを下げることができます。 社内環境にはうってつけですね。

環境構築の前に

Cloud Runを利用したDifyの環境構築については、すでにkotamatさんがZennでスクラップ記事を投稿されています。 /zenn.dev

これを参考に、以下のサービスを利用します。

ユーザを招待する際に招待メールを送信するのですが、その際、CloudRunだとSMTPを使ったメール送信ができません。幸いにもDifyは、ResendのAPIを使ってメール送信も行えるため、こちらを利用します。

Difyのコンポーネント

構築の前に、Difyにはどういったコンポーネントがあるのか簡単に紹介します。 詳細に関しては、エムスリーさんの記事がわかりやすくまとめられているのでご参照ください。

www.m3tech.blog

本記事では、以下の4つのコンポーネントをデプロイします。

  • api
  • web
  • worker
  • sandbox

それぞれをどういうアーキテクチャでデプロイするかは悩みどころですが、弊社では3つのCloud Runリソースを作り、以下の形で構成しています。

またnginxは使わず、Cloud Load Balancingを使っています。

環境構築をする

Difyを構築するためには、30個ほど環境変数の設定が必要になります。。 少し長くなるのですが、上記のSaaSを使った環境変数の設定とそれを使ったTerraformでの構築を紹介していきます。

ステップとしては以下のようになります

  1. SupabaseからPostgreSQL用の環境変数の値を取得
  2. SupabaseからVectorDB用の環境変数の値を取得
  3. UpstashからRedis用の環境変数の値を取得
  4. Resendからメール送信用の環境変数の値を取得
  5. Cloud Storageからモデル保存用の 環境変数の値を取得
  6. デプロイ用のTerraformのコードを書く

1. SupabaseからPostgreSQL用の環境変数の値を取得

Supabaseへのログイン後、プロジェクトを選択、左サイドバーのProject SettingsからCONFIGURATION->Databaseを選択すると必要な要素が出てくると思います。 DB_PASSWORDのみプロジェクトに作成する際に入力した値になります。

Supabaseのページが以下の図の場合、値はそれぞれ以下のようになります

{ name = "DB_USERNAME", value = "postgres.***" },
{ name = "DB_PASSWORD", value = "***" },
{ name = "DB_HOST", value = "aws-0-ap-northeast-1.pooler.supabase.com" },
{ name = "DB_PORT", value = "6543" },
{ name = "DB_DATABASE", value = "postgres"},

2. SupabaseからVectorDB用の環境変数の値を取得

必要な情報は、Supabaseの設定とほぼ同じですが、ベクトルDBとしても使う場合、pgvector拡張を有効にする必要があります。 Supabaseのサイドバーから「Database」->「DATABASE MANEGEMENT」->「Extensions」を選択すると拡張一覧が表示されるので、"pgvector"と入力し、「vector」拡張をONにしてください

VectorDBまわりの環境変数はステップ1と合わせて以下のようになります。

{ name = "VECTOR_STORE", value = "pgvector" },  # pgvectorを利用するので`pgvector`にする
{ name = "PGVECTOR_HOST", value = "aws-0-ap-northeast-1.pooler.supabase.com" },
{ name = "PGVECTOR_PORT", value = "6543" },
{ name = "PGVECTOR_USER", value = "postgres.***" },  # Step 1のDB_USERNAMEと同じ
{ name = "PGVECTOR_PASSWORD", value = "***" },  # Step 1のDB_PASSWORDと同じ
{ name = "PGVECTOR_DATABASE", value = "postgres" },

3. UpstashからRedis用の環境変数の値を取得

Upstashへのログイン後、DATABASESからデータベースを選択すると、必要な要素が出てくると思います。 Databaseページが以下の図の場合、値はそれぞれ以下のようになります。 SSL接続するため、CELERY_BROKER_URLは、redisではなくredissにすることに注意してください。

Upstashのページが以下の図の場合、値はそれぞれ以下のようになります。

Upstashの画面
Upstashの画面

{ name = "REDIS_HOST", value = "picked-***.upstash.io" },
{ name = "REDIS_PORT", value = "6379" },
{ name = "REDIS_USERNAME", value = "default" },  # デフォルト値が 'default'
{ name = "REDIS_PASSWORD", value = "***" },
{ name = "REDIS_USE_SSL", value = "true" }, # SSLを使うので`true`
{ name = "REDIS_DB", value = "0" },
{ name = "CELERY_BROKER_URL", value = "rediss://defualt:<PASSWORD>@picked-***.upstash.io:6379/0" },   # redis`s`に注意

4. Resendからメール送信用の環境変数の値を取得

招待メールを送信が不要ならおそらくこのステップは不要です。 Resendへのログイン後、ドメインの追加とAPIキーの発行をしてください。

{ name  = "MAIL_TYPE", value = "resend" },
{ name = "RESEND_API_KEY", value = "***" },
{ name = "RESEND_API_URL", value = "https://api.resend.com" },
{ name  = "MAIL_DEFAULT_SEND_FROM", value = "noreply@***"},  # 設定したドメインならなんでもいいはず

5. Cloud Storageからモデル保存用の 環境変数の値を取得

Cloud Runを使うためローカルのストレージ領域は使えません。 ストレージの指定がないと、モデルのAPIキーがうまく保存されないためストレージに保存する必要があります。

ストレージ作成からサービスアカウントキー取得までのステップは以下のようになります。

  1. GCP上からGCSのバケットを作成(dify-storage-***)します。
  2. その後、サービスアカウントを作成、ロールは「Storage オブジェクト ユーザー」をつけます。
  3. サービスアカウント用の鍵を作成、ダウンロードし、その値をbase64にします。

これらを使った値は以下のようになります。

{ name  = "STORAGE_TYPE", value = "google-storage" },
{ name  = "GOOGLE_STORAGE_BUCKET_NAME", value = "dify-storage-***" },
{ name  = "GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64", value = "***" },

6. デプロイ用のTerraformのコードを書く

初期設定を以下のようにします。

locals {
  project = "***"  # GCPのプロジェクト名
  region  = "asia-northeast1"
  host = "xxx.example.com  # ローカルDifyのドメイン

  dify_version = "0.8.0"
  dify_sandbox_version = "0.2.7"
}

provider "google" {
  project = local.project
  region  = local.region
}

次にCloud Load Balancingの設定です。 apiとwebへのリクエストを外部からリクエストできるようにしています。

##################################
# Web Network
##################################
resource "google_compute_region_network_endpoint_group" "dify-web-neg" {
  name                  = "dify-web-neg"
  network_endpoint_type = "SERVERLESS"
  region                = local.region
  cloud_run {
    service = google_cloud_run_v2_service.dify-web.name
  }
}

resource "google_compute_backend_service" "dify-web-backend-service" {
  name                  = "dify-web-backend-service"
  load_balancing_scheme = "EXTERNAL"
  protocol              = "HTTP"

  backend {
    group = google_compute_region_network_endpoint_group.dify-web-neg.id
  }
}

##################################
# API Network
##################################
resource "google_compute_region_network_endpoint_group" "dify-api-neg" {
  name                  = "dify-api-neg"
  network_endpoint_type = "SERVERLESS"
  region                = local.region
  cloud_run {
    service = google_cloud_run_v2_service.dify-api.name
  }
}

resource "google_compute_backend_service" "dify-api-backend-service" {
  name                  = "dify-api-backend-service"
  load_balancing_scheme = "EXTERNAL"
  protocol              = "HTTP"

  backend {
    group = google_compute_region_network_endpoint_group.dify-api-neg.id
  }
}

resource "google_compute_url_map" "dify_url_map" {
  name            = "dify-url-map"
  default_service = google_compute_backend_service.dify-web-backend-service.self_link

  host_rule {
    hosts        = [local.host]
    path_matcher = "path-matcher"
  }

  path_matcher {
    name            = "path-matcher"
    default_service = google_compute_backend_service.dify-web-backend-service.self_link

    path_rule {
      paths   = ["/console/api/*", "/api/*", "/v1/*", "/files/*"]
      service = google_compute_backend_service.dify-api-backend-service.self_link
    }

    path_rule {
      paths   = ["/"]
      service = google_compute_backend_service.dify-web-backend-service.self_link
    }
  }
}

module "lb-http" {
  source            = "GoogleCloudPlatform/lb-http/google//modules/serverless_negs"
  version           = "~> 11.0"

  project           = local.project
  name              = "dify-lb"

  ssl                             = true
  managed_ssl_certificate_domains = [local.host]
  https_redirect                  = true

  backends = {
    default = {
      protocol                        = "HTTP"
      port_name                       = "http"
      enable_cdn                      = false
      log_config = {
        enable = true
        sample_rate = 1.0
      }
      groups = [
        {
          group = google_compute_region_network_endpoint_group.dify-web-neg.id
        }
      ]

      iap_config = {
        enable               = false
      }
    }
  }

  create_url_map = false
  url_map        = google_compute_url_map.dify_url_map.self_link
}

次にAPI側のコンテナ環境を作ります。

locals {
  env_vars = [
    { name = "CONSOLE_WEB_URL" value = "https://${local.host}" },
    # ツール一覧の画像表示に必要だったりする
    { name = "CONSOLE_API_URL", value = "https://${local.host}" },
    { name = "APP_WEB_URL", value = "https://${local.host}" },
    # セッションクッキーを安全に署名し、データベース上の機密情報を暗号化するためのキー。
    # `openssl rand -base64 42`で作成
    { name = "SECRET_KEY", value = "" },

    # 上記で取得した値
    { name = "DB_USERNAME", value = "" },
    { name = "DB_PASSWORD", value = "" },
    { name = "DB_HOST", value = "" },
    { name = "DB_PORT", value = "6543" },
    { name = "DB_DATABASE", value = "postgres" },
    { name = "MIGRATION_ENABLED", value = "false" },

    # 上記で取得した値
    { name = "REDIS_HOST", value = "" },
    { name = "REDIS_PORT", value = "" },
    { name = "REDIS_USERNAME", value = "default" },
    { name = "REDIS_PASSWORD", value = "" },
    { name = "REDIS_USE_SSL", value = "true" },
    { name = "REDIS_DB", value = "0" },
    { name  = "CELERY_BROKER_URL", value = "" },

    # 上記で取得した値
    { name = "MAIL_TYPE", value = "resend" },
    { name = "RESEND_API_KEY", value = "" },
    { name = "RESEND_API_URL", value = "https://api.resend.com" },
    # Resend上で設定したドメインを使った適当なメールアドレス
    { name = "MAIL_DEFAULT_SEND_FROM", value = "noreply@" },

    # 上記で取得した値
    { name = "STORAGE_TYPE", value = "google-storage" },
    { name = "GOOGLE_STORAGE_BUCKET_NAME", value = "" },
    { name = "GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64", value = "" },

    # 上記で取得した値
    { name = "VECTOR_STORE", value = "pgvector" },
    { name = "PGVECTOR_HOST", value = "" },
    { name = "PGVECTOR_PORT", value = "6543" },
    { name = "PGVECTOR_USER", value = "" },
    { name = "PGVECTOR_PASSWORD", value = "" },
    { name = "PGVECTOR_DATABASE", value = "postgres" },

    { name = "CODE_EXECUTION_ENDPOINT", value = google_cloud_run_v2_service.dify-sandbox.uri },
    # CODE_EXECUTION間の認証で使用するキー。自分で生成する
    { name = "CODE_EXECUTION_API_KEY", value = "" },

    # 必要に応じて
    { name = "LOG_LEVEL", value = "INFO" },
    { name = "DEBUG", value = "true" },
    { name = "SENTRY_DSN", value = "" },
  ]
}

resource "google_cloud_run_v2_service" "dify-api" {
  name     = "dify-api"
  location = local.region
  project  = local.project

  template {
    session_affinity                 = "true"
    max_instance_request_concurrency = 1000
    timeout                          = "3600s"
    execution_environment            = "EXECUTION_ENVIRONMENT_GEN2"

    scaling {
      max_instance_count = 1
      min_instance_count = 1
    }

    containers {
      name  = "api"
      image = "langgenius/dify-api:${local.dify_version}"
      resources {
        limits = {
          cpu    = "1"
          memory = "2Gi"
        }
        cpu_idle          = "true"
        startup_cpu_boost = "true"
      }
      ports {
        container_port = 5001
      }
      startup_probe {
        tcp_socket {
          port = 5001
        }
        failure_threshold = 1
        period_seconds    = 240
        timeout_seconds   = 240
      }
      dynamic "env" {
        for_each = local.env_vars
        content {
          name  = env.value.name
          value = env.value.value
        }
      }
      env {
        name  = "MODE"
        value = "api"
      }
    }

    containers {
      name  = "worker"
      image = "langgenius/dify-api:${local.dify_version}"
      resources {
        limits = {
          cpu    = "2"
          memory = "2Gi"
        }
        cpu_idle          = "false"
        startup_cpu_boost = "true"
      }
      dynamic "env" {
        for_each = local.env_vars
        content {
          name  = env.value.name
          value = env.value.value
        }
      }
      env {
        name  = "MODE"
        value = "worker"
      }
    }
  }
}

resource "google_cloud_run_service_iam_member" "dify-api-member" {
  location = google_cloud_run_v2_service.dify-api.location
  project  = google_cloud_run_v2_service.dify-api.project
  service  = google_cloud_run_v2_service.dify-api.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

次にWEBとのコンテナを作ります

resource "google_cloud_run_v2_service" "dify-web" {
  name     = "dify-web"
  location = local.region
  project  = local.project
  template {
    session_affinity = "true"
    max_instance_request_concurrency = 1000
    scaling {
      max_instance_count = 1
      min_instance_count = 0
    }
    containers {
      name  = "web"
      image = "langgenius/dify-web:${local.dify_version}"
      ports {
        container_port = 8080
      }
      resources {
        limits = {
          cpu    = "2"
          memory = "2Gi"
        }
        cpu_idle = "true"
        startup_cpu_boost = "true"
      }
      startup_probe {
        tcp_socket {
          port = 8080
        }
        failure_threshold = 1
        period_seconds    = 240
        timeout_seconds   = 240
      }
      env {
        name  = "CONSOLE_API_URL"
        value = "https://${local.host}"
      }
      env {
        name  = "APP_API_URL"
        value = "https://${local.host}"
      }
      env {
        name  = "SENTRY_DSN"
        value = ""
      }
    }
  }
}

resource "google_cloud_run_service_iam_member" "dify-web-member" {
  location = google_cloud_run_v2_service.dify-web.location
  project  = google_cloud_run_v2_service.dify-web.project
  service  = google_cloud_run_v2_service.dify-web.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

最後にSandboxのコンテナを作ります

resource "google_cloud_run_v2_service" "dify-sandbox" {
  name     = "dify-sandbox"
  location = local.region
  project  = local.project
  template {
    session_affinity = "true"
    max_instance_request_concurrency = 1000

    scaling {
      max_instance_count = 1
      min_instance_count = 0
    }

    containers {
      name  = "sandbox"
      image = "langgenius/dify-sandbox:${local.dify_sandbox_version}"
      ports {
        container_port = 8194
      }
      resources {
        limits = {
          cpu    = "2"
          memory = "2Gi"
        }
        cpu_idle = "true"
        startup_cpu_boost = "true"
      }
      startup_probe {
        tcp_socket {
          port = 8194
        }
        failure_threshold = 1
        period_seconds    = 240
        timeout_seconds   = 240
      }
      env {
        name  = "API_KEY"
        value = ""
      }
      env {
        name  = "GIN_MODE"
        value = "release"
      }
      env {
        name = "WORKER_TIMEOUT"
        value = "100"
      }
      env {
        name = "SANDBOX_PORT"
        value = "8194"
      }
    }
  }
}

resource "google_cloud_run_service_iam_member" "dify-sandbox-member" {
  location = google_cloud_run_v2_service.dify-sandbox.location
  project  = google_cloud_run_v2_service.dify-sandbox.project
  service  = google_cloud_run_v2_service.dify-sandbox.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

以上で完了です。お疲れ様でした!

運用

運用するうえでいくつかのTipsも紹介します。

DIfyのバージョンを上げる

Terraform上のdify_versionとdify_sandbox_versionを変更し、terraform applyすれば終わりです。

DBマイグレーション

バージョンが上がるとデータベースのマイグレーションをする必要があるケースがあります APIのコンテナの環境変数に、MIGRATION_ENABLEDがあるこれをtrueにすればコンテナの起動時にマイグレーションを実行してくれるっぽいのですが、弊社の環境(CloudRunかな)だと初回はうまくいくが、バージョンアップ時にうまく動かなかったので手動でマイグレーションするようにしています。

マイグレーション方法は以下の手順で行います。

$ git clone https://github.com/langgenius/dify
$ cd dify/api
$ poetry install

# データベース環境変数の設定
$ vim .env
#> DB_USERNAME=
#> DB_PASSWORD=
#> DB_HOST=
#> DB_PORT=6543
#> DB_DATABASE=

$ poetry run dotenv run flask db current

その他

Terraformのコードを見てもらえるとわかるのですが、apiだけmin_instance_countを1に設定しています。 コンテナが0の状態だと、アクセスした際に1回でうまくページが表示されないためです。(一度webページにアクセスし、しばらく後に更新すると表示されます)

最後に

少し長くなりましたが、Dify環境の構築に役立つと幸いです。