sitateru tech blog: GCP

sitateru tech blog

シタテルの技術やエンジニアの取り組みを紹介するテックブログです。

ラベル GCP の投稿を表示しています。 すべての投稿を表示
ラベル GCP の投稿を表示しています。 すべての投稿を表示

2019年2月18日月曜日

Google Cloud Functionsで画像を自動リサイズする

2月 18, 2019

Google Cloud Functions

こんにちは、DevOpsチームの甲斐です。
今回は、Google Cloud Functions(以下、GCF)で画像を自動リサイズする手順を紹介したいと思います。

GCFとはAWSでいうところのLambdaです。いわゆる、サーバーレスってやつですね。
最近はGCPを触ることが多いのですが、先日GCP上のWordPressのアップロード画像をリサイズするために、
GCFを使って自動的に画像をリサイズするようにしました。
AWSのLambdaも触ったことありますが、GCFのほうが手間が軽い印象を持ちました。
まだGCFをお使いになったことがない方は、ぜひご参考にしていただければと思います。

前準備

GCFを使う上で必要な環境は以下のとおりです。

  • GCPプロジェクトの作成
  • 課金の有効化
  • Cloud Functions APIの有効化
  • Cloud SDKのインストール

詳細は、以下に書かれていますので、こちらをご参照ください。
https://cloud.google.com/functions/docs/quickstart-console?hl=ja

今回の仕様

今回の仕様は以下のとおりです。

  • Google Cloud Storage(以下、GCS)の当該バケットのuploadsディレクトリにアップロードされた画像を自動的にリサイズ
  • リサイズする画像フォーマットはimage/jpegのみ
  • オリジナル画像は".orig"拡張子をつけてバックアップ
  • リサイズされた画像はオリジナルの画像と同じ名前で上書き

実装

それでは、早速実装の手順を紹介していきたいと思います。
今回はNode.js v8で書きました。それ以外にもNode.js v6, Python(beta), Go(beta)が使えます。

1. 作業ディレクトリの作成

mkdir -p gcf/convert_image
cd !$

2. npm init

npm init

3. 必要なパッケージをインストール

今回は、@google-cloud/storage(GCS関連パッケージ)とgm(画像編集パッケージ)をインストールします。

npm install @google-cloud/storage gm --save

4. コーディング

コードは以下のとおりです。
最初に少しハマったところとしては、リサイズした画像でオリジナル画像を上書きすると
再びイベントが発火されるので無限ループに陥ってしまったことです。
対策としてメタデータにコンバート済みである旨を記述し、処理の最初にメタデータをチェックすることで判断するようにしました。

'use strict';

const gm = require('gm').subClass({imageMagick: true});
const fs = require('fs');
const path = require('path');
const {Storage} = require('@google-cloud/storage');
const storage = new Storage();

exports.convertImage = data => {
  if (data.resourceState === 'not_exists') {
    console.log(`Skip because not_exists`);
    return;
  } else if (!data.name) {
    console.log(`Skip because no name`);
    return;
  } else if (!data.name.startsWith('uploads')) {
    console.log(`Skip because not uploads: ${data.name}`);
    return;
  } else if (data.name.endsWith('.orig')) {
    console.log(`Skip because original image: ${data.name}`);
    return;
  } else if (data.contentType !== 'image/jpeg') {
    console.log(`Skip because not image/jpeg: ${data.contentType}`);
    return;
  }

  const file = storage.bucket(data.bucket).file(data.name);
  console.log(`Uploaded image : ${file.name}`);

  const tempLocalPath = `/tmp/${path.parse(file.name).base}`;
  return file
    .getMetadata()
    .then(data => {
      console.log('[Check already converted or not]');
      const metadata = data[0];
      if (metadata.metadata && metadata.metadata.isConverted) {
        console.log(`${file.name} is already converted.`);
        Promise.reject();
      } else {
        console.log(`${file.name} is not yet converted.`);
        Promise.resolve();
      }
    })
    .catch(err => {return;})
    .then(() => {
      console.log('[Download file]');
      return file
        .download({destination: tempLocalPath})
        .catch(err => {
          console.error('Failed to download file.', err);
          return Promise.reject(err);
        });
    })
    .then(() => {
      console.log('[Backup a image]');
      return file.copy(`${file.name}.orig`);
    })
    .then(() => {
      console.log('[Convert a image]');
      return new Promise((resolve, reject) => {
        gm(tempLocalPath)
          .samplingFactor(2, 2)
          .strip()
          .quality(85)
          .interlace('Line')
          .colorspace('sRGB')
          .write(tempLocalPath, (err, stdout) => {
            if (err) {
              console.error('Failed to convert image.', err);
              reject(err);
            } else {
              resolve(stdout);
            }
          });
      });
    })
    .then(() => {
      console.log('[Upload a converted image]');
      const options = {
        destination: file,
        resumable: false,
        metadata: {
          metadata: {
            isConverted: true
          }
        }
      };
      return file.bucket
        .upload(tempLocalPath, options)
        .catch(err => {
          console.error('Failed to upload a converted image.', err);
          return Promise.reject(err);
        });
    })
    .then(() => {
      console.log('[Remove a temporary file]');
      return new Promise((resolve, reject) => {
        fs.unlink(tempLocalPath, err => {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        });
      });
    });
};

5. デプロイ

デプロイは以下のようにします。
今回の場合、runtimeはnodejs8になります。また、メモリやタイムアウトもオプションで設定できます。

gcloud functions deploy <name> --entry-point <entry-point> --runtime <runtime> --trigger-bucket <trigger-bucket> --region <region> [--memory <memory> --timeout <timeout>]

デプロイが完了したら、以下のコマンドで正常に登録されたことを確認します。

gcloud functions list

6. テスト

サンプル画像を当該バケットにアップロードし、正常にリサイズされることを確認します。

% gsutil cp sample.jpg gs://<bucket>/uploads/
% gsutil ls -l "gs://<bucket>/uploads/sample.jpg*"
   2601078  2019-02-14T02:37:45Z  gs://<bucket>/uploads/sample.jpg
  14583723  2019-02-14T02:37:43Z  gs://<bucket>/uploads/sample.jpg.orig
TOTAL: 2 objects, 17184801 bytes (16.39 MiB)

まとめ

簡単ですが、GCFで画像をリサイズする実装手順を紹介しました。
今回はGCSをトリガーにしていましたが、それ以外にもGoogle Cloud Pub/SubやHTTPリクエストをトリガーにすることも出来ます。
GCFの詳細については本家ドキュメントをご参照ください。

https://cloud.google.com/functions/docs/?hl=en

また、以下のGitリポジトリにGCFのサンプルプログラムがありますので参考にしてみてください。

https://github.com/GoogleCloudPlatform/nodejs-docs-samples/tree/master/functions

2018年11月2日金曜日

Google Cloud EndpointsとAWS上のAPIサーバをつないでみる

11月 02, 2018

こんにちは、シタテルの茨木です。

シタテルではSCS(Sitateru-Control-System)という生産管理システムをRuby on Railsで実装し、AWSでホスティングしています。

SCS自体もViewを持っているのですが、徐々にAPI化とフロントエンドの分離を進めており、一部のフロントエンドは既にSPAで分離されていたりします。

今後はシステムをAPI仕様ベースで分離&疎結合化していきたいねーという流れで、API-Gateway系のプロダクト、今回は特にGoogle Cloud Endpointsに触ってみました。

やること

既にあるAWS上のOpenAPI仕様で実装されたAPIサーバと、Google Cloud Endpointsを接続して、APIアクセスを管理できるか見てみる。

とりあえずトラフィックが見えるところまでで、認証系とかはやりません。

構成イメージ

API-Server ---- ESP ---- Client App
                 |
                 |
          Cloud Endpoints

ESP(Extensible Service Proxy)

ESPはEndpoints固有の要素で、リバースプロキシとしてAPIリクエストを一次受けし、Cloud Endpointsと連携しつつ、通していいリクエストだけバックエンド(API-Server)に流してくれます

  • API-Serverの手前でESPを動作させるのが必須
  • ESPはdockerで稼働が必要、ESPの稼働プラットフォームは任意
  • ESPを間に噛ませさえすれば、API Serverのプラットフォームも任意

手順

身も蓋もないですが公式ガイドに書いてあるのを読むのが確実です

ESP をローカルまたは別のプラットフォームで実行する

ざっと流れを書いておきます

  • GCPにプロジェクト作る

  • プロジェクトにサービスアカウント作って秘密鍵を払い出す

    • サービスアカウントはESPがendpointにアクセスする際に使われます
  • OpenAPIの定義ファイル(いわゆるswagger)をAPIサーバから持ってくる

    • 今回はgrape-swaggerで生成したものを使いました
    • ガイドだとyamlになってますが、jsonでも読んでくれます
  • APIサーバのドメイン所有権の証明を行う

  • gcloud endpoints services deploy [API定義ファイル]でendpoint構成をデプロイする

    • デプロイが通ると、下図の様にGCPのポータル上で見えるようになります
    • あくまでendpoint構成をデプロイしただけで、ESPと繋いでないので何も起きません
  • ESP用のインスタンスをAWS上に作って、dockerでESPを稼働させる

    • docker導入は一般的な手順なので割愛
    • ESP稼働させるdocker runは下記の様になります(サンプル)
sudo docker run \
    --detach \
    --name="esp" \
    --net="host" \
    --volume=$HOME/esp:/esp \
    --publish=8082 \
    gcr.io/endpoints-release/endpoints-runtime:1 \
    --service=hogehoge.sitateru.com \
    --rollout_strategy=managed \
    --http_port=8082 \
    --backend=https://hogehoge.sitateru.com \
    --service_account_key=/esp/key.json

ざっとオプション説明

--service
endpoint構成のサービス名(≒APIサーバのドメイン名≒swaggerのhostエントリ)と一致させる

--backend
ESPのプロキシ先。APIサーバを指定する

--service_account
サービスアカウントの鍵ファイルを指定する

  • ここまでやるとESP経由でリクエストが通るようになります
    • curl等で試してみましょう
curl --request POST \
    --header "Content-Type:application/json" \
    --header "APIサーバ固有で必要なヘッダ等あれば" \
    --data '{"email":"hogehoge", "password":"fugafuga"}' \
    http://(ESPインスタンスのIP):8082/path/to/api
  • ESP経由でのアクセスは、GCPポータルからログが確認できます

ハマった点

上記手順ではすんなりswaggerを読ませていますが、実際は結構エラーと戦いました。

ちゃんと読むとガイドにも書いてありますが、endpoint構成はOpenAPI仕様を完全にはサポートしていないようです。

例1. type: fileはダメっぽい

"/definitions/putApiV2Topics/properties/files/items/type": domain: validation; keyword: enum; message: value not found in enum; enum: ["array","boolean","integer","null","number","object","string"]; value: "file"

type: fileはサポートしてない。
仕様的にはstringに置換してよさそう?

例2. request-paramの渡し方に制限がある

 ERROR: unknown location: http: body field path 'ids' must be a non-repeated message.
 ERROR: unknown location: http: body field path 'api_v2_files' must be a non-repeated message.
 ERROR: unknown location: http: body field path 'api_v2_items_id_files' must be a non-repeated message.
 ERROR: unknown location: http: body field path 'emails' must be a non-repeated message.'

request-parameterのbody直下にarrayはサポートしない模様。
API側のパラメタ受け取り仕様で、arrayはobjectでくるむように作る必要がある?

まとめ

ESPまわりの構成は実際に組むまでイメージ湧かなかったので、その辺の助けになれば幸いです。

OpenAPIはほぼデファクトだと思っていたので、制約がいくつか出てきたのは意外でした。gRPC推しなんですかね?