タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Ruby による Amazon Personalize の使い方

AWS機械学習レコメンデーションサービスである Amazon Personalize を案件で利用したのでSDKの使い方メモです。

Amazon Personalize

aws.amazon.com

Amazon Personalize は、機械学習の知識がなくても、簡単にレコメンデーションをアプリに組み込むことができるサービスです。

これまで、AWS機械学習レコメンデーションを行うには、ElasticMapReduce + Mahoutで自前で計算するなど、インフラ構築と保守の手間があったり、モデル設計や評価についての知識が必要だったりとなかなかに大変だったのですが、一般的なレコメンデーションに限っては Amazon Personalize を使うことで、サーバーレスで簡単に実現できるようになりました。

この記事では Amazon Personalize の使い方については解説しません。公式ドキュメントその他を参照してください。

docs.aws.amazon.com

Ruby SDK

github.com

リソースの作成

データセットグループ

DATASET_GROUP_NAME = "my-first-datasetgroup"

personalize = Aws::Personalize::Client.new

dataset_group_arn = personalize.create_dataset_group(name: DATASET_GROUP_NAME).dataset_group_arn

スキーマ

ユーザー、アイテム、インタラクションのどのデータセットタイプなのかににより、適切な形式のスキーマを作成する。

docs.aws.amazon.com

SCHEMA_NAME = "Users"
DATASET_TYPE = "users" # or "items" or "interactions "

personalize = Aws::Personalize::Client.new

avro_schema = case DATASET_TYPE
when "users"
  {
    "type": "record",
    "name": "Users",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "USER_ID",
        "type": "string"
      },
      {
        "name": "AGE",
        "type": "int"
      },
      {
        "name": "GENDER",
        "type": "string"
      }
    ],
    "version": "1.0"
  }
when "items"
  {
    "type": "record",
    "name": "Items",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "ITEM_ID",
        "type": "string"
      },
      {
        "name": "LABEL",
        "type": "string",
        "categorical": true
      },
      {
        "name": "CAST_ID",
        "type": "string",
        "categorical": true
      },
      {
        "name": "DURATION",
        "type": "long"
      },
      {
        "name": "PUBLISH_AT",
        "type": "long"
      }
    ],
    "version": "1.0"
  }
when "interactions"
  {
    "type": "record",
    "name": "Interactions",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "USER_ID",
        "type": "string"
      },
      {
        "name": "ITEM_ID",
        "type": "string"
      },
      {
        "name": "EVENT_TYPE",
        "type": "string"
      },
      {
        "name": "EVENT_VALUE",
        "type": "float"
      },
      {
        "name": "TIMESTAMP",
        "type": "long"
      }
    ],
    "version": "1.0"
  }
end

schema_arn = personalize.create_schema(
  name: SCHEMA_NAME,
  schema: avro_schema.to_json
).schema_arn

データセット

DATASET_NAME = "users"
DATASET_TYPE = "users" # or "items" or "interactions "
SCHEMA_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:schema/{{SCHEMA_NAME}}"
DATASET_GROUP_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}"

personalize = Aws::Personalize::Client.new

personalize.create_dataset(
  name: DATASET_NAME,
  schema_arn: SCHEMA_ARN,
  dataset_group_arn: DATASET_GROUP_ARN ,
  dataset_type: DATASET_TYPE 
)

学習用データを置くS3バケット

Amazon Personalize からアクセスできるようにバケットポリシーを設定する必要があります。

BUCKET_NAME = "myapp-personalize"

s3 = Aws::S3::Client.new

s3.create_bucket(bucket: S3_BUCKET_NAME)
s3.put_bucket_policy(
  bucket: S3_BUCKET_NAME,
  policy: {
    "Version": "2008-10-17",
    "Id": "PolicyForPersonalizePrivateContent",
    "Statement": [
      {
        "Sid": "PersonalizeS3BucketAccessPolicy",
        "Effect": "Allow",
          "Principal": {
            "Service": "personalize.amazonaws.com"
        },
        "Action": [
          "s3:GetObject",
          "s3:ListBucket"
        ],
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}",
          "arn:aws:s3:::#{S3_BUCKET_NAME}/*"
        ]
      }
    ]
  }.to_json
)

学習用データのインポートに使う実行ロール

AmazonPersonalizeFullAccess のサービスロールを使うのに加え、学習用データを置くS3バケットへのアクセスを許可します。

BUCKET_NAME = "myapp-personalize"
ROLE_NAME = "personalizeExecutionRole"
ROLE_PATH = "/myapp/"

iam = Aws::IAM::Client.new

role = iam.create_role(
  assume_role_policy_document: {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "Service": "personalize.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
      }
    ]
  }.to_json,
  path: ROLE_PATH,
  role_name: ROLE_NAME,
).role
iam.attach_role_policy(
  policy_arn: "arn:aws:iam::aws:policy/service-role/AmazonPersonalizeFullAccess",
  role_name: role.role_name,
)
iam.put_role_policy(
  policy_document: {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": [
          "s3:ListBucket"
        ],
        "Effect": "Allow",
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}"
        ]
      },
      {
        "Action": [
          "s3:GetObject",
          "s3:PutObject"
        ],
        "Effect": "Allow",
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}/*"
        ]
      }
    ]
  }.to_json,
  policy_name: "AllowAccessToS3Bucket",
  role_name: role.role_name,
)

イベントトラッカー

イベントトラッキングを行うには、イベントトラッカーを作る必要があります。 イベントトラッカーを作ると、イベントトラッカーが受け取ったデータを投入するデータセットなども一緒に作られます。

docs.aws.amazon.com

DATASET_GROUP_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}"

personalize = Aws::Personalize::Client.new

event_tracker_arn = personalize.create_event_tracker(
  name: EVENT_TRACKER_NAME,
  dataset_group_arn: DATASET_GROUP_ARN
).event_tracker_arn

学習用データのインポート

ユーザー、アイテム、インタラクションそれぞれ学習させたいデータセットタイプにあわせてCSVを作成します。 アップロードしたCSVを指定したデータセットインポートジョブを作成します。

データセットインポートジョブのパラメータとして、インポート先のデータセット、データセットタイプに対応するスキーマで作成したCSVのS3オブジェクトキー、実行IAMロールを指定します。

# CSV
BUCKET_NAME = "myapp-personalize"
OBJECT_KEY = "/users.csv"
FILE_PATH = "/tmp/users.csv"
# Amazon Personalize リソース
DATASET_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}/{{DATASET_NAME}}" # 作成したデータセット
ROLE_ARN = "arn:aws:iam::{{ACCOUNT_ID}}:role/myapp/{{ROLE_NAME}}" # 作成した実行ロール

s3 = Aws::S3::Client.new

File.open(FILE_PATH, 'r') do |file|
  s3.put_object(
    bucket: BUCKET_NAME,
    key: OBJECT_KEY,
    body: file,
    content_type: 'text/csv'
  )
end

personalize = Aws::Personalize::Client.new

personalize.create_dataset_import_job(
  job_name: "users-import-job-#{Time.current.to_i}",  # 重複はできない
  dataset_arn: DATASET_ARN,
  data_source: {
    data_location: File.join('s3://', BUCKET_NAME , OBJECT_KEY)
  },
  role_arn: ROLE_ARN,
)

リアルタイムイベントトラッキング

EVENT_TRACKER_TRACKING_ID = "0000000-0000-0000-0000-000000000000"
USER_ID = current_user.id
SESSION_ID = session.id
EVENT_TYPE = "rating"  # 今回は★1-5のレビュー評価をトラッキングする想定。自由に決められる。
ITEM_ID = review.item_id
EVENT_VALUE = review.rating
SENT_AT = review.created_at

personalize_events = Aws::PersonalizeEvents::Client.new

payload = {
  tracking_id: EVENT_TRACKER_TRACKING_ID,
  user_id: USER_ID.to_s,
  session_id: SESSION_ID ,
  event_list: [
    {
      event_type: EVENT_TYPE,
      properties: {
        itemId: ITEM_ID.to_s,
        eventValue: EVENT_VALUE,
      }.to_json,
      sent_at: SENT_AT,
     }
  ]
}
personalize_events.put_events(payload)

レコメンデーション結果の取得

アイテムベース (SIMS)も、ユーザーベースも、レコメンデーション結果を取得するAPIは同じです。 パラメータが違います。

アイテムベース (SIMSレシピのソリューションを使ったキャンペーン)

item_id を渡します。

CAMPAIGN_ARN = "arn:aws:personalize:{{REGION}}:{{AWS_ACCOUNT_ID}}:campaign/{{CAMPAIGN_NAME}}"
ITEM_ID = 1234
NUM_RESULTS = 20

personalize_runtime = Aws::PersonalizeRuntime::Client.new

# item_list[0].item_id #=> String
# item_list[0].score #=> 常に 0
item_list = personalize_runtime.get_recommendations(
  campaign_arn: CAMPAIGN_ARN ,
  item_id: ITEM_ID.to_s,
  num_results: NUM_RESULTS,
).item_list

ユーザーベース (HRNN レシピのソリューションを使ったキャンペーン)

user_id を渡します。

結果セットにスコアが含まれるので、おすすめ順に並べるのに使えますね。

CAMPAIGN_ARN = "arn:aws:personalize:{{REGION}}:{{AWS_ACCOUNT_ID}}:campaign/{{CAMPAIGN_NAME}}"
USER_ID = 1234
NUM_RESULTS = 20

personalize_runtime = Aws::PersonalizeRuntime::Client.new

# item_list[0].item_id #=> String
# item_list[0].score #=> Float
item_list = personalize_runtime.get_recommendations(
  campaign_arn: CAMPAIGN_ARN ,
  user_id: USER_ID.to_s,
  num_results: NUM_RESULTS,
).item_list

OpenAPI Specification からTypeScript型定義を生成するジェネレータ、swagger-to-tsを試す

GraphQLにおける graphql-codegen のような、 OpenAPI のSpecification から TypeScript の型定義を作ってくれる swagger-to-ts を触ってみました。

github.com

まとめ

  • VSCodeの補完が効いてうれしい
    • typoやパラメータの不足などのミスを防ぐ

使い方

型定義ファイルの生成

Shipments.v1.yaml という OpenAPI 3.0 Specification から、Shipments.ts という TypeScriptを生成します。

OpenAPI 3.0 Specification は YAML でも JSON でも大丈夫です。

"scripts": {
    "generate:difinitions:shipments": "npx @manifoldco/swagger-to-ts Shipments.v1.yaml -o Shipments.ts"
  }
使用した OpenAPI 3.0 Specification
swagger: "2.0"
info:
  title: Shipments
  version: "1.0"
  description: ""
host: "localhost:3000"
schemes:
  - http
produces:
  - application/json
consumes:
  - application/json
paths:
  /addresses:
    get:
      summary: List all addresses
      tags:
        - Shipments
        - Addresses
      responses:
        "200":
          description: OK
          schema:
            type: object
            properties:
              next_token:
                type: string
                format: uuid
              addresses:
                type:
                  - string
                  - array
                items:
                  $ref: "#/definitions/Address"
              total:
                type: integer
          examples:
            example-1: {}
      operationId: get-addresses
      description: List all addresses
      parameters:
        - type: integer
          in: query
          name: limit
        - type: string
          in: query
          name: token
          format: uuid
          allowEmptyValue: false
    post:
      summary: Create a new address
      tags:
        - Shipments
        - Addresses
      operationId: post-addresses
      responses:
        "200":
          description: OK
          schema:
            $ref: "#/definitions/Address"
definitions:
  Address:
    title: Address
    type: object
    x-tags:
      - Addresses
      - Shipments
    x-examples:
      example-1: {}
    description: Delivery address
    properties:
      id:
        type: string
      country:
        type: string
        enum:
          - US
          - JP
        example: JP
        description: Country code
      state:
        type: string
      city:
        type: string
      street:
        type: string
      name:
        type: string
      company:
        type: string
      email:
        type: string
        format: email
      phone:
        type: string
        pattern: "[0-9]{10,11}"
    required:
      - country
      - state
      - city
      - street
      - name
生成された TypeScript 型定義

リクエストのパラメータなどはなく、definitions のみのようですね。

/**
 * This file was auto-generated by swagger-to-ts.
 * Do not make direct changes to the file.
 */

export interface definitions {
  /**
   * Delivery address
   */
  Address: {
    id?: string;
    /**
     * Country code
     */
    country: "US" | "JP";
    state: string;
    city: string;
    street: string;
    name: string;
    company?: string;
    email?: string;
    phone?: string;
  };
}

生成された TypeScript 型定義を使う

import * as Shipments from "../lib/Shipments";

const address: Shipments.definitions["Address"] = {
  country: "JP",
  state: "Saitama-ken",
  city: "Saitama Shi Omiya Ku",
  street: "Ginza Bld.7F, 1-5, Miyacho",
  name: "Yuichi Takeuchi",
};

f:id:uzuki05:20200509175735p:plain
補完できてうれしい

Stoplight Studio を試す

OpenAPI の定義ファイルの作成と管理のためのツールを探していて、Stoplight Studio が良いと聞いたので試してみることにしました。

stoplight.io

f:id:uzuki05:20200509103856p:plain
Stoplight Studio

Stoplight Studio の触れ込み

Next gen editor for API design & technical docs (API設計と技術文書のための次世代エディタ)

Design APIs 10x faster with our free OpenAPI editor. Prototype and share your API within minutes.

無料の OpenAPI エディタを使用して、API を 10 倍速く設計します。数分以内にAPIをプロトタイプ化して共有します。

OpenAPI v2 & v3 API Designer (OpenAPI v2 & v3 APIデザイナー)

Start designing your API in minutes. Use the OpenAPI Specification (formerly known as Swagger) with Stoplight Studio to design consistent and standardized HTTP APIs for your organization. Without writing any code, model complex APIs faster than ever while simultaneously getting feedback on your prototypes with our instant mock servers.

数分でAPIの設計を開始します。Stoplight StudioでOpenAPI仕様(旧称Swagger)を使用して、一貫性のある標準化されたHTTP APIを設計します。コードを書くことなく、複雑なAPIをこれまで以上に迅速にモデル化すると同時に、インスタントモックサーバーを使用してプロトタイプのフィードバックを得ることができます。

A Powerful OpenAPI v2 & v3 Visual Editor (パワフルなOpenAPI v2 & v3ビジュアルエディタ)

Design an API with speed and efficiency from scratch. Stoplight Studio decreases the learning curve so you don’t need to be an OpenAPI expert to create first-class API designs. Describe endpoints, headers, bodies, multiple responses, query string parameters, shared models, and examples, and much more for complex APIs.

ゼロから迅速かつ効率的に API を設計します。Stoplight Studioは学習曲線を減らしてくれるので、OpenAPIの専門家でなくてもファーストクラスのAPIデザインを作成できます。エンドポイント、ヘッダ、ボディ、複数応答、クエリ文字列パラメータ、共有モデル、例など、複雑な API のための多くの機能を説明します。

Git Integration for Collaboration and Offline File Support (Git との統合によるコラボレーションとオフラインファイルのサポート)

Use the Git integration with your organization’s Git provider (GitHub, GitLab, Bitbucket, etc.). Invite teammates, partners, and API consumers to view and collaborate on your API designs privately or publicly with the tooling you already know and use.

With Offline File Support, open an existing OpenAPI document on your local machine or start from scratch with a new API, zero coding required. You can also share your OpenAPI document where ever you want.

組織のGitプロバイダー(GitHub、GitLab、Bitbucketなど)とのGit統合を使用します。チームメイト、パートナー、API消費者を招待して、APIデザインを非公開で、または既に知っていて使用しているツールを使って公開して、APIデザインを表示して共同作業を行うことができます。

オフラインファイルサポートでは、ローカルマシン上で既存のOpenAPIドキュメントを開くか、新しいAPIでゼロから始めることができ、コーディングは必要ありません。また、お好きな場所で OpenAPI ドキュメントを共有することもできます。

Instant Mock Servers (インスタントモックサーバー)

Instantly prototype and collaborate on your API design with our integrated, instant Mock Servers, powered by Prism. A mock API simulates the behavior of a real API allowing collaboration and feedback on your API design. Automatically update to match your API design. Mock a single API or multiple APIs at once.

Increase efficiency by allowing your frontend teams to start implementation while the backend team develops the API.

Prismを搭載した統合されたインスタントモックサーバーを使用して、API設計のプロトタイプを即座に作成し、共同作業を行うことができます。モックAPIは、実際のAPIの動作をシミュレートし、API設計のコラボレーションとフィードバックを可能にします。APIデザインに合わせて自動的に更新されます。1つのAPIまたは複数のAPIを一度にモックします。

バックエンドチームがAPIを開発している間にフロントエンドチームが実装を開始できるようにすることで、効率を向上させます。

Prism

stoplight.io

Prism, an Open-Source HTTP Mock & Proxy Server

Accelerate API development with realistic mock servers, powered by OpenAPI documents.

オープンソースのHTTPモック&プロキシサーバPrism

OpenAPIドキュメントを利用したリアルなモックサーバーでAPI開発を加速します。

Generate API Documentation with OpenAPI and Markdown (OpenAPIとMarkdownAPIドキュメントを生成する)

Create API documentation in minutes. Combine OpenAPI documents with Markdown to create robust, searchable, internal and external API documentation for your organization.

数分でAPIドキュメントを作成します。OpenAPIドキュメントとMarkdownを組み合わせて、組織のための堅牢で検索可能な内部および外部APIドキュメントを作成します。

OpenAPI Linting and API Style Guides (OpenAPIリンティングとAPIスタイルガイド)

Ask 100 API designers what makes a good API design and you’ll get 101 answers, but all most developers really want is consistency. Using an API style guide, can reduce decision making and improve consistency for all your teams.

With built-in linting and API style guides, powered by Spectral, Stoplight Studio can improve the quality of your API. You can use the default style guide, extend it, or write one to match your organization’s style guide (coming soon in Studio).

100人のAPIデザイナーに何が良いAPIデザインになるのかを聞いてみると、101の答えが返ってきます。APIスタイルガイドを使用することで、意思決定を減らし、すべてのチームの一貫性を向上させることができます。

Spectralが提供する組み込みのリンティングとAPIスタイルガイドにより、Stoplight StudioはAPIの品質を向上させることができます。デフォルトのスタイルガイドを使用することも、拡張することも、組織のスタイルガイドに合わせて記述することもできます(Studioでは近日公開予定)。

Spectral

stoplight.io

Spectral, an Open Source JSON/YAML Linter

Improve the quality of your API descriptions, Kubernetes config, GitHub Actions, or any other JSON/YAML data.

Spectral, オープンソースJSON/YAML リンター

API の説明、Kubernetes の設定、GitHub のアクション、その他の JSON/YAML データの品質を向上させます。

インストール

Web版、Macアプリ、Windowsアプリ、Linuxアプリ が提供されています。

私は Windows 使いなので、Windowsアプリ版をダウンロード・インストールします。

f:id:uzuki05:20200509111457p:plain

Getting Started

ドキュメントのGetting Startedに従って進めてみます。

stoplight.io

最初のプロジェクトを作成する

Open Git Project に提供されているサンプルプロジェクトのGitHubリポジトリURL https://github.com/stoplightio/studio-demo を指定して、プロジェクトを作成します。

f:id:uzuki05:20200509112102p:plain

f:id:uzuki05:20200509112406p:plain
プロジェクト作成完了時の画面

API記述ドキュメントの作成

f:id:uzuki05:20200509112724p:plain
新しいAPIの作成

API作成モーダルが開くので、

  • API
  • 仕様

を入力して Create をクリック。

f:id:uzuki05:20200509112822p:plain
API作成ダイアログ

API記述ドキュメントを作成できました。

f:id:uzuki05:20200509113041p:plain
作成されたAPI記述ドキュメント

エンドポイントの作成

エンドポイントは、個別のパスと操作です。

f:id:uzuki05:20200509115507p:plain
新しいエンドポイントの作成

エンドポイント作成モーダルが開くので、

  • エンドポイントを作成したいAPI記述ドキュメント
  • 管理用のタグ
  • Path
  • 存在する操作とその説明

を入力して Create をクリック。

f:id:uzuki05:20200509120009p:plain
エンドポイント作成モーダル

エンドポイントを作成できました。

f:id:uzuki05:20200509120414p:plain
作成されたエンドポイント

モデルの作成

モデルではAPIで使用されるデータ構造を定義できます。

stoplight.io

f:id:uzuki05:20200509135331p:plain
新しいモデルの作成

モデル作成モーダルが開くので

  • モデル名
  • タグ
  • スコープ

を入力して Create をクリック。

f:id:uzuki05:20200509135612p:plain
モデル作成モーダル

モデルを作成できました。

f:id:uzuki05:20200509135738p:plain
作成されたモデル

スキーマ定義

わかりやすいGUIスキーマエディタが使えます。

f:id:uzuki05:20200509144850p:plain
スキーマエディター

フォーマットやEnumなど追加のプロパティも設定できます。

f:id:uzuki05:20200509145105p:plain
追加のプロパティ

stoplight.io

モデルをエンドポイントの応答に使う

GET /addresses の 200 応答として Address モデルの配列を返す定義を作ってみます。

応答の Schema で

  • Type: `array``
  • Subtype: $ref
  • $ref target: #/definitions/Address

を選びます。

f:id:uzuki05:20200509150121p:plain
エンドポイントの応答にモデルを指定する

モックサーバーを使ってみる

f:id:uzuki05:20200509150738p:plain
モックURLを確認する

応答は定義内容から自動生成してくれてますね。

f:id:uzuki05:20200509151231p:plain
モックサーバーの応答

Linter を使ってみる

右下にLinting結果が表示されています。

f:id:uzuki05:20200509151610p:plain
警告あり

クリックすると警告内容が表示されました。

f:id:uzuki05:20200509151702p:plain
警告一覧

クリックすると該当箇所にジャンプします。 今回はレスポンスを1つも定義していない操作があることについての警告なので、該当する操作が表示されました。

f:id:uzuki05:20200509151905g:plain
警告から該当ファイルに移動

この画面でレスポンスを定義すると警告が消えました。

f:id:uzuki05:20200509152716g:plain
警告が消えた

まとめ

VScodeの拡張でもOpenAPI記述ドキュメント用のフォームを表示できるものはあるが、やはり専用の統合開発環境だけあって、使い勝手がよいように感じました。

GUIで指示に従って操作するだけで形になるので、仕様に詳しい人がいないチームで「なんもわからん」という状態でも始めやすいかなと思いました。

しばらく使ってみて、各機能の使い方などまたまとめていきたいと思います。

Lambda から EC2 インスタンス内でコマンドを実行する

Amazon SSM を利用することで、Lambda を使って、EC2インスタンス内で任意のコマンドを実行することができます。

これを CloudWatch Events と組み合わせると、従来CRONによって行っていたような定期実行タスクを、特定のEC2インスタンスをSPOFにすることなく実装することができ、オートスケーリングを有効にしたEC2クラスタ環境などで便利です。

blog.takeyuweb.co.jp

Amazon EC2 Simple Systems Manager (SSM)

対象のEC2インスタンスには事前にSSMエージェントをインストールしておく必要があります。 手動や、cloud-initなどでインストールしておきます。

docs.aws.amazon.com

CloudFormation テンプレート(抜粋)

f:id:uzuki05:20200501174049p:plain

Parameters:
  Command:
    Type: String

  # 実行したい Lambda Function
  # 起動中のEC2インスタンスから1件を取り出してコマンドを実行する
  Function:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          const AWS = require('aws-sdk');
          const ssm = new AWS.SSM({apiVersion: '2014-11-06'});
          const ec2 = new AWS.EC2({apiVersion: '2016-11-15'});
          const { Command } = process.env;

          const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
          const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); }

          class InstanceNotFoundError extends Error {
            constructor(message) {
              super(message);
              this.name = 'InstanceNotFoundError';
            }
          }

          exports.handler = async (event, context) => {
            console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event));

            const describeInstancesParams = {
              Filters: [
                {
                  Name: "tag:Service",
                  Values: [
                    "app"
                  ]
                },
                {
                  Name: "instance-state-name",
                  Values: [
                    "running"
                  ]
                }
              ]
            };
            debug("describeInstancesParams", describeInstancesParams);
            const describeInstancesResult = await ec2.describeInstances(describeInstancesParams).promise();
            debug("describeInstancesResult", describeInstancesResult);
            const reservation = describeInstancesResult.Reservations[0];
            if (!reservation) {
              throw new InstanceNotFoundError("App is Not Found");
            }

            const instanceStatus = reservation.Instances[0];
            const sendCommandParams = {
              DocumentName: 'AWS-RunShellScript',
              InstanceIds: [instanceStatus.InstanceId],
              Parameters: {
                commands: [Command],
                executionTimeout: ['3600']
              },
              MaxConcurrency: '1',
              MaxErrors: '0',
              TimeoutSeconds: 3600,
            };
            debug("sendCommandParams", sendCommandParams);
            const sendCommandResult = await ssm.sendCommand(sendCommandParams).promise();
            debug("sendCommandResult", sendCommandResult);

            const results = {
              ec2InstanceId: instanceStatus.InstanceId,
              sendCommandParams: sendCommandParams,
              sendCommandResult: sendCommandResult
            };
            debug("results", results);
            return results;
          };
      Environment:
        Variables:
          Command: !Ref Command
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 60

  # Lambda の実行ロール
  # 標準の AWSLambdaBasicExecutionRole サービスロールポリシーに加えて、
  # SSMコマンド実行用の ssm:SendCommand
  # EC2インスタンス一覧取得用の ec2:describeInstances
  # をインラインポリシーに追加
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      Policies:
        - PolicyName: run-command
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - ssm:SendCommand
                  - ec2:describeInstances
                Resource: "*"

CloudFormation で CloudWatch Events + Lambda による定期実行タスクを作成する

AWSで定期的な処理を行いたいときは、CloudWatch Events を使い、 Lambda ファンクションの実行をスケジューリングすることで行います。

docs.aws.amazon.com

設定画面

f:id:uzuki05:20200501172633p:plain
CloudWatch Events のルール

f:id:uzuki05:20200501172808p:plain
Lambdaファンクションの実行権限設定

CloudFormation テンプレート

f:id:uzuki05:20200501163233p:plain

Parameters:
  ScheduleExpression:
    Type: String
  Enabled:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
Conditions:
  isEnabled: !Equals [!Ref Enabled, "true"]
Resources:
  # 実行したい Lambda Function
  Function:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          exports.handler = (event, context, callback) => {
            console.log('LogScheduledEvent');
            console.log('Received event:', JSON.stringify(event, null, 2));
            callback(null, 'Finished');
          };
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 60

  # ScheduleExpression で指定したスケジュールで、 Function を実行する
  Rule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: !Ref ScheduleExpression
      State: !If [isEnabled, "ENABLED", "DISABLED"]
      Targets:
        - Id: Lambda
          Arn: !GetAtt Function.Arn

  # スケジュールイベントからのLambda実行を許可
  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref Function
      Principal: events.amazonaws.com
      SourceArn: !GetAtt Rule.Arn
  
  # Lambda の実行ロール
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

DockerCompose + PostgreSQL Replication

  • Railsの複数データベース機能を開発環境でも有効にしたかった
  • docker-compose up でセットアップから起動まで一発で動くようにしたい

Docker Compose

docker-compose.yml

version: "3"
volumes:
  pg_primary_data:
    driver: local
  pg_readonly_data:
    driver: local
services:
  pg_primary:
    build:
      context: ./docker/pg
      dockerfile: Dockerfile.primary
    command: postgres -c log_destination=stderr -c log_statement=all
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - pg_primary_data:/var/lib/postgresql/data
  pg_readonly:
    build:
      context: ./docker/pg
      dockerfile: Dockerfile.readonly
    command: postgres -c log_destination=stderr -c log_statement=all
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - pg_readonly_data:/var/lib/postgresql/data
    depends_on:
      - pg_primary

docker/pg/Dockerfile.primary

FROM postgres:11-alpine

ENV LANG C.UTF-8

COPY ./setup-primary.sh /docker-entrypoint-initdb.d/setup-primary.sh
RUN chmod 0666 /docker-entrypoint-initdb.d/setup-primary.sh

docker/pg/setup-primary.sh

#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE ROLE replication_user LOGIN REPLICATION PASSWORD 'replicationpassword';
EOSQL

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
SELECT * FROM pg_create_physical_replication_slot('node_a_slot');
EOSQL

mkdir $PGDATA/archive

cat >> "$PGDATA/postgresql.conf" <<EOF
wal_level = hot_standby
max_wal_senders = 10
max_replication_slots = 10
synchronous_commit = off
EOF

echo "host replication replication_user 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf"

docker/pg/Dockerfile.readonly

FROM postgres:11-alpine

ENV LANG C.UTF-8
ENV ENTRYKIT_VERSION 0.4.0

RUN wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
  && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
  && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
  && mv entrykit /bin/entrykit \
  && chmod +x /bin/entrykit \
  && entrykit --symlink

# ログ転送にscpを使用する
RUN apk --update add openssh-client && rm -rf /var/cache/apk/*

COPY ./setup-readonly.sh /setup-readonly.sh
RUN chmod +x /setup-readonly.sh

# https://github.com/docker-library/postgres/blob/primary/11/Dockerfile
ENTRYPOINT [ \
    "prehook", \
        "/setup-readonly.sh", \
        "--", \
    "docker-entrypoint.sh" \
]
CMD ["postgres"]

docker/pg/setup-primary.sh

#!/bin/bash
set -e

if [ ! -s "$PGDATA/PG_VERSION" ]; then
    echo "*:*:*:replication_user:replicationpassword" > ~/.pgpass
    chmod 0600 ~/.pgpass
    until ping -c 1 -W 1 pg_primary
    do
        echo "Waiting for primary to ping..."
        sleep 1s
    done

    until pg_basebackup -h pg_primary -D ${PGDATA} -U replication_user -vP -W
    do
        echo "Waiting for primary to connect..."
        sleep 1s
    done

    sed -i 's/wal_level = hot_standby/wal_level = replica/g' ${PGDATA}/postgresql.conf

    cat > ${PGDATA}/recovery.conf <<EOF
standby_mode = on
primary_conninfo = 'host=pg_primary port=5432 user=replication_user password=replicationpassword application_name=pg_readonly'
primary_slot_name = 'node_a_slot'
EOF

    chown postgres:postgres ${PGDATA} -R
    chmod 700 ${PGDATA} -R
fi

Rails で使う

https://github.com/takeyuweb/rails6-multidb-sample

docker-compose.yml

  app:
    build:
      context: ./docker/app
      dockerfile: Dockerfile.dev
    environment:
      DATABASE_HOST_PRIMARY: pg_primary
      DATABASE_HOST_READONLY: pg_readonly
      DATABASE_USER: postgres
      DATABASE_PASSWORD: password
    user: ruby
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    volumes:
      - .:/src
    ports:
      - 3000:3000
    tty: true
    stdin_open: true
    depends_on:
      - pg_primary
      - pg_readonly

config/database.yml

default: &default
  adapter: postgresql
  encoding: unicode
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  # 仮にこれを `hoge:` にすると `schema.rb` は `hoge_schema.rb` になる。
  primary:
    <<: *default
    database: MyApp_development
    host: <%= ENV.fetch('DATABASE_HOST_PRIMARY') { 'db' } %>
    username: <%= ENV.fetch('DATABASE_USER') { 'postgres' } %>
    password: <%= ENV.fetch('DATABASE_PASSWORD') { 'password' } %>
  primary_readonly:
    <<: *default
    database: MyApp_development
    host: <%= ENV.fetch('DATABASE_HOST_READONLY') { 'db' } %>
    username: <%= ENV.fetch('DATABASE_USER') { 'postgres' } %>
    password: <%= ENV.fetch('DATABASE_PASSWORD') { 'password' } %>
    replica: true

test:
  primary:
    <<: *default
    database: MyApp_test
    host: <%= ENV.fetch('DATABASE_HOST_PRIMARY') { 'db' } %>
    username: <%= ENV.fetch('DATABASE_USER') { 'postgres' } %>
    password: <%= ENV.fetch('DATABASE_PASSWORD') { 'password' } %>
  primary_readonly:
    <<: *default
    database: MyApp_test
    host: <%= ENV.fetch('DATABASE_HOST_READONLY') { 'db' } %>
    username: <%= ENV.fetch('DATABASE_USER') { 'postgres' } %>
    password: <%= ENV.fetch('DATABASE_PASSWORD') { 'password' } %>
    replica: true

app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :primary, reading: :primary_readonly }
end

Ruby で Firebase User を作成

動機

  • Firebase Auth を使ったWebアプリの Rails System Spec を書きたい
  • フロントエンドで Firebase JavaScript SDK を使っているため、バックエンドでのstubでは書けない

サンプルコード(一部抜粋)

  • Google::Apis::IdentitytoolkitV3::IdentityToolkitService Google::Apis::IdentitytoolkitV3::SignupNewUserRequest で作成できる
  • メールアドレスは実際に受信するなら MailSlurp あたりを使って用意する
require 'rails_helper'
require 'google/apis/identitytoolkit_v3'

RSpec.feature 'login', type: :system, js: true do
  def sign_in(email, password)
    visit root_path
    within(:css, '#login_modal') do
      fill_in('login_email', with: email)
      fill_in('login_password', with: password)
      find_button('Login', wait: 10).click  # Firebase JavaScript SDK によるメールアドレス認証
    end
    expect(page).to have_button('Logout', wait: 10)
  end

  def sign_out
    find_button('Logout', wait: 10).click
  end

  before do
    # Create Firebase User
    # https://qiita.com/asflash8/items/17775895e35272ae7ec8
    # https://firebase.google.com/docs/auth/admin/manage-users?hl=ja
    firebase_service_account_json_key = Rails.application.credentials.dig(:raw, :firebase_service_account_json_key)
    @identity_toolkit_service = Google::Apis::IdentitytoolkitV3::IdentityToolkitService.new
    @identity_toolkit_service.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
      json_key_io: StringIO.new(firebase_service_account_json_key),
      scope: 'https://www.googleapis.com/auth/identitytoolkit'
    )
    user_data = {
      email: 'test@takeyuweb.co.jp',
      email_verified: true,
      password: 'password1234',
      disabled: false
    }
    request = Google::Apis::IdentitytoolkitV3::SignupNewUserRequest.new(user_data)
    @account = @identity_toolkit_service.signup_new_user(request)

    # Signin
    sign_in(user_data.fetch(:email), user_data.fetch(:password))

    @current_user = User.find_by(email: user_data.fetch(:email))
    @password = user_data.fetch(:password)
  end

  after do
    if @account
      request = Google::Apis::IdentitytoolkitV3::DeleteAccountRequest.new(local_id: @account.local_id)
      @identity_toolkit_service.delete_account(request)
    end
  rescue Google::Apis::ClientError => e
    Rails.logger.error(e.message)
  end

  scenario "edit profile" do
    visit root_path
    find_link('Profile').click

    expect(page).to have_field('Name')
    expect(page).to have_field('Telephone')

    fill_in 'Name', with: 'Yuichi Takeuchi'
    fill_in 'Telephone', with: '81487003094'

    # (snip)
  end
end

参考

qiita.com

firebase.google.com