פריסת אפליקציית JavaScript בסביבת סטאק מלאה ב-Cloud Run באמצעות Cloud SQL ל-PostgreSQL

1. סקירה כללית

Cloud Run היא פלטפורמה מנוהלת באופן מלא שמאפשרת להריץ את הקוד ישירות על גבי התשתית הניתנת להתאמה של Google. ב-Codelab הזה נראה איך לחבר אפליקציית Next.js ב-Cloud Run למסד נתונים של Cloud SQL ל-PostgreSQL.

בשיעור ה-Lab הזה תלמדו איך:

  • יצירת מכונות של Cloud SQL ל-PostgreSQL
  • פריסת אפליקציה ב-Cloud Run שמתחברת למסד הנתונים של Cloud SQL

2. דרישות מוקדמות

  1. אם אין לכם חשבון Google, אתם צריכים ליצור חשבון Google.
    • משתמשים בחשבון לשימוש אישי במקום בחשבון לצורכי עבודה או בחשבון בית ספרי. יכול להיות שבחשבונות לצורכי עבודה או בחשבונות בית ספריים יש הגבלות שלא מאפשרות להפעיל את ממשקי ה-API שנדרשים למעבדה הזו.

3. הגדרת הפרויקט

  1. נכנסים למסוף Google Cloud.
  2. מפעילים את החיוב במסוף Cloud.
    • העלות של השלמת ה-Lab הזה במשאבי Cloud צריכה להיות פחות מ-1$.
    • כדי למחוק משאבים ולמנוע חיובים נוספים, אפשר לבצע את השלבים בסוף ה-Lab הזה.
    • משתמשים חדשים זכאים לתקופת ניסיון בחינם בשווי 300$‎.
  3. יוצרים פרויקט חדש או בוחרים להשתמש בפרויקט קיים.

4. פתיחת Cloud Shell Editor

  1. עוברים אל Cloud Shell Editor
  2. אם הטרמינל לא מופיע בתחתית המסך, פותחים אותו:
    • לוחצים על סמל האפשרויות הנוספות (3 קווים) סמל של תפריט שלושת הקווים.
    • לוחצים על Terminal (מסוף).
    • לוחצים על New Terminal (טרמינל חדש)פתיחת טרמינל חדש ב-Cloud Shell Editor.
  3. בטרמינל, מגדירים את הפרויקט באמצעות הפקודה הבאה:
    • פורמט:
      gcloud config set project [PROJECT_ID]
      
    • דוגמה:
      gcloud config set project lab-project-id-example
      
    • אם אתם לא זוכרים את מזהה הפרויקט:
      • כדי לראות את כל מזהי הפרויקטים, מריצים את הפקודה:
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      הגדרת מזהה הפרויקט בטרמינל של Cloud Shell Editor
  4. אם מתבקשים לאשר, לוחצים על אישור כדי להמשיך. לוחצים כדי לתת הרשאה ל-Cloud Shell
  5. תוצג ההודעה הבאה:
    Updated property [core/project].
    
    אם מופיע WARNING ומוצגת השאלה Do you want to continue (Y/N)?, סביר להניח שהזנתם את מזהה הפרויקט בצורה שגויה. לוחצים על 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. הגדרה של חשבון שירות

יוצרים ומגדירים חשבון שירות ב-Google Cloud לשימוש ב-Cloud Run, כדי שיהיו לו ההרשאות הנכונות להתחבר ל-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 באופן הבא כדי להוסיף את התפקיד Log Writer לחשבון השירות ב-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. יצירת מדיניות של חיבור שירות כדי לאפשר קישוריות לרשת מ-Cloud Run ל-Cloud SQL באמצעות Private Service Connect
    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 כדי ליצור מסד נתונים ב-Cloud SQL בתוך quickstart-instance.
    gcloud sql databases create quickstart_db \
      --instance=quickstart-instance
    

8. הכנת הבקשה

מכינים אפליקציית Next.js שמגיבה לבקשות HTTP.

  1. כדי ליצור פרויקט חדש של Next.js בשם task-app, משתמשים בפקודה:
    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. מתקינים את pg כדי לבצע פעולות לגבי מסד הנתונים של PostgreSQL.
    npm install pg
    
  4. כדי להשתמש באפליקציית TypeScript Next.js, צריך להתקין את @types/pg כ-dev dependency.
    npm install --save-dev @types/pg
    
  5. פותחים את הקובץ actions.ts ב-Cloud Shell Editor:
    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. פותחים את הקובץ page.tsx ב-Cloud Shell Editor:
    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 באופן הבא כדי להוסיף את התפקיד Artifact Registry Writer למשתמש הנוכחי בשירות Cloud Run שאתם עומדים ליצור.
    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. אם מוצגת הנחיה, מקישים על Y ועל Enter כדי לאשר שרוצים להמשיך:
    Do you want to continue (Y/n)? Y
    

אחרי כמה דקות, האפליקציה אמורה לספק כתובת URL שאפשר להיכנס אליה.

עוברים לכתובת ה-URL כדי לראות את האפליקציה בפעולה. בכל פעם שתבקרו בכתובת ה-URL או תרעננו את הדף, תראו את אפליקציית המשימות.

10. הוספת תכונה באמצעות Gemini Code Assist

עכשיו פרסתם אפליקציית אינטרנט עם מסד נתונים. בשלב הבא, נוסיף תכונה חדשה לאפליקציית next.js באמצעות AI.

  1. חזרה ל-Cloud Shell Editor
  2. פתיחה מחדש של page.tsx
    cd ~/task-app
    cloudshell edit app/page.tsx
    
  3. עוברים אל Gemini Code Assist ב-Cloud Shell Editor:
    • לוחצים על סמל 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. אם מוצגת הנחיה, מקישים על Y ועל Enter כדי לאשר שרוצים להמשיך:
    Do you want to continue (Y/n)? Y
    

12. מזל טוב

בשיעור ה-Lab הזה למדתם איך:

  • יצירת מכונות של Cloud SQL ל-PostgreSQL
  • פריסת אפליקציה ב-Cloud Run שמתחברת למסד הנתונים של Cloud SQL

הסרת המשאבים

ל-Cloud SQL אין רמת שירות חינמית, ותחויבו אם תמשיכו להשתמש בו. כדי להימנע מחיובים נוספים, אפשר למחוק את פרויקט Cloud.

ב-Cloud Run לא מחויבים כשלא משתמשים בשירות, אבל יכול להיות שתחויבו על אחסון קובץ האימג' של הקונטיינר ב-Artifact Registry. כשמוחקים פרויקט ב-Cloud, החיוב על כל המשאבים שנעשה בהם שימוש באותו פרויקט נפסק.

אם רוצים, מוחקים את הפרויקט:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

מומלץ גם למחוק משאבים מיותרים מהדיסק של Cloud Shell. אתם יכולים:

  1. מוחקים את ספריית הפרויקט של ה-codelab:
    rm -rf ~/task-app
    
  2. אזהרה! אי אפשר לבטל את הפעולה הבאה! אם רוצים למחוק את כל מה שיש ב-Cloud Shell כדי לפנות מקום, אפשר למחוק את כל ספריית הבית. חשוב לוודא שכל מה שרוצים לשמור נשמר במקום אחר.
    sudo rm -rf $HOME
    

לומדים בכיף