Amazon API Gateway + AWS Lambdaで、iOSでの定期購読をリアルタイムでSlackへ投稿する

2017年7月18日のnewsで、自動更新登録を含むすべてのレシートに、カスタマーの登録のステータスに関するリアルタイムの情報が含まれるように、なったためリアルタイムでslack通知するためにサクッと作りました。
Amazon API Gateway + AWS Lambda のレシピ用意されてて簡単だったので、ぜひ :roller_coaster:

構成

server-notifications.jpeg

  1. iTunes Connect

    • Server notifications for auto-renewable subscriptions
  2. Amazon API Gateway
  3. AWS Lambda
  4. Slack

Lambda 準備

Amazon API Gateway + AWS Lambda の構成はレシピ用意されている。
今回は、python3.6のfunctionを利用(node.js・python2.7も用意されている)

Lambda code

Webhook URLを取得して以下のコード
http://qiita.com/vmmhypervisor/items/18c99624a84df8b31008

import json
import urllib.request
import time

print('Loading function')

def respond(err, res=None):
    return {
        'statusCode': '400' if err else '200',
        'body': err.message if err else json.dumps(res),
        'headers': {
            'Content-Type': 'application/json',
        },
    }

class Receipt:
    def __init__(self, params):
         # 必要に応じて追加する
        self.notification_type = params['notification_type']
        self.product_id        = params['latest_receipt_info']['product_id']
        self.bid               = params['latest_receipt_info']['bid']

def send_slack(channel, receipt): 
    # slack webhook URLは入れ替え 
    url = 'https://hooks.slack.com/services/xxxxxxxxx/xxxxxxxxxxx'
    attachments = [
            {
                "fallback": "iOS Subscription Report",
                "color": "#36a64f",
                "author_name": "mikan",
                "author_link": "http://mikan.link",
                "author_icon": "http://flickr.com/icons/bobby.jpg",
                "title": "購読レポート",
                "text": "App: " + receipt.bid,
                "fields": [
                    {
                        "title": "購読",
                        "value": receipt.notification_type,
                        "short": False
                    },
                    {
                        "title": "商品id",
                        "value": receipt.product_id,
                        "short": False
                    }
                ],
                "image_url": "http://my-website.com/path/to/image.jpg",
                "thumb_url": "http://example.com/path/to/thumb.png",
                "footer": "Server notifications for auto-renewable subscriptions",
                "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png",
                "ts": time.time()
            }
        ]
    params = {"channel": channel, "username": "dekopon", "attachments": attachments}
    params = json.dumps(params).encode("utf-8")
    req = urllib.request.Request(url, data=params, method="POST")
    res = urllib.request.urlopen(req)

def lambda_handler(event, context):
    params = json.loads(json.dumps(event))
    body = params['body']
    receipt = Receipt(json.loads(body))
    send_slack("playground", receipt)
    return respond(None, "Post slack successfully!")

処理できるかテスト

以下のような方法で、jsonをPOSTしてみてテスト
sample jsonは記事下部に掲載しておきました。

  • Lambdaでテストイベントを設定して実行
  • curlやDHCでPOST実行

Subscription Status URL を設定

iTunes ConnectのMy Appsから、対象アプリを選択して、Subscription Status URLに作成したAPI GateWayのエンドポイントを指定
App StoreApp InformationSubscription Status URL

https://help.apple.com/itunes-connect/developer/#/dev0067a330b

あとは購読してもらえるものを作り込みましょう :rocket:

sample json

{
    "body": {
        "auto_renew_product_id": "link.mikan.sub.sample", 
        "auto_renew_status": "true", 
        "environment": "PROD", 
        "latest_receipt": "xxxxxxxxx", 
        "latest_receipt_info": {
            "app_item_id": "xxxxxxxxx", 
            "bid": "link.mikan.sample", 
            "bvrs": "1", 
            "expires_date": "123456", 
            "expires_date_formatted": "2017-09-05 22:19:54 Etc/GMT", 
            "expires_date_formatted_pst": "2017-09-05 15:19:54 America/Los_Angeles", 
            "item_id": "xxxxxxxxx", 
            "original_purchase_date": "2017-08-05 22:19:56 Etc/GMT", 
            "original_purchase_date_ms": "1501971596000", 
            "original_purchase_date_pst": "2017-08-05 15:19:56 America/Los_Angeles", 
            "original_transaction_id": "730000169590752", 
            "product_id": "link.mikan.sub.sample", 
            "purchase_date": "2017-08-05 22:19:54 Etc/GMT", 
            "purchase_date_ms": "1501971594000", 
            "purchase_date_pst": "2017-08-05 15:19:54 America/Los_Angeles", 
            "quantity": "1", 
            "transaction_id": "xxxxxxxxx", 
            "unique_identifier": "xxxxxxxxx", 
            "unique_vendor_identifier": "xxxxxxxxx", 
            "version_external_identifier": "xxxxxxxxx", 
            "web_order_line_item_id": "xxxxxxxxx"
        }, 
        "notification_type": "INITIAL_BUY", 
        "password": "xxxxxxxxx"
    }
}

参考

Lambda 関数を公開するための API を作成する
Amazon API Gatewayを使ってAWS LambdaをSDKなしでHTTPS越しに操作する
Advanced StoreKit – WWDC 2017 – Videos – Apple Developer

続きを読む

[ChatOps]Opsidianとdroneを使って、SlackからAWSへデプロイしてみた

はじめに

 先日【drone.ioを使ってCI環境を構築したので、色々整理してみた___<基本編>】を書きました。タイトルは揃っていませんが、今回はそれの続編で、Slackからデプロイコマンドを打って、droneがおとなしく指示通りにやってくれる方法について書きます、。

SlackでのChatOps

 (AWS寄りになってしまいますが)Slackを使って、ChatOpsをする時に、下記API Gatewayを使うパータンがよくあるのではないかと思います。もちろん、SlackからHubotを呼ぶのもあります。
スクリーンショット 0029-08-01 20.56.17.png
 ちなみに、Slack → API Gateway → Lambdaにご興味がある方は、下記、昨年私が書いた記事を見て頂ければと思います。
【Slack de AWS CLI】SlackのChannelからAWS CLIを実行できるようにしてみました!

 元々、自分もAPI Gatewayを使うと考えていました…が、つい先日(夜眠れない日)に、「なんか面白いツールないかな」とSlackのインテグレーションを漁っていて、偶然に見つかったのはOpsidianというツールです。正確的にはOpsidian.aiという名で、aiが付いています…よ。Opsidian.aiにご興味がある方は、是非Opsidian.aiの公式ページを見てください。面白いことをいっぱい書いてあります。
※ 私はあまりにも感動しすぎて、めちゃくちゃの英語でこのツールの親であるewadwornikowskaさんにメッセージを送りました。

 話を戻りますと、最終的にAPI Gatewayを辞めて、こんな感じにしました。

スクリーンショット 0029-08-01 21.00.52.png
 一見、API GatewayのかわりにOpsidianを使っているだけじゃんというツッコミもありますが、個人的にはOpsidianのほうが結構楽だと思います。
 API Gatewayもそれなりの使い方がありますので、ChatOpsをやる時に、選択肢が1つ増えたという捉え方のほうがいいと思っています。

 API GatewayとOpsidianを簡単に比べてみますと、

 1. API Gatewayの設定より、Opsidianのほいうが楽
 2. API Gatewayを利用する場合、認証とか入れないと、エンドポイントがバレたら、だれでも叩けるようになる。一方、OpsidianはSlackに設定したアクセスキーでLambdaをinvokeするので、安全(?)かも
 3. だれがLambdaを呼べるかの処理は、Opsidian側で制御可能。
 4. Opsidianって、ただLambdaをinvokeするだけではなく、AWSリソースのモニタリングもできる。
 ※ 私個人的な意見にはなりますが、OpsidianってChatOpsの神ツールだと思っています。

SlackからAWS ECRにイメージプッシュまでやってみる

 流れ的には、こんな感じになります。
 Slack → Opsidian → Lambda → GitHub → drone → AWS ECR
 何をやっているかとざっくり説明しますと、
 1: Slackからデプロイの指示を出します
 2: Opsidianはその指示通りにLambdaをinvokeします。
 3: invokeされたLamndaはGitHubへdeploy APICallします。
 4: GitHubはdeployのWebhookをdroneに送信します。
 5: droneは受信したWebhookからソースを取得し、ビルドを実行し、AWS ECRへイメージをpushします。

 細かい設定とかは割愛します。今後時間がありましたら、書くかもしれません。…と言っても、簡単に何を設定しているかを書きます。

1: SlackからOpsidianを呼べるようにしました。
  - Opsidianのインストールはこちら
  - Opsidianの設定はこちら

2: OpsidianがinvokeするLambdaを作りました。
  - OpsidianのDashboardにて、Slackから呼べるLambdaのコマンドdeployを設定しました。
  WechatIMG27.jpeg

  - 下記、超雑なLambdaサンプルコード
   ※ Opsidianのコマンドから渡された引数を使って、GitHubへdeploy APICall
   ※ GithubのPersonal access tokenはlambdaの環境変数GIT_KEYに設定しています。

deploy
import boto3
import json
import os
import http.client

def lambda_handler(event, context):
    conn = http.client.HTTPSConnection("api.github.com")
    print(event)
    args = event["args"].split(" ")
    repo    = args[0]
    branch  = args[1]
    env     = args[2]
    payload = {
        "ref": branch,
        "auto_merge": False,
        "environment": env,
        "description": "Deploy to "+ env,
        "required_contexts": []
    }

    headers = {
        'authorization': "token " + os.environ['GIT_KEY'],
        'User-Agent': "Awesome-Octocat-App"
    }

    conn.request("POST", "/repos/"+ repo +"/deployments", json.dumps(payload).encode("utf-8"), headers)

    res = conn.getresponse()
    data = res.read()

    print(data.decode("utf-8"))
    return json.dumps({'message': 'Hello world!'})

3: Github
  - 下記2つのファイル(あくまでもサンプル)が対象のリポジトリの配下に格納しました。

.drone.yml

# masterブランチに対して、
# GitHubのevnetがdeploymentかつenvironmentがproductionと指定された場合
# ビルドを実行

pipeline:
  ecr:
    image: plugins/ecr
    region: ap-northeast-1
    repo: 284****1948.dkr.ecr.ap-northeast-1.amazonaws.com/ecr
    tags: [ latest,drone-test ]
    secrets: [ ECR_ACCESS_KEY,ECR_SECRET_KEY ]
    when:
      branch: master
      event: deployment
      environment: production
Dockerfile
FROM centos:6

RUN set -x && \
    yum install -y httpd

4: drone
  - AWS ECRへpushできるdroneのプラグインplugins/drone-ecrを利用しています
    ※ ソースコードはこちら
  - 対象ブランチを有効化しました。
  1.jpeg
  
  - droneにecrへpushできる権限のキーを設定しました。
   ※ plugins/drone-ecr利用時、設定する環境変数はECR_ACCESS_KEYECR_SECRET_KEYになります。
 2.jpeg

  - 今回はGitHubのdeploymentの場合ですので、「Settings」の「Deploy Hook」を有効にしました。
3.jpeg

5: ECR
  - droneがビルドしたdockerイメージのpush先を予め用意しました。
WechatIMG16.jpeg

Slackからやってみる

1: Slackから /ops run deploy [repository] [branch] [environment]を打ってみます。
W1.jpeg

2: Opsidianから返信が来ました. やっはり Hello worldにすべきじゃなかったかもしれません。微妙に違和感が感じますが、一旦よしとします。
W2.jpeg

3: droneが動いていますね。
W3.jpeg

W4.jpeg

W5.jpeg
ちゃんとpushまでできましたね!

4: ECRにpushされたことも確認できました。
W7.jpeg

5: 終了

最後

 Opsidianとdroneで、SlackからAWS ECRへイメージをpushするまでについて整理しました。繰り返しにはなってしまいますが、Opsidianが結構便利なツールなので、ChatOpsだけではなく、モニタリングとかにも活用したいと思っています。
 今日はとりあえずここまでにします。

続きを読む

drone.ioを使ってCI環境を構築したので、色々整理してみた___<基本編>

初めに

drone.png

参加している開発プロジェクトでは、CIツールdroneを使っています。

私はdroneの知識がほぼゼロで、CI環境構築をスタートしたので、ハマリまくりました。
相当苦戦しましたが、先日やっとデプロイパイプラインを作れたので、droneについて、色々整理して書きたいと思います。
※ 使っているdroneはversion 0.7.3になります。
※ つい先日に、droneのversion 0.8がリリースされましたので、興味ある方は【release-0.8.0】を確認してください

詳細なアーキテクチャなどは説明できませんが、
こんな感じでdroneを使っています。
Github Enterprise → drone → AWS
  
Slack
5Fodx3O8rDVwXltL-855CD.png

今回はとりあえず下記2点について、まとめました。
droneの仕様
GithubのWebhook送信から、droneのジョブ実行までの流れ

droneについて、まだまだ理解が足りていないところがたくさんありますので、間違いとかありましたら、ご指摘ください。

droneの仕様

構成

  1. droneにはdrone serverdrone agentがあります。

    • drone server: 管理画面、認証、githubなどとの連携
    • drone agent: ジョブの実行
  2. droneが利用するデフォールトのデータベースはSQLiteで、Docker HubにあるdroneイメージにSQLiteが入っています。ビルド履歴、ユーザ情報、シークレット情報などすべてdatabaseに格納しています。
  3. droneはデフォルトでDocker Hubからイメージを取ってきますが、プライベートのレジストリも利用できます。

ビルドジョブの実行

  1. .drone.ymlにdroneにやって欲しいこと(どの条件でどういったこと)を定義します。
  2. drone serverはやってほしいジョブをキューに入れ、drone agentは順番にキューからジョブを取得し、実行します。
  3. drone serverとdrone agentは指定されたシークレットキー(任意の文字列)でお互いに認証を行い、WebSocketで通信する。

GithubのWebhookから、droneのジョブ実行までの流れ

Github側の設定は、drone公式ページの【GitHub】に記載されています。
GithubのWebhook送信から、droneのジョブ実行までの流れを自分なりに整理しました。間違っていたら、是非ご指摘ください。
スクリーンショット 0029-07-30 12.38.28.png

  1. GitHub上何かしらのイベントが発生したら、GithubがdroneへWebhookを送信します。
    Webhookの詳細内容はGithub → Settings → Webhooksにて確認できます。

  2. drone serverはGithubからのWebhookを確認し、Githubへレスポンスを返信します。

    • Webhookから送られたeventが.drone.ymlに書かれたeventであれば、drone serverがジョブをキューに入れます。
    • 対象ブランチに.drone.ymlがないと、drone serverはGithubにエラーを返します。
      ※ 具体的な動作について、(Goで書かれた)ソースコードを見てもよく分からなかったので、ご存知の方がいれば、是非教えてください。
      Webhook例:
      .drone.ymlがなく、drone serverから404が返された例
      01.jpeg
  3. drone agentはキューから順番にジョブを取得します。

  4. drone agentは.drone.ymlに書かれたジョブを実行します。
    4-1: gitのコンテナーを起動して、ソースコードを取得します。
    4-2 ~ 4-n: .drone.ymlに書かれたタスクを順番に実行します。

最後

今回下記2点について、自分なりに整理しました。
droneの仕様
GithubのWebhook送信から、droneのジョブ実行までの流れ

時間がありましたら、SlackでのChatOpsとか自分がハマったところとかについて整理して共有したいと思っています。

続きを読む

CodeBuildのビルド結果をSlackで通知する

2017-06-22のアップデートでCodeBuildのビルド結果をCloudWatchEventsで捕捉できるようになりました。
そのままSNSで通知してもいいのですが、LambdaからIncoming Webhookを使ってSlackに通知してみようと思います。

事前準備

以下のことはすでに済んでいる前提で話を進めます。

  • CodeBuildのプロジェクト作成
  • SlackのIncoming Webhookの設定(URL発行)

実装

さくっと済ませたいのでServerlessフレームワークを使います。

serverless.yml設定

まあこいつを見てもらおうか

serverless.yml
service: notify-build-result
provider:
  name: aws
  runtime: nodejs6.10
  timeout: 180
  stage: ${opt:stage, self:custom.defaultStage}
  #profileオプション追加
  profile: ${opt:profile, self:custom.defaultProfile}
  #regionオプション追加
  region: ${opt:region, self:custom.defaultRegion}
custom:
  defaultStage: dev
  #何も指定がなければ default profile
  defaultProfile: default
  #何も指定がなければ 北バージニア
  defaultRegion: us-east-1

functions:
  notify:
    handler: handler.notify
    memorySize: 128
    events:
      - cloudwatchEvent:
          event:
            source:
              - "aws.codebuild"
            detail-type:
              - "CodeBuild Build State Change"
            detail:
              build-status:
                - SUCCEEDED
                - FAILED
                - STOPPED
              project-name:
                - my-project-name
          enabled: true

イベントの設定

今回キモになるeventsの設定項目は詳しめに説明します。
cloudwatchEventを指定することで、cloudwatchのイベントをトリガーにできます。
cloudwatchEvent.event 以下の項目は

項目名 解説
source cloudwatchで見る対象 今回はCodeBuildが対象なのでaws.codebuildでOK
detail-type 検知するイベントの種類 “CodeBuild Build State Change”を指定するとCodeBuildのステートが変化したタイミングでイベントが発火します。
detail detail以下はCodeBuild特有の設定
detail.build-status CodeBuildのステートがここで設定したものになるとイベントが発火します。用意されているのはSUCCEEDED(成功), FAILED(失敗), STOPPED(中断), PROGRESS(進行中)がありますが、今回は結果を教えてほしいのでPROGRESSは外してます。
detail.project-name 監視対象のプロジェクト 配列の形で複数指定することもできます
enabled トリガーの有効/無効

Lambdaファンクション

payload確認

Lambdaファンクションの実装前に、cloudwatchEventからLambdaをコールしたときにevent引数に何が渡されるか確認しておきます。
いや、まじでトリガーによって引数全然違うから確認しとかないと死ぬ。

イベントの種類によってはLambdaのコンソールのテストイベントの設定からこんな感じで見れますが、
test_event.PNG

残念ながらCodeBuildのcloudwatchEventはこの方法では見られなかったので、Lambdaのコード内でconsole.log(event)とかやって調べます。

その結果がこちら

event.json
{
  "version": "0",
  "id": "50639ce0-1497-4911-baa0-79d9ca880a21",
  "detail-type": "CodeBuild Build State Change",
  "source": "aws.codebuild",
  "account": "account-number",
  "time": "2017-06-26T07:10:43Z",
  "region": "us-east-1",
  "resources": [
     "arn:aws:codebuild:us-east-1:account-number:build/my-project-name:9eb34205-9cba-40f2-a5d0-220497a9643a"
  ],
  "detail": {
    "build-status": "SUCCEEDED",
    "project-name": "my-project-name",
    "build-id": "arn:aws:codebuild:us-east-1:account-number:build/my-project-name:9eb34205-9cba-40f2-a5d0-220497a9643a",
    "current-phase": "COMPLETED",
    "current-phase-context": "[]",
    "version": "1"
  }
}

event.detail以下を見れば何とかなりそうですね。

ファンクション本体

それでは本体を実装していきましょう。
方針としては

  1. event引数からプロジェクト名とビルド結果を取得
  2. 予め用意したSlackのwebhook URLに送信

これだけです。
requestモジュールでPOSTするだけの簡単なお仕事ですね。

handler.js
"use strict";
const request = require('request');

function sendRequest(url, message, callback){

    const post = {
        text: message
    };
    //ヘッダーを定義
    const headers = {
        "Content-Type":"application/json"
    };

    const options = {
        url: url,
        method: "POST",
        headers: headers,
        json: true,
        body: post
    };
    request(options,  (error, response, body) => {
        console.log(body);
        return callback();
    })
}

module.exports.notify = (event, context, callback) => {
  const url = "your-webhook-url";
  const buildDetail = event.detail;
  const message = `${buildDetail["project-name"]}のビルドが${buildDetail["build-status"]}デース!`;
  sendRequest(url, message, () => {
    callback(null);
  });
};

結果確認

成功

pegasus_succeeded.PNG

失敗

pegasus_failed.PNG

なんでペガサスなの?

  1. SUCCEEDEDとかFAILEDとかわざわざ日本語に直すのがめんどくさかった
  2. かといって全部英文にして「英語で言われてもわかんねーよ」とか怒られるのも嫌だった
  3. せや、ペガサスだったら英語と日本語が混ざってても違和感ない!いけるやん!

実装してから気づいたこと、改善案

  1. cloudwatchEventの発火がめっちゃ早い

    • CodePipeline内に設置したCodeBuildに仕込んでたところ、ペガサスの通知が来てから数十秒間はCodePipelineのコンソールではビルド中と表示されていた。
  2. メンション欲しいかも
    • CodePipelineの手動承認とかと組み合わせるなら、担当者へのメンションが欲しい

続きを読む

JAWS-UG コンテナ支部 #9

ECS アップデート情報 Amazon Web Services Japan K.K. 岩永亮介さん (@riywo)

  • ConsleからSpotFleetが利用可能になった。
  • 時間やイベント駆動のタスク起動が可能になった。
  • RunTaskとStartTaskでCPUとメモリの設定が上書き可能になった。
  • AWSブログでFAQ的なソリューションを公開してる。
    • CI/CDパイプラインはどうすればいい?
    • デプロイはどうすればいい?
    • Blue/Greenデプロイメントのやり方には何がある?
    • クラスタを安全にスケールインするには?
    • 機密情報をうまく扱いには?
    • etc…

本番サービスでECSを使ってみた話(仮) 株式会社ナビタイムジャパン 渡部茂久さん

  • Slide
  • ECS移行にあたりハマったポイントを紹介
  • ELBのアイドルタイムアウトにハマる
    • ELBのタイムアウト<webサーバーのKeepAlive
  • ECSAgentの接続性監視
    • agentConnectedがfalseのインスタンス自動でクラスタから除外されず
  • インスタンスIDとDockerコンテナIDのマッピング
    • DockerコンテナIDを確認するにはホスト上でintrospectionAPIを実行する必要がある
    • fluentdコンテナ起動時にEC2のメタデータ取得APIを叩いてる
  • Dockerコンテナのメトリクス監視
    • Cloudwatchで監視
  • コンテナインスタンスのドレイニング
    • Labmdaで。クラスターサイズによってsleepを調整。
  • オートスケールとデプロイの両立
    • ecs-deployをカスタマイズして利用
  • ECSの良かったところ
    • 柔軟なデプロイ
    • Cloudwatch/Lambda/Cloudformationとの親和性
  • あとで清書。

AWS ECSのサービスをslack botでデプロイする話 株式会社クラウドワークス 福島明さん (@h3_poteto)

  • Slide
  • ecs-deploy使ってた。
  • DBのmigration問題。ecs-deployでは出来ない。
  • ecs-goploy作った。

GitHub + ECSで快適Review環境 株式会社Speee 天野太智さん (@pataiji)

LT: AWS CodeBuild カスタムDockerイメージを使ってビルドする 株式会社オープンストリーム 飯分俊行さん (@tiibun)

  • Slide

LT: ECS x Mackerel ~ 動的ポートマッピングに対応したタスクのメトリクスを取得する ~ 株式会社サイバーエージェント・クラウドファンディング 吉田慶章さん (@kakakakakku)

続きを読む

Serverless Framework+Node.jsをつかったLambda関数の開発フロー

元記事はこちら

今回は、Serverless Framework+Node.jsをつかったLambda関数の具体的な開発フローを書いてみる。動作確認やテスト、CI連携まで一貫して行なったので、参考になればと思う。

つくったもの

Webアプリケーション側である変更がはいった際に、複数のRDBとElasticsearchにまたがるデータを合わせて更新する、という、中間的な役割を実装した。S3に更新のデータが5分毎にPUTされるので、そこからイベントを取る。

フロー全体

一連の開発フローとしては以下のような流れになる。

  1. ロジック実装/ユニットテスト
  2. ローカルでの結合テスト
  3. development環境での結合テスト
  4. PR作成(CIでテストの実行)
  5. レビュー
  6. マージ(CIでテストの実行)
  7. staging(production)環境へデプロイ
  8. staging(production)環境での結合テスト

UIがないというのもあり、型とテストを主軸に開発をすすめるスタイルとなった。型・テストで動作担保しつつ、ローカルで動作確認、デプロイして結合動作確認、本番へ、というイメージ。以下、一つ一つ説明していく。

アーキテクチャ

Serverless Framework+Node.jsで普通に設計するという記事にも書いたが、構成としては以下のような感じである。

  • デプロイ/パッケージング

    • Serverless Framework
  • ビルド
    • webpack+Babel
  • 開発効率系
    • AVA
    • Flow
    • eslint
    • yarn
  • その他
    • axios/bluebird/lodash/momentなど

モジュール等はLambdaのサイズを削減するため、例えば以下のような読み込み方をしている。

// 👌使いたいモジュールのみを読み込む
import {isArray} from 'lodash';
isArray([1, 2, 3]);

// 🙅モジュール全部を読み込む
import lodash from 'lodash';
lodash.isArray([1, 2, 3]);

Lambda関係なしにこうするべきだが、今回は特に心がけた。それでもAWSコンソール上で書くLambda関数よりはだいぶ大きくなってしまうが…そこは多少複雑になるとやむを得ないのかなと思う。もしくは、webpack以外のbundlerを使って容量を削減するのは正しい気がする。

UIを考慮する必要がない分かなりシンプルな設計に落とし込めたと。特にフレームワーク等も使う必要はなかったが、モジュール単位で開発ができたのは品質に貢献したかなと思う。普段使っているスタックと変わらない構成で開発ができたが、特に、開発効率系がそのまま使えたのは嬉しかった。

ロジック実装/テスト

AWS SDKのテスト

Writing Testable AWS Lambda FunctionsTestable Lambda: Working Effectively with Legacy Lambdaでも述べられているが、Seam(接合部)に着目し、ロジックを切り分ければテストを書くのは難しくない。最初の記事ではLocalStackを使用しているが、今回はそこまでのことはせず、関数の責務を切り分け、テスタブルになるよう実装した。例えば以下のような感じである。

export const putToS3 = (params: TParams, s3: TS3Instance = S3Instance) => {
  const putObject = promisify(s3.putObject, {context: s3});
  return putObject(params)
    .then((data) => data)
    .catch((error) => error);
};

// 実際に使う時
import S3 from 'aws-sdk/clients/s3';
putToS3(new S3());

// テスト
import S3 from 'aws-sdk/clients/s3';
const s3Mock = {
  putObject: (param, callback) => {
    callback('', DATA_SUCCESS); // レスポンスもモックを返す
  },
};
putToS3(s3Mock);

これだけだと意味がないテストのようにも見えるが、実際には複数の関数が結合されるので、この手法を応用してテストを書いておくと、リファクタや機能追加時にかなり安心できる。RDBのテストも、コレと同じ要領でDBインスタンスをモックしてやったり、mockeryを使うなどしてまるごと書き換えたりをした。これにより、あとあとになって開発効率が上がってゆく感じになりとてもよかった。

関数の分離

↑の話ともかぶってしまうのだが、関数の責務をわけて、なるべく副作用のない純粋関数になるように気をつけた。これによってLambda関数がテスタブルになるというのはもちろんだが、責務が小さいために、実装のスピードにも影響すると感じた。

Node FunctionをPromise化

他に気をつけた点として、例外が起きた時のビジネス的な損失が大きな機能だったため、Node FunctionなどはすべてPromise化して、どこでエラーが起きてもcatchできるようにしたというのがある。例えば以下のような感じである。

import xml2js from 'xml2js';
const parser = new xml2js.Parser();
const parseString = promisify(parser.parseString, {context: parser});
return parseString(xmlData)
  .then((results) => results)
  .catch((error) => error);

このようにシンプルな処理ならもちろんNode Functionでもよいのだが、やはりPromiseにした方が見通しが良くなり値が扱いやすいのと、万が一、成功時の処理のなかでエラーが起きてもcatchされるので安心できた。

エラーログ

前述したが、例外が起きた時の損失に耐えうるように、基本的に確認しうるすべての箇所でエラーログをとった(CloudWatchにログされる)。また、Slackやメール通知、Lambda自体の監視(こちらはコードの管轄外だが)も行なった。

ローカルでの結合テスト

Serverless Frameworkを使うとLambda関数をローカルでも実行できる。これはWeb開発でいうと、ブラウザでの動作確認のようなもので、基本的に開発フローとしては、テストが通った後に結合テスト的な意味合いで確認した。例えば、handler.jsにLambdaへ露出させる関数を以下のように書いていたとする。

export const runDeleteFromDB = async () => {
  const ids = ['50681112', '50733612'];
  const result = await deleteFromDB(ids);
  console.log(result);
};

これは下記のようなコマンドで実行できる(実際はyarnコマンドにまとめてある)。

$(npm bin)/serverless webpack invoke --function runDeleteFromDB

DBの環境はDockerで用意してもらっていたが、関数を実行することできちんとローカルのDBに対して更新の処理をかけることができる。また、イベントなども任意で指定できる。

development環境での結合テスト

一通りテストと動作確認ができたら、デプロイして結合テストを行う。これは、あらかじめdevelopment環境を用意しておき、本番にいく前に確認する。

AWSの設定

Serverless Frameworkではデプロイする関数に対して指定するIAMロールを自動生成してくれる。例えば以下のように指定すると、BUCKET_NAMEに対してS3のGETアクションがかけられるようになる。

provider:
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource: "arn:aws:s3:::BUCKET_NAME/*"

BUCKET_NAMEなどの値を環境毎に切り替えるには以下のように記述できる。これで、Serverless CLIのstageオプションによってバケット名が切り替えられたりする。環境毎に値を切り替えたければ基本的にcustomに環境ごとの値を作っておけば切り分けができる。

provider:
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource: "arn:aws:s3:::${self:custom.bucketNameForUpload.${opt:stage}}/*"

custom:
  bucketNameForUpload:
    development: ${env:S3_BUCKET_NAME_FOR_UPLOAD_LOCAL} # ${env:}で環境変数を取得できる
    staging: ${env:S3_BUCKET_NAME_FOR_UPLOAD_STAGING}
    production: ${env:S3_BUCKET_NAME_FOR_UPLOAD_PRODUCTION}

VPCも以下のように設定できる。

provider:
  vpc: ${self:custom.vpc.${opt:stage}}

custom:
  vpc:
    development:
      securityGroupIds:
      subnetIds:
    staging:
      securityGroupIds:
        - sg-xxx
      subnetIds:
        - subnet-xxx
        - subnet-xxx
    production:
      securityGroupIds:
        - sg-yyy
      subnetIds:
        - subnet-yyy
        - subnet-yyy

このように、実装によってAWSの設定を変更する場合があればその対応をする。

デプロイ・実行

準備ができたらdevelopment環境へデプロイし、動作確認する。

# デプロイ
$(npm bin)/serverless deploy --stage development

# アップロードされているLambda関数をターミナルから実行
$(npm bin)/serverless invoke --log --stage development --function runDeleteFromDB

PR作成(CIでテストの実行)

ここまでできたらプッシュして、PRを作成する。CIでテストの実行は、yarn testでlintとユニットテストが流れる。

レビュー、マージ(CIでテストの実行)

レビューで指摘事項があれば修正し、マージする。マージ後、再度CIでテストが実行される。

staging(production)環境へデプロイ

マージ先がmasterブランチならステージングへ、releaseなら本番環境へデプロイされる。

staging(production)環境での結合テスト

Webでもなんでもそうかもしれないが、本番へデプロイされたら結合テストを行い、動作確認終了、これにて一連のフローが完了である。

その他

  • MySQLのコネクションは実行終了後に毎回切っておかないと、例えばVPCの設定を接続できないように変えても、同じインスタンスで接続し続けてしまうので、Lambdaであるにもかかわらずステートレスでなくなってしまう。また、JS側の側での接続インスタンス作成処理は、変数などにいれずに毎回リフレッシュしないと、これも同じインスタンスを使い続けてしまう。
  • 稀に、Lambdaの関数実行中のタイミングと合わせてデプロイすると(?)、Unable to import module 'handler': Errorみたいなエラーが起こることがあって、詳細まで原因を切り分けられていない…。そのため、デプロイのタイミングは実際にはCI自動デプロイではなく、手動でやることも多かった。もしわかる方いれば教えてください。
  • ハマった時にAWS側の基礎知識がそこそこ必要。僕のように普段フロントエンドをやってる人は最初は辛い時があるかも
  • 既存のS3バケットにトリガーをアタッチするときはこちら

まとめ

Webの開発フローをかなり踏襲できるとはいえ、Lambda特有のくせもあったので、今回やってみて、Lambdaの開発フローを通しでできたのは良かった。しっかり設計を行って開発ができたことは、品質(に対する要求を楽に実装するために…)に貢献したと思う。まだLambdaの開発フローは確立されていないところがあるので、よりよいスタイルを目指してやっていきたい。

続きを読む

GAになったLambda@Edgeを使ってSPAをSSR無しでOGPとかに対応させてみる

ということで、前回前々回の続きでServer Side Rendering無しでOGPとかに対応する試みその3です。

今回は先ほどGAになったLambda@Edgeを使ってみます。前回から時間が空いたのはLambda@Edgeが中々Previewを抜けず、またPreview中の情報公開は規約違反になってしまうためです。

Lambda@Edgeとは

Lambda@Edgeは簡単に言うとCloudFrontのいくつかのイベントをトリガーにLambda関数を実行する機能です。UAによって返すコンテンツを変えたりできるようになるので、ABテストとかに利用できます。今回はこれを使って、一般的なブラウザなら本来のアプリを表示し、それ以外の場合は静的なコンテンツを返すことでOGP対応してみます。

手順

今回のソースはここにあります。

Reactアプリを作る

前回同様create-react-appで作ります。

yarn create react-app edge-demo

必要なパッケージを追加

今回はreact-router-domとreact-helmetのみ使います。helmetはOGPとかに対応するだけなら必要ありませんが普通にアプリを表示させた場合にもヘッダを書き換えたいので入れてます。

yarn add react-router-dom react-helmet

ルートを作ってビルドする

前回とほぼ同じ内容なので省略しますが、HomeとAboutの二つのルートを作ります。

そしてyarn buildするとbuildディレクトリ以下に静的ファイルが出来上がります。前回のprerenderと違ってabout.htmlは作られずindex.htmlしか作られないのでこれをS3に配置しただけだとOGPの情報は拾えませんが、一旦これをS3に配置します。

Lambda関数を作る

AWS Lambdaのコンソールからviewer-requestをトリガーとした以下のような関数を作ります。

'use strict';

const whitelist = [
  'chrome',
  'crios', // chrome for ios
  'firefox',
  'fxios', // firefox for ios
  'googlebot', // since google bot can render javascript. TODO: update to react-router v4 on front
];

const isSupportedBrowser = (uas) => {
  if (uas && Array.isArray(uas) && uas.length > 0) {
    return uas.some(ua => whitelist.some(w => ua.value.toLowerCase().indexOf(w) !== -1));
  }
  return false;
};

exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;
  const ua = 'user-agent';

  if (!isSupportedBrowser(headers[ua])) {
    if (!request.uri || request.uri === '/' || request.uri.indexOf('index.html') !== -1) {
      request.uri = '/index.static.html';
    } else {
      request.uri += '.static.html';
    }
  }
  callback(null, request);
};

かなりいい加減ですが、Chromeの場合とgooglebotの場合はそのままアプリを表示、それ以外のUAの場合はパス+.static.htmlというファイルを表示するようにしています。
ちなみに静的ファイルのパスに書き換えるだけじゃなく、レスポンスの内容を動的に作って返すこともできますが、自動的にマッピングした静的ファイルを作成するような仕組みを考えているのでこのようにしました。
というか、この辺はどうするのがベストか模索中なのでオススメの方法があれば教えて頂けると嬉しいです。(というか今後出てくると思ってます)

.static.htmlを配置

上記でuriの書き換え先にしたindex.static.htmlとabout.static.htmlを配置します。

index.static.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Home</title>
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="Home">
  <meta name="twitter:description" content="description of Home">
  <meta name="twitter:image" content="http://path/to/image">
  <meta property="og:title" content="Home">
  <meta property="og:type" content="website">
  <meta property="og:url" content="http://path/to/this/url">
  <meta property="og:image" content="http://path/to/image">
  <meta property="og:description" content="description of Home">
</head>
<body>
  Home
</body>
</html>

about.static.htmlは省略します。

ブラウザから確認

まずはChromeで確認すると通常のアプリの画面が表示されます。画面自体は前回のこの画面と全く同じなので省略します。

Slackで確認

前回同様Slackに貼るときちんとOGPの情報を解釈してルートごとに表示してくれています。
でも前回と同じ画像にしてしまったので、前回のこのスクショと見た目が全く同じになってしまいました。別のテキストや画像にすれば良かったですね。

googlebot

ソースにある通りですが、googlebot向けには普通のアプリを返してます。これは前回まででgooglebotはネットワークリクエスト等のあるSPAでも問題なくindexできることが分かっているためと、クローキングと見なされる可能性があるためです。(確認はしていません)

所感

このやり方の利点は、既存AWSユーザはそのままCloudFront使い続けられることと、既存のアプリには手を加えずに対応できる(マッピングさせる処理などは除く)ことだと思います。一部のURLしか対応しなくて良い場合は楽な選択肢なんじゃないでしょうか。

ただCloudFrontでしか使えないので、AWSユーザ限定になってしまいますね。また、実際のLambda関数は特定のリージョンで動くので結局CDNの利点が損なわれてる気もします。この辺はその内改善されることを期待したいと思います。あと真面目にマッピングさせたり変更時にInvalidateさせたりするとそれなりに手間なので、素直にSSRするなり、Prerendering使うなりした方が良いって結論になるかもしれません。

最後に

ということで簡単ですがLambda@Edgeを使ってSSR無しでOGPに対応させる方法の紹介でした。これで一旦SSR無しのOGP対応のネタは終わりの予定です。

個人的には今回の用途ならNetlifyみたいなサービスが一番だと思いますが、Lambda@Edge自体は今回以外の用途にも色々応用できるので非常に強力なサービスだと思います。

ということで、参考になれば幸いです。
実運用等して気付いた点があれば追記したいと思います。

続きを読む

Lambda と LINE Notify でアラームを LINE に通知

LINE Notify を使うと、LINE に通知できるみたいなので試してみました。

LINE Notify とは

Webサービスからの通知をLINEで受信

Webサービスと連携すると、LINEが提供する公式アカウント”LINE Notify”から通知が届きます。
複数のサービスと連携でき、グループでも通知を受信することが可能です。

LINE Notify

準備

LINE Notify のアクセストークンを取得する必要があるので、LINE Notify にアクセスしてログインします。

アカウントの登録がまだの場合はここで登録します。

アクセストークン発行

ログインが完了したら、自分のアカウント→マイページと進みます。
以下のページが表示されるので、トークンを発行するをクリックします。

line1.png

トークンは再表示できないみたいなので、しっかりコピーします。

実装

LINE Notify で通知する基本的なコードは以下のサイトを参考にさせていただきました。
request モジュールのインストールも済ませておきましょう。
AWS Lambda + API GatewayでLINE Notifyを実装してみた

コード

main.js
const request = require('request');

// メイン処理
exports.handler = function(event, context) {
  (event.Records || []).forEach(function (rec) {
    const header_val = {
      'Content-Type':'application/x-www-form-urlencoded',
      'Authorization': 'Bearer [アクセストークン]', 
    };

    // ここから追加
    var message = JSON.parse(rec.Sns.Message);
        var status = message.NewStateValue

        if (status === "ALARM") {
            status = ":( " + status;
        }
        if (status === "OK") {
            status = ":) " + status;
        }
        var str = status +
                  ": " +
                  message.AlarmDescription +
                  "\n" +
                  message.NewStateReason;
    // ここまで

    const options = {
      url: 'https://notify-api.line.me/api/notify',
      method: 'POST',
      headers: header_val,
      form: { message : str} // メッセージを str に変更
    };

    // リクエスト送信
    request(options, (err, res, body) => {
      console.log(res.body);
    });
  });
};

Lambda へアップロード

コードとモジュールを zip で固めてアップロードします。
line2.png

実行

アラームの設定と SNS→Lambda トリガー設定は割愛します。
yes >> /dev/null & で負荷を高めていきましょう。

すると

Screenshot_20170712-123856.png

通知できました!!

Screenshot_20170712-124308.png

復旧も通知することが出来ました!!
(通知が来続けるので消しました。)

感想

無事 LINE に通知することが出来ました。
LINE グループにも通知できるようですので何れ試してみたいです。

参考

AWS Lambda + API GatewayでLINE Notifyを実装してみた
Amazon CloudWatchからアラームをslackに通知してみた

続きを読む