Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

サーバーレス関数は、クラウドサービスを開発・デプロイするための新しいプログラミングパラダイムです。サーバーレスの世界では、バックエンドサービスのプロビジョニング、保守、スケーリングといった作業をすべてクラウドプロバイダーに任せることができます。これにより、開発者は本来解決すべき課題に集中できるようになり、開発生産性が大幅に向上します。サーバーレス関数には多くのメリットとデメリットがありますが、構築時に検討すべきポイントの一つが「言語サポート」です。近年、GoogleはGoogle Cloud FunctionsにおけるRuby 2.7のサポートを発表しました。本記事では、Rubyを使ったサーバーレス関数の構築・テスト・デプロイ方法と、サーバーレス関数の長所・短所について詳しく解説します。

サーバーレスOTPシステムの構築

ワンタイムパスワード(OTP)とは、認証目的で使用される短い数字のコードです。銀行が本人確認のためにSMSでOTPを送信してくるのを見たことがある方も多いでしょう。

本記事では、次の3つのコアな役割を持つOTP関数を構築します。

POST /otp:指定されたphone_numberに対してOTPを生成し、メッセージを送信します。

# リクエスト
{
  "phone_number": "+2347012345678"
}

# レスポンス
{
  "status": true,
  "message": "OTP sent successfully",
  "data": {
    "phone_number": "+2347012345678",
    "otp": 6872,
    "expires_at": "2021-02-09 07:15:25 +0100"
  }
}

PUT /otp/verify:ユーザーが入力したOTPと照合して検証します。

# リクエスト
{
  "phone_number": "+2347012345678",
  "otp": 7116
}

# レスポンス
{
  "status": true,
  "message": "OTP verified",
  "data": {}
}

PUT /otp/resend:指定されたphone_numberに対してOTPを再生成し、再送信します。

# リクエスト
{
  "phone_number": "+2347012345678"
}

# レスポンス
{
  "status": true,
  "message": "OTP sent successfully",
  "data": {
    "phone_number": "+2347012345678",
    "otp": 8533,
    "expires_at": "2021-02-09 08:59:16 +0100"
  }
}

シンプルにするため、今回のクラウド関数では、本格的なSQLやNoSQLデータベースの代わりにCloud Memorystore(GCP上のRedisまたはMemcached)をバックエンドとして使用します。これにより、ステートレスな環境における「共有ステート」の扱い方についても学ぶことができます。

RubyによるGoogle Cloud Functionsの実装

GCF(Google Cloud Functions)で関数を作成するには、Google Cloudチームが提供しているFunctions Frameworkを使用します(詳細は後述します)。

まず、アプリケーション用のディレクトリを作成し、そのディレクトリに移動します。

mkdir otp-cloud-function && cd otp-cloud-function

次に、Gemfileを作成して依存関係をインストールします。

多くの標準的なRubyアプリケーションと同様に、bundlerを使って関数の依存関係を管理します。

source "https://rubygems.org"

# コア
gem "functions_framework", "~> 0.7"

# SMS送信用Twilio
gem 'twilio-ruby', '~> 5.43.0'

# データベース
gem 'redis'

# コネクションプーリング
gem 'connection_pool'

# 時間管理
gem 'activesupport'

# APIシリアライゼーション
gem 'active_model_serializers', '~> 0.10.0'

group :development, :test do
  gem 'pry'
  gem 'rspec'
  gem 'rspec_junit_formatter'
  gem 'faker', '~> 2.11.0'
end
bundle install

関数の作成

一般的に、ホスティング環境によっては関数を記述するファイル名を自由に指定できますが、Google Cloud Functionsではプロジェクトディレクトリのルートにあるapp.rbというファイル名である必要があります。それでは、関数を書いていきましょう。

app.rbを開いて、以下のように関数を作成します。

# Cloud Functions エントリーポイント

require 'functions_framework'
require 'connection_pool'
require 'active_model_serializers'
require './lib/store'
require './lib/send_sms_notification'
require './lib/response'
require './lib/serializers/models/base_model'
require './lib/serializers/models/otp_response'
require './lib/serializers/application_serializer'
require './lib/serializers/base_model_serializer'
require './lib/serializers/otp_response_serializer'

FunctionsFramework.on_startup do |function|
  # 共有Redisクライアントのセットアップ
  require 'redis'
  set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
end

# HTTP関数の定義
FunctionsFramework.http "otp" do |request|

  store = Store.new(global(:redis_client))
  data = JSON.parse(request.body.read)

  if  request.post? && request.path == '/otp'
    phone_number = data['phone_number']
    record = store.get(phone_number)
    unless record.nil? || record.expired?
      data = Models::OtpResponse.new(phone_number: phone_number,
                                      otp: record['otp'],
                                      expires_at: record['expires_at'])
      json = Response.generate_json(status: true,
                            message: 'OTP previously sent',
                            data: data)

      return json
    end

    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call

    data = Models::OtpResponse.new(phone_number: phone_number,
                                    otp: record['otp'],
                                    expires_at: record['expires_at'])

    Response.generate_json(status: true,
                          message: 'OTP sent successfully',
                          data: data)

  elsif request.put? && request.path == '/otp/verify'
    phone_number = data['phone_number']
    record = store.get(phone_number)

    if record.nil?
      return Response.generate_json(status: false, message: "OTP not sent to number")
    elsif record.expired?
      return Response.generate_json(status: false,  message: 'OTP code expired')
    end

    is_verified = data['otp'] == record['otp']

    if is_verified
      return Response.generate_json(status: true, message: 'OTP verified')
    else
      return Response.generate_json(status: false, message: 'OTP does not match')
    end

  elsif request.put? && request.path == '/otp/resend'
    phone_number = data['phone_number']
    store.del(phone_number)

    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call

    data = Models::OtpResponse.new(phone_number: phone_number,
                                    otp: record['otp'],
                                    expires_at: record['expires_at'])

    json = Response.generate_json(status: true,
                          message: 'OTP sent successfully',
                          data: data)
  else
    Response.generate_json(status: false,
                            message: 'Request method and path did not match')
  end
end

コード量が多いので、要点を分解して見ていきましょう。

  • FunctionsFramework.on_startupは、関数がリクエストの処理を開始する前に、各Rubyインスタンスごとに実行されるブロックです。関数が呼び出される前に行いたい初期化処理を記述するのに最適です。ここでは、Redisサーバーへのコネクションプールを作成して共有するために使用しています。

    set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
    

    これにより、複数の同時実行される関数呼び出し間でRedis接続オブジェクトのプールを安全に共有できます。スタートアップブロックは複数定義することも可能で、定義された順序で実行されます。なお、Functions Frameworkには関数完了後に実行する特別なフックが用意されていない点にも注意してください。

  • FunctionsFramework.http 'otp' do |request|は、関数のリクエストおよびレスポンス処理を担う部分です。この関数は3つの異なるルートパターンをサポートしています。また、他のGoogleサービスからのイベントを処理する別タイプの関数(例:FunctionsFramework.cloud_event 'otp' do |event|)を定義することもできます。さらに、同じファイル内に複数の関数を定義し、それぞれ独立してデプロイすることも可能です。

  • store = Store.new(global(:redis_client))では、globalメソッドを使ってグローバル共有ステートに保存されたオブジェクトを取得しています。上記の例では、startupブロックで設定したコネクションプールからRedisクライアントを取得しています。

  • ResponseModels::OtpResponseは、active_model_serializersを使用してレスポンスをシリアライズし、適切にフォーマットされたJSONレスポンスを返します。

ローカル環境での関数テスト

Functions Frameworkライブラリを使えば、クラウドにデプロイする前にローカル環境で簡単に関数をテストできます。ローカルテストは以下のコマンドで実行します。

bundle exec functions-framework-ruby --target=otp --port=3000

--targetオプションは、起動する関数を選択するために使用します。

手動テストも有効ですが、自動テストと自己検証型ソフトウェアこそがテストの理想形です。Functions Frameworkは、MinitestRSpecの両方向けにヘルパーメソッドを提供しており、httpハンドラーとcloudeventsハンドラーの両方のテストに対応しています。テストのサンプルは以下の通りです。

require './spec/spec_helper.rb'
require 'functions_framework/testing'

describe  'OTP Functions' do
  include FunctionsFramework::Testing

  describe 'Send OTP', redis: true do
    let(:phone_number) { "+2347012345678" }
    let(:body) { { phone_number: phone_number }.to_json }
    let(:headers) { ["Content-Type: application/json"] }

    it 'should send OTP successfully' do
      load_temporary "app.rb" do
        request = make_post_request "/otp", body, headers

        response = call_http "otp", request
        expect(response.status).to eq 200
        expect(response.content_type).to eq("application/json")

        parsed_response = JSON.parse(response.body.join)
        expect(parsed_response['status']).to eq true
        expect(parsed_response['message']).to eq 'OTP sent successfully'
      end
    end
  end
end

関数のデプロイ

まず、この関数が依存しているRedisサーバーをGoogle Cloud Memorystoreでデプロイする必要があります。GCPへのRedisサーバーのデプロイ手順は本記事の範囲外のため、詳細は割愛します。

Google Cloud Functions環境へのデプロイ方法は複数あります。ローカルマシンからのデプロイ、GCPコンソールからのデプロイ、そしてコードリポジトリからのデプロイです。現代のソフトウェアエンジニアリングでは、多くの開発工程においてCI/CDプロセスの導入が推奨されています。そこで本記事では、GitHub Actionsとdeploy-cloud-functionsアクションを使用して、GitHubからCloud Functionをデプロイする方法に焦点を当てます。

デプロイ用ファイル(.github/workflows/deploy.yml)をセットアップしましょう。

name: Deployment
on:
  push:
    branches:
      - main
jobs:
  deploy:
    name: Function Deployment
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - id: deploy
        uses: google-github-actions/deploy-cloud-functions@main
        with:
          name: otp-cloud-function
          runtime: ruby26
          credentials: ${{ secrets.gcp_credentials }}
          env_vars: "TWILIO_ACCOUNT_SID=${{ secrets.TWILIO_ACCOUNT_SID }},TWILIO_AUTH_TOKEN=${{ secrets.TWILIO_AUTH_TOKEN }},TWILIO_PHONE_NUMBER=${{ secrets.TWILIO_PHONE_NUMBER }},REDIS_URL=${{ secrets.REDIS_URL }}"

環境変数

上記のコードの最終行では、Google Cloud環境内で関数から利用できる環境変数を指定しています。セキュリティ上の理由から、これらの変数はコードベースに直接記述せず、代わりにGitHub Actionsのシークレット機能を使って情報を非公開に保っています。トークンが正しくデプロイされているかどうかは、Google Consoleで対象のCloud Functionを確認すればチェックできます。

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

認証

Cloud Functions AdminService Account Userのロールを持つサービスアカウントを作成します。

サービスアカウントは、マシン間(machine-to-machine)のIAM認証に使用されます。つまり、Google Cloud上で稼働しているかどうかにかかわらず、あるシステムがGoogle Cloud上の別のシステムと通信する際には、誰がGoogleリソースへのアクセスを要求しているのかを識別するためにサービスアカウントが必要になります。Cloud Functions AdminService Account Userのロールにより、そのユーザーがリソースへのアクセスを許可されているかどうかを判定できます。このシナリオでは、GitHub Actionsランナーがサービスアカウントとして認証を行い、関数のデプロイに必要な権限を持ってGoogle Cloudと通信します。

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

サービスアカウントキーを作成し、JSON形式でダウンロードして、GitHub Secretsに登録します。

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

これで完成です!🎉 クラウド関数が正常にデプロイされました。

Cloud Functionsの制限 vs. AWSの制限

以下は、2大サーバーレス関数プロバイダーの詳細な比較表です。

Rubyで構築するGoogle Cloud Functions:OTP認証システムの実装からテスト、デプロイまで

Functions Framework契約 vs. Serverless Framework

本記事ではGoogle Cloud Functions向けのクラウド関数の構築について取り上げてきました。最後に、Functions Frameworkを使った構築とServerless Frameworkを使った構築を比較してみましょう。

  • Serverless Frameworkserverless.ymlをベースとしています。一方、Functions FrameworkはFunctions Framework契約に基づいており、これはGoogle Cloudインフラ全体でサーバーレス関数をデプロイするために使用されます。
  • Serverless Frameworkの場合、参考になる事例が少なく、Rubyでサーバーレス関数を構築してGoogleのさまざまなサーバーレス環境(Cloud Functions、Cloud Run、Knative環境)へデプロイする方法が必ずしも明確ではありません。一方、Functions Framework契約を使用すれば、これらの異なるGoogle製品全体でRubyによる構築がシンプルに行えます。
    • 前述の点とも関連しますが、Functions Framework契約により、デプロイプロセスを大きく変更することなく、関数の背後にある言語を簡単に切り替えることが可能です。
  • 本稿執筆時点では、Functions FrameworkはGoogle Cloudサーバーレス環境とKnative環境間の相互運用性のみをサポートしています。対照的に、Serverless Frameworkは複数プロバイダーの複数プラットフォームに対応しています。

参考までに、記事で使用した完全なコードはこちらから入手できます。

  1. FluentdとObjectRocketで実現するハイブリッドクラウドのログ集約

    本記事は Hart Hoover と Ryan Walker による共著です。New RelicアラートをRackspaceサポートへ連携するサービスRackspace の DevOps Automation チームは最近、New Relic から発せられたアラートを Rackspace サポートに通知する新しいサービスを発表しました。このサービスでは、アラートが発生すると自動的にチケットが作成され、DevOps エンジニアが対応します。たとえ深夜3時にアラートが発生しても、お客様は安心して眠ることができるのです。さらに、お客様の環境から収集した他のデータポイントと組み合わせることで、エンジニ

  2. Googleクラウドプリントとは?仕組みと設定・共有方法をわかりやすく解説

    Googleクラウドプリントとは? Googleクラウドプリント(Google Cloud Print)は、Googleが開発したクラウド印刷サービスです。クラウド経由でプリンターと接続するため、離れた場所にいても、スマートフォンやタブレットなど任意のデバイスから印刷指示を出せるのが大きな特徴です。オフィスの資料や、お子様の課題に使う画像などを、外出先から自宅のプリンターへ直接印刷できます。 利用には以下のいずれかの環境が必要です。 インターネットに接続されたパソコンにプリンターが接続されている 「Cloud Print対応」プリンターを使用している(パソコン不要) さらに、複数のユーザー