Cloud Run Functions でサーバーレスアプリをデプロイハンズオン

はじめに

仕事の都合で GoogleCloudを触り始めた、GoogleCloud歴 4か月の駆け出しエンジニアです。

GoogleCloudのCloud Run Functionsを利用することが多くなってきたので、こちらのデプロイに挑戦してみました。

Cloud Run Functionsとは?

  • Goolge Cloud が提供するサーバーレスのクラウドコンピューティングサービスのひとつ。ユーザーは「サーバーの存在を意識することなく」クラウド上でコードを実行することができます。
    AWSを利用している方は「Lambda」と類似したサービスだと捉えていいかと思います。

  • HTTPリクエストをトリガに起動する「HTTP関数」や、特定のイベントをトリガに起動する「イベントドリブン関数」があり、それぞれのトリガによって最長実行時間も変わります。

名称の整理

  • 2024年8月22日に「Cloud Functions(第1世代)」から、「Cloud Run Functions(第2世代)」にリブランディングされました。

  • Cloud Run はコンテナ化したアプリを実行する のに対し、Cloud Run Functions はコード単位で実行 するという違いがあります。
    実行基盤が Cloud Run のコンテナであるため、Cloud Run Functions も自動スケール機能を備えており、リクエストに応じてインスタンスが増減します。
    アイドル状態が続くとインスタンスがスケールダウンして 0 になる点も Cloud Run と共通しています。

  • 私個人の観測になりますが「Cloud Run Functions」を、従来の「Cloud Functions」という名称で呼んでる方もいるので、個人的に混乱しました。

世代 名称 備考
第1世代 Cloud Functions 独自の実行基盤
第2世代 Cloud Run Functions Cloud Runを基盤に動作

GoogleCloudブログ:
Cloud Functions は Cloud Run Functions に変わり、1 つの統合サーバーレス プラットフォームでのイベントドリブン プログラミングが可能になりました

GUIの画面

  • 現在GUIでは「Cloud Functions」の作成は、「Cloud Run」の画面から構築します。
    左上の赤枠「Cloud Run」とありますが緑枠の「Functions」を選択すると青枠「ランタイム」が表示され作成することが可能です。
    file

比較表

  • Cloud Run FunctionsとLambdaを比較してみました。
項目 Cloud Run Functions Lambda
対応言語 Node.js/Python/Go/Java/Ruby/PHP/.NET Node.js/Python/Java/Ruby/.NET/OS専用ランタイム
実行時間① HTTP関数:60分(同期) 15分(同期/非同期)
実行時間② イベントドリブン:9分(非同期)
料金体系 vCPU、メモリ、リクエスト数に基づく リクエスト数とコード実行時間に基づく

ハンズオン

  • 今回は「To-Doリストアプリ」を構築していきます。

注意点

  • 実行環境は CloudShellにて実施するため、停止すると保存内容は削除されます。保存したい場合は別途GCSとの連携を考慮ください。

構成

  • 以下構成で構築をする。
├── main.py             # Flask のアプリ
├── requirements.txt    # 必要なライブラリ
└── templates
    └── index.html      # Flask でレンダリングするHTML

1.Cloud Shellでの準備

1.1.事前準備

  • マネジメントコンソール右上 [>.] アイコンより、Cloud Shellの起動し以下コマンドを実施する。

  • Cloud Shellでディレクトリ作成

    mkdir ${フォルダ名} && cd ${フォルダ名}
    touch main.py
    touch requirements.txt
    mkdir templates && cd templates
    touch index.html

1.2.「main.py」について

1.2.1.処理フロー
  • 以下の処理フローを実装する。
項番 処理内容 出力ファイル
1 To-Do リストの画面表示 Flask が index.html をレンダリングし、画面を表示
2 新規タスクの追加 API(POST /add))を呼出し、保存
3 タスク一覧の取得 API(GET /list)で呼出し、一覧取得
4 タスク状態の切替え API(POST /update)を呼出し、True/False 切替え
5 タスクの削除 API(POST /delete_completed)で呼出し、タスク削除
1.2.2.留意点
  • 本構築では/tmpにデータを保存する。そのため永続化をする場合はGCSなどの保存を考えること。
1.2.3.「app.py」のなかみ

※生成AIにて構築

import os
import json
from flask import Flask, request, jsonify, render_template
import functions_framework

app = Flask(__name__)
TMP_FILE = "/tmp/todo_list.json"

def load_todo_list():
    """To-Do リストを `/tmp` からロード"""
    if os.path.exists(TMP_FILE):
        with open(TMP_FILE, "r") as f:
            return json.load(f)
    return []

def save_todo_list(todo_list):
    """To-Do リストを `/tmp` に保存"""
    with open(TMP_FILE, "w") as f:
        json.dump(todo_list, f)

@app.route('/')
def index():
    return render_template("index.html")

@app.route('/add', methods=['POST'])
def add_todo():
    if not request.is_json:
        return jsonify({"error": "Invalid content type, expected 'application/json'"}), 415

    data = request.get_json()
    todo_list = load_todo_list()
    todo_list.append({
        "task": data.get("task", "No Task"),
        "deadline": data.get("deadline", "未設定"),
        "completed": False
    })
    save_todo_list(todo_list)
    return jsonify({"message": "Task added"})

@app.route('/list', methods=['GET'])
def get_todo():
    todo_list = load_todo_list()
    return jsonify({"tasks": todo_list})

@app.route('/complete', methods=['POST'])
def complete_todo():
    if not request.is_json:
        return jsonify({"error": "Invalid content type, expected 'application/json'"}), 415

    data = request.get_json()
    index = data.get("index")
    todo_list = load_todo_list()

    if index is not None and 0 <= index < len(todo_list):
        todo_list[index]["completed"] = not todo_list[index]["completed"]
        save_todo_list(todo_list)
        return jsonify({"message": "Task status updated"})
    return jsonify({"error": "Invalid task index"}), 400

@app.route('/delete_completed', methods=['POST'])
def delete_completed():
    todo_list = load_todo_list()
    todo_list = [task for task in todo_list if not task["completed"]]
    save_todo_list(todo_list)
    return jsonify({"message": "Completed tasks deleted"})

@functions_framework.http
def main(request):
    """Cloud Functions のリクエストを Flask に適用"""
    with app.test_request_context(request.path, method=request.method, json=request.get_json()):
        return app.full_dispatch_request()

1.2.「requirements.txt」について

1.2.1.ライブラリ内容
項番 記載名 主な内容
1 flask 軽量なWebフレームワーク。APIエンドポイントの作成と index.html のレンダリングを担当
2 functions-framework Cloud Functions で Flask を実行するためのフレームワーク
1.2.2.「requirements.txt」のなかみ
flask
functions-framework

1.3.「index.html」について

1.3.1.「index.html」のなかみ
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>てつてつToDoリスト</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
        }
        .task-item {
            margin: 10px 0;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .completed {
            text-decoration: line-through;
            background-color: #f8f8f8;
        }
        .controls {
            margin: 20px 0;
        }
        input, button {
            padding: 8px;
            margin-right: 5px;
        }
    </style>
</head>
<body>
    <h1>てつてつToDoリスト</h1>
    <div class="controls">
        <input type="text" id="taskInput" placeholder="タスクを入力">
        <input type="date" id="deadlineInput">
        <button onclick="addTask()">追加</button>
        <button onclick="deleteCompleted()">完了済みを削除</button>
    </div>
    <ul id="taskList"></ul>

    <script>
        const API_URL = "https://asia-northeast1-${プロジェクトID}.cloudfunctions.net/todo-app";

        // ページ読み込み時にタスク一覧を取得
        document.addEventListener('DOMContentLoaded', () => {
            loadTasks();
        });

        // タスク一覧を取得して表示する関数
        async function loadTasks() {
            try {
                const response = await fetch(`${API_URL}/list`);
                if (!response.ok) {
                    throw new Error(`HTTPエラー: ${response.status}`);
                }

                const data = await response.json();
                console.log("📥 タスク一覧:", data);

                const taskList = document.getElementById('taskList');
                taskList.innerHTML = '';

                if (data.tasks && data.tasks.length > 0) {
                    data.tasks.forEach((task, index) => {
                        const li = document.createElement('li');
                        li.className = `task-item ${task.completed ? 'completed' : ''}`;

                        const checkbox = document.createElement('input');
                        checkbox.type = 'checkbox';
                        checkbox.checked = task.completed;
                        checkbox.onchange = () => toggleTaskComplete(index, !task.completed);

                        const taskText = document.createElement('span');
                        taskText.textContent = `${task.task} (期限: ${task.deadline || '未設定'})`;

                        li.appendChild(checkbox);
                        li.appendChild(taskText);
                        taskList.appendChild(li);
                    });
                } else {
                    taskList.innerHTML = '<li>タスクがありません</li>';
                }
            } catch (err) {
                console.error("❌ タスク取得エラー:", err);
                document.getElementById('taskList').innerHTML = 
                    '<li style="color: red;">タスクの取得に失敗しました。ネットワーク接続を確認してください。</li>';
            }
        }

        // 新しいタスクを追加する関数
        async function addTask() {
            const taskInput = document.getElementById('taskInput');
            const deadlineInput = document.getElementById('deadlineInput');

            const task = taskInput.value.trim();
            if (!task) {
                alert('タスクを入力してください');
                return;
            }

            const taskData = {
                task: task,
                deadline: deadlineInput.value || '未設定'
            };

            console.log("📤 タスク追加リクエスト:", taskData);

            try {
                const response = await fetch(`${API_URL}/add`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(taskData)
                });

                if (!response.ok) {
                    throw new Error(`HTTPエラー: ${response.status}`);
                }

                console.log("✅ タスク追加成功");
                taskInput.value = '';
                deadlineInput.value = '';
                loadTasks();  // タスク一覧を再読み込み
            } catch (err) {
                console.error("❌ タスク追加エラー:", err);
                alert('タスクの追加に失敗しました');
            }
        }

        // タスクの完了状態を切り替える関数
        async function toggleTaskComplete(index, completed) {
            console.log(`📤 タスク ${index} の完了状態を ${completed ? '完了' : '未完了'} に変更`);

            try {
                const response = await fetch(`${API_URL}/update`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        index: index,
                        completed: completed
                    })
                });

                if (!response.ok) {
                    throw new Error(`HTTPエラー: ${response.status}`);
                }

                const data = await response.json();
                console.log("✅ タスク更新成功:", data);

                // タスク一覧を再読み込み(UI更新)
                loadTasks();
            } catch (err) {
                console.error("❌ タスク更新エラー:", err);
                alert('タスクの更新に失敗しました');

                // エラーが発生した場合、UI上のチェックボックスを元の状態に戻す
                loadTasks();
            }
        }

        // 完了済みタスクを削除する関数
        async function deleteCompleted() {
            console.log("📤 `/delete_completed` へリクエスト送信:", `${API_URL}/delete_completed`);

            try {
                const response = await fetch(`${API_URL}/delete_completed`, {
                    method: "POST",
                    headers: { 
                        "Content-Type": "application/json"
                    },
                    // 空のJSONを送信する場合は、空のオブジェクトを文字列化
                    body: JSON.stringify({})
                });

                console.log("📥 `/delete_completed` レスポンスステータス:", response.status);

                if (!response.ok) {
                    throw new Error(`HTTPエラー: ${response.status}`);
                }

                const data = await response.json();
                console.log("✅ `/delete_completed` 成功:", data);

                // タスク一覧を再取得
                loadTasks();
            } catch (err) {
                console.error("❌ `/delete_completed` エラー:", err);
                alert('完了済みタスクの削除に失敗しました');
            }
        }
    </script>
</body>
</html>

2.Cloud Run Functionsへデプロイ

2.1.デプロイコマンド

2.1.1.コマンドの内容
  • PoCのため「認証なしで誰でもアクセス可能」の設定です。こちらの設定でデプロイする場合、セキュリティの観点より構築確認後速やかに削除ください。
  • 削除コマンドは 4.1.リソース削除コマンドにて記載
項番 記載名 主な内容
1 –runtime python311 Python 3.11 環境で実行
2 –trigger-http HTTP リクエストで実行(Web API 用)
3 –allow-unauthenticated 認証なしで誰でもアクセス可能
4 –entry-point main Cloud Functions のエントリーポイントを main に指定
5 –region asia-northeast1 東京リージョンにデプロイ

2.1.2.コマンド実行

  • 以下、デプロイコマンドを実施
    gcloud functions deploy ${関数名(例:todo-app)} \
    --runtime python311 \
    --trigger-http \
    --allow-unauthenticated \
    --entry-point main \
    --region asia-northeast1

    -以下、デプロイ後表示されるURLを押下

    url: https://asia-northeast1-{プロジェクトID}.cloudfunctions.net/todo-app

    3.デプロイ画面

    3.1.WEB画面

  • URLアクセス直後の画面
    file

3.2.WEB画面

  • タスクを追加して、チェックを入れてタスク終了の画面(「完了済みを削除」押下で一覧からは削除される)
    file

4.片づけ

4.1.リソース削除コマンド

gcloud functions delete ${関数名(例:todo-app)} --region=asia-northeast1

おわりに

得られた知見

  • GoogleCloud ShellからのCloud Run Functionsのデプロイ方法
  • Cloud Run FunctionsとLambdaの違い
  • WEBアプリを通しての フロント-バックエンドの構築

今後の課題

  • Cloud Run Functions と Cloud Runの差異について調査。
  • AWS ECS(Fargate)などコンテナサービスについての調査。
+3
Last modified: 2025-03-20

Author