איך מעלים תמונות ומציגים אותן באמצעות Cloud Storage, ‏ Firestore ו-Cloud Run

1. מבוא

סקירה כללית

בשיעור ה-Codelab הזה תלמדו איך להעלות תמונות ולהציג אותן באמצעות Cloud Storage,‏ Firestore ו-Cloud Run. בנוסף, תלמדו איך להשתמש בספריות הלקוח של Google לצורך אימות כדי לבצע קריאות ל-Gemini.

מה תלמדו

  • איך פורסים אפליקציית FastAPI ב-Cloud Run
  • איך משתמשים בספריות הלקוח של Google לצורך אימות
  • איך מעלים קובץ ל-Cloud Storage באמצעות שירות Cloud Run
  • איך קוראים ומזינים נתונים ב-Firestore
  • איך מאחזרים ומציגים תמונות מ-Cloud Storage בשירות Cloud Run

2. הגדרה ודרישות

מגדירים משתני סביבה שישמשו לאורך סדנת הקוד הזו.

PROJECT_ID=dogfood-gcf-saraford
REGION=us-central1
GCS_BUCKET_NAME=dogfood-gcf-saraford-codelab-wietse-2

SERVICE_NAME=fastapi-storage-firestore
SERVICE_ACCOUNT=fastapi-storage-firestore-sa
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

הפעלת ממשקי API

gcloud services enable run.googleapis.com \
                       storage.googleapis.com \
                       firestore.googleapis.com \
                       cloudbuild.googleapis.com \
                       artifactregistry.googleapis.com

יצירת קטגוריה ב-Cloud Storage לאחסון התמונות

gsutil mb -p dogfood-gcf-saraford -l us-central1 gs://$GCS_BUCKET_NAME

נותנים גישה ציבורית לקטגוריה שבה אפשר להעלות תמונות ולהציג אותן באתר:

gsutil iam ch allUsers:objectViewer gs://$GCS_BUCKET_NAME

יוצרים חשבון שירות באמצעות הפקודה הבאה:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
    --display-name="SA for CR $SERVICE_ACCOUNT"

ומעניקים לחשבון ה-SA גישה ל-Firestore ולקטגוריה של GCS.

gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member="serviceAccount:$SERVICE_ACCOUNT_ADDRESS" \
    --role="roles/datastore.user"

gsutil iam ch serviceAccount:$SERVICE_ACCOUNT_ADDRESS:roles/storage.objectAdmin gs://$GCS_BUCKET_NAME

3. יצירת מסד הנתונים ב-Firestore

מריצים את הפקודה הבאה כדי ליצור מסד נתונים של Firestore

gcloud firestore databases create --location=nam5

4. יצירת האפליקציה

יוצרים ספרייה לקוד.

mkdir codelab-cr-fastapi-firestore-gcs
cd codelab-cr-fastapi-firestore-gcs

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

mkdir templates
cd templates

יוצרים קובץ חדש בשם index.html עם התוכן הבא:

<!DOCTYPE html>
<html>
<head>
    <title>Cloud Run Image Upload Demo</title>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        .upload-form { margin-bottom: 20px; padding: 15px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; }
        .image-list { margin-top: 30px; }
        .image-item { border-bottom: 1px solid #eee; padding: 10px 0; }
        .image-item img { max-width: 100px; max-height: 100px; vertical-align: middle; margin-right: 10px;}
        .error { color: red; font-weight: bold; margin-top: 10px;}
    </style>
</head>
<body>

    <h1>Upload an Image</h1>
    <p>Files will be uploaded to GCS bucket: <strong>{{ bucket_name }}</strong> and metadata stored in Firestore.</p>

    <div class="upload-form">
        <form action="/upload" method="post" enctype="multipart/form-data">
            <input type="file" name="file" accept="image/*" required>
            <button type="submit">Upload Image</button>
        </form>
        {% if error_message %}
            <p class="error">{{ error_message }}</p>
        {% endif %}
    </div>

    <div class="image-list">
        <h2>Recently Uploaded Images:</h2>
        {% if images %}
            {% for image in images %}
            <div class="image-item">
                <a href="{{ image.gcs_url }}" target="_blank">
                   <img src="{{ image.gcs_url }}" alt="{{ image.filename }}" title="Click to view full size">
                </a>
                <span>{{ image.filename }}</span>
                <small>(Uploaded: {{ image.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if image.uploaded_at else 'N/A' }})</small><br/>
                <small><a href="{{ image.gcs_url }}" target="_blank">{{ image.gcs_url }}</a></small>
            </div>
            {% endfor %}
        {% else %}
            <p>No images uploaded yet or unable to retrieve list.</p>
        {% endif %}
    </div>

</body>
</html>

עכשיו יוצרים את קוד Python ואת הקבצים האחרים בתיקיית השורש.

cd ..

יוצרים קובץ .gcloudignore עם התוכן הבא:

__pycache__

יוצרים קובץ בשם main.py עם התוכן הבא:

import os
import datetime
from fastapi import FastAPI, File, UploadFile, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from google.cloud import storage, firestore

# --- Configuration ---
# Get bucket name and firestore collection from Cloud Run env vars
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", "YOUR_BUCKET_NAME_DEFAULT")
FIRESTORE_COLLECTION = os.environ.get("FIRESTORE_COLLECTION", "YOUR_FIRESTORE_DEFAULT")

# --- Initialize Google Client Libraries ---
# These client libraries will use the Application Default Credentials
# for your service account within the Cloud Run environment 
storage_client = storage.Client()
firestore_client = firestore.Client()

# --- FastAPI App ---
app = FastAPI()
templates = Jinja2Templates(directory="templates")

# --- Routes ---
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
    """Serves the main upload form."""
    
    # Query Firestore for existing images to display 
    images = []
    try:
        docs = firestore_client.collection(FIRESTORE_COLLECTION).order_by(
            "uploaded_at", direction=firestore.Query.DESCENDING
        ).limit(10).stream() # Get latest 10 images
        for doc in docs:
            images.append(doc.to_dict())
    except Exception as e:
        print(f"Warning: Could not fetch images from Firestore: {e}")
        # Continue without displaying images if Firestore query fails

    return templates.TemplateResponse("index.html", {
        "request": request,
        "bucket_name": GCS_BUCKET_NAME,
        "images": images # Pass images to the template
    })

@app.post("/upload")
async def handle_upload(request: Request, file: UploadFile = File(...)):
    """Handles file upload, saves to GCS, and records in Firestore."""
    if not file:
        return {"message": "No upload file sent"}
    elif not GCS_BUCKET_NAME or GCS_BUCKET_NAME == "YOUR_BUCKET_NAME_DEFAULT":
         return {"message": "GCS Bucket Name not configured."}, 500 # Internal Server Error

    try:
        # 1. Upload to GCS
        # note: to keep the demo code short, there are no file verifications
        # for an actual real-world production app, you will want to add checks
        gcs_url = upload_to_gcs(file, GCS_BUCKET_NAME)

        # 2. Save metadata to Firestore
        save_metadata_to_firestore(file.filename, gcs_url, FIRESTORE_COLLECTION)

        # Redirect back to the main page after successful upload
        return RedirectResponse(url="/", status_code=303) # Redirect using See Other

    except Exception as e:
        print(f"Upload failed: {e}")

        return templates.TemplateResponse("index.html", {
            "request": request,
            "bucket_name": GCS_BUCKET_NAME,
            "error_message": f"Upload failed: {e}",
            "images": [] # Pass empty list on error or re-query
        }, status_code=500)

# --- Helper Functions ---
def upload_to_gcs(uploadedFile: UploadFile, bucket_name: str) -> str:
    """Uploads a file to Google Cloud Storage and returns the public URL."""
    try:
        bucket = storage_client.bucket(bucket_name)

        # Create a unique blob name (e.g., timestamp + original filename)
        timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
        blob_name = f"{timestamp}_{uploadedFile.filename}"
        blob = bucket.blob(blob_name)

        # Upload the file
        # Reset file pointer just in case
        uploadedFile.file.seek(0)
        blob.upload_from_file(uploadedFile.file, content_type=uploadedFile.content_type)

        print(f"File {uploadedFile.filename} uploaded to gs://{bucket_name}/{blob_name}")
        return blob.public_url # Return the public URL

    except Exception as e:
        print(f"Error uploading to GCS: {e}")
        raise  # Re-raise the exception for FastAPI to handle

def save_metadata_to_firestore(filename: str, gcs_url: str, collection_name: str):
    """Saves image metadata to Firestore."""
    try:
        doc_ref = firestore_client.collection(collection_name).document()
        doc_ref.set({
            'filename': filename,
            'gcs_url': gcs_url,
            'uploaded_at': firestore.SERVER_TIMESTAMP # Use server timestamp
        })
        print(f"Metadata saved to Firestore collection {collection_name}")
    except Exception as e:
        print(f"Error saving metadata to Firestore: {e}")
        # Consider raising the exception or handling it appropriately
        raise # Re-raise the exception

יוצרים קובץ Dockerfile עם התוכן הבא:

# Build stage
FROM python:3.12-slim AS builder

WORKDIR /app

# Install poetry
RUN pip install poetry
RUN poetry self add poetry-plugin-export

# Copy poetry files
COPY pyproject.toml poetry.lock* ./

# Copy application code
COPY . .

# Export dependencies to requirements.txt
RUN poetry export -f requirements.txt --output requirements.txt 

# Final stage
FROM python:3.12-slim

WORKDIR /app

# Copy files from builder
COPY --from=builder /app/ .

# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Compile bytecode to improve startup latency
# -q: Quiet mode 
# -b: Write legacy bytecode files (.pyc) alongside source
# -f: Force rebuild even if timestamps are up-to-date
RUN python -m compileall -q -b -f .

# Expose port
EXPOSE 8080

# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

ויצרו pyproject.toml עם

[tool.poetry]
name = "cloud-run-fastapi-demo"
version = "0.1.0"
description = "Demo FastAPI app for Cloud Run showing GCS upload and Firestore integration."
authors = ["Your Name <you@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.110.0"
uvicorn = {extras = ["standard"], version = "^0.29.0"} # Includes python-multipart
google-cloud-storage = "^2.16.0"
google-cloud-firestore = "^2.16.0"
jinja2 = "^3.1.3"
python-multipart = "^0.0.20"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

5. פריסה ב-Cloud Run

בהמשך מופיעה הפקודה לפריסה ב-Cloud Run. הקוד יאוחסן בקובץ zip ויישלח ל-Cloud Build, שבו נעשה שימוש בקובץ Dockerfile כדי ליצור את קובץ האימג'.

מכיוון שמדובר בפריסה שמבוססת על מקור ב-Cloud Run, בכרטיסייה Source (מקור) במסוף Cloud של השירות יופיע הקוד שלכם.

gcloud run deploy $SERVICE_NAME \
 --source . \
 --allow-unauthenticated \
 --service-account=$SERVICE_ACCOUNT_ADDRESS \
 --set-env-vars=GCS_BUCKET_NAME=$GCS_BUCKET_NAME \
 --set-env-vars=FIRESTORE_COLLECTION=$FIRESTORE_COLLECTION

6. בדיקת השירות

פותחים את כתובת ה-URL של השירות בדפדפן האינטרנט ומעלים תמונה. היא תופיע ברשימה.

7. שינוי ההרשאות בקטגוריה הציבורית של Cloud Storage

כפי שצוין קודם, בקודלאב הזה נעשה שימוש בקטגוריה ציבורית ב-GCS. מומלץ למחוק את הקטגוריה או להסיר את הרשאת הגישה של allUsers לקטגוריה על ידי הרצת הפקודה הבאה:

gsutil iam ch -d allUsers:objectViewer gs://$GCS_BUCKET_NAME

כדי לוודא שהגישה של allUsers הוסרה, מריצים את הפקודה הבאה:

gsutil iam get gs://$GCS_BUCKET_NAME

8. מזל טוב

כל הכבוד על השלמת ה-Codelab!

מה עסקנו בו

  • איך פורסים אפליקציית FastAPI ב-Cloud Run
  • איך משתמשים בספריות הלקוח של Google לצורך אימות
  • איך מעלים קובץ ל-Cloud Storage באמצעות שירות Cloud Run
  • איך קוראים ומזינים נתונים ב-Firestore
  • איך מאחזרים ומציגים תמונות מ-Cloud Storage בשירות Cloud Run

9. הסרת המשאבים

כדי למחוק את שירות Cloud Run, נכנסים למסוף Cloud של Cloud Run בכתובת https://console.cloud.google.com/run ומוחקים את השירות.

כדי למחוק את הקטגוריה של Cloud Storage, אפשר להריץ את הפקודות הבאות:

echo "Deleting objects in gs://$GCS_BUCKET_NAME..."
gsutil rm -r gs://$GCS_BUCKET_NAME/*

echo "Deleting bucket gs://$GCS_BUCKET_NAME..."
gsutil rb gs://$GCS_BUCKET_NAME

אם בוחרים למחוק את הפרויקט כולו, אפשר לעבור אל https://console.cloud.google.com/cloud-resource-manager, לבחור את הפרויקט שיצרתם בשלב 2 ולבחור באפשרות Delete (מחיקה). אם תמחקו את הפרויקט, תצטרכו לשנות את הפרויקטים ב-Cloud SDK. כדי להציג את רשימת כל הפרויקטים הזמינים, מריצים את הפקודה gcloud projects list.