こうさくきろく

つくるのたのしい

Google API Gateway 使って認証付き API を実装する

GCPAPI Gateway の機能提供するサービスは Apigee だけだと思っていたところ、API Gateway のサービスがベータで公開されていたので、試します。

やること

GCPAPI Gateway を使って Firebase で認証が必要な API を実装します。

cloud.google.com

サンプルアプリの構成

サンプルでは下記の API が実装されています。

f:id:mukopikmin:20210105223906p:plain

Next.js で実装したフロントエンドアプリは Cloud Run 経由で配信します。 このアプリから Firebase Authentication で認証し、API へのアクセスにはこの認証情報を使用します。

API サーバーも Cloud Run で動作しています。 この API へのアクセスはすべて API Gateway を経由するようにし、認証情報の検証は API Gateway で吸収します。

実装は GitHub で公開しています。

github.com

サンプル API の構成

今回は Go 言語を使って下記 3 つの API を実装します。

  • /unprotected
    • 認証が不要な API
  • /protected
    • 認証が必要な API
  • /mirror
    • 認証が必要で、認証しているユーザーの情報を返す API

/unprotected/protected では下記のレスポンスを返すようにしています。

[
  {
    "id": 1,
    "title": "Sample book 1",
    "description": "This is first sample book"
  },
  {
    "id": 2,
    "title": "Sample book 2",
    "description": "This is second sample book"
  },
  {
    "id": 3,
    "title": "Sample book 3",
    "description": "This is third sample book"
  }
]

/mirror では API Gateway が接続先の API に渡している下記の認証情報をそのまま返します。

{
  "name": "**********",
  "picture": "https://**********",
  "iss": "https://securetoken.google.com/**********",
  "aud": "**********",
  "auth_time": 0000000000,
  "user_id": "**********",
  "sub": "**********",
  "iat": 0000000000,
  "exp": 0000000000,
  "email": "**********@**********",
  "email_verified": true,
  "firebase": {
    "identities": {
      "google.com": ["**********"],
      "email": ["**********@**********"]
    },
    "sign_in_provider": "google.com"
  }
}

ただし、この認証情報は API Gateway が接続先の API にアクセスするときに X-Apigateway-Api-Userinfo ヘッダーに Base64 エンコードされた状態で渡されています。 したがって、API を実装しているアプリケーション側でデコードし、JSON としてパースする必要があります。

API Gateway のデプロイ

API 定義の作成

API Gatewayゲートウェイを作成するためには、Swagger 2.0 で API の定義を作成する必要があります。 試しに、Open API Specification 3.0 のフォーマットで作成したファイルを使ってみましたがアップロードできませんでした。

Firebase Authentication で API へのアクセスを制限するには、Swagger 2.0 の定義に対して、API Gateway の拡張構文を追記します。

cloud.google.com

認証方法を定義します。

securityDefinitions:
  firebase:
    authorizationUrl: ""
    flow: "implicit"
    type: "oauth2"
    ### Replace YOUR-PROJECT-ID with your project ID
    x-google-issuer: "https://securetoken.google.com/YOUR-PROJECT-ID"
    x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com"
    x-google-audiences: "YOUR-PROJECT-ID"

そして、認証が必要なスコープに対して、security を定義します。 すべての API に対して アクセス制限をするならルート要素として、特定の API のみの場合は path や 特定の HTTP メソッド以下に記述します。

security:
  - firebase: []

API ゲートウェイの作成

作成するゲートウェイに必要な情報を設定しておきます。

PROJECT_ID=REPLACE_WITH_PROJECT
API_ID=sample-api-id
CONFIG_ID=samle-config-id
GATEWAY_ID=samle-gateway-id
GCP_REGION=asia-east1

まず、APIAPI Gateway 内のプロジェクトのような扱いのもの)を作成します。

cloud.google.com

gcloud beta api-gateway apis create $API_ID --project=$PROJECT_ID

次に、作成した API 定義をアップロードして、ゲートウェイの設定を作成します。

cloud.google.com

gcloud beta api-gateway api-configs create $CONFIG_ID \
  --api=$API_ID \
  --openapi-spec=openapi.yaml \
  --project=$PROJECT_ID

そして、ゲートウェイを作成します。

cloud.google.com

gcloud beta api-gateway gateways create $GATEWAY_ID \
  --api=$API_ID \
  --api-config=$CONFIG_ID \
  --location=$GCP_REGION \
  --project=$PROJECT_ID

作成したゲートウェイの情報を確認します。

gcloud beta api-gateway gateways describe $GATEWAY_ID \
  --location=$GCP_REGION \
  --project=$PROJECT_ID

以下のように表示されます。 defaultHostnameAPI Gateway にアクセスするためのエンドポイントのホスト名です。

apiConfig: projects/PROJECT_ID/locations/global/apis/API_ID/configs/CONFIG_ID
createTime: '2021-01-05T14:12:16.021807663Z'
defaultHostname: *********.de.gateway.dev
displayName: GATEWAY_ID
name: projects/PROJECT_ID/locations/GCP_REGION/gateways/GATEWAY_ID
state: ACTIVE
updateTime: '2021-01-05T14:14:00.175372465Z'

試しにアクセスして、レスポンスが変えることを確認します。

curl https://*********.de.gateway.dev

/API として定義していないので 404 が返ります。 (作成したアプリケーションは/でフロントエンドアプリケーションを返すようにしています)

{
  "code": 404,
  "message": "Request `GET /` is not defined by this API."
}

一方、認証が必要な API にアクセスすると 401 を返します。

curl https://*********.de.gateway.dev/protected
{
  "message": "Jwt is missing",
  "code": 401
}

当然ですが、認証が必要ない API は 200 を返します。

curl https://*********.de.gateway.dev/unprotected
[
  {
    "id": 1,
    "title": "Sample book 1",
    "description": "This is first sample book"
  },
  {
    "id": 2,
    "title": "Sample book 2",
    "description": "This is second sample book"
  },
  {
    "id": 3,
    "title": "Sample book 3",
    "description": "This is third sample book"
  }
]

クライアントアプリを使った動作確認

サンプルアプリでは Fireabase Authentication を使った認証と、バックエンドの各 API を呼び出せます。

認証していない状態では、/mirror/protectedにアクセスできません。

f:id:mukopikmin:20210118021400p:plain

一方、認証している状態ではすべての API にアクセスできていることがわかります。

f:id:mukopikmin:20210118021345p:plain

その他の動作確認

カスタムヘッダーを付加する

Swagger 2.0 の拡張できる内容は下記の URL にまとめられています。

cloud.google.com

よくある処理として、API Gateway で CORS ヘッダーを追加したりがあると思いますが、現時点ではできないようです。

前段に Cloud Load Balancing を配置した場合、Cloud Load Balancing 側でヘッダーを付加することはできます。

cloud.google.com

カスタムドメインを使用する

現時点(2021/01/18)ではカスタムドメインは使用できないようです。 ただ、Cloud Load Balancing を前段に置く形であればカスタムドメインを使用できるようです。

cloud.google.com

For the Beta release, custom domain names are not supported on GCP for API Gateway. If you want to customize the domain name for the Beta, you have to create a load balancer to use your custom domain name and then direct requests to the gateway.dev domain of your deployed API.

バックエンド API への API Gateway 以外からのアクセスを拒否する

API 定義から設定を作成するときに、--backend-auth-service-accountをつけることでサービスアカウントを指定できます。

実際に試したわけではありませんが、このサービスアカウントの権限を適切に設定することで、アクセス制御が実現できそうです。

まとめ

GCPAPI Gateway を使ってバックエンド API に認証機能を付加することができました。 API の定義に対して設定を記述することで機能を付加できる点は、個人的にはわかりやすくて好みでした。

ベータ版ということもあって機能は少なかったり、実装されていない機能もあるようですが、Cloud Load Balancing を使うことである程度補完できそうです。

Cloud Build から Code Climate にテストレポートを送信する

Cloud Build から Code Climate にテストカバレッジを含むテスト結果を送信しようとしたところ、ひと手間必要だったので、その手順を紹介します。

Code Climate Test Reporterの実行方法

Getting Started にあるように、Test Reporter のバイナリをダウンロードして実行することで、Code Climate にテスト結果を送信できます。

docs.codeclimate.com

Linux 環境であれば、Git リポジトリ内で下記のコマンドを実行します。 (事前に Code Climate 側でリポジトリの設定とトークンの発行をしておく必要があります)

# 実行ファイルの取得
curl -L \
  https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 \
  ./cc-test-reporter
chmod +x ./cc-test-reporter

# 実行前の準備
./cc-test-reporter before-build

# テスト結果の送信(SimpleCovの場合)
CC_TEST_REPORTER_ID=XXXXXXXXXXXXXXX ./cc-test-reporter after-build -t simplecov

今回はテストレポートを SimpleCov で出力したため -t simplecov としていますが、このオプションはテスト環境に応じて置き換えてください。

Cloud Buildでの実行

GitHub から Cloud Build への連携は GitHub Apps として Cloud Build に登録しておきます。

f:id:mukopikmin:20200115210227p:plain

Cloud Build で実行する場合も手順は変わりませんが、Cloud Build で取得されるリポジトリには .git ディレクトリが含まれていないことに注意する必要があります。

cc-test-reporterは実行時に、Git リポジトリの下記の情報を利用しています。

  • ブランチ名(GIT_BRANCH
  • コミットハッシュ(GIT_COMMIT_SHA
  • コミットタイムスタンプ(GIT_COMMITED_AT

.gitディレクトがないとこれらの情報が取得できないので、手動で与えるか、別の手段で .git ディレクトリを取得する必要があります。

手動で与える場合

手動で必要な情報を与える場合は、下記のスクリプトのように GIT_COMMITED_AT を手動で与える必要があります。

今回は .git ディレクトリがなく本来のコミットタイムスタンプを取得でないため、現在時刻を代わりに与えています。 なお、このタイムスタンプは Unixtime で与える必要があります。

#! /bin/sh

set -ex

curl \
  -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 \
  > ./cc-test-reporter
chmod +x ./cc-test-reporter

GIT_COMMITTED_AT=$(date +%s) ./cc-test-reporter $@

このスクリプトを使う場合の、cloudbuild.ymlは下記のようになります。

steps:
  # テスト用のイメージのビルド
  - name: gcr.io/cloud-builders/docker
    args: 
      - build
      - -t
      - gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
      - .
  # Test Reporter実行前の準備
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    dir: /app
    entrypoint: sh
    args:
      - scripts/test_report.sh
      - before-build
  # テストの実行
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    dir: /app
    args: 
      - bundle
      - exec
      - rake
      - spec
    volumes:
      - name: coverage
        path: /app/coverage
  # テスト結果の送信
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    entrypoint: sh
    dir: /app
    args:
      - scripts/test_report.sh
      - after-build
      - -t
      - simplecov
    env:
      - CC_TEST_REPORTER_ID=XXXXXXXXXXXXXXX
      - GIT_COMMIT_SHA=$COMMIT_SHA
      - GIT_BRANCH=$BRANCH_NAME
    volumes:
      - name: coverage
        path: /app/coverage

GIT_COMMIT_SHAGIT_BRANCH は Cloud Build でのビルド実行時に変数として与えるために変数名を変えて与えています。

一応、結果は送信されているようですが、コミットタイムスタンプに適当なものを与えている点が気になります。

.gitディレクトリを取得する場合

.gitディレクトリなしでパラメーターを与える場合、どうしてもコミットタイムスタンプなどのリポジトリ固有の情報を取得できません。

コミットタイムスタンプの正しい値を取得するためには、Git リポジトリの情報(.gitディレクトリ)が必要になります。 これを取得するアプローチを取った cloudbuild.yml は次のようになります。

steps:
  # リポジトリの取得
  - name: gcr.io/cloud-builders/git
    dir: /git
    args:
      - clone
      - https://github.com/user/repo.git
      - .
    volumes:
      - name: git
        path: /git
  # コミットタイムスタンプをボリュームに書き出す
  - name: gcr.io/cloud-builders/git
    entrypoint: sh
    dir: /git
    args:
      - -c
      - "git show -s --format=%ct $COMMIT_SHA > /git/timestamp"
    volumes:
      - name: git
        path: /git
  # テスト用のイメージのビルド
  - name: gcr.io/cloud-builders/docker
    args: 
      - build
      - -t
      - gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
      - .
  # Test Reporter実行前の準備
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    dir: /app
    entrypoint: sh
    args:
      - scripts/test_report.sh
      - before-build
    volumes:
      - name: git
        path: /git
  # テストの実行
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    dir: /app
    args: 
      - bundle
      - exec
      - rake
      - spec
    volumes:
      - name: coverage
        path: /app/coverage
  # テスト結果の送信
  - name: gcr.io/$PROJECT_ID/$REPO_NAME:$SHORT_SHA
    entrypoint: sh
    dir: /app
    args:
      - scripts/test_report.sh
      - after-build
      - -t
      - simplecov
    env:
      - CC_TEST_REPORTER_ID=XXXXXXXXXXXXXXX
      - GIT_COMMIT_SHA=$COMMIT_SHA
      - GIT_BRANCH=$BRANCH_NAME
    volumes:
      - name: coverage
        path: /app/coverage
      - name: git
        path: /git

この処理では、gitコマンドが使えるコンテナ内でリポジトリを取得し、以降の処理で必要になる情報をボリュームにファイルとして書き出しています。 このようにすることで、リポジトリのコミット情報を以降の処理で再利用できます。

リポジトリの情報であれば同様の手順で利用できるので、例えばコミットメッセージを利用したい場合は下記のステップを追加することで実現できます。

steps:
  # コミットメセージをボリュームに書き出す
  - name: gcr.io/cloud-builders/git
    entrypoint: sh
    dir: /git
    args:
      - -c
      - "git log --format=%B -n 1 $COMMIT_SHA > /git/message"
    volumes:
      - name: git
        path: /git

取得した値の参照は下記のようなスクリプトで利用できます。

#! /bin/sh

set -ex

curl \
  -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 \
  > ./cc-test-reporter
chmod +x ./cc-test-reporter

GIT_COMMITTED_AT=$(cat /git/timestamp) ./cc-test-reporter $@

まとめ

CI サービスでは、.gitディレクトリがないためリポジトリに関する情報を CI で再利用しにくいことがあります。 Cloud Build では上記の手順でボリュームに一時的に書き出すことでリポジトリの情報を利用できます。

Rails アプリケーションを Cloud Run にデプロイする

Cloud Run(Cloud Run on GKE でないほう)に Rails アプリケーションをデプロイします。

デプロイは下記の流れで進めます。

  1. Rails アプリの Docker イメージをローカルで作成して、Container Registry に Push
  2. Cloud SQL インスタンスとデータベースを作成
  3. ローカルで Cloud SQL Proxy を使って Schema Migration を実行
  4. Cloud Run にアプリケーションをデプロイ

今回作成したソースコードは下記においてあります。 scripts/ 以下に gcloud コマンドの実行サンプルもおいてあります。

github.com

Cloud Run

Cloud Run は GCP での Knative のマネージドサービスです。

cloud.google.com

cloud.google.com

Knative は Kubernetes 上で動作するソフトウェアで、Functions as a Service のような機能が使えます。

Cloud Run には Google が管理する Kubernertes 環境で動作する Cloud Run と自身の管理する GKE で動作する Cloud Run on GKE があります。

アプリケーションの構成

Active Record を使うシンプルな Rails アプリケーションを想定しています。

データベースには MySQL(Cloud SQL) を使います。 今回のデプロイ先は Cloud Run on GKE ではないため Cloud SQL へは VPC から接続できません。 Cloud SQL Proxy を使って Cloud SQL へ接続します。

Cloud Memorystore などの Cloud SQL 以外のサービスに接続するには現状 Cloud Run on GKE を使う必要がありそうなので、今回は扱いません。

Rails アプリケーションの作成

rails コマンドで Rails アプリを作成します。

rails new rails-cloud-run-sample --skip-bundle
cd rails-cloud-run-sample
bundle install --path vendor/bundle

Cloud SQLMySQL に接続するので mysql2 をインストールします。

bundle add mysql2

適当にモデルを作成します。

bundle exec rails g scaffold user name:string
bundle exec rails db:migrate

config/environments/production.rb を編集して、Production 環境でのアセットコンパイルを有効にしておきます。

- config.assets.compile = false
+ config.assets.compile = true

動作を一度確認するために、サーバーを起動して http://localhost:3000/users にアクセスします。

bundle exec rails s

f:id:mukopikmin:20190620232823p:plain

config/database.yml を編集します。

default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: db/development.sqlite3

test:
  <<: *default
  database: db/test.sqlite3

production:
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: <%= ENV['MYSQL_PASSWORD'] %>
  socket: <%= ENV['MYSQL_SOCKET'] %>
  database: sample
  username: root

次に、Dockerfile を作成します。

FROM ruby:2.6

ADD Gemfile Gemfile.lock /
RUN bundle install

WORKDIR /app

ADD . .

CMD ["bundle", "exec", "rails", "server"]

あらかじめ Container Registry API を有効にしておき、作成したイメージを Push します。

docker build -t rails-cloud-run-sample .
docker tag rails-cloud-run-sample gcr.io/<PROJECT_ID>/rails-cloud-run-sample
docker push gcr.io/<PROJECT_ID>/rails-cloud-run-sample

Cloud SQL インスタンスの作成

今回作成したアプリケーションが接続する MySQL サーバーを用意します。 Cloud Run が現時点で us-central1 でしか使えないので、それにあわせて us-central1 にインスタンスを作成します。

gcloud sql instances create cloud-run-sample \
  --region us-central1 \
  --assign-ip \
  --tier db-f1-micro \
  --root-password <PASSWORD>

Cloud Run では Cloud SQL Proxy 経由で接続するので、--assign-ip オプションを付けておきます。 パスワードは適当なものを設定してください。 (コンソールから作成すると乱数文字列を設定できます)

作成に成功すると下記のように出力されます。

Creating Cloud SQL instance...done.
Created [https://www.googleapis.com/sql/v1beta4/projects/<YOUR_PROJECT>/instances/cloud-run-sample].
NAME              DATABASE_VERSION  LOCATION       TIER              PRIMARY_ADDRESS  PRIVATE_ADDRESS  STATUS
cloud-run-sample  MYSQL_5_7         us-central1-a  db-f1-micro       xx.xx.xx.xx      -                RUNNABLE

次に、データベースを作成します。

gcloud sql databases create sample --instance cloud-run-sample

作成に成功すると下記のように出力されます。

Creating Cloud SQL database...done.
Created database [sample].
instance: cloud-run-sample
name: sample
project: <YOUR_PROJECT>

Schema Migration の実行

作成した Cloud SQL のデータベースに Rails アプリで使うテーブルを作成します。

まず、Cloud SQL にローカルから接続するために、ドキュメントにしたがってCloud SQL Proxy を用意します。

cloud.google.com

curl -o cloud_sql_proxy https://dl.google.com/cloudsql/cloud_sql_proxy.darwin.amd64
chmod +x cloud_sql_proxy
sudo mkdir /cloudsql
sudo chmod 777 /cloudsql

Cloud SQL Proxy は Docker でも実行できますが、Docker for Mac ではマウントした Unix ソケットを使って接続ができなかったのでローカルで実行するようにしています。 (TCP ソケットは Docker を使っても問題なく接続できました)

試してはいませんが、Linux 環境であれば Docker でも問題ないと思います。

Cloud SQL Proxy を起動します。 -credential_file-instances オプションなしで実行すると自動的に接続可能なインスタンスが選択され、gcloud でログインしているユーザーで実行されます。 複数のインスタンスが存在していたり、サービスアカウントを指定する場合は適宜設定してください。

./cloud_sql_proxy -dir /cloudsql

Cloud SQL Proxy を起動したままの状態で、Schema Migration を実行します。

export RAILS_ENV=production
export MYSQL_SOCKET=/cloudsql/<PROJECT_ID>:us-central1:cloud-run-sample
export MYSQL_PASSWORD=<PASSWORD>

bundle exec rails db:migrate

MYSQL_SOCKET にはプロジェクト ID と 次に作成する Cloud SQL インスタンスの名前とリージョンを組み合わせて指定します。

成功すれば下記のように表示されます。

== 20190620142533 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.1815s
== 20190620142533 CreateUsers: migrated (0.1816s) =============================

Cloud Run へのデプロイ

ドキュメントの記載に従ってデプロイします。

cloud.google.com

Rails アプリケーションで使う SECRET_KEY_BASE を生成します。

export SECRET_KEY_BASE=$(bundle exec rails g secret)

下記のコマンドを実行して、作成したアプリケーションを Cloud Run にデプロイします。

gcloud beta run deploy rails-cloud-run-sample \
  --image gcr.io/<PROJECT_ID>/rails-cloud-run-sample \
  --add-cloudsql-instances cloud-run-sample \
  --allow-unauthenticated \ 
  --region us-central1 \
  --memory 512Mi \
  --set-env-vars " \
    RAILS_ENV=production, \
    RACK_ENV=production, \
    MYSQL_SOCKET=/cloudsql/<PROJECT_ID>:us-central1:cloud-run-sample,\
    MYSQL_PASSWORD=<PASSWORD>, \
    SECRET_KEY_BASE=$SECRET_KEY_BASE \
  "

このとき、--add-cloudsql-instances オプションで接続する Cloud SQL インスタンスを指定しています。

デプロイに成功すると、下記のように表示さます。

Deploying container to Cloud Run service [rails-cloud-run-sample] in project [<PROJECT_ID>] region [us-central1]
✓ Deploying... Done.
  ✓ Creating Revision...
  ✓ Routing traffic...
  ✓ Setting IAM Policy...
Done.
Service [rails-cloud-run-sample] revision [rails-cloud-run-sample-00001] has been deployed and is serving traffic at https://rails-cloud-run-sample-xxxxxxxxx-uc.a.run.app

表示された URL にアクセスしてアプリケーションの動作を確認します。 初回アクセス時はコンテナを起動するのに 10 秒程度かかります。 2回目以降はすぐにレスポンスを返しますが、10 分から 15 分程度アクセスがなくなるとゼロスケールしてしまい、コンテナの起動を待つ必要があります。

f:id:mukopikmin:20190624235012p:plain

アプリケーションがステータス 500 を返す場合は下記のログが残っていないかを確認してください。 エラーメッセージにしたがって、Cloud SQL Admin API を有効にしてみてください。

CloudSQL connection failed: ensure that the account has access to "<YOUR-PROJECT>:us-central1:cloud-run-sample" (and make sure there's no typo in that name). 
Error during createEphemeral for <YOUR-PROJECT>:us-central1:cloud-run-sample: googleapi: Error 403: Cloud SQL Admin API has not been used in project XXXXXXXX before or it is disabled. 
Enable it by visiting https://console.developers.google.com/apis/api/sqladmin.googleapis.com/overview?project=XXXXXXXX then retry. 
If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

まとめ

Cloud Run を使うことで Docker コンテナのデプロイを PaaS 感覚で実行できました。 Cloud Run は Cloud Functions のようにリクエストがあったときの実行時間に対するリソース(メモリとCPU)に対して課金されるので、コスト最適化にも向いていると思います。

ただ現時点では制約が多く、ユースケースはよく検討する必要がありそうです。

1つ目は他のサービスとの連携です。 機能を拡大していくには VPC アクセスができる Cloud Run on GKE が選択肢に上がってきそうです。 Cloud Run on GKE では Cloud Run に加えて GKE のリソースに対する課金が発生するので注意が必要です。

cloud.google.com

2つ目はリージョンです。 現時点で US リージョンのみでの提供のためレイテンシは少し大きい点にも注意が必要です。

3つ目はオートスケールです。 Cloud Run の特徴でもあるオートスケールによって、アクセスがないときはアプリケーションはゼロスケールしてコンテナはゼロの状態になります。 この状態でアクセスするとコンテナの起動に少し時間がかかるため、ゼロスケールを防ぎたいのであれば Cloud Scheduler で定期的にアクセスさせるなどの工夫が必要です。

最初に Function as a Service のように使えると書きましたが、どちらかというと PaaS に近い気がします。 一般的な PaaS だとリクエストがない時間も課金対象ですが、そういった時間のリソースを効率的に管理してくれるという点で Function as a Service と PaaS のいいとこ取りをしているといってもいいかもしれません。

imgaug を使って Object Detection の学習データを拡張する

TL;DR

  • Object Detection 用学習データの拡張を自動化した
  • 拡張したデータが学習に与えた影響については未検証

github.com

Object Detection で用いる学習データ

今回、Object Detection のモデル構築には SSD のコードを Keras にポーティングした下記のコードを使用しており、このコードで使用できる学習データを生成することを目標としています。

github.com

学習に使うデータは画像に加えてアノテーション(画像内のオブジェクトの座標とラベル)が必要になります。 例えば、meat というオブジェクトを学習させる場合は、下記のように水平な矩形内にオブジェクトが収まるように指定した座標と、そのオブジェクトを識別するラベルを付与します。

f:id:mukopikmin:20181203233144p:plain

ラベル付けには labelImg を使用しました。

github.com

labelImg では Pascal VOC と Yolo の形式でアノテーションデータを生成できますが、 ssd_keras では Pascal VOC の形式が使用されているため、Pascal VOC の形式で出力します。

上記の肉画像のアノテーションは下記のようになります。

<annotation>
    <folder>bounding-box-augmentation</folder>
    <filename>pic.jpg</filename>
    <path>/Users/mukopikmin/Development/bounding-box-augmentation/pic.jpg</path>
    <source>
        <database>Unknown</database>
    </source>
    <size>
        <width>500</width>
        <height>281</height>
        <depth>3</depth>
    </size>
    <segmented>0</segmented>
    <object>
        <name>meat</name>
        <pose>Unspecified</pose>
        <truncated>0</truncated>
        <difficult>0</difficult>
        <bndbox>
            <xmin>76</xmin>
            <ymin>61</ymin>
            <xmax>463</xmax>
            <ymax>242</ymax>
        </bndbox>
    </object>
</annotation>

学習データ(画像+アノテーション)の拡張

認識対象にもよりますが、ある程度のボリューム(数百〜数千)で学習データを用意する必要があります。 ただ、画像をたくさん用意することができなかったり、人手でラベル付けをするのはつらかったりするかと思います。

そこで、画像データとアノテーションデータをセットで拡張することを考えます。 ここでいう画像データの拡張は下記のような操作で似た画像データを複数生成する方法を指します。

  • 変形させる(回転、移動、歪みなど)
  • ノイズを付加する(ぼかし、欠損など)
  • 色の変更(反転、グレースケールなど)

このような操作は Keras*1ImageDataGenerator でも実現できますがアノテーションデータまでは拡張できないため、imgaug を使って画像データとアノテーションデータをセットで自動生成します。

github.com

このライブラリでは画像が回転したり歪んだりしても、画像に与えた操作からオブジェクトの座標を再計算してくれます。

例えば画像とオブジェクトの座標をセットでそのまま回転するとこのようになります。

f:id:mukopikmin:20181204001842p:plain

imgaug では to_deterministic() を使うことで画像に加えた操作と同様の操作をアノテーションにも加え、座標で囲まれる範囲を水平な形に補正してくれます。

from imgaug import augmenters as iaa
import cv2

images = [cv2.imread('path/to/file')]
seq = iaa.Sequential([...])

seq_det = seq.to_deterministic()
images_aug = seq_det.augment_images(images)

f:id:mukopikmin:20181204233730p:plain

ただし、ドキュメント*2にも記載されているように、回転にはあまり強くなく想定した範囲よりも広い範囲を囲んでしまうようです。 上記の結果も肉の範囲よりも若干広い範囲が指定されています。

また、オプションを指定することで画像からはみ出てしまったオブジェクトの座標を画像内におさまる範囲の座標にすることもできます。 (画像外に完全に出てしまった座標は削除されます)

bbs_aug = []
for bb in seq_det.augment_bounding_boxes(bbs):
    _bb = bb.remove_out_of_image().cut_out_of_image()
    bbs_aug.append(_bb)

画像を加工した結果、画像内にオブジェクトが存在しない場合もあります。 このようなアノテーションデータは別途検査して取り除く必要があります。 (ssd_keras ではオブジェクトのないアノテーションデータを読み込むとエラーとなります)

まとめ

  • 学習データを画像とアノテーションのセットで自動的に拡張することができた
  • ノイズが入りすぎてオブジェクトを目視で認識できない場合もアノテーションは残ってしまうので、このようなデータは手動で取り除く必要がある
  • SSD は内部的には画像の拡張を行っているらしく、今回の方法で得たデータが学習にどれだけの効果を発揮するかはわからない*3

実装はほぼサンプルコード通りですが、ここで公開しています。

github.com