GAミント至上主義

Web Monomaniacal Developer.

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の教科書

Google Apps ScriptからChartworkに投稿する

社内システムで最初、JavaScriptでブラウザからAPI叩こうとしたら、おそらくChatwork API側がPreflight requestに対応してないのが原因でCORSエラーが出て送れない・・・。
そのためGASのウェブアプリ側で送るようにしました。

developer.mozilla.org


UrlFetchApp.fetchで簡単に送れました。

APIについて詳細は公式

developer.chatwork.com

const chatworkRoomId = {ルームIDいれてね}
const chatworkApiToken = '{APIトークンいれてね}'

function sendChatWork (message) {
  const apiUrl = `https://api.chatwork.com/v2/rooms/${chatworkRoomId}/messages`;
  UrlFetchApp.fetch(apiUrl, {
    method: 'post',
    headers: {'X-ChatWorkToken': chatworkApiToken},
    payload: {body: message},
  });
}
// 使い方
sendChatWork('test')