Cloud SQL for PostgreSQL を使用してフルスタック JavaScript アプリケーションを Cloud Run にデプロイする

1. 概要

Cloud Run は、Google のスケーラブルなインフラストラクチャ上で直接コードを実行できるフルマネージド プラットフォームです。この Codelab では、Cloud Run 上の Next.js アプリケーションを Cloud SQL for PostgreSQL データベースに接続する方法について説明します。

このラボでは、次の方法について学びます。

  • Cloud SQL for PostgreSQL インスタンスを作成する
  • Cloud SQL データベースに接続するアプリケーションを Cloud Run にデプロイする

2. 前提条件

  1. Google アカウントをまだお持ちでない場合は、Google アカウントを作成する必要があります。
    • 仕事用または学校用アカウントではなく、個人アカウントを使用します。職場用アカウントと学校用アカウントには、このラボに必要な API を有効にできないようにする制限が設定されている場合があります。

3. プロジェクトの設定

  1. Google Cloud コンソールにログインします。
  2. Cloud コンソールで課金を有効にします
    • このラボを完了するのにかかる Cloud リソースの費用は 1 米ドル未満です。
    • このラボの最後の手順に沿ってリソースを削除すると、それ以上の料金は発生しません。
    • 新規ユーザーは、300 米ドル分の無料トライアルをご利用いただけます。
  3. 新しいプロジェクトを作成するか、既存のプロジェクトを再利用します。

4. Cloud Shell エディタを開く

  1. Cloud Shell エディタに移動します。
  2. ターミナルが画面の下部に表示されない場合は、開きます。
    • ハンバーガー メニュー ハンバーガー メニュー アイコン をクリックします。
    • [Terminal] をクリックします。
    • [New Terminal] をクリックします。Cloud Shell エディタで新しいターミナルを開く
  3. ターミナルで、次のコマンドを使用してプロジェクトを設定します。
    • 形式:
      gcloud config set project [PROJECT_ID]
      
    • 例:
      gcloud config set project lab-project-id-example
      
    • プロジェクト ID を忘れた場合:
      • 次のコマンドを使用すると、すべてのプロジェクト ID を一覧表示できます。
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      Cloud Shell エディタのターミナルでプロジェクト ID を設定する
  4. 承認を求められたら、[承認] をクリックして続行します。Cloud Shell を承認する
  5. 次のようなメッセージが表示されます。
    Updated property [core/project].
    
    WARNING が表示され、Do you want to continue (Y/N)? と表示された場合は、プロジェクト ID が正しく入力されていない可能性があります。N を押して Enter を押し、gcloud config set project コマンドをもう一度実行してみてください。

5. API を有効にする

ターミナルで API を有効にします。

gcloud services enable \
  compute.googleapis.com \
  sqladmin.googleapis.com \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com \
  networkconnectivity.googleapis.com \
  servicenetworking.googleapis.com \
  cloudaicompanion.googleapis.com

承認を求められたら、[承認] をクリックして続行します。Cloud Shell を承認する

このコマンドが完了するまで数分かかる場合がありますが、最終的には次のような成功メッセージが表示されます。

Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.

6. サービス アカウントを設定する

Cloud Run で使用する Google Cloud サービス アカウントを作成して構成し、Cloud SQL に接続するための適切な権限を付与します。

  1. 次の gcloud iam service-accounts create コマンドを実行して、新しいサービス アカウントを作成します。
    gcloud iam service-accounts create quickstart-service-account \
      --display-name="Quickstart Service Account"
    
  2. 次のように gcloud projects add-iam-policy-binding コマンドを実行して、作成した Google Cloud サービス アカウントにログ書き込みのロールを追加します。
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
      --member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --role="roles/logging.logWriter"
    

7. Cloud SQL データベースを作成する

  1. Private Service Connect を使用して Cloud Run から Cloud SQL へのネットワーク接続を許可するサービス接続ポリシーを作成する
    gcloud network-connectivity service-connection-policies create quickstart-policy \
        --network=default \
        --project=${GOOGLE_CLOUD_PROJECT} \
        --region=us-central1 \
        --service-class=google-cloud-sql \
        --subnets=https://www.googleapis.com/compute/v1/projects/${GOOGLE_CLOUD_PROJECT}/regions/us-central1/subnetworks/default
    
  2. データベースの固有のパスワードを生成する
    export DB_PASSWORD=$(openssl rand -base64 20)
    
  3. gcloud sql instances create コマンドを実行して Cloud SQL インスタンスを作成する
    gcloud sql instances create quickstart-instance \
        --project=${GOOGLE_CLOUD_PROJECT} \
        --root-password=${DB_PASSWORD} \
        --database-version=POSTGRES_17 \
        --tier=db-perf-optimized-N-2 \
        --region=us-central1 \
        --ssl-mode=ENCRYPTED_ONLY \
        --no-assign-ip \
        --enable-private-service-connect \
        --psc-auto-connections=network=projects/${GOOGLE_CLOUD_PROJECT}/global/networks/default
    

このコマンドが完了するまで数分かかることがあります。

  1. gcloud sql databases create コマンドを実行して、quickstart-instance 内に Cloud SQL データベースを作成します。
    gcloud sql databases create quickstart_db \
      --instance=quickstart-instance
    

8. アプリケーションを準備する

HTTP リクエストに応答する Next.js アプリケーションを準備します。

  1. task-app という名前の新しい Next.js プロジェクトを作成するには、次のコマンドを使用します。
    npx --yes create-next-app@15 task-app \
      --ts \
      --eslint \
      --tailwind \
      --no-src-dir \
      --turbopack \
      --app \
      --no-import-alias
    
  2. ディレクトリを task-app に変更します。
    cd task-app
    
  3. PostgreSQL データベースを操作するには、pg をインストールします。
    npm install pg
    
  4. TypeScript Next.js アプリケーションを使用するには、@types/pg を開発依存関係としてインストールします。
    npm install --save-dev @types/pg
    
  5. Cloud Shell エディタで actions.ts ファイルを開きます。
    cloudshell edit app/actions.ts
    
    画面の上部に空のファイルが表示されます。この actions.ts ファイルを編集できます。コードが画面の上部に表示されることを示す
  6. 次のコードをコピーして、開いた actions.ts ファイルに貼り付けます。
    'use server'
    import pg from 'pg';
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
    };
    
    const { Pool } = pg;
    
    const pool = new Pool({
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      ssl: {
        // @ts-expect-error require true is not recognized by @types/pg, but does exist on pg
        require: true,
        rejectUnauthorized: false, // required for self-signed certs
        // https://node-postgres.com/features/ssl#self-signed-cert
      }
    });
    
    const tableCreationIfDoesNotExist = async () => {
      await pool.query(`CREATE TABLE IF NOT EXISTS tasks (
        id SERIAL NOT NULL,
        created_at timestamp NOT NULL,
        status VARCHAR(255) NOT NULL default 'IN_PROGRESS',
        title VARCHAR(1024) NOT NULL,
        PRIMARY KEY (id)
      );`);
    }
    
    // CREATE
    export async function addNewTaskToDatabase(newTask: string) {
      await tableCreationIfDoesNotExist();
      await pool.query(`INSERT INTO tasks(created_at, status, title) VALUES(NOW(), 'IN_PROGRESS', $1)`, [newTask]);
      return;
    }
    
    // READ
    export async function getTasksFromDatabase() {
      await tableCreationIfDoesNotExist();
      const { rows } = await pool.query(`SELECT id, created_at, status, title FROM tasks ORDER BY created_at DESC LIMIT 100`);
      return rows;
    }
    
    // UPDATE
    export async function updateTaskInDatabase(task: Task) {
      await tableCreationIfDoesNotExist();
      await pool.query(
        `UPDATE tasks SET status = $1, title = $2 WHERE id = $3`,
        [task.status, task.title, task.id]
      );
      return;
    }
    
    // DELETE
    export async function deleteTaskFromDatabase(taskId: string) {
      await tableCreationIfDoesNotExist();
      await pool.query(`DELETE FROM tasks WHERE id = $1`, [taskId]);
      return;
    }
    
  7. Cloud Shell エディタで page.tsx ファイルを開きます。
    cloudshell edit app/page.tsx
    
    画面の上部に既存のファイルが表示されます。この page.tsx ファイルを編集できます。コードが画面の上部に表示されることを示す
  8. page.tsx ファイルの既存の内容を削除します。
  9. 次のコードをコピーして、開いた page.tsx ファイルに貼り付けます。
    'use client'
    import React, { useEffect, useState } from "react";
    import { addNewTaskToDatabase, getTasksFromDatabase, deleteTaskFromDatabase, updateTaskInDatabase } from "./actions";
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
    };
    
    export default function Home() {
      const [newTaskTitle, setNewTaskTitle] = useState('');
      const [tasks, setTasks] = useState<Task[]>([]);
    
      async function getTasks() {
        const updatedListOfTasks = await getTasksFromDatabase();
        setTasks(updatedListOfTasks);
      }
    
      useEffect(() => {
        getTasks();
      }, []);
    
      async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
        e.preventDefault();
        await addNewTaskToDatabase(newTaskTitle);
        await getTasks();
        setNewTaskTitle('');
      };
    
      async function updateTask(task: Task, newTaskValues: Partial<Task>) {
        await updateTaskInDatabase({ ...task, ...newTaskValues });
        await getTasks();
      }
    
      async function deleteTask(taskId: string) {
        await deleteTaskFromDatabase(taskId);
        await getTasks();
      }
    
      return (
        <main className="p-4">
          <h2 className="text-2xl font-bold mb-4">To Do List</h2>
          <div className="flex mb-4">
            <form onSubmit={handleSubmit} className="flex mb-8">
              <input
                type="text"
                placeholder="New Task Title"
                value={newTaskTitle}
                onChange={(e) => setNewTaskTitle(e.target.value)}
                className="flex-grow border border-gray-400 rounded px-3 py-2 mr-2 bg-inherit"
              />
              <button
                type="submit"
                className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-nowrap"
              >
                Add New Task
              </button>
            </form>
          </div>
          <table className="w-full">
            <tbody>
              {tasks.map(function (task) {
                const isComplete = task.status === 'COMPLETE';
                return (
                  <tr key={task.id} className="border-b border-gray-200">
                    <td className="py-2 px-4">
                      <input
                        type="checkbox"
                        checked={isComplete}
                        onClick={() => updateTask(task, { status: isComplete ? 'IN_PROGRESS' : 'COMPLETE' })}
                        className="transition-transform duration-300 ease-in-out transform scale-100 checked:scale-125 checked:bg-green-500"
                      />
                    </td>
                    <td className="py-2 px-4">
                      <span
                        className={`transition-all duration-300 ease-in-out ${isComplete ? 'line-through text-gray-400 opacity-50' : 'opacity-100'}`}
                      >
                        {task.title}
                      </span>
                    </td>
                    <td className="py-2 px-4">
                      <button
                        onClick={() => deleteTask(task.id)}
                        className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded float-right"
                      >
                        Delete
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </main>
      );
    }
    

これで、アプリケーションをデプロイする準備が整いました。

9. アプリケーションを Cloud Run にデプロイする

  1. 次の gcloud projects add-iam-policy-binding コマンドを実行して、作成する Cloud Run サービスの Cloud Run サービス アカウントにネットワーク ユーザーのロールを追加します。
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
        --member "serviceAccount:service-$(gcloud projects describe ${GOOGLE_CLOUD_PROJECT} --format="value(projectNumber)")@serverless-robot-prod.iam.gserviceaccount.com" \
        --role "roles/compute.networkUser"
    
  1. 次の gcloud projects add-iam-policy-binding コマンドを実行して、作成する Cloud Run サービスの現在のユーザーに Artifact Registry 書き込みロールを追加します。
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
        --member=user:$(gcloud auth list --filter=status:ACTIVE --format="value(account)") \
        --role="roles/artifactregistry.writer"
    
  1. 次のコマンドを実行して、アプリケーションを Cloud Run にデプロイします。
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=. \
      --set-env-vars DB_NAME="quickstart_db" \
      --set-env-vars DB_USER="postgres" \
      --set-env-vars DB_PASSWORD=${DB_PASSWORD} \
      --set-env-vars DB_HOST="$(gcloud sql instances describe quickstart-instance --project=${GOOGLE_CLOUD_PROJECT} --format='value(settings.ipConfiguration.pscConfig.pscAutoConnections.ipAddress)')" \
      --service-account="quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --network=default \
      --subnet=default \
      --allow-unauthenticated
    
  2. 続行するかどうかを確認するメッセージが表示されたら、YEnter を押します。
    Do you want to continue (Y/n)? Y
    

数分後、アプリケーションからアクセス可能な URL が提供されます。

URL に移動して、アプリケーションの動作を確認します。URL にアクセスしたり、ページを更新したりするたびに、タスクアプリが表示されます。

10. Gemini Code Assist を使用して機能を追加する

これで、データベースを含むウェブアプリがデプロイされました。次に、AI アシスタンスの機能を使用して、next.js アプリに新しい機能を追加します。

  1. Cloud Shell エディタに戻る
  2. page.tsx をもう一度開く
    cd ~/task-app
    cloudshell edit app/page.tsx
    
  3. Cloud Shell エディタで Gemini Code Assist に移動します。
    • 画面左側のツールバーにある Gemini アイコン Gemini Code Assist アイコン をクリックします。
    • ログインを求められたら、Google アカウントの認証情報でログインします。
    • プロジェクトを選択するよう求められたら、この Codelab 用に作成したプロジェクトを選択します。Gemini Code Assist のプロジェクトを選択する
  4. プロンプト「Add the ability to update the title of the task. The code in your output should be complete and working code.」を入力します。レスポンスには、handleEditStart 関数と handleEditCancel 関数を追加する次のスニペットのようなものが含まれます。
    const [editingTaskId, setEditingTaskId] = useState('');
    const [editedTaskTitle, setEditedTaskTitle] = useState('');
    
    function handleEditStart(task: Task) {
      setEditingTaskId(task.id);
      setEditedTaskTitle(task.title);
    };
    
  5. page.tsx は、Gemini Code Assist の出力に置き換えます。有効な例は次のとおりです。
    'use client'
    import React, { useEffect, useState } from "react";
    import { addNewTaskToDatabase, getTasksFromDatabase, deleteTaskFromDatabase, updateTaskInDatabase } from "./actions";
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
    };
    
    export default function Home() {
      const [newTaskTitle, setNewTaskTitle] = useState('');
      const [tasks, setTasks] = useState<Task[]>([]);
      const [editingTaskId, setEditingTaskId] = useState('');
      const [editedTaskTitle, setEditedTaskTitle] = useState('');
    
      async function getTasks() {
        const updatedListOfTasks = await getTasksFromDatabase();
        setTasks(updatedListOfTasks);
      }
    
      useEffect(() => {
        getTasks();
      }, []);
    
      async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
        e.preventDefault();
        await addNewTaskToDatabase(newTaskTitle);
        await getTasks();
        setNewTaskTitle('');
      };
    
      async function updateTask(task: Task, newTaskValues: Partial<Task>) {
        await updateTaskInDatabase({ ...task, ...newTaskValues });
        await getTasks();
        setEditingTaskId('');
        setEditedTaskTitle('');
      }
    
      async function deleteTask(taskId: string) {
        await deleteTaskFromDatabase(taskId);
        await getTasks();
      }
    
      function handleEditStart(task: Task) {
        setEditingTaskId(task.id);
        setEditedTaskTitle(task.title);
      };
    
      return (
        <main className="p-4">
          <h2 className="text-2xl font-bold mb-4">To Do List</h2>
          <div className="flex mb-4">
            <form onSubmit={handleSubmit} className="flex mb-8">
              <input
                type="text"
                placeholder="New Task Title"
                value={newTaskTitle}
                onChange={(e) => setNewTaskTitle(e.target.value)}
                className="flex-grow border border-gray-400 rounded px-3 py-2 mr-2 bg-inherit"
              />
              <button
                type="submit"
                className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-nowrap"
              >
                Add New Task
              </button>
            </form>
          </div>
          <table className="w-full">
            <tbody>
              {tasks.map(function (task) {
                const isComplete = task.status === 'COMPLETE';
                return (
                  <tr key={task.id} className="border-b border-gray-200">
                    <td className="py-2 px-4">
                      <input
                        type="checkbox"
                        checked={isComplete}
                        onClick={() => updateTask(task, { status: isComplete ? 'IN_PROGRESS' : 'COMPLETE' })}
                        className="transition-transform duration-300 ease-in-out transform scale-100 checked:scale-125 checked:bg-green-500"
                      />
                    </td>
                    <td className="py-2 px-4">
                      {editingTaskId === task.id ? (
                        <form
                          onSubmit={(e) => {
                            e.preventDefault();
                            updateTask(task, { title: editedTaskTitle });
                          }}
                          className="flex"
                        >
                          <input
                            type="text"
                            value={editedTaskTitle}
                            onChange={(e) => setEditedTaskTitle(e.target.value)}
                            onBlur={() => updateTask(task, { title: editedTaskTitle })} // Handle clicking outside input
                            className="flex-grow border border-gray-400 rounded px-3 py-1 mr-2 bg-inherit"
                          />
                        </form>
                      ) : (
                        <span
                          onClick={() => handleEditStart(task)}
                          className={`transition-all duration-300 ease-in-out cursor-pointer ${isComplete ? 'line-through text-gray-400 opacity-50' : 'opacity-100'}`}
                        >
                          {task.title}
                        </span>
                      )}
                    </td>
                    <td className="py-2 px-4">
                      <button
                        onClick={() => deleteTask(task.id)}
                        className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded float-right"
                      >
                        Delete
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </main>
      );
    }
    

11. アプリケーションを Cloud Run に再デプロイする

  1. 次のコマンドを実行して、アプリケーションを Cloud Run にデプロイします。
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=. \
      --set-env-vars DB_NAME="quickstart_db" \
      --set-env-vars DB_USER="postgres" \
      --set-env-vars DB_PASSWORD=${DB_PASSWORD} \
      --set-env-vars DB_HOST="$(gcloud sql instances describe quickstart-instance --project=${GOOGLE_CLOUD_PROJECT} --format='value(settings.ipConfiguration.pscConfig.pscAutoConnections.ipAddress)')" \
      --service-account="quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --network=default \
      --subnet=default \
      --allow-unauthenticated
    
  2. 続行するかどうかを確認するメッセージが表示されたら、YEnter を押します。
    Do you want to continue (Y/n)? Y
    

12. 完了

このラボでは、以下の操作について学習しました。

  • Cloud SQL for PostgreSQL インスタンスを作成する
  • Cloud SQL データベースに接続するアプリケーションを Cloud Run にデプロイする

クリーンアップ

Cloud SQL には無料枠がないため、引き続き使用すると課金されます。Cloud プロジェクトを削除して、追加料金が発生しないようにすることができます。

サービスが使用されていない場合、Cloud Run の料金は発生しませんが、コンテナ イメージを Artifact Registry に保存すると課金される場合があります。Cloud プロジェクトを削除すると、そのプロジェクト内で使用されているすべてのリソースに対する課金が停止します。

必要に応じて、プロジェクトを削除します。

gcloud projects delete $GOOGLE_CLOUD_PROJECT

cloudshell ディスクから不要なリソースを削除することもできます。次のことが可能です。

  1. Codelab プロジェクト ディレクトリを削除します。
    rm -rf ~/task-app
    
  2. 警告: この操作は元に戻せません。Cloud Shell のすべてのデータを削除して空き容量を増やす場合は、ホーム ディレクトリ全体を削除できます。残しておきたいものはすべて別の場所に保存されていることを確認してください。
    sudo rm -rf $HOME
    

学習を継続