GAミント至上主義

Web Monomaniacal Developer.

Google Apps ScriptとNuxtJSで簡易来客受付システムをつくったら実質サーバーコスト無料だった

シニアジョブのオフィスの引越にあたり、セキュリティの面からも来客の記録システムが必要になりました。

最初すでにいろんな会社が作ってると思ったので、それ使えばいいじゃんと思いましたが、仕様を聞くとたしかに超簡易的なものなのでまあいいかということで作りました。
とりあえず一通り動くまで2,3日、あとCIとかの環境まわり、デザイナーさんの工数とか入れたら1週間ぐらい?

できたもの

iPadでこんな感じに表示されていて
f:id:uyamazak:20210309171400p:plain

名前とかいれて呼び出しすると「ピンポーン」という音がして
\ピンポーン/
f:id:uyamazak:20210309171503p:plain

スプレッドシートに記録されて
f:id:uyamazak:20210309171555p:plain

Chatworkにも送信されます
f:id:uyamazak:20210309171633p:plain

システム的には

  1. Google App Engineで公開されたNuxtJSアプリ(静的ファイル配信のみ)
  2. GASウェブアプリ(REST API的なもの)で来客リクエスト受け付け + iPadでピンポーン鳴らす
  3. GASでスプレッドシートにログ書き込み & Chatworkにメッセージ送信
  4. 備え付けたiPadでこれを開く

だけのシンプルな構成です。

iPadの選定

まずこれから頼まれた。
生まれてからMac製品買ったこと無い人間なので、いろいろ調べてこんな条件で中古で選んで買ってもらいました。
3万ちょっとだった気がする。

  • 新しめ(2019)
  • 大きめ(10.2インチ)
  • WiFiで厚さとかストレージ気にしない

Raspbery Pi + タッチディスプレイも考えたけどめんどいよね。

フロント側選定

他でも使ってるしNuxtJSでいいよね。UIも大してないので定番のVuetify
いざとなったらサーバー側の処理もできるし(結局GASで済んだのでいらなかった)

インフラ選定

NetlifyとかVercelとか便利だけど、スプレッドシートへの書き込みなので、Google内が良さそう。
常時起動の必要はなく、必要なときだけ起動するGoogle App Engine(スタンダード環境)を選びました。
NuxtJSで普通に作ってローカルで動かしたあとapp.yaml作るだけでデプロイ出来ました。

休日開けとかの起動時間がちょっとネックだけど10秒はいかない感じなので今の所常時起動とかはまあいいかなと。

おそらく無料枠に収まるので実質無料では?

サーバー側選定

この記事で詳しく書いたけど結局サーバーサイド的な処理はGASのウェブアプリだけで完結していましました。
もともとGoogle Workplaceはつかってるので実質無料では?
uyamazak.hatenablog.com

CI、デプロイ

これもGitHubのmainブランチへのプッシュをフックにGCPのCloud Buildを設定しました。
権限周りでちょっとハマったけど、無料枠以上デプロイする気がしないので実質無料では?
uyamazak.hatenablog.com

小技集

ピンポーン鳴らす

もしネットが切れてるときにお客さんが操作するとただエラー画面が表示されるだけで、Chatworkにもスプレッドシートにも飛ばないので人をイラだたせる板になってしまいます。
そこでエラー時にも音出せば原始的なチャイムとして機能する?と思ってつけました。
音量最大にしてオフィスの中聞こえるかはまだ新オフィス行ったこと無いのでわかりません。

音はmp3などでネットで無料でライセンス的にも問題なさそうなのを拾ってきます。
ブラウザで音鳴らすの随分久しぶりな気がしましたが、この関数だけで動きました。
リモートなので実機テストはデザイナーの方に頼みましたが問題なかったそう。

ping-pong.ts

let audio: HTMLAudioElement

export const pingPong = () => {
  if (!audio) {
    audio = new Audio('pingpong.mp3')
  }
  audio.pause()
  audio.currentTime = 0
  audio.play()
  console.log('pinpong')
}

エラーページ出たらしばらくしたらトップにもどる

ネットワーク等なにかの問題でエラー画面がでてそのままだったらうざいので、自動でトップに戻るようにしました。

せっかくなので新しいcomposition-apiプラグイン的に作りました
手で戻った場合に備えてonUnmountedでキャンセルするのも忘れない。

plugins/setup-auto-back-top

import { onMounted, onUnmounted, useRouter } from '@nuxtjs/composition-api'

export const setupAutoBackToTop = (timeout: number) => {
  return () => {
    const router = useRouter()
    let timeoutId: number
    onMounted(() => {
      timeoutId = window.setTimeout(() => {
        router.push('/')
      }, timeout)
    })
    onUnmounted(() => {
      window.clearTimeout(timeoutId)
    })
  }
}

これをエラーページのテンプレートでsetupに時間(ミリ秒)を付けて入れるだけ。
15秒でトップに自動で戻ります。

error.vue

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
import { setupAutoBackToTop } from '@/plugins/setup-auto-back-top'

export default defineComponent({
  setup: setupAutoBackToTop(15000),
})
</script>

PWAでiPadにインストール&アドレスバー無しを実現する

そのままだとアドレスバーとか出ててダサいのでアプリっぽく動くようにします。
といってもNuxtJSのモジュール入れて、アイコンとか設定するだけ。

pwa.nuxtjs.org

これでiPadにインストールすれば、アプリっぽくなります。
iPad手元にないので画像はない。

IP制限でオフィスからだけ見れるようにする

App Engineということでお気づきの方はいるかもしれませんが、グローバルに公開されちゃいます。
知らない国の知らない人に来客通知送られても困るので制限します。

メニューのファイアウォールで、デフォルト拒否にして、許可するIP追加するだけなので簡単。
ファイアウォールによるアクセスの制御  |  Python 2 の App Engine スタンダード環境  |  Google Cloud

ベーシック認証とかも考えたけど、認証切れたりした画面をお客さんに見せたくない&新オフィスは固定IPがあるのでIP制限にしました。

そういえばApp Engineの選定理由にこれもありました。

あともし同じIPを使われてURLを知っていて悪意がある人がいても、できるのはスプレッドシートへのログ書き込みとChatwork通知だけで、個人情報漏洩とかの心配はなかったものあり。
なにかあってもすぐGASのウェブアプリを止めればOK。


たぶん実運用が始まってからいろいろと問題が出ると思いますが、ご来社の際は触ってみてください(画面消毒用のアイテム用意しないと)

Laravelの操作ログをGCPのDataflowテンプレートを使ってBigQueryに記録する

経緯

シニアジョブの人材管理システムで、もしもの時のセキュリティのために、すべての操作ログを記録することにしました。
Dataflowのテンプレートを使うことでちょっとのコードでできてしまったので、コードにならない部分の記録をするため雑にメモ。
記事中のコードは適宜はしょってるので動きません。

ElasticsearchではなくBigQueryにした理由

最初すでにElasticsearchを使っていることもあり、そちらに保存しようと思ったけど、ESの設定や維持管理、保存のためにも結構コードを書く必要もあり、
また見る際もいろいろコードを書くか、GUIのツールを使わないといけないので大変だなぁと思っているところで、以前使ったDataflowのテンプレートを思い出しました。

Google 提供のストリーミング テンプレート  |  Cloud Dataflow  |  Google Cloud

これを使うとPub/SubにBigQueryテーブルに合わせたJSONを送るだけでいろいろいい感じにインサートしたり、失敗した時はエラー用のテーブルに記録してくれたりします。
Pub/Subをかましているので、急激な負荷の変化をある程度吸収したり、BigQuery側が落ちててもリトライとかもいい感じにやってくれるはず。

これを使うことでいわゆるサーバーレス、マネージレス的に設定だけでGCP側は完了します。

LaravelのMiddlewareを作る

まずはログに必要なリクエストごとにJSONを送る仕組みをLaravel側につくります。
Middlewareを使うのが良さそうですね

使ってるLaravelのバージョンはまだ7です(もう8出てるんだ)。

Middleware - Laravel - The PHP Framework For Web Artisans


処理のタイミングがいくつかあるんですが、レスポンス終了後に行ったほうが、ユーザーへのページ表示が遅くならないので良さそうです。
ということでterminate()を使います。

大きすぎるPOSTは省略したり、内容をいい感じに整理するのに試行錯誤したけどこんな感じ。
normalize()は状況によって違うので省略するけど、ここでいい感じにしてます。

    /**
     * レスポンス速度の影響を防ぐため終了後にログを送信する
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Http\Response  $response
     * @return void
     */
    public function terminate($request, $response)
    {
        // query()とpost()の内容が重複することが多いのでマージしたものをpayloadとして保存する
        $query = $this->normalize($request->query());
        $post = $this->normalize($request->post());
        $payload = array_merge($query, $post);
        $logContent = [
            'timestamp' => Carbon::now('UTC')->format('Y-m-d H:i:s.u'),
            'method' => $request->method(),
            'path' => $request->path(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'status' => $response->status(),
        ];
        $userId = $request->user()->id ?? null;
        if ($userId) {
            $logContent['user_id'] = $userId;
        }
        // 空配列JSONや文字列"null"をBigQueryに保存したくない
        if ($payload) {
            $logContent['payload'] = json_encode($payload, JSON_UNESCAPED_UNICODE);
        }
    }

BigQueryのタイムスタンプ型に入れるときは、特殊?なこの形式にするのは気をつけないといけません。あとは普通に文字列か数字。

Carbon::now('UTC')->format('Y-m-d H:i:s.u')

今回はCSVじゃないけどここに書いてるのと同じっぽい
Cloud Storage からの CSV データの読み込み  |  BigQuery  |  Google Cloud


しばらく$logContentの内容をログに出しながら動作確認して良さそうだったら、これに合わせてBigQueryのテーブルを作ります。
nullになる可能性があるpayloadとかuser_idとかはBigQuery側でnullableにしておきます。

Cloud Consoleでポチポチして作っておきます。IPも無いことはなさそうだからREQUIREDでいいかなと思ったりもしたけどそのまま。
f:id:uyamazak:20210304111839p:plain

ついでにPub/Subのトピックと、それを読むsubscriptionをポチポチして作っておきます。特に設定はない。

あと専用のサービスアカウントを作り、Pub/SubにPublishできる権限だけを作って、認証用JSONをダウンロードしておきます。

ここまでできたら、Pub/Subに送信します。
Pub/Subの処理は大したことしないけど、一応Middlewareと分けるためServicceとしました。
こんなの。

<?php

namespace App\Services;

use Google\Cloud\PubSub\PubSubClient;

class GcpPubSubService
{
    private $pubSubClient;
    private $topic;

    /**
     * @param string $jsonPath
     * @param string $topic
     * @return void
     */
    public function __construct(string $jsonPath, string $topic)
    {
        $this->pubSubClient = new PubSubClient([
            'keyFilePath' =>  $jsonPath
        ]);
        $this->topic = $this->pubSubClient->topic($topic);
    }

    /**
     * @param string $data
     * @return void
     */
    public function publish(string $data)
    {
        $this->topic->publish([
            'data' => $data,
        ]);
    }
}

こいつを使って上記terminateの最後でpublishします

$gcpPubSubService = new GcpPubSubService('保存した認証用JSONのパス', '作ったトピック名');
// 日本語見やすくするためJSON_UNESCAPED_UNICODE付けてる
$gcpPubSubService->publish(json_encode($logContent, JSON_UNESCAPED_UNICODE));

Dataflowのテンプレートでジョブを起動する

Pub/Subにエラーなく送れてそうな気がしたら、いよいよ本題のDataflowです。

「テンプレートからジョブを作成」を選んで`Pub/Sub Subscription to BigQuery`を選択します。
f:id:uyamazak:20210304112555p:plain

先程作ったPub/SubのサブスクリプションやBigQueryのテーブル名などを注釈どおりに入れます。

あと、ここで一時ファイル置き場として、Storageのフォルダが必要になりますね。
一時ファイルなので、可用性とかしらんので、安いusでregionalなバケットをつくり、tmpみたいなフォルダ作って指定しました。動作中みると空だけどいつ使うんだろう。
f:id:uyamazak:20210304113220p:plain

Dataflowのリソースをなるべくケチる

必須だけいれてポチっでも動いてしまいます。
しかし、多くても数十人が使う社内システムのログなのにCPU4とかHDD1.2TBとかメモリ15GBとか信じられないリソースが目に入ります。
ケチりたい。

ということで、Dataflowのこの隠されてる「オプションパラメータの表示」をクリックします
f:id:uyamazak:20210304113625p:plain

ここの設定は状況によって異なりますが、今回は平凡なマシン1台で十分だろってことで、
マシンタイプにn1-standard-1(ふつー)、
ワーカーの数も1、
MAXワーカーも1
で行きました。あとは空のまま。

MAXワーカー数が曲者で、デフォルトが3らしく、空のままいくと420GB×3のHDDを用意してしまいます。今回は絶対いらんやろと確信。

3度目の正直で、まあこんなもんかというリソースにできました。
f:id:uyamazak:20210304113952p:plain

デフォルトだったら1日うん十万かそれ以上のリクエストでもさばけそうですね

トラブルシューティング

Pub/Sub送れてるのにBigQueryに入ってこないなぁってなったら、BigQueryで作ったテーブルと同じデータセット

{yourtablename}_error_records

というテーブルが出来ているのでこちらを確認しましょう。
型違いとかカラム名違いとかで入れられなかった場合は、その情報をここに保存してくれます
この機能も超便利。

そもそもGCPが落ちてて送れない時は諦めてSlackにでも送りつけるしかないかな。

結果

あとは普通にBigQueryで簡単なSQLを実行するだけでログを確認することができるようになりました。
ビューアーはブラウザ(Cloud Console)でいいし、Googleアカウントで権限管理も簡単だし、
サーバー管理も不要だし、ほぼ1分以内に入ってくるのでリアルタイム性あるのがこんな簡単に作れるの嬉しいなぁ。


f:id:uyamazak:20210304114146p:plain

Cloud BuildでApp EngineにデプロイしようとしたらPERMISSION_DENIED

NuxtJSで使った社内用アプリをCloud BuildでApp Engineに自動デプロイしようとしたら2時間くらいハマったのでメモ。

GitHub Actionsもいいけど、やっぱGCP内で完結させたいなぁということでCloud Build使いました。GitHub Actionsと比べるとユーザーと情報が少ないのが玉に瑕。

まずは公式参考にやる。
cloud.google.com

node_modulesディレクトリ等はレポジトリに含まれないため、installやビルドが必要。
cloudbuild.yamlはこんな感じ。
timeoutは公式そのまま。

steps:
- name: 'gcr.io/cloud-builders/yarn'
  args: ["install"]
- name: 'gcr.io/cloud-builders/yarn'
  args: ["nuxt-ts", "build"]
- name: "gcr.io/cloud-builders/gcloud"
  args: ["app", "deploy", "app.yaml", "-q", "--project", "project-name"]
timeout: "1600s"

ちょっと関係ないけど、argsは配列渡ししないと↓こういうエラーになるので注意。

error Command "nuxt-ts build" not found.

で、上記の公式どおり権限などセッティングしたものの下記のエラーが消えない。

Beginning deployment of service [default]...
#============================================================#
#= Uploading 1 file to Google Cloud Storage                 =#
#============================================================#
File upload done.
ERROR: (gcloud.app.deploy) PERMISSION_DENIED: You do not have permission to act as 'project-name@appspot.gserviceaccount.com'
- '@type': type.googleapis.com/google.rpc.ResourceInfo
  description: You do not have permission to act as this service account.
  resourceName: project-name@appspot.gserviceaccount.com
  resourceType: serviceAccount

f:id:uyamazak:20210222134452p:plain

いろいろ見ていくと、Cloud Buildは{Project ID}@cloudbuild.gserviceaccount.comを使うはずなのに
{Project ID}@appspot.gserviceaccount.comを使っているので気になる。

Cloud Buildの設定を見直したところ「サービス アカウント ユーザー」というそれっぽいロールがあったので有効化したところ、無事成功しました。

f:id:uyamazak:20210222134452p:plain

GCPの教科書

GCPの教科書