社内AIチャットは、手元のパソコンで動いた時点では「まだ誰も使えない」状態です。社員が使えるようにするには、サーバー上で24時間動かし、パスワードやAPIキーを安全に渡し、DBにつなぎ、社外の人からは見えないようにする必要があります。第8回では評価ケースで回答の品質を自動テストできるようにしました。今回は、いよいよ公開の準備です。
「社内のAIチャットを本番で動かしたいが、サーバーはどう用意するのか、APIキーやパスワードをどこに置けばいいのか、勝手に誰かに使われないか不安」
先に結論をお伝えします。アプリを「コンテナ」という持ち運べる箱に詰め、Google Cloud Runというコンテナ実行サービスに載せれば、サーバー管理なしで公開できます。ただし、APIキーとDBパスワードは専用の金庫(Secret Manager)に入れ、アクセスは「ログインした人だけ」に絞るのが出発点です。 第9回では、Dockerfileの整え方、Cloud SQL(マネージドのPostgreSQL)への接続、秘密情報の渡し方、デプロイ手順、運用で見落としやすい点を、サンプルコードで説明します。
この記事で作るのは、次の4つです。
- 本番向けに整えたDockerfile(待ち受けポートの扱い、停止シグナル)
- 「プロセスが生きているか」と「質問を受けられる状態か」を分ける2つの確認用API(
/healthと/ready) - DBの準備(拡張機能・テーブル)だけを行うマイグレーション専用コマンド
- デプロイ手順書(
deploy/cloudrun.sh)
先にお断りです。この連載の作業環境にはDockerの実行基盤もGoogle Cloudのアカウントも無く、docker buildとCloud Runへの実デプロイは実行できていません。 確認できたのは、Python側のテスト・Lint、PostgreSQL+pgvectorでのマイグレーション、コンテナの起動コマンドと同じ形でのAPI起動です。デプロイ手順書はコマンドの構文確認までで、実際に流すときは自社のプロジェクトで少しずつ確かめてください。
社内AIチャットをCloud Runで動かすと何が楽になるのか
Cloud Runは、コンテナを渡すと、アクセスに応じて起動台数を増減しながら動かしてくれるサービスです。サーバーのOSの更新や台数の管理は、利用者側の仕事ではなくなります。社内チャットのように「昼間は使われ、夜は使われない」システムと相性が良い一方、向かない場面もあります。
| 観点 | Cloud Run(コンテナ実行サービス) | 仮想サーバー(EC2・Compute Engine等) |
|---|---|---|
| サーバーのOS管理 | 不要(提供元が管理) | 必要(更新・設定を自分たちで行う) |
| 使われない時間の費用 | 台数を0にできる設定がある | 起動している限り発生 |
| 向いている用途 | リクエストごとに処理が終わるWeb API | 常時動くバッチ、特殊な設定が必要な処理 |
| つまずきやすい点 | 起動直後は遅くなることがある(コールドスタート)、ファイルの保存先がない | OSやミドルの保守を運用側が担う |
※ 費用の金額は、料金表の改定があるため本文では書きません。見積もりは公式の料金ページで、想定アクセス数をもとに確認してください。
この連載のECS編で扱ったAWSのECSと、考え方はほぼ同じです。違いは「どこまで提供元に任せるか」で、Cloud Runのほうが設定項目が少ない分、細かい調整の自由度は下がります。
本番向けにDockerfileを整える
第0回から使ってきたDockerfileを、Cloud Run向けに3点だけ直しました。
code/Dockerfile
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /srv
COPY pyproject.toml .
RUN pip install --no-cache-dir "fastapi==0.141.1" "uvicorn==0.54.0" "pydantic-settings==2.15.0" "psycopg[binary]==3.3.6" "httpx==0.28.1" "pypdf==6.19.0"
COPY app ./app
COPY db ./db
RUN useradd -m appuser
USER appuser
ENV PORT=8080
EXPOSE 8080
CMD exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT}
直した点は次のとおりです。
- 待ち受けポートを環境変数
PORTにした: Cloud Runは、起動するコンテナに待ち受けポートをPORTという環境変数で渡します。固定値の8000のままだと、「起動したのに応答がない」と判断されて失敗しやすくなります。ローカルでも既定値8080で動くようにしてあります。 execを付けて、uvicornを主プロセスにした: 停止の合図(SIGTERM)を直接受け取れるので、入れ替え時に処理中のリクエストをきちんと終わらせて止まれます。PYTHONUNBUFFERED=1にした: ログが溜め込まれず、すぐ出力されます。障害時に「最後のログが出ていない」ことを防げます。
あわせてdb/(マイグレーション用のSQL)をイメージに含めました。一方で.dockerignoreで、.env(秘密情報)、tests/、sample_docs/などはイメージに入れないようにしています。.envがイメージに入ると、イメージを見られる人全員にAPIキーが渡るため、ここは必ず確認する点です。
📰 出典:Google Cloud「Cloud Run コンテナ実行契約」
※ このページの内容は今回の作業環境からは取得できませんでした。PORTの扱いは同サービスの一般的な仕様としての記載です。実装前に最新の公式ドキュメントでご確認ください。
「生きている」と「使える」を分けて確認する
運用では、アプリが動いているかを確認する窓口が2種類必要です。
| 確認用API | 返すもの | 使いどころ |
|---|---|---|
/health | プロセスが生きていれば常に「ok」 | 動いているかの簡易確認 |
/ready | DBに接続できたら「ready」、できなければ503 | 起動直後に「質問を受けられる状態か」の確認 |
DBがつながらない状態で「正常」と返してしまうと、利用者は「質問しても毎回エラー」を体験するのに、監視画面は緑のままになります。そこで/readyを追加しました。
code/app/main.py
@app.get("/ready")
def ready(settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, str]:
try:
with connect(settings.database_url) as conn:
conn.execute("SELECT 1")
except Exception as e:
raise HTTPException(status_code=503, detail="database unavailable") from e
return {"status": "ready"}
エラー時の応答はdatabase unavailableだけにしています。接続先のホスト名やDBのエラー文を返すと、外から見える情報になってしまうためです。この点はtests/test_main.pyの自動テストで、パスワードや接続先が応答に出ないことを確認しています。
DBはマネージドのCloud SQLを使う
第2回ではDocker ComposeでPostgreSQLを動かしました。本番では、バックアップやセキュリティ更新を提供元が行うマネージドDB(Cloud SQL for PostgreSQL)を使うのが一般的です。ベクトル検索の拡張機能(pgvector)は、Cloud SQLの対応する版でCREATE EXTENSION vectorにより有効にできます(執筆時点で、PostgreSQL 13〜17で利用できると説明されている資料があります。公式の拡張機能一覧で、使う版を必ず確認してください)。
📰 出典:Google Cloud「Cloud Runから接続する(Cloud SQL for PostgreSQL)」
接続は、Cloud Runが用意するUnixソケット(同じ機械の中だけで通じるつなぎ口)を使います。公式の説明では、ソケットは/cloudsql/<インスタンス接続名>にでき、サービスアカウントに「Cloud SQL クライアント」の役割が必要です。接続文字列は、ホスト部分を空にしてhost=でソケットを指す形になります。
postgresql://rag_app:<パスワード>@/rag?host=/cloudsql/<プロジェクト>:<リージョン>:<インスタンス名>
これは第2回から使っているDATABASE_URLにそのまま入れられます。アプリのコードは変えません。接続先は環境変数だけで切り替えるという第0回からの方針が、ここで効いてきます。
マイグレーションは「アプリ起動時」ではなく別コマンドで流す
ローカルではdb/initのSQLがDBの初回起動時に自動で流れましたが、マネージドDBでは自動実行されません。そこで、テーブルの準備だけを行うコマンドを用意しました。
code/app/db/migrate.py
def main() -> None:
from app.config import get_settings
with psycopg.connect(get_settings().database_url) as conn:
conn.execute(INIT_SQL.read_text(encoding="utf-8"))
print(f"適用したマイグレーション: {apply_migrations(conn)}")
if __name__ == "__main__":
main()
実行はpython -m app.db.migrateです。SQLは何度流しても同じ結果になる書き方(IF NOT EXISTS)なので、空のPostgreSQL+pgvectorに対して2回続けて実行し、どちらも成功することを確認しました。
アプリの起動時に自動で流す方法もありますが、Cloud Runは台数が増える(複数台が同時に起動する)ため、同時にDBを書き換える事故を避ける意味で、デプロイの手順の中で1回だけ実行する形にしています。
秘密情報はSecret Managerに入れ、環境変数として渡す
APIキーとDBパスワードは、コードにもイメージにも入れません。Google CloudのSecret Manager(秘密情報の金庫)に保存し、Cloud Runの起動時に環境変数として渡します。アプリ側は第4回までに作ったpydantic-settingsが環境変数DATABASE_URLとLLM_API_KEYを読むので、コードの変更はありません。
deploy/cloudrun.sh(抜粋)
printf '%s' "<生成AIのAPIキー>" | gcloud secrets create rag-llm-api-key --data-file=-
gcloud run deploy "${SERVICE}" --image "${IMAGE}" --region "${REGION}" \
--service-account "${SA}" --add-cloudsql-instances "${INSTANCE_CONN}" \
--set-secrets DATABASE_URL=rag-database-url:latest,LLM_API_KEY=rag-llm-api-key:latest \
--min-instances 0 --max-instances 3 --memory 512Mi --concurrency 20 \
--no-allow-unauthenticated
ポイントは3つです。
- 実行用のサービスアカウントを専用に作り、必要最小限の権限だけ与える(Cloud SQLへの接続と、この2つのシークレットの読み取りのみ)
--max-instances 3で台数に上限を付ける。想定外のアクセスやループで、利用料が青天井にならないようにする--no-allow-unauthenticatedで、ログインしていない人のアクセスを拒否する(次の節で説明)
一番の注意点:このまま公開してはいけない
第6回で作った部署別のアクセス制御は、X-Departmentsというヘッダーで「どの部署の人か」を受け取る動作確認用の仕組みでした。ヘッダーは利用者が自由に書き換えられるため、インターネットに公開すると、誰でも「人事部です」と名乗って給与表を検索できてしまいます。
そのため、手順書では--no-allow-unauthenticatedを付け、認証済みの人しか呼べない状態にしています。本番運用では、次のいずれかが必要です。
- 会社のログイン基盤(SSO)で本人確認を行い、サーバー側で所属部署を決める
- Cloud Runの前段に、Googleの認証付きプロキシ(Identity-Aware Proxy)などを置く
この部分は連載では実装していません。「動く」と「公開してよい」は別のものだと、必ず切り分けてください。
デプロイと動作確認の手順
deploy/cloudrun.shは、次の順に進める手順書です。一括実行ではなく、1ステップずつ結果を見ながら進めることを想定しています。
- APIを有効化する: Cloud Run、Artifact Registry、Cloud SQL、Secret Manager、Cloud Build
- イメージをビルドして保管する:
gcloud builds submit。手元にDockerが無くてもクラウド側でビルドできます - Cloud SQLを作る: PostgreSQLのインスタンスとDB、アプリ用ユーザー
- シークレットを登録する:
DATABASE_URLとLLM_API_KEY - 実行用サービスアカウントを作り、権限を絞って付ける
- マイグレーションをCloud Runジョブとして1回実行する:
python -m app.db.migrate - サービスをデプロイする: 認証必須・台数上限つき
- 本人のトークンを付けて
/readyを呼ぶ: 「ready」が返れば、DBまでつながっています
文書の取り込み(第1・7回のpython -m app.db <フォルダ>)は、本番では別途の作業が必要です。社内文書をどこに置き、誰がいつ取り込み直すかは、手順書に含めていません。ここは運用設計の大きな論点になります。
つまずきやすい点・運用の注意
- ファイルは保存できない: Cloud Runのコンテナの中に書いたファイルは、再起動で消えます。保存はDB(や別のストレージ)に行います。この連載はDBに保存するので問題ありません
- 起動直後の最初の応答は遅くなりやすい: 台数が0の状態から起動するためです。気になる場合は、最小台数を1にします(その分、常時費用が発生します)
- ログに質問や回答の全文を残すか決める: 社内文書の内容を含む可能性があります。誰が見られるか、何日で消すかを決めてください
- AIのモデルは入れ替わる:
LLM_MODELの設定値を、提供元の廃止予定に合わせて更新する担当を決めておきます - 第8回の評価をデプロイ前に必ず流す: 変更のたびに自動で評価を流す(CI)設定にすると、品質の後退に気づけます
- このコードは第9回時点で、単一の埋め込み方式(
HashingEmbedder)のままです: 本番では本物の埋め込みモデルに替え、評価ケースでmax_distanceを測り直してください(第5回・第8回の既知の課題)
発注者向けメモ
社内AI検索を開発会社に依頼する際、本番環境の構成と、運用で誰が何をするかを契約前に確認してください。「作る」ことと「安全に動かし続ける」ことは別の仕事で、後者の範囲が曖昧だと、リリース後に追加の費用や責任の押し付け合いが起きやすくなります。
発注者がやることチェックリストは次のとおりです。
- 本番環境のクラウドアカウントが発注者の名義になっているか(開発会社の名義だと、契約終了後に引き継げないことがあります)
- APIキーとDBパスワードの保管場所が決まっており、担当者が退職しても入れ替えられるか
- ログインできる人だけが使える仕組み(SSO等)が、要件に入っているか
- 利用料に上限(予算アラート・台数の上限)を設定する運用になっているか
- 障害時の連絡先と、対応する時間帯(営業時間内のみか、夜間も含むか)が決まっているか
- バックアップの頻度と、復旧の訓練をいつ行うか決まっているか
- 社内文書の更新(取り込み直し)を、誰がどの頻度で行うか決まっているか
開発会社への質問例は次のとおりです。
- 「本番環境は、うちの会社のクラウドアカウントに作っていただけますか。引き継ぎ時の手順もいただけますか」
- 「APIキーやパスワードは、どこに保存し、誰が見られる設計ですか」
- 「利用者の本人確認は、どの方式で、どの部署の人かをどう決めますか」
- 「想定外のアクセスで利用料が増えないよう、どんな上限を設定しますか」
- 「回答の品質を下げずにAIのモデルを入れ替える手順(評価のやり直し)は、運用に含まれますか」
法律・契約に関わる個別の判断(個人情報や秘密情報をクラウド上で扱う際の取り決めなど)は、弁護士や専門家にご確認ください。
まとめと連載のふりかえり
第9回では、Dockerfileを本番向けに整え、DBの準備を別コマンドにし、秘密情報をSecret Managerから渡す形にして、Google Cloud Runへのデプロイ手順をまとめました。コード側の確認(pytest 49件・ruff・PostgreSQL+pgvectorでのマイグレーション)は通っていますが、docker buildと実際のデプロイは未確認です。そして、ログイン(SSO)による本人確認は未実装で、公開前に必須です。
連載全体では、文書の取り込み、ベクトル検索、出典つき回答、「見つかりません」の判定、部署別アクセス制御、PDF対応、評価、そしてデプロイまでを順に作りました。次の一歩としては、本物の埋め込みモデルへの切り替え、SSO連携、文書の定期取り込みが候補になります。最後までお読みいただき、ありがとうございました。









