1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

相手の戦闘力を測るスカウター風のWebアプリをCloud Run functions上に実装してみた

1
Last updated at Posted at 2025-10-27

はじめに

最近、Google Cloudをよく知るために、いろいろなものを作っています。
時間を置くと、大幅にパワーアップされているので、常にキャッチアップが必要です。

Javascriptで何かを作るのが好きなので、それを活かしてみたいと思うのですが、シンプルなアプリだとGoogle CloudのFaaSであるCloud Run functionsで簡単に動作してしまいます。それがクラウドおよびサーバレスの良いところではありますが、せっかくなので少し変わったことをしたいと思います。
そこで、今回はCloud Run functionsを使いつつ、Terraformを使ってIaCを実現しようと思います。

今回の検証用アプリはスカウター風のWebアプリです。本物は相手が発するエネルギーを測るものかと思いますが、スマホのカメラではエネルギーは見えないので、別の指標を使ってそれっぽくしてみました

検証用アプリを作成

スカウター風のWebアプリ

さっそくJavascriptで実装しました。HTML、スタイル含めて400行くらいになったので、重要なところを説明します。(全コードは本ブログ内にあります。)今回はCloud Run functionsとTerraformをシンプルに使うため、React.jsではなく、Node.js上でJavascriptを埋め込んだHTMLを返す簡単なWebアプリとしました。

肝心な戦闘力は顔の幅と肩の幅で測定しています。子どもなら戦闘力少なめで、大人なら戦闘力高めというような考え方です。また、比率を見ているのは肩幅が広いほど筋肉がついていそうという仮置きです。

この数値は、AIがWebカメラの映像から検出した「肩幅」と「顔の幅」の比率を基に、特定の計算式で算出しています。

Javascriptの一部
// スクリプトの部分の一部抜粋です
function drawArDisplay(landmarks, currentTime) {
            
            // 評価のタイミング
            // 5秒(UPDATE_INTERVAL)に1回だけ評価を実行
            if (currentTime - lastPowerUpdateTime > UPDATE_INTERVAL) {
                lastPowerUpdateTime = currentTime;
                justUpdatedTimestamp = currentTime; // 評価時の発光エフェクト用

                // AIによる骨格の検出
                // AIが検出した骨格情報から、肩と耳の座標を取得
                const p11 = landmarks[11], // 左肩
                      p12 = landmarks[12], // 右肩
                      p7 = landmarks[7],   // 左耳
                      p8 = landmarks[8];   // 右耳
                
                // 肩と耳が一定の精度で認識できている場合のみ計算を実行
                if (p11.visibility > 0.6 && p12.visibility > 0.6 && p7.visibility > 0.4 && p8.visibility > 0.4) {
                    
                    // 「顔の幅」と「肩幅」の測定
                    const shoulderWidth = Math.abs(p11.x - p12.x); // 肩幅
                    const faceWidth = Math.abs(p7.x - p8.x);     // 顔の幅

                    if (faceWidth > 0.01) {
                        
                        // 比率の計算
                        const calculatedRatio = shoulderWidth / faceWidth;
                        
                        // 戦闘力への変換
                        // 戦闘力 = 99999 × (比率) - 179997.2
                        const calculatedPower = Math.max(1, 99999 * calculatedRatio - 179997.2);
                        
                        // 計算結果を画面表示用の変数に保存
                        displayedPower = Math.round(calculatedPower);
                        displayedRatio = calculatedRatio.toFixed(2);
                    }
                }
            }

            // (以下、計算結果をAR表示するための描画処理が続く)
            // ...
        }

戦闘力(?)判定の仕組み

(1) AIによる骨格の検出

カメラに映った人物の骨格を、Googleの「MediaPipe」というAI技術を使ってリアルタイムで検出します。これにより、人の肩や耳などの体の特徴点がどこにあるかを把握します。

import { PoseLandmarker, FilesetResolver, DrawingUtils } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.12";

(2) 「顔の幅」と「肩幅」の測定

検出した特徴点の中から、以下の4つの点の座標を使って幅を計算します。

顔の幅: 左右の耳の間の距離
肩幅: 左右の肩の間の距離

(3) 比率の計算

次に、単純な割り算で「肩幅が顔の幅の何倍あるか」という比率を計算します。
比率 = 肩幅 ÷ 顔の幅
一般的に、大人は子どもよりもこの比率が大きくなります。

(4) 戦闘力への変換

最後に、算出した比率を戦闘力に変換します。原作の基準に合わせるとほとんどの人が一桁になってしまうので、「平均的な成年男子の戦闘力を100,000にする」という前提にしておきました。
以下の計算式に基づいています。

戦闘力 = 99999 × (比率) - 179997.2

この計算式は、体格の比率が約1.8のとき戦闘力が1になり、約2.8のときに100,000に近くなるように調整されています。もし計算結果が1未満になった場合は、最低値の1が表示されます。

(5) 評価のタイミング

この一連の計算と評価は、UPDATE_INTERVALという設定に従い、5秒に1回の頻度で行われます。評価が行われる瞬間に、数値が強く光る演出が入ります。これは人が常に動いているので都度取得だと体型の見た目がすぐ変わり、画面に表示される数値の変化が早すぎで読み取れないための対応です。

ローカルで起動して試してみる

動作を検証したいので、実装したHTMLをローカルに置いてブラウザで表示させます。カメラ使用の許可をすることで起動します。

image.png

プライバシーの都合で、Nano Banana(Gemini)で人物と背景変えました。もともとは成人の男(私)です。
エネルギーという絶対値ではなく、体型という相対値を使っているので角度等で数値が変動してしまいますが、それっぽくなったと思います。

見た目をもっとこだわりたいところですが、我慢してTerraformを使いCloud Run functions上に公開したいと思います。

Terraformとは

Terraformは、インフラの構成をコードで管理する「Infrastructure as Code」(IaC)を実現するためのツールです。HCLという専用の言語を用いて、サーバーやデータベース、ネットワークといったインフラの「あるべき姿(最終的な構成)」を定義ファイルに記述します。

実行すると、Terraformが現在の状態との差分を検出し、定義通りになるよう自動でインフラを構築・変更します。適用前に「実行計画」が表示され、何が作成・変更・削除されるかを事前に確認できるため、安全な操作が可能です。Google CloudやAWSなど多様なプラットフォームに対応しており、インフラの再現性を高め、迅速な環境構築と人的ミスの削減に大きく貢献します。

コード実装

構成

terraform-scouter-app/
├── main.tf
├── variables.tf
├── outputs.tf
└── source/
    ├── index.js
    ├── package.json
    └── public/
        └── index.html

ソースコード(sourceディレクトリ)

sourceディレクトリを作成し、その中にソースコードと必須のpackage.jsonを配置します。

source/package.json

Node.jsプロジェクトに必須のファイルです。依存パッケージ( express )と実行エンジンを定義します。

source/package.json
{
  "name": "ar-scouter-app",
  "version": "1.0.0",
  "description": "AR Scouter App for Cloud Functions",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "engines": {
    "node": "20"
  }
}
source/index.js

Node.jsのコードです。WebサーバとしてExpressを使います。Javascript使いの方にとってはテンプレな形です。

source/index.js
// Node.jsの標準モジュールとExpressをインポート
const express = require('express');
const path = require('path');

// Expressアプリケーションを作成
const app = express();

// 'public' ディレクトリを静的ファイルの配信元として設定
// これにより、index.htmlや将来追加するかもしれないCSS, JS, 画像ファイルが配信される
app.use(express.static(path.join(__dirname, 'public')));

// ルートURL ('/') へのGETリクエストに対して、index.htmlを返す
app.get('/*', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// Cloud FunctionsがこのExpressアプリをHTTPリクエストのハンドラとして使用できるようにエクスポート
// デプロイ時にこの 'arScouterApp' をエントリーポイントとして指定
exports.arScouterApp = app;
source/public/index.html

HTMLのコードです。本体です。長いです。

サンプルコード(ここをクリックすると開きます)
source/public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>戦闘力スカウター AR</title>
    <!-- Scouter-like font -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&display=swap" rel="stylesheet">
    <style>
        :root {
            --scouter-green: #39ff14;
            --danger-red: #ff4757;
            --background-dark: #000;
            --font-family: 'Orbitron', sans-serif;
        }

        body {
            background-color: var(--background-dark);
            color: var(--scouter-green);
            font-family: var(--font-family);
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            overflow: hidden;
        }

        #scouter-ui {
            width: 100%;
            max-width: 90vw;
            max-height: 90vh;
            aspect-ratio: 4 / 3;
            background: transparent;
            border: 3px solid var(--scouter-green);
            border-radius: 20px;
            padding: 15px;
            box-shadow: 0 0 30px var(--scouter-green), inset 0 0 20px rgba(57, 255, 20, 0.5);
            position: relative;
            transition: all 0.3s ease;
        }

        .display-container {
            position: absolute;
            top: 15px;
            left: 15px;
            right: 15px;
            bottom: 15px;
            background: #050505;
            border-radius: 10px;
            overflow: hidden;
        }

        #webcam, #output-canvas, #loading-message, .overlay-grid {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
        }

        #webcam {
            object-fit: cover;
            transition: transform 0.5s;
        }
        #webcam.mirrored {
            transform: scaleX(-1); /* Mirror view for front camera */
        }

        #loading-message {
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: rgba(0, 0, 0, 0.8);
            z-index: 10;
            transition: opacity 0.5s;
        }
        #loading-message.hidden {
            opacity: 0;
            pointer-events: none;
        }

        .spinner {
            border: 4px solid rgba(57, 255, 20, 0.3);
            border-radius: 50%;
            border-top: 4px solid var(--scouter-green);
            width: 50px;
            height: 50px;
            animation: spin 1.5s linear infinite;
            margin-bottom: 1rem;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        .overlay-grid {
            background-image:
                linear-gradient(var(--scouter-green) 1px, transparent 1px),
                linear-gradient(90deg, var(--scouter-green) 1px, transparent 1px);
            background-size: 40px 40px;
            opacity: 0.1;
        }

        .scanline {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 4px;
            background: var(--scouter-green);
            box-shadow: 0 0 10px var(--scouter-green);
            opacity: 0.7;
            animation: scan 4s linear infinite;
            z-index: 5;
        }

        @keyframes scan {
            0% { transform: translateY(0); }
            50% { transform: translateY(calc(100% - 4px)); }
            100% { transform: translateY(0); }
        }

        #switch-camera-btn {
            position: absolute;
            top: 25px;
            right: 25px;
            width: 44px;
            height: 44px;
            background-color: rgba(0, 0, 0, 0.5);
            border: 1px solid var(--scouter-green);
            border-radius: 50%;
            cursor: pointer;
            z-index: 20;
            display: flex;
            justify-content: center;
            align-items: center;
        }
        #switch-camera-btn svg {
            width: 24px;
            height: 24px;
            fill: var(--scouter-green);
        }
    </style>
</head>
<body>
    <div id="scouter-ui">
        <button id="switch-camera-btn" title="カメラを切り替え">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.82,5.15A3.48,3.48,0,0,1,12,5a3.5,3.5,0,0,1,2.18.66l.51.4,1.52-1.52L15.32,3.7A5.44,5.44,0,0,0,12,3,5.5,5.5,0,0,0,7.5,7.5h0L9,9Z"/><path d="M20.5,10.5h-2A8.5,8.5,0,0,0,3.5,10.5h-2a10.5,10.5,0,0,1,21,0Z"/><path d="M12,21a3.5,3.5,0,0,1-2.18-.66l-.51-.4-1.52,1.52.89.88A5.44,5.44,0,0,0,12,23a5.5,5.5,0,0,0,4.5-4.5h0L15,17Z"/><path d="M14.18,18.85A3.48,3.48,0,0,1,12,19a3.5,3.5,0,0,1-2.18-.66l-.51-.4-1.52,1.52.89.88A5.44,5.44,0,0,0,12,23a5.5,5.5,0,0,0,4.5-4.5h0L15,17Z"/></svg>
        </button>
        <!-- Video and Canvas container -->
        <div class="display-container">
            <video id="webcam" autoplay playsinline class="mirrored"></video>
            <canvas id="output-canvas"></canvas>
            <div id="loading-message">
                <div class="spinner"></div>
                <p>SYSTEM BOOTING...</p>
            </div>
            <!-- Scouter overlay elements -->
            <div class="overlay-grid"></div>
            <div class="scanline"></div>
        </div>
    </div>

    <!-- JavaScript code is now embedded here -->
    <script type="module">
        import { PoseLandmarker, FilesetResolver, DrawingUtils } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.12";

        // DOM Elements
        const scouterUI = document.getElementById("scouter-ui");
        const video = document.getElementById("webcam");
        const canvasElement = document.getElementById("output-canvas");
        const canvasCtx = canvasElement.getContext("2d");
        const loadingMessage = document.getElementById("loading-message");
        const switchCameraButton = document.getElementById("switch-camera-btn");

        let poseLandmarker = undefined;
        let webcamRunning = false;
        let facingMode = "user"; // 'user' for front, 'environment' for back

        // State variables for smooth display
        let displayedPower = "---";
        let displayedRatio = "---";
        let lastPowerUpdateTime = 0;
        const UPDATE_INTERVAL = 5000; // 5 seconds
        let lastKnownState = null;
        let targetLostTimestamp = 0;
        let justUpdatedTimestamp = 0; // For flash effect

        document.addEventListener("DOMContentLoaded", initialize);

        async function initialize() {
            setupEventListeners();
            try {
                const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.12/wasm");
                poseLandmarker = await PoseLandmarker.createFromOptions(vision, {
                    baseOptions: {
                        modelAssetPath: `https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task`,
                        delegate: "GPU"
                    },
                    runningMode: "VIDEO",
                    numPoses: 1,
                    minPoseDetectionConfidence: 0.5,
                    minTrackingConfidence: 0.5
                });
                await enableWebcam();
            } catch (error) {
                console.error("Failed to initialize model:", error);
                loadingMessage.innerHTML = `<p>ERROR: MODEL FAILED TO LOAD</p>`;
            }
        }
        
        function setupEventListeners() {
            switchCameraButton.addEventListener("click", handleCameraSwitch);
        }
        
        async function handleCameraSwitch() {
            facingMode = (facingMode === "user") ? "environment" : "user";
            video.classList.toggle("mirrored", facingMode === "user");
            
            if (video.srcObject) {
                video.srcObject.getTracks().forEach(track => track.stop());
            }
            await enableWebcam();
        }

        async function enableWebcam() {
            webcamRunning = true;
            const constraints = { 
                video: { 
                    width: { ideal: 1280 },
                    height: { ideal: 720 },
                    facingMode: facingMode 
                } 
            };
            try {
                const stream = await navigator.mediaDevices.getUserMedia(constraints);
                video.srcObject = stream;
                video.onloadeddata = () => {
                    if (!webcamRunning) return;
                    startPredictionLoop();
                };
            } catch (err) {
                console.error("Error accessing webcam:", err);
                loadingMessage.innerHTML = `<p>ERROR: WEBCAM ACCESS DENIED</p>`;
            }
        }

        function startPredictionLoop() {
            loadingMessage.classList.add("hidden");
            window.requestAnimationFrame(predictWebcam);
        }

        let lastVideoTime = -1;
        async function predictWebcam() {
            if (video.paused || video.ended) {
                webcamRunning = false;
                return;
            }

            canvasElement.width = video.videoWidth;
            canvasElement.height = video.videoHeight;

            const startTimeMs = performance.now();
            if (lastVideoTime !== video.currentTime) {
                lastVideoTime = video.currentTime;
                poseLandmarker.detectForVideo(video, startTimeMs, (result) => {
                    canvasCtx.save();
                    canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
                    
                    if (result.landmarks && result.landmarks.length > 0) {
                        targetLostTimestamp = 0;
                        drawArDisplay(result.landmarks[0], startTimeMs);
                    } else {
                        if (targetLostTimestamp === 0) targetLostTimestamp = startTimeMs;
                        
                        if (startTimeMs - targetLostTimestamp > 1000) {
                            drawStatusText("SEARCHING...");
                            lastKnownState = null; 
                        } else {
                            drawLastKnownState();
                        }
                    }
                    canvasCtx.restore();
                });
            }

            if (webcamRunning) {
                window.requestAnimationFrame(predictWebcam);
            }
        }

        function drawArDisplay(landmarks, currentTime) {
            if (currentTime - lastPowerUpdateTime > UPDATE_INTERVAL) {
                lastPowerUpdateTime = currentTime;
                justUpdatedTimestamp = currentTime; // Set timestamp for flash effect
                const p11 = landmarks[11], p12 = landmarks[12], p7 = landmarks[7], p8 = landmarks[8];
                if (p11.visibility > 0.6 && p12.visibility > 0.6 && p7.visibility > 0.4 && p8.visibility > 0.4) {
                    const shoulderWidth = Math.abs(p11.x - p12.x);
                    const faceWidth = Math.abs(p7.x - p8.x);
                    if (faceWidth > 0.01) {
                        const calculatedRatio = shoulderWidth / faceWidth;
                        const calculatedPower = Math.max(1, 99999 * calculatedRatio - 179997.2);
                        displayedPower = Math.round(calculatedPower);
                        displayedRatio = calculatedRatio.toFixed(2);
                    }
                }
            }

            const mirroredLandmarks = video.classList.contains("mirrored") ? landmarks.map(lm => ({...lm, x: 1 - lm.x, y: lm.y})) : landmarks;
            const m_p7 = mirroredLandmarks[7], m_p8 = mirroredLandmarks[8], m_p0 = mirroredLandmarks[0];
            if (m_p7.visibility > 0.4 && m_p8.visibility > 0.4 && m_p0.visibility > 0.5) {
                const faceWidthPx = Math.abs(m_p7.x - m_p8.x) * canvasElement.width;
                const faceHeightPx = faceWidthPx * 1.2;
                const centerX = m_p0.x * canvasElement.width;
                const centerY = m_p0.y * canvasElement.height;
                
                const textX = centerX;
                const textY = (centerY + faceHeightPx / 2) + 40;
                
                lastKnownState = {
                    box: {x: centerX, y: centerY, w: faceWidthPx, h: faceHeightPx},
                    text: {x: textX, y: textY}
                };
                
                drawTargetingBox(lastKnownState.box);
                drawArText(lastKnownState.text);
            }
        }
        
        function drawLastKnownState() {
            if (lastKnownState) {
                drawTargetingBox(lastKnownState.box);
                drawArText(lastKnownState.text);
            } else {
                drawStatusText("SEARCHING...");
            }
        }

        function drawTargetingBox({x, y, w, h}) {
            canvasCtx.strokeStyle = "rgba(57, 255, 20, 0.8)";
            canvasCtx.lineWidth = 2;
            canvasCtx.beginPath();
            canvasCtx.rect(x - w / 2, y - h / 2, w, h);
            canvasCtx.stroke();
            drawBrackets(canvasCtx, x - w / 2, y - h / 2, w, h, 20, 4);
        }

        function drawArText({x, y}) {
            const currentTime = performance.now();
            const isFlashing = (currentTime - justUpdatedTimestamp) < 500; // Flash for 0.5s

            canvasCtx.textAlign = "center";
            // Power Level Label
            canvasCtx.font = "32px Orbitron";
            canvasCtx.fillStyle = "#ff4757";
            canvasCtx.shadowColor = "transparent";
            canvasCtx.fillText("POWER LEVEL", x, y);

            // Power Level Value
            let fontSize = 96;
            const powerString = `${displayedPower}`;
            if (powerString.length >= 7) fontSize = 68;
            else if (powerString.length >= 5) fontSize = 84;

            canvasCtx.font = `bold ${fontSize}px Orbitron`;
            canvasCtx.fillStyle = "#ff4757";
            canvasCtx.shadowColor = "#ff4757";
            canvasCtx.shadowBlur = isFlashing ? 30 : 15; // Flash effect
            canvasCtx.fillText(powerString, x, y + 100);
            canvasCtx.shadowBlur = 0;

            // Ratio
            canvasCtx.font = "36px Orbitron";
            canvasCtx.fillStyle = "rgba(57, 255, 20, 0.9)";
            canvasCtx.fillText(`RATIO: ${displayedRatio}`, x, y + 170);
        }

        function drawBrackets(ctx, x, y, w, h, size, lineWidth) {
            ctx.lineWidth = lineWidth;
            ctx.beginPath();
            const b = lineWidth / 2;
            ctx.moveTo(x + b, y + size); ctx.lineTo(x + b, y + b); ctx.lineTo(x + size, y + b);
            ctx.moveTo(x + w - size, y + b); ctx.lineTo(x + w - b, y + b); ctx.lineTo(x + w - b, y + size);
            ctx.moveTo(x + b, y + h - size); ctx.lineTo(x + b, y + h - b); ctx.lineTo(x + size, y + h - b);
            ctx.moveTo(x + w - size, y + h - b); ctx.lineTo(x + w - b, y + h - b); ctx.lineTo(x + w - b, y + h - size);
            ctx.stroke();
        }

        function drawStatusText(text) {
            canvasCtx.font = "bold 24px Orbitron";
            canvasCtx.fillStyle = "rgba(57, 255, 20, 0.8)";
            canvasCtx.textAlign = "center";
            canvasCtx.fillText(text, canvasElement.width / 2, canvasElement.height / 2);
        }
    </script>
</body>
</html>

Terraform設定ファイル

variables.tf

プロジェクトIDやリージョンなどを変数として定義します。

variables.tf
variable "project_id" {
  type        = string
  description = "The Google Cloud project ID to deploy the function to."
}

variable "region" {
  type        = string
  description = "The region to deploy the function to."
  default     = "asia-northeast1"
}

variable "function_name" {
  type        = string
  description = "The name for the Cloud Function."
  default     = "ar-scouter-function"
}
main.tf

デプロイのメインとなるファイルです。ソースコードのアップロードとCloud Run functionsの作成を行います。

main.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = ">= 2.2"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

resource "random_string" "bucket_prefix" {
  length  = 8
  special = false
  upper   = false
}

# 1. ソースコードをアップロードするための一時的なGCSバケットを作成
resource "google_storage_bucket" "source_bucket" {
  name          = "${var.project_id}-scouter-src-${random_string.bucket_prefix.result}"
  location      = var.region
  force_destroy = true # プロジェクト破棄時にバケットを削除
  uniform_bucket_level_access = true
}

# 2. 'source' ディレクトリをzipに固める
data "archive_file" "source_zip" {
  type        = "zip"
  source_dir  = "${path.module}/source"
  output_path = "/tmp/ar-scouter-source.zip"
}

# 3. zipファイルをGCSバケットにアップロード
resource "google_storage_bucket_object" "source_object" {
  name   = "source.zip"
  bucket = google_storage_bucket.source_bucket.name
  source = data.archive_file.source_zip.output_path
}

# 4. 必要なAPIを有効化
resource "google_project_service" "apis" {
  for_each = toset([
    "cloudfunctions.googleapis.com",
    "run.googleapis.com",
    "storage.googleapis.com",
    "cloudbuild.googleapis.com",
    "artifactregistry.googleapis.com"
  ])
  service                    = each.key
  disable_on_destroy         = false
  disable_dependent_services = true
}

# 5. Cloud Function (第2世代) を作成
resource "google_cloudfunctions2_function" "ar_scouter" {
  name     = var.function_name
  location = var.region

  build_config {
    runtime     = "nodejs20"
    entry_point = "arScouterApp" # index.jsでexportsした関数名
    source {
      storage_source {
        bucket = google_storage_bucket.source_bucket.name
        object = google_storage_bucket_object.source_object.name
      }
    }
  }

  service_config {
    max_instance_count = 1
    min_instance_count = 0
    available_memory   = "256Mi"
    timeout_seconds    = 60
    # 認証なしで誰でもアクセスできるように設定
    all_traffic_on_latest_revision = true
    ingress_settings               = "ALLOW_ALL"
  }

  depends_on = [google_project_service.apis]
}

# 6. Cloud Functionを公開URLで呼び出せるようにIAM権限を設定
resource "google_cloud_run_service_iam_member" "invoker" {
  location = google_cloudfunctions2_function.ar_scouter.location
  service  = google_cloudfunctions2_function.ar_scouter.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}
output.tf

デプロイ完了後、関数のURLを出力します。

outputs.tf
output "function_url" {
  description = "The URL of the deployed AR Scouter function."
  value       = google_cloudfunctions2_function.ar_scouter.service_config[0].uri
}

IaCコードによるデプロイ実行

(1) Google Cloud CLIの認証

Google Cloudにログインしていない場合、ログインをします。

bash
gcloud auth login
gcloud auth application-default login

(2) Terraformの初期化

terraform-scouter-app ディレクトリに移動し、以下のコマンドを実行します。

bash
cd terraform-scouter-app
terraform init

(3) デプロイの実行

以下のコマンドを実行します。 は実際のGoogle CloudプロジェクトIDに置き換えてください。

bash
terraform apply -var="project_id=<YOUR_PROJECT_ID>"

実行計画が表示されたら、内容を確認し yes と入力してデプロイを承認します。

(4) 完了

デプロイが完了すると、function_url としてアプリのURLが出力されます。そのURLにブラウザでアクセスすると、ARスカウターが起動します。

動作確認

公開先URL

本アプリを公開したURLは、terraform applyが正常終了したあと、「function_url = 」のあとに記載されています。

terraform apply 実行結果
Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

Outputs:

function_url = "https://(公開されたURL).run.app"

実行画面(編集あり)

できました!簡単でした。

image.png

先程と同じように人物と背景をNano Banana(Gemini)で変更しましたが、同じスレッド内で変更したせいか、先程と変わらない画像になってしまったような気がします。

後片付け

無事確認できたら、Cloud Runのサービスからアプリを削除するため、「terraform destroy」をしておきましょう。

bash
terraform destroy -var="project_id=<YOUR_PROJECT_ID>"

終わりに

Google Cloudコンソールでのデプロイも慣れてきましたが、IaCで設定が管理できると、更に管理がしやすくなります。また、繰り返し作業となった場合もコマンド実行で再現できるため、生産性が上がります。
今度はReact.jsで実装し、Cloud Run(コンテナ)に挑戦したいと思います。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?