かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

API Gateway で受信したメッセージを SNS 経由で Slack へ通知する

概要

記事一覧はこちらです。

API Gateway で受信したメッセージを Lambda で SNS へ転送し、SNS から Lambda で Slack へメッセージを送信してみます。

f:id:ksby:20200622232633p:plain:w450

API GatewaySNS 連携は AWS Service Proxy という機能を使えば直接送信できるそうですが、今回は Lambda 経由で送信します。

以下の仕様で実装します。

  • API Gateway にアクセスする時の URL は https://<deploy 時に表示されるドメイン名>/dev/send-msg
  • request body には message の項目だけが記述された JSON をセットします。 f:id:ksby:20200623235605p:plain
  • message にセットされた文字列を Slack の #general に送信します。

参照したサイト・書籍

  1. SNS
    https://www.serverless.com/framework/docs/providers/aws/events/sns/

  2. AWS - Resources
    https://www.serverless.com/framework/docs/providers/aws/guide/resources/

    • 以下の内容を参考にしました。
      • resources に定義する AWS リソースに DependsOn が定義できる。
      • serverless.xml 内で定義した lambda function を他の AWS リソースから参照する時に、
        • 末尾に LambdaFunction を追加した文字列で参照できる。
        • Override AWS CloudFormation Resource に function 名は normalizedFunctionName にする(先頭は大文字に変える、-_は文字列に変える)。
        • 例えば notifySlack という function ならば、先頭を大文字に変えて末尾に LambdaFunction を追加した NotifySlackLambdaFunction というリソース名になる。
  3. AWS CloudFormation でプッシュベースのイベントソースに AWS Lambda 関数をサブスクライブするにはどうすればよいですか?
    https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-subscribe-push-cloudformation/

  4. AWS: Publish SNS message for Lambda function via boto3 (Python2)
    https://stackoverflow.com/questions/34029251/aws-publish-sns-message-for-lambda-function-via-boto3-python2

  5. Requests: HTTP for Humans
    https://requests.readthedocs.io/en/master/

    • 今回から urllib3 ではなく requests を使ってみます。
    • urllib3 は boto3 の依存関係にあるので追加インストールの必要がありませんが、requests は個別にインストールする必要があります。
  6. Amazon SNS (from AWS) - The Ultimate Guide
    https://www.serverless.com/amazon-sns/

  7. Sending messages using Incoming Webhooks
    https://api.slack.com/messaging/webhooks

  8. ウェブフックを使用して Amazon SNS メッセージを Amazon Chime、Slack や Microsoft Teams に発行する方法を教えてください。
    https://aws.amazon.com/jp/premiumsupport/knowledge-center/sns-lambda-webhooks-chime-slack-teams/

  9. Requests: HTTP for Humans
    https://requests.readthedocs.io/en/master/

  10. Variables
    https://www.serverless.com/framework/docs/providers/aws/guide/variables/

  11. Encrypting messages published to Amazon SNS with AWS KMS
    https://aws.amazon.com/jp/blogs/compute/encrypting-messages-published-to-amazon-sns-with-aws-kms/

  12. moto(boto3のmockモジュール)の使い方:SQS/SNS
    https://qiita.com/ck_fm0211/items/08a7bc5a0c98de112cb7

  13. django setting environment variables in unittest tests
    https://stackoverflow.com/questions/31195183/django-setting-environment-variables-in-unittest-tests

  14. An Introduction to Mocking in Python
    https://www.toptal.com/python/an-introduction-to-mocking-in-python

  15. notify-slack
    https://registry.terraform.io/modules/terraform-aws-modules/notify-slack/aws/3.3.0

    • 書き終わるころに見つけました。メッセージを Slack に送信する手段が欲しいだけならば Terraform の module にしておいて簡単に作成できるようにしておくのもありですね。

目次

  1. apigw-sns-slack-project プロジェクトを作成する
  2. Serverless Framework で message_service サブプロジェクトを作成し serverless.yml を変更する
  3. requirements.txt を作成する
  4. API Gateway から呼び出す Lambda を実装する
  5. Slack App を作成し Webhook URL を生成する
  6. SNS から呼び出す Lambda を実装する
  7. ユニットテストを作成する
  8. deploy する
  9. 動作確認する

手順

apigw-sns-slack-project プロジェクトを作成する

以下の手順で apigw-sns-slack-project プロジェクトを作成します。具体的な手順は IntelliJ IDEA+Node.js+npm+serverless framework+Python の組み合わせで開発環境を構築して AWS Lambda を作成してみる 参照。

  • apigw-sns-slack-project の Empty Project を作成する。
  • Python の仮想環境を作成する。
  • Serverless Framework をローカルインストールする。
  • .envrc を作成する。
  • npm install --save-dev serverless-python-requirements
  • IDEA で Terminal を起動して boto3、requests、moto をインストールする。
    • pip install boto3
    • pip install requests
    • pip install moto

Serverless Framework で message_service サブプロジェクトを作成し serverless.yml を変更する

プロジェクトのルートディレクトリの下で npx sls create --template aws-python3 --path message_service を実行して message_service サブプロジェクトを作成します。

f:id:ksby:20200623042915p:plain

生成された serverless.yml を以下の内容に変更します。

service: message-service

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    dockerizePip: true

provider:
  name: aws
  runtime: python3.8

  stage: dev
  region: ap-northeast-1

  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "kms:GenerateDataKey"
        - "kms:Decrypt"
        - "sns:Publish"
        - "sns:Subscribe"
      Resource:
        - !Ref NotifySlackTopic

functions:
  sendMsg:
    handler: apigw_handler.send_msg
    environment:
      TOPIC_ARN: !Ref NotifySlackTopic
    events:
      - http:
          path: send-msg
          method: post
          cors: true

  notifySlack:
    handler: sns_handler.notify_slack
    environment:
      SLACK_WEBHOOK_URL: ${env:SLACK_WEBHOOK_URL}
    events:
      # - sns: notify-slack-topic という書き方ではなく resources で定義した Topic に関連付けている
      # ただしこの書き方だと Subscription を作成してくれないし、Topic にメッセージが publish されても Lambda 関数が実行されない
      # ので resources で Subscription を作成して必要な権限を付与している
      - sns:
        arn: !Ref NotifySlackTopic
        topicName: notify-slack-topic

resources:
  Resources:
    NotifySlackTopic:
      Type: AWS::SNS::Topic
      Properties:
        TopicName: notify-slack-topic

    # 以下のリソースは notifySlack 関数が作成された後に生成する
    # 既に作成されている Topic だと自動で Subscription を作成してくれないので、以下の定義で作成する
    NotifySlackSubscription:
      Type: AWS::SNS::Subscription
      DependsOn:
        # AWS - Resources
        # https://www.serverless.com/framework/docs/providers/aws/guide/resources/
        # notifySlack function ならば、先頭を大文字に変えて末尾に LambdaFunction を付けた
        # NotifySlackLambdaFunction という名称で参照できる
        - NotifySlackLambdaFunction
      Properties:
        TopicArn: !Ref NotifySlackTopic
        Endpoint:
          Fn::GetAtt:
            - NotifySlackLambdaFunction
            - Arn
        Protocol: lambda
    # Subscription を作成するだけでは Lambda を実行できないので、以下の定義で実行できるようにする
    NotifySlackLambdaResourcePolicy:
      Type: AWS::Lambda::Permission
      DependsOn:
        - NotifySlackLambdaFunction
      Properties:
        FunctionName: !Ref NotifySlackLambdaFunction
        Principal: sns.amazonaws.com
        Action: "lambda:InvokeFunction"
        SourceArn: !Ref NotifySlackTopic
  • Serverless Framework では SNS に関連付けたい場合 functions の events に - sns: notify-slack-topic と記述すればよいのですが、Topic へメッセージを publish する Lambda 関数も同じ serverless.xml に定義したい場合どうやって参照したらよいのかが分からなかったので、resources で NotifySlackSubscription を定義して、sendMsg と notifySlack の Lambda 関数から !Ref NotifySlackTopic で参照できるようにしました。
  • sendMsg ではメッセージを publish する時に NotifySlackTopic の arn が必要になるので、environment に TOPIC_ARN: !Ref NotifySlackTopic を定義し、環境変数で arn を渡しています。
  • 通常 resources に定義された AWS リソースが全て作成されてから Lambda 関数が作成されますが、今回は NotifySlackTopic → sendMsg と notifySlack の Lambda 関数 → NotifySlackSubscription と NotifySlackLambdaResourcePolicy の順に作成する必要があるため、NotifySlackSubscription と NotifySlackLambdaResourcePolicy に DependsOn を定義して Lambda 関数が作成された後に作成されるようにしています。
  • IAM Role は Lamda 関数毎に分けた方が良さそうですが、簡略化したかったので sns:Publishsns:Subscribe を1つの Role に入れました。

requirements.txt を作成する

message_service サブプロジェクトの下に requirements.txt を作成し、以下の内容を記述します。

requests==2.24.0

API Gateway から呼び出す Lambda を実装する

message_service サブプロジェクトを作成した時に作られている handler.py のファイル名を agigw_handler.py に変更した後、以下の内容を記述します。

import json
import logging
import os

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def send_msg(event, context):
    # body に { "message": "..." } のフォーマットで Slack へ送信したいメッセージを格納する
    logger.info(event)
    body = json.loads(event['body'])
    logger.info(f"message={body['message']}")

    sns_client = boto3.client('sns')
    response = sns_client.publish(
        TopicArn=os.environ['TOPIC_ARN'],
        Message=body['message']
    )

    response_body = {
        "message": f"published message to SNS (MessageId={response['MessageId']})",
        "input": event
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(response_body)
    }

    return response

Slack App を作成し Webhook URL を生成する

https://api.slack.com/ にアクセスした後、画面右上の「Your Apps」をクリックします。

f:id:ksby:20200623211218p:plain

以下の画像の画面が表示されるので「Create an App」ボタンをクリックします。

f:id:ksby:20200623211426p:plain

「Create a Slack App」ダイアログが表示されます。「App Name」に「SnsToSlack」を入力し、「Development Slack Workspace」を選択した後「Create App」ボタンをクリックします。

f:id:ksby:20200623211800p:plain:w300

「SnsToSlack」App が作成されて「Basic Information」画面が表示されます。「Incoming Webhooks」をクリックします。

f:id:ksby:20200623212015p:plain

「Incoming Webhooks」画面が表示されます。右上のトグルを On に変えます。

f:id:ksby:20200623212229p:plain

画面の下に「Webhook URLs for Your Workspace」が表示されます。「Add New Webhook to Workspace」ボタンをクリックします。

f:id:ksby:20200623212523p:plain

以下の画面が表示されます。「#general」を選択してから「許可する」ボタンをクリックします。

f:id:ksby:20200623212749p:plain

「Webhook URLs for Your Workspace」に発行された Webhook URL が表示されるのでコピーします。

f:id:ksby:20200623213101p:plain

SNS から呼び出す Lambda を実装する

Webhook URL は直接 Lambda 内には記述せず環境変数 SLACK_WEBHOOK_URL で渡すことにします。

.envrc に export SLACK_WEBHOOK_URL=... を追加します(AWS_PROFILE、SLACK_WEBHOOK_URL の ..... の部分には実際の値を記述します)。

export AWS_PROFILE=.....
# Windows 上の Python で UTF-8 をデフォルトにする
# https://qiita.com/methane/items/9a19ddf615089b071e71
export PYTHONUTF8=1

export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/.....

serverless.yml の notifySlack 関数に環境変数 SLACK_WEBHOOK_URL の値を渡すよう記述を追加します。

  notifySlack:
    handler: sns_handler.notify_slack
    environment:
      SLACK_WEBHOOK_URL: ${env:SLACK_WEBHOOK_URL}
    events:
      # - sns: notify-slack-topic という書き方ではなく resources で定義した Topic に関連付けている
      # ただしこの書き方だと Subscription を作成してくれないし、Topic にメッセージが publish されても Lambda 関数が実行されない
      # ので resources で Subscription を作成して必要な権限を付与している
      - sns:
        arn: !Ref NotifySlackTopic
        topicName: notify-slack-topic

message_service サブプロジェクトの下に sns_handler.py を新規作成し、以下の内容を記述します。

import json
import logging
import os

import requests

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def notify_slack(event, context):
    msg = {
        'text': event['Records'][0]['Sns']['Message']
    }
    encoded_msg = json.dumps(msg).encode('utf-8')
    res = requests.post(os.environ['SLACK_WEBHOOK_URL'], data=encoded_msg)
    logger.info(res.status_code)
    logger.info(res.headers)
    logger.info(res.text)

ユニットテストを作成する

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成し、中身が空の __init__.py ファイルを作成します。

まずは sendMsg 関数のテストから。tests ディレクトリの下に test_apigw_handler.py を作成し、以下の内容を記述します。

import json
import unittest
from unittest.mock import patch

import boto3
from moto import mock_sns

from message_service import apigw_handler

TOPIC_NAME = 'notify-slack-topic'


@mock_sns
class TestApigwHandler(unittest.TestCase):
    def setUp(self):
        sns_client = boto3.client('sns')
        response = sns_client.create_topic(
            Name=TOPIC_NAME
        )
        self._topic_arn = response['TopicArn']
        self.env = patch.dict('os.environ', {
            'TOPIC_ARN': response['TopicArn'],
        })

    def tearDown(self):
        sns_client = boto3.client('sns')
        sns_client.delete_topic(
            TopicArn=self._topic_arn
        )

    def test_send_msg(self):
        with self.env:
            with open('tests/apigw_event.json', encoding='utf-8', mode='r') as f:
                apigw_event = json.load(f)

            response = apigw_handler.send_msg(apigw_event, None)
            self.assertEqual(response['statusCode'], 200)

apigw_event.json も作成し、以下の内容を記述します。

{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "foo": "bar"
  },
  "multiValueQueryStringParameters": {
    "foo": [
      "bar"
    ]
  },
  "pathParameters": {
    "proxy": "/path/to/resource"
  },
  "stageVariables": {
    "baz": "qux"
  },
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip, deflate, sdch",
    "Accept-Language": "en-US,en;q=0.8",
    "Cache-Control": "max-age=0",
    "CloudFront-Forwarded-Proto": "https",
    "CloudFront-Is-Desktop-Viewer": "true",
    "CloudFront-Is-Mobile-Viewer": "false",
    "CloudFront-Is-SmartTV-Viewer": "false",
    "CloudFront-Is-Tablet-Viewer": "false",
    "CloudFront-Viewer-Country": "US",
    "Host": "1234567890.execute-api.ap-northeast-1.amazonaws.com",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Custom User Agent String",
    "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
    "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
    "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },
  "multiValueHeaders": {
    "Accept": [
      "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
    ],
    "Accept-Encoding": [
      "gzip, deflate, sdch"
    ],
    "Accept-Language": [
      "en-US,en;q=0.8"
    ],
    "Cache-Control": [
      "max-age=0"
    ],
    "CloudFront-Forwarded-Proto": [
      "https"
    ],
    "CloudFront-Is-Desktop-Viewer": [
      "true"
    ],
    "CloudFront-Is-Mobile-Viewer": [
      "false"
    ],
    "CloudFront-Is-SmartTV-Viewer": [
      "false"
    ],
    "CloudFront-Is-Tablet-Viewer": [
      "false"
    ],
    "CloudFront-Viewer-Country": [
      "US"
    ],
    "Host": [
      "0123456789.execute-api.ap-northeast-1.amazonaws.com"
    ],
    "Upgrade-Insecure-Requests": [
      "1"
    ],
    "User-Agent": [
      "Custom User Agent String"
    ],
    "Via": [
      "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)"
    ],
    "X-Amz-Cf-Id": [
      "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA=="
    ],
    "X-Forwarded-For": [
      "127.0.0.1, 127.0.0.2"
    ],
    "X-Forwarded-Port": [
      "443"
    ],
    "X-Forwarded-Proto": [
      "https"
    ]
  },
  "requestContext": {
    "accountId": "123456789012",
    "resourceId": "123456",
    "stage": "prod",
    "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
    "requestTime": "09/Apr/2015:12:34:56 +0000",
    "requestTimeEpoch": 1428582896000,
    "identity": {
      "cognitoIdentityPoolId": null,
      "accountId": null,
      "cognitoIdentityId": null,
      "caller": null,
      "accessKey": null,
      "sourceIp": "127.0.0.1",
      "cognitoAuthenticationType": null,
      "cognitoAuthenticationProvider": null,
      "userArn": null,
      "userAgent": "Custom User Agent String",
      "user": null
    },
    "path": "/prod/path/to/resource",
    "resourcePath": "/{proxy+}",
    "httpMethod": "POST",
    "apiId": "1234567890",
    "protocol": "HTTP/1.1"
  },
  "body": "{\r\n    \"message\": \"これはテストです  \"\r\n}",
  "isBase64Encoded": "False"
}

IDEA からテストを実行して成功することを確認します。

f:id:ksby:20200624095023p:plain

次は notifySlack 関数のテストです。tests ディレクトリの下に test_sns_handler.py を作成し、以下の内容を記述します。

import json
import os
import unittest
from unittest.mock import patch

from message_service import sns_handler


class TestApigwHandler(unittest.TestCase):
    def setUp(self):
        self.env = patch.dict('os.environ', {
            'SLACK_WEBHOOK_URL': 'https:/localhost/service/test',
        })

    def tearDown(self):
        None

    @patch('requests.post')
    def test_notify_slack(self, mock_requests):
        with self.env:
            with open('tests/sns_event.json', encoding='utf-8', mode='r') as f:
                sns_event = json.load(f)

            sns_handler.notify_slack(sns_event, None)

            msg = {
                'text': 'これはテストです'
            }
            encoded_msg = json.dumps(msg).encode('utf-8')
            mock_requests.assert_called_with(os.environ['SLACK_WEBHOOK_URL'], data=encoded_msg)

sns_event.json も作成し、以下の内容を記述します。

{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:ap-northeast-1:446693287859:notify-slack-topic:2e6206ff-f201-400c-86f8-5ad2a168d1d0",
      "Sns": {
        "Type": "Notification",
        "MessageId": "accf8eb7-8119-54a8-a2e4-d9db0cb190fb",
        "TopicArn": "arn:aws:sns:ap-northeast-1:446693287859:notify-slack-topic",
        "Subject": "None",
        "Message": "これはテストです",
        "Timestamp": "2020-06-23T23:08:24.463Z",
        "SignatureVersion": "1",
        "Signature": "INeRjQ+zIe3qolznKBSlM5p9c1IguSBa9CBkd3AXwmwJm3SEpqKD+g3+Xmg/KT5v2wg8MVpOMpu1UO6zYdQ4lSnU90DP6Q1e6Bngr9uvy5ypgpE7Hy5s5L24vUT5bdqQCpY8Ig+Pt4Fx1PMFaRw9WnASl+26JRlGR53hMPux7GZlugQIYrlAhPJ/ZUlD0gqP5+hTg86FLdQ4GYblruvV1TqI7nEzd7ou88lviXj/RC4YYUUK0fonaR5U9tLTJmHwyr/cV3oII2kw6FzsxASpEHi3GhalKRuy1bW4Lx+Y4a0ITavK+KvvCs5iK9jZ+W07E8HyWd/vjqtRjMjwa83kYQ==",
        "SigningCertUrl": "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-a86cb10b4e1f29c941702d737128f7b6.pem",
        "UnsubscribeUrl": "https://sns.ap-northeast-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-northeast-1:446693287859:notify-slack-topic:2e6206ff-f201-400c-86f8-5ad2a168d1d0",
        "MessageAttributes": {}
      }
    }
  ]
}

IDEA からテストを実行して成功することを確認します。

f:id:ksby:20200624095352p:plain

最後にコマンドラインの venv 環境下で python -m unittest -v を実行してテストが成功することを確認します。

f:id:ksby:20200624095523p:plain

deploy する

message_service を deploy します。

f:id:ksby:20200624110607p:plain f:id:ksby:20200624110717p:plain f:id:ksby:20200624110806p:plain

動作確認する

Postman を使用して API Gateway にメッセージを送信します。赤枠の部分が設定した箇所です。

f:id:ksby:20200624111022p:plain

Slack の #general にメッセージが届きました。

f:id:ksby:20200624111301p:plain

npx sls logs -f sendMsgnpx sls logs -f notifySlack --startTime 1h(ログが出てこなかったため過去1時間を表示するようオプションを追加しています) を実行してログを確認しても特に問題はなさそうです。

f:id:ksby:20200624111518p:plain f:id:ksby:20200624112129p:plain

npx sls remove -v を実行して削除します。

履歴

2020/06/24
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その92 )( http-proxy-middleware の createProxyMiddleware 関数の引数 context には Proxy させない URI を後に書く )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その91 )( Doma 2 を 2.28.0 → 2.34.0 へバージョンアップする+domaGen タスクを doma-codegen-plugin を利用したものに作り直す ) の続きです。

参照したサイト・書籍

目次

  1. 今の記述だと Proxy させない URI も Proxy されてしまう
  2. createProxyMiddleware 関数の引数 context に Proxy させない URI を後に記述するよう修正する

手順

今の記述だと Proxy させない URI も Proxy されてしまう

bs-springboot-config.js の createProxyMiddleware 関数の記述は現在以下のように Proxy させない URI を先に、Proxy させる URI を後に記述していますが、

const {createProxyMiddleware} = require("http-proxy-middleware");
const proxy = createProxyMiddleware(
  [
    // /css, /js, /vendor と *.html は Tomcat に転送しない
    "!/css/**/*",
    "!/js/**/*",
    "!/vendor/**/*",
    "!/**/*.html",
    "/**/*"
  ],
  {target: "http://localhost:8080"}
);

..........

npm run browser-sync:springboot コマンドを実行して Browsersync だけ起動した後(Tomcat は起動しません)、

f:id:ksby:20200621172846p:plain

http://localhost:9080/css/common.css にアクセスすると "!/css/**/*" の設定により Proxy されず /css/common.css が取得できると思っていましたが、Error occured while trying to proxy to: localhost:9080/css/common.css のエラーメッセージが表示されます。

f:id:ksby:20200621173048p:plain

Browsersync から Tomcat に Proxy しようとして Tomcat にアクセスできないのでエラーになっているようです。

createProxyMiddleware 関数の引数 context に Proxy させない URI を後に記述するよう修正する

この問題は "/**/*" の記述を一番最初にすることで解決できました。

bs-springboot-config.js を以下のように修正し、

const {createProxyMiddleware} = require("http-proxy-middleware");
const proxy = createProxyMiddleware(
  [
    // /css, /js, /vendor と *.html は Tomcat に転送しない
    "/**/*",
    "!/css/**/*",
    "!/js/**/*",
    "!/vendor/**/*",
    "!/**/*.html"
  ],
  {target: "http://localhost:8080"}
);

Browsersync を再起動してから http://localhost:9080/css/common.css にアクセスすると、今度は /css/common.css の内容が返ってきました。

f:id:ksby:20200621173935p:plain

Tomcat を起動して http://localhost:8080/inquiry/input/01 にアクセスすると入力画面1も表示されたので、問題ないようです。

f:id:ksby:20200621174923p:plain

履歴

2020/06/21
初版発行。

外部パッケージの Pillow と独自モジュール(.py ファイル)を Lambda Layer に配置する(後編)

概要

記事一覧はこちらです。

外部パッケージの Pillow と独自モジュール(.py ファイル)を Lambda Layer に配置する(前編) からの続きです。

参照したサイト・書籍

  1. PyCharm and PYTHONPATH
    https://stackoverflow.com/questions/28326362/pycharm-and-pythonpath

  2. Install a Python package into a different directory using pip?
    https://stackoverflow.com/questions/2915471/install-a-python-package-into-a-different-directory-using-pip

  3. AWS: Delete lambda layer still retains layer version history
    https://stackoverflow.com/questions/60824745/aws-delete-lambda-layer-still-retains-layer-version-history

目次

  1. IntelliJ IDEA 上でユニットテストが成功するように設定する
  2. コマンドプロンプトから python -m unittest -v を実行してユニットテストが成功するように設定する
  3. Docker コンテナから python -m unittest -v を実行してユニットテストが成功するように設定する
  4. IntelliJ IDEA からユニットテストを実行する時に Docker コンテナで実行する(docker-compose 版)
  5. deploy する
  6. 画像をアップロードしてサムネイル画像を生成してみる
  7. 1度 Lambda を実行してから Lambda Layer 側の内容を変更して再度実行すると変更は反映されるのか?
  8. 最後に

手順

IntelliJ IDEA 上でユニットテストが成功するように設定する

今の状態だとモジュール検索のパスに my_module_layer/python が追加されていないので resize_service/handler.py に追加した from image_lib import resize_utils に赤波線が表示されます。

f:id:ksby:20200617231806p:plain

IntelliJ IDEA でどうやって環境変数 PYTHONPATH を設定すればよいのか調べてみたのですが、my_module_layer/pythonコンテキストメニューを表示してから「Mark Directory as」-「Sources Root」を選択して、

f:id:ksby:20200617232232p:plain

Source Folder(青色のフォルダーアイコンになります)にすれば、

f:id:ksby:20200617232630p:plain

resize_service/handler.py の from image_lib import resize_utils から赤波線が消えました。

f:id:ksby:20200617232831p:plain

これで tests/test_resize.py で「Run 'Unittests for test_r...'」を選択してテストを実行すると、

f:id:ksby:20200617233020p:plain

テストが成功しました。breakpoint を設定して debug 実行することも出来ました。

f:id:ksby:20200617233220p:plain

コマンドプロンプトから python -m unittest -v を実行してユニットテストが成功するように設定する

venv/Scripts/activate.bat、venv/Scripts/deactivate.bat 内で環境変数 PYTHONPATH に my_module_layer/python絶対パス(自分の環境では D:\project-serverless\ksbysample-serverless\python-lambda-layer-project\my_module_layer\python)をセットします。

venv/Scripts/activate.bat の最後に以下の記述を追加します。

if defined PYTHONPATH (
    set _OLD_PYTHONPATH=%PYTHONPATH%
)
set PYTHONPATH=%PYTHONPATH%;D:\project-serverless\ksbysample-serverless\python-lambda-layer-project\my_module_layer\python

venv/Scripts/deactivate.bat の最後に以下の記述を追加します。

set PYTHONPATH=
if defined _OLD_PYTHONPATH (
    set PYTHONPATH=%_OLD_PYTHONPATH%
)
set _OLD_PYTHONPATH=

これでコマンドプロンプトから python -m unittest -v を実行してテストが成功するようになります。

f:id:ksby:20200618064915p:plain f:id:ksby:20200618065108p:plain

ただし IntelliJ IDEA の Terminal からは成功しませんでした。(venv) の文字が表示されていますが、venv/Scripts/activate.bat で activate している訳ではないようです。

f:id:ksby:20200618072704p:plain

Settings ダイアログの「Tools」-「Terminal」の「Environment Variables」に PYTHONPATH=D:\project-serverless\ksbysample-serverless\python-lambda-layer-project\my_module_layer\python をセットすれば成功しましたが、あちこち設定するのは好みではないので Terminlal には設定せず pip install 専用にしようと思います。

f:id:ksby:20200618073206p:plain f:id:ksby:20200618073411p:plain

Docker コンテナから python -m unittest -v を実行してユニットテストが成功するように設定する

今回は volume で指定したいディレクトリが2ヶ所あるので docker-compose で実行します。

まずはプロジェクトのルートディレクトリ直下に Dockerfile を新規作成し、以下の内容を記述します。

FROM lambci/lambda:build-python3.8

COPY shared_package_layer/requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip
RUN pip install -r /tmp/requirements.txt
RUN pip install moto

ENV PYTHONPATH "${PYTHONPATH}:/opt/python"

次にプロジェクトのルートディレクトリ直下に docker-compose.yml を新規作成し、以下の内容を記述します。

# docker-compose build
# docker-compose run --rm python-unittest

version: '3'

services:
  python-unittest:
    build:
      context: .
    image: lambci/lambda:build-python3.8-python-lambda-layer-project
    container_name: python-unittest
    volumes:
      - .:/var/task
      - ./my_module_layer/python:/opt/python
    command: python -m unittest -v

コマンドプロンプトを起動して docker-compose build を実行し Docker Image を作成します。

f:id:ksby:20200619045944p:plain f:id:ksby:20200619050041p:plain f:id:ksby:20200619050138p:plain f:id:ksby:20200619050232p:plain

最後に docker-compose run --rm python-unittest を実行すると Docker コンテナで python -m unittest -v が実行されてテストが成功します。

f:id:ksby:20200619050535p:plain

IntelliJ IDEA からユニットテストを実行する時に Docker コンテナで実行する(docker-compose 版)

IntelliJ IDEA のメインメニューから「File」-「Project Structure...」を選択して「Project Structure」ダイアログを表示します。

画面左側で「SDKs」を選択した後、中央で「+」-「Add Python SDK...」を選択します。

f:id:ksby:20200619051224p:plain

「Add Python Interpreter」ダイアログが表示されます。画面左側で「Docker Compose」を選択した後、右側の設定を以下の画像のようにしてから「OK」ボタンをクリックします。

f:id:ksby:20200619051325p:plain

「Name」に長い名前が設定されるので Remote Python 3.8.3 Docker Compose (python-lambda-layer-project) に変更して「OK」ボタンをクリックしダイアログを閉じます。

f:id:ksby:20200619051657p:plain

IntelliJ IDEA のメインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示します。

画面左側で「Templates」-「Python tests」-「Unittests」を選択し、以下の内容を設定して「OK」ボタンをクリックします。

f:id:ksby:20200619052511p:plain

  • Python interpreter」で「Use specified interpreter」を選択した後「Remote Python 3.8.3 Docker Compose (python-lambda-layer-project)」を選択します。
  • 「Working directory」に D:\project-serverless\ksbysample-serverless\python-lambda-layer-project を入力します。

設定は以上で完了です。エディタから「Debug 'Unittests for test_r...'」を選択して1回 debug 実行します。

f:id:ksby:20200619052918p:plain

この時テストは成功しますが、まだ docker-compose で実行されていません。

再度「Run/Debug Configurations」ダイアログを表示すると左側に「Python tests」-「Unittests for test_resize.TestResizeService.test_resize」が追加されているので、「Python interpreter」を「Use SDK of module」→「Use specified interpreter」に変更します。

f:id:ksby:20200619055335p:plain

再度エディタから「Debug 'Unittests for test_r...'」を選択して debug 実行します。

Console に以下のように表示されて、

f:id:ksby:20200619055831p:plain

「Step Into」「Step Over」で進めると my_module_layer/python/image_lib/resize_utils.py の resize_image 関数まで進めることができます。

f:id:ksby:20200619060002p:plain

Services Tool Window を見ると python-unittest コンテナが実行されており、

f:id:ksby:20200619060419p:plain

Debug Tool Window で「Resume Program」ボタンを押してテストを最後まで実行すると成功します。

f:id:ksby:20200619060520p:plain

Services Tool Window を見ると pycharm_helptest_IU コンテナが残っているのは docker コマンドで実行した時と同じですが、python-unittest コンテナまで残っています。。。

f:id:ksby:20200619061502p:plain

「Run/Debug Configurations」ダイアログから docker-compose 実行時のオプションを確認すると --rm オプションがありません。「Command and options」で設定しようとしましたが、何かエラーが出て設定できませんでした。どうやったら設定できるのだろう?

f:id:ksby:20200619061722p:plain

手動で残っているコンテナを削除することにします。自動で削除できないなら docker-compose ではなく docker コマンドで実行する方式に変更した方が良さそうです。

deploy する

resize_service を delploy します。

f:id:ksby:20200619062817p:plain f:id:ksby:20200619062916p:plain

マネジメントコンソールで deploy された resize-service-dev-resize を見ると shared-package-layer と my-module-layer の2つの Lambda Layer が使用されていることが分かります。

f:id:ksby:20200619063233p:plain

画像をアップロードしてサムネイル画像を生成してみる

ksbysample-upload-bucket に sample2.jpg をアップロードすると、

f:id:ksby:20200619063801p:plain f:id:ksby:20200619063900p:plain

ksbysample-resize-bucket に sample2_thumb.jpg が生成されています。

f:id:ksby:20200619064026p:plain f:id:ksby:20200619064106p:plain

npx sls logs -f resize を実行してログを見ても特にエラーは出ていませんでした。

f:id:ksby:20200619064233p:plain

1度 Lambda を実行してから Lambda Layer 側のみ内容を変更して再度実行すると変更は反映されるのか?

my_module_layer/python/image_lib/resize_utils.py で thumbnail_size の値をログに出力するよう変更してから、

import logging

from PIL import Image

logger = logging.getLogger()
logger.setLevel(logging.INFO)

thumbnail_size = 320, 180


def resize_image(image_path, resized_path):
    logger.info(thumbnail_size)

    with Image.open(image_path) as image:
        image.thumbnail(thumbnail_size)
        image.save(resized_path)

thumbnail_size = 320, 180thumbnail_size = 160, 90 に変更して my_module_layer のみ deploy したら変更が反映されるのか確認します。

まずは念の為 resize_service → my_module_layer → shared_package_layer の順に remove して、逆の順番で deploy し直します。thumbnail_size = 320, 180 の状態です。

sample.jpg をアップロードしてログを確認すると (320, 180) と出力されており、初回なので Init Duration も出力されています。

f:id:ksby:20200620090133p:plain

thumbnail_size = 160, 90 に変更して my_module_layer のみ deploy してから sample.jpg をアップロードすると、

f:id:ksby:20200620090520p:plain

ログに出力される値は (320, 180) のまま変わらず、既にインスタンスが生成されて再利用されているので Init Duration は出力されません。

生成済みのインスタンスが破棄されたら変更された Lambda Layer の内容が反映されるのか確認したいので、20分程度何もせずに放置します。

約20分後に sample.jpg をアップロードすると、

f:id:ksby:20200620093133p:plain

Init Duration が出ているにもかかわらず (320, 180) のままでした。。。

原因は、マネジメントコンソールで my-module-layer のバージョンを見ると現在 12 なのですが、

f:id:ksby:20200620093428p:plain

resize-service-dev-resize 関数が参照している my-module-layer のバージョンは 11 で、最新の 12 に更新されていないためのようです。latest のような指定が出来ないのかも見てみましたが、設定できるところが見当たりませんでした。

f:id:ksby:20200620093650p:plain

ちなみに resize_service を何も変更せずに delploy すると、関数が参照している my-module-layer のバージョンは 12 に更新されて、

f:id:ksby:20200620094059p:plain

sample.jpg をアップロードすると今度は (160, 90) が出力されました。

f:id:ksby:20200620094252p:plain

Lambda Layer を deploy した場合には、それを利用する関数側も deploy しないと反映されない、という結果でした。latest で指定できるようにならないかな。。。

最後に

まとめてみると、

  • Python + Serverless Framework で Lambda Layer を使うなら serverless-python-requirements プラグインの「Lambda Layer」の説明を見ること。Serverless Framework のドキュメントの AWS - Layers ではない。
  • Lambda Layer を deploy したら、それを利用する関数も deploy し直すこと。そうしないと更新された Layer の最新バージョンを参照してくれない。
  • Lambda Layer は Python のサイズの大きなパッケージを配置する程度に留めた方がよいのかもしれない(deploy 時間短縮のため)。

履歴

2020/06/20
初版発行。

外部パッケージの Pillow と独自モジュール(.py ファイル)を Lambda Layer に配置する(前編)

概要

記事一覧はこちらです。

前々々回、前々回、前回の記事で作成した resize-image-app-project プロジェクト をベースに別プロジェクトを作成して、外部パッケージの Pillow と独自モジュール(.py ファイル)を Lambda Layer に配置するサンプルを作成します。

長いので2回に分けます。

尚、動作させてみて分かりましたが、Lambda Layer を更新した場合、利用している Lambda 関数の方も deploy し直さないと新しいバージョンの Lambda Layer を利用してくれません。利用する Lambda Layer は latest ではなくバージョン固定で指定されていて(マネジメントコンソールで AWS Lambda の関数を見るとどのバージョンを利用しているのか表示されます)、Lambda Layer を更新しても Lambda 関数が参照する Layer のバージョンは変わらないからです。

Pillow のようなパッケージなら delploy 時間を短縮するために Lambda Layer に配置するのはありだと思いますが、ちょっとした独自の共有モジュール程度なら serverless-package-external でいいんじゃないかなという気がします。

参照したサイト・書籍

  1. AWS - Layers
    https://www.serverless.com/framework/docs/providers/aws/guide/layers/

  2. Serverless Python Requirements
    https://www.serverless.com/plugins/serverless-python-requirements/

    • 「Lambda Layer」の記述を参照しました。
  3. Getting started with AWS Lambda Layers for Python
    https://medium.com/@adhorn/getting-started-with-aws-lambda-layers-for-python-6e10b1f9a5d

  4. How to publish and use AWS Lambda Layers with the Serverless Framework
    https://www.serverless.com/blog/publish-aws-lambda-layers-serverless-framework/

  5. Reference CloudFormation Outputs
    https://www.serverless.com/framework/docs/providers/aws/guide/variables#reference-cloudformation-outputs

目次

  1. python-lambda-layer-project プロジェクトを作成する
  2. resize-image-app-project プロジェクトから resize_service、tests ディレクトリをコピーし、boto3、Pillow、moto をインストールする
  3. 外部パッケージの Pillow を配置する Lambda Layer 用の shared_package_layer サブプロジェクトを作成する
  4. 独自モジュール(.py ファイル)を配置する Lambda Layer 用の my_module_layer サブプロジェクトを作成する
  5. resize_service ディレクトリ内のファイルを変更する
  6. 後編に続く

手順

python-lambda-layer-project プロジェクトを作成する

以下の手順で python-lambda-layer-project プロジェクトを作成します。具体的な手順は IntelliJ IDEA+Node.js+npm+serverless framework+Python の組み合わせで開発環境を構築して AWS Lambda を作成してみる 参照。

  • python-lambda-layer-project の Empty Project を作成する。
  • Python の仮想環境を作成する。
  • Serverless Framework をローカルインストールする。
  • .envrc を作成する。
  • npm install --save-dev serverless-python-requirements

resize-image-app-project プロジェクトから resize_service、tests ディレクトリをコピーし、boto3、Pillow、moto をインストールする

resize-image-app-project プロジェクトから resize_service、tests ディレクトリをコピーします。resize_service の下の .serverless、pycache ディレクトリは削除します。

f:id:ksby:20200617012409p:plain:w300

IDEA の Terminal を起動して boto3、Pillow、moto をインストールします。

  • pip install boto3
  • pip install Pillow
  • pip install moto

コマンドラインIntelliJ IDEA からユニットテストが成功することを確認します。

f:id:ksby:20200617012533p:plain f:id:ksby:20200617012638p:plain

deploy が成功することも確認します(キャプチャは省略)。

外部パッケージの Pillow を配置する Lambda Layer 用の shared_package_layer サブプロジェクトを作成する

まずは外部パッケージの Pillow を Lambda Layer に配置します。ポイントは、Serverless Framework のドキュメントの AWS - Layers の設定は一切使わず、serverless-python-requirements プラグインのドキュメント の「Lambda Layer」に記載されている設定を使う点です。これ、分からなくて結構悩みました。。。

プロジェクトのルートディレクトリの下で npx sls create --template aws-python3 --path shared_package_layer を実行して shared_package_layer サブプロジェクトを作成します。

f:id:ksby:20200617015045p:plain

serverless.yml を以下の内容に変更します。

service: shared-package-layer

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    dockerizePip: true
    # Lambda Layer の定義は pythonRequirements の下に記述する
    layer:
      name: shared-package-layer
      description: 共通パッケージ用 Lambda Layer

provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: ap-northeast-1

resources:
  Outputs:
    # 他の Stack から Lambda Layer を参照できるようにする
    # Value に記載している "PythonRequirementsLambdaLayer" はこの文字列固定である
    SharedPackageLayer:
      Value:
        Ref: PythonRequirementsLambdaLayer

以下のファイルを削除・移動します。

  • handler.py は不要なので削除します。
  • resize_service/requirements.txt を shared_package_layer ディレクトリの下に移動します。

ここまででディレクトリ構成は以下のようになります。

f:id:ksby:20200617063023p:plain

deploy します。

f:id:ksby:20200617064144p:plain f:id:ksby:20200617064309p:plain

マネジメントコンソールで確認すると Lambda Layer が作成されており(バージョンが 1 でないのは何度も試しているからです)、

f:id:ksby:20200617064544p:plain

「ダウンロード」ボタンをクリックして zip ファイルの中身を確認すると、

f:id:ksby:20200617064716p:plain

python ディレクトリの下に Pillow のパッケージが入っていました。

f:id:ksby:20200617065301p:plain

独自モジュール(.py ファイル)を配置する Lambda Layer 用の my_module_layer サブプロジェクトを作成する

外部パッケージだけでなく自分で作成したモジュール(.py ファイル)も Lambda Layer に配置してみます。ポイントは以下の2点です。

プロジェクトのルートディレクトリの下で npx sls create --template aws-python3 --path my_module_layer を実行して my_module_layer サブプロジェクトを作成します。

f:id:ksby:20200617070223p:plain

serverless.yml を以下の内容に変更します。

service: my-module-layer

provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: ap-northeast-1

package:
  include:
    - ./python/**

layers:
  # こちらの名称には "Layer" は付けない
  # "my-module-layer" の前半の "my-module" だけ取り出して MyModule にする
  MyModule:
    path: .
    name: my-module-layer
    description: 独自モジュール用 Lambda Layer
    compatibleRuntimes:
      - python3.8

resources:
  Outputs:
    # How to publish and use AWS Lambda Layers with the Serverless Framework
    # https://www.serverless.com/blog/publish-aws-lambda-layers-serverless-framework/
    #
    # こちらの名称には "Layer" を付ける
    # "my-module-layer" から MyModuleLayer にする
    # 他の Stack から参照する時のキーになる
    MyModuleLayer:
      Value:
        # layers に書いた "MyModule" + "LambdaLayer" の文字列で参照する
        Ref: MyModuleLambdaLayer

handler.py は不要なので削除し、my_module_layer ディレクトリの下に python ディレクトリを作成します。

独自モジュール(.py ファイル)は my_module_layer/python の下に image_lib ディレクトリを作成し、その下に resize_utils.py を作成して、resize_service/handler.py の中に定義していた resize_image 関数を持ってくることにします。

my_module_layer/python/image_lib/resize_utils.py に以下の内容を記述します。

from PIL import Image

thumbnail_size = 320, 180


def resize_image(image_path, resized_path):
    with Image.open(image_path) as image:
        image.thumbnail(thumbnail_size)
        image.save(resized_path)

ここまででディレクトリ構成は以下のようになります。

f:id:ksby:20200617073328p:plain

deploy します。

f:id:ksby:20200617211754p:plain f:id:ksby:20200617211901p:plain

マネジメントコンソールで確認すると Lambda Layer が作成されており(バージョンが 1 でないのは何度も試しているからです)、

f:id:ksby:20200617212038p:plain

「ダウンロード」ボタンをクリックして zip ファイルの中身を確認すると、python ディレクトリの下に image_lib/resize_utils.py が入っていました。

f:id:ksby:20200617212232p:plain

resize_service ディレクトリ内のファイルを変更する

serverless.yml の以下の点を変更します。

service: resize-service

provider:
  name: aws
  runtime: python3.8

  stage: dev
  region: ap-northeast-1

  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "s3:GetObject"
      Resource:
        - "arn:aws:s3:::ksbysample-upload-bucket/*"
    - Effect: "Allow"
      Action:
        - "s3:PutObject"
      Resource:
        - "arn:aws:s3:::ksbysample-resize-bucket/*"

functions:
  resize:
    handler: handler.resize
    events:
      # Using existing buckets
      # https://www.serverless.com/framework/docs/providers/aws/events/s3#using-existing-buckets
      - s3: ksbysample-upload-bucket
    layers:
      # Stack名はマネジメントコンソールの CloudFormation > スタック で確認する
      - ${cf:shared-package-layer-dev.SharedPackageLayer}
      - ${cf:my-module-layer-dev.MyModuleLayer}

resources:
  Resources:
    KsbysampleResizeBucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ksbysample-resize-bucket
  • serverless-python-requirements プラグインを使用しないので plugins、custom の定義を削除します。
  • functions - resize の下に layers を追加し、以下の2行を追加します。他の Stack の Outputs を参照する方法は Reference CloudFormation Outputs に記載があります。
    • - ${cf:shared-package-layer.SharedPackageLayer}
    • - ${cf:my-module-layer.MyModuleLayer}

handler.py の以下の点を変更します。

import logging
import os
import re
import uuid
from urllib.parse import unquote_plus

import boto3

from image_lib import resize_utils

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def resize(event, context):
    s3_client = boto3.client('s3')

    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        key = unquote_plus(record['s3']['object']['key'])

        # ディレクトリの場合には何もしない
        if key.endswith('/'):
            return

        tmpkey = key.replace('/', '')
        download_path = '/tmp/{}{}'.format(uuid.uuid4(), tmpkey)
        resized_path = '/tmp/resized-{}'.format(tmpkey)

        filename = key.split('/')[-1]
        dirname = re.sub(filename + '$', '', key)
        basename, ext = os.path.splitext(filename)
        resized_key = '{}{}_thumb{}'.format(dirname, basename, ext)

        s3_client.download_file(bucket, key, download_path)
        resize_utils.resize_image(download_path, resized_path)
        s3_client.upload_file(resized_path, "ksbysample-resize-bucket", resized_key)
        logger.info('サムネイルを生成しました({})'.format(resized_key))
  • my_module_layer/python/image_lib/resize_utils.py に移動した resize_image 関数を削除します。
  • 以下の記述も削除します。
    • from PIL import Image
    • thumbnail_size = 320, 180
  • 以下の記述を追加します。
    • from image_lib import resize_utils
  • resize_image(download_path, resized_path)resize_utils.resize_image(download_path, resized_path) に変更します。

後編に続く

履歴

2020/06/20
初版発行。

resize-image-app-project プロジェクトで作成した AWS Lambda のユニットテストを Docker コンテナ上で動作させる

概要

記事一覧はこちらです。

resize-image-app-project プロジェクトで作成した AWS Lambda のユニットテストを作成する(local動作版)ユニットテストを作成しましたが、Pillow は OS 依存のバイナリがあるので lambci/lambda:build-python3.8 の Docker Image 上でユニットテストを実行して動作確認する方法を調べてみます。

最初 lambci/lambda:python3.8 を利用してユニットテストを動かそうとしたのですが、

  • lambci/lambda:python3.8 で Lambda の関数(resize_service/handler.resize)を呼び出す方法は分かったのですが、python -m unittest -v コマンドを実行する方法が全然分かりません。
  • lambci / docker-lambdaBuild Examples を見ると docker run -it lambci/lambda:build-python3.8 bash と記述されており、これで bash を起動すると python -m unittest -v コマンドを実行することが出来ました。

という理由で lambci/lambda:build-python3.8 の Docker Image を利用することにしました。

参照したサイト・書籍

  1. lambci / docker-lambda
    https://github.com/lambci/docker-lambda

  2. In Git for windows “git bash”, how to “print working directory” in Windows path format that is usable by cmd and Windows explorer?
    https://stackoverflow.com/questions/44842275/in-git-for-windows-git-bash-how-to-print-working-directory-in-windows-path/48777179

  3. Configure an interpreter using Docker
    https://www.jetbrains.com/help/pycharm/using-docker-as-a-remote-interpreter.html

目次

  1. lambci/lambda:build-python3.8 をベースに Pillow、moto をインストールしたカスタム Docker Image を作成する
  2. Docker コンテナでユニットテストを実行する
  3. IntelliJ IDEA からユニットテストを debug 実行する時にコードが Docker コンテナで動くようにする
  4. (メモ書き)lambci/lambda:python3.8 をベースにカスタム Docker Image を作成する

手順

lambci/lambda:build-python3.8 をベースに Pillow、moto をインストールしたカスタム Docker Image を作成する

プロジェクトのルートディレクトリ直下に Dockerfile を作成して以下の内容を記述します。

FROM lambci/lambda:build-python3.8

COPY resize_service/requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip
RUN pip install -r /tmp/requirements.txt
RUN pip install moto

コマンドプロンプト(今回は git-bash)で docker build . -t lambci/lambda:build-python3.8-resize-image-app-project を実行してカスタム Docker Image を作成します。

f:id:ksby:20200613200742p:plain f:id:ksby:20200613200847p:plain f:id:ksby:20200613200950p:plain f:id:ksby:20200613201052p:plain

Docker コンテナでユニットテストを実行する

docker run --rm -it -v ``pwd -W``:/var/task:rw,delegated lambci/lambda:build-python3.8-resize-image-app-project bashpwd -W 前後の ` は1つだけに変更すること)を実行して Docker コンテナで bash を起動します。

/var/task にプロジェクトのルートディレクトリがマウントされていることが確認できます。

f:id:ksby:20200613202726p:plain

pwd -W の部分は Web の記事を見ると $(pwd) と書かれていますが、

  • Windows 上の git-bash だと $(pwd)/d/project-serverless/ksbysample-serverless/resize-image-app-project を返す。
  • Windows で docker コマンドの -v オプションにマウント元を渡す時には /d/... ではなく D:\... で渡さないとマウントされない(エラーになる)。

という理由で pwd -W に変えました。試しに git-bash で実行してみると以下のようになります。

f:id:ksby:20200613203017p:plain

python -m unittest -v を実行するとテストが1件成功しました。

f:id:ksby:20200613203149p:plain

exit コマンドで Docker コンテナから抜けます。

IntelliJ IDEA からユニットテストを debug 実行する時にコードが Docker コンテナで動くようにする

IntelliJ IDEA のメインメニューから「File」-「Project Structure...」を選択して「Project Structure」ダイアログを表示します。

画面左側で「SDKs」を選択した後、中央のリストの上部から「+」-「Add Python SDK...」を選択します。

f:id:ksby:20200613211844p:plain

「Add Python Interpreter」ダイアログが表示されます。画面左側で「Docker」を選択してから、画面右側の「Image name」で作成したカスタム Docker Image(lambci/lambda:build-python3.8-resize-image-app-project)を選択して「OK」ボタンをクリックします。

f:id:ksby:20200613212253p:plain

「Project Structure」ダイアログに戻ると「Remote Python 3.8.3 Docker (lambci/lambda:build-python3.8-resize-image-app-project)」が追加されています。「OK」ボタンをクリックしてダイアログを閉じます。

f:id:ksby:20200613212449p:plain

IntelliJ IDEA のメインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurationis」ダイアログを表示します。

前回作成した Python tests の設定で以下の点を変更します。

f:id:ksby:20200613213100p:plain

  • Python initerpreter」を「Use SDK of module」→「Use specified interpreter」に変更した後、「Remote Python 3.8.3 Docker (lambci/lambda:build-python3.8-resize-image-app-project)」を選択します。

次に「Docker container settings」で右側のフォルダアイコンをクリックします。

f:id:ksby:20200613213300p:plain

「Edit Docker Container Settings」ダイアログが開くのでマウント先の Container Path を「/opt/project」→「/var/task」に変更します。

f:id:ksby:20200613213508p:plain

「Run/Debug Configurationis」ダイアログに戻ってから「Docker container settings」の設定が以下の画像のようになっていることを確認した後「OK」ボタンをクリックしてダイアログを閉じます。

f:id:ksby:20200613213810p:plain

以上で設定は完了です。次は debug 実行してみます。

まず IntelliJ IDEA の Services Tool Window で Docker コンテナが1つも存在しないことを確認してから、

f:id:ksby:20200613214426p:plain

IntelliJ IDEA のエディタ上で handler.resize(event, None) の行に breakpoint を設定して「Debug 'Unittests for test_r...'」を選択します。

f:id:ksby:20200613214638p:plain

Console に以下の画像のように表示された後、breakpoint で止まります。

f:id:ksby:20200613214903p:plain f:id:ksby:20200613215024p:plain

Services Tool Window を見ると debug 用に起動していると思われるコンテナ(実行中)と /pycharm_helpers_IU... から始まるコンテナ(停止している)がありました。

f:id:ksby:20200613215205p:plain

Debugger で「Step Info」「Step Over」で処理を進めることができることも確認できます。

debug 実行を終了させると、

f:id:ksby:20200613215634p:plain

コンテナは /pycharm_helpers_IU... から始まるもののみ残っていました。こちらは自動で削除されないようです。

f:id:ksby:20200613215750p:plain

(メモ書き)lambci/lambda:python3.8 をベースにカスタム Docker Image を作成する

今回は作成しませんでしたが build- が付かない lambci/lambda:python3.8 をベースにカスタム Docker Image を作成する時は Dockerfile の書き方が少し異なるのでメモ書きとして残しておきます。

FROM に lambci/lambda:python3.8 を記述した Dockerfile を作成し、

FROM lambci/lambda:python3.8

COPY resize_service/requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip
RUN pip install -r /tmp/requirements.txt
RUN pip install moto

docker build . -t lambci/lambda:python3.8-resize-image-app-project を実行すると ERROR が出て Docker Image を作成できません。

f:id:ksby:20200613220653p:plain

Dockerfile に USER root を追加します。

FROM lambci/lambda:python3.8

USER root

COPY resize_service/requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip
RUN pip install -r /tmp/requirements.txt
RUN pip install moto

再度 docker build . -t lambci/lambda:python3.8-resize-image-app-project を実行すると今度は Docker Image が作成できます。

f:id:ksby:20200613221009p:plain (..........途中省略..........) f:id:ksby:20200613221107p:plain

履歴

2020/06/13
初版発行。

resize-image-app-project プロジェクトで作成した AWS Lambda のユニットテストを作成する(local動作版)

概要

記事一覧はこちらです。

S3 にアップロードされた画像ファイルから Lambda でサムネイル画像を生成してみる で作成した AWS Lambda のユニットテストを作成してみます。

前回 deploy するための外部ライブラリを収集するのに lambci/lambda:build-python3.8 という Docker Image を利用したので、おそらく Docker 上で動作確認することもできると思いますが、Testing Serverless Services という Web ページを見つけて local で動作させることもできるようなので試してみます。

参照したサイト・書籍

  1. Testing Serverless Services
    https://towardsdatascience.com/testing-serverless-services-59c688812a0d

  2. Python実践入門 ── 言語の力を引き出し、開発効率を高める WEB+DB PRESS plus

    • unittest モジュールの使い方が記載されていて参考にしました。
  3. spulec / moto
    https://github.com/spulec/moto

  4. What is the fastest way to empty s3 bucket using boto3?
    https://stackoverflow.com/questions/43326493/what-is-the-fastest-way-to-empty-s3-bucket-using-boto3

目次

  1. ディレクトリ名を resize-service → resize_service に変更する
  2. プロジェクトのルートディレクトリ直下に tests ディレクトリを作成してテストのサンプルを作成・実行する
  3. moto モジュールをインストールする
  4. test_resize.py にテストを実装して実行してみる。。。が NoCredentialsError が発生する
  5. NoCredentialsError のエラーを解消する
  6. IntelliJ IDEA から debug 実行してみる
  7. 最後にディレクトリ構成を記載する

手順

ディレクトリ名を resize-service → resize_service に変更する

プロジェクト名に -(ハイフン) が含まれているとテストのファイルから AWS Lambda のファイルを import できません。IDEA で import 文を書いた時に赤波線が表示されて気づきました。

f:id:ksby:20200613110218p:plain

PEP 8 -- Style Guide for Python CodePackage and Module Names には all-lowercase names にするか Underscores が使用できると記載されていますが、ディレクトリ名を _(アンダースコア) に変更して serverless.yml の service 名も同じに変更すると今度は deploy が成功しません。

f:id:ksby:20200613113132p:plain

The stack service name "resize_service-dev" is not valid. A service name should only contain alphanumeric (case sensitive) and hyphens. It should start with an alphabetic character and shouldn't exceed 128 characters. のエラーが出ます。

ディレクトリ名は _(アンダースコア) に変更し(resize_service)、serverless.yml の service 名は -(ハイフン)のままにします(resize-service)。

service: resize-service

..........

ちなみに npx sls create --template aws-python3 --path xxxxxxxx_service と create コマンドに渡すディレクトリ名に _(アンダースコア)を含めた場合、

f:id:ksby:20200613115112p:plain

serverless.yml の service 名は -(ハイフン)になりました。

f:id:ksby:20200613115234p:plain

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成してテストのサンプルを作成・実行する

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成し、その下に __init__.py というファイルを作成します(中身は空)。

test_resize.py というファイルも作成し以下の内容を記述してから、

import unittest


class TestResizeService(unittest.TestCase):
    def test_resize(self):
        None

コマンドプロンプトを起動して以下のコマンドを実行すると、unittest モジュールにテストのファイルが認識されて1件成功します。

  • venv\Scripts\activate
  • python -m unittest -v

f:id:ksby:20200613120422p:plain f:id:ksby:20200613120618p:plain

テストを実行すると tests ディレクトリの下に __pycache__ というディレクトリが作成されますが git で管理する必要のないディレクトリなので .gitignore に __pycache__/ の記述を追加して無視されるようにします。

..........

# Ignore Python unittest
__pycache__/

..........

ちなみに __init__.py というファイルがないと unittest モジュールにテストが認識されません。最初テストのファイルだけ作成して認識されなくて少し悩みました。。。

f:id:ksby:20200613121536p:plain

moto モジュールをインストールする

AWS Service をモック化してくれる moto モジュールをインストールします。

pip install moto を実行します。依存するモジュールがいろいろインストールされるので少し時間がかかります。

f:id:ksby:20200613122935p:plain

test_resize.py にテストを実装して実行してみる。。。が NoCredentialsError が発生する

tests ディレクトリの下にテストで使用する sample.jpg をコピーし、s3_event.json というファイルを新規作成して S3 のイベントの JSON を記述します。

{
  "Records": [
    {
      "eventVersion": "2.1",
      "eventSource": "aws:s3",
      "awsRegion": "ap-northeast-1",
      "eventTime": "2020-06-10T00:45:25.995Z",
      "eventName": "ObjectCreated:Put",
      "userIdentity": {
        "principalId": "AWS:XXXXXXXXXXXXXXXXXXXXX"
      },
      "requestParameters": {
        "sourceIPAddress": "xxx.xxx.xxx.xxx"
      },
      "responseElements": {
        "x-amz-request-id": "11F4BEB2A6E4D924",
        "x-amz-id-2": "k2RJ6yeU8sk5tRp7ntfyM8QDmR58rF1TIdacJimdExhLOOSQ3N6pOtIAWXyk6pGjTeBGOBx995ELqaK53zhdAo6Ky/PZb08e"
      },
      "s3": {
        "s3SchemaVersion": "1.0",
        "configurationId": "66e09001-2a37-4900-9a98-06da126cc896",
        "bucket": {
          "name": "ksbysample-upload-bucket",
          "ownerIdentity": {
            "principalId": "XXXXXXXXXXXXXX"
          },
          "arn": "arn:aws:s3:::ksbysample-upload-bucket"
        },
        "object": {
          "key": "sample.jpg",
          "size": 669286,
          "eTag": "febe0aaf4d817457776b6be293973442",
          "sequencer": "005EE02D28714EE2DC"
        }
      }
    }
  ]
}

test_resize.py にテストを実装します。

import json
import unittest

import boto3
from moto import mock_s3

from resize_service import handler


@mock_s3
class TestResizeService(unittest.TestCase):
    UPLOAD_BUCKET = 'ksbysample-upload-bucket'
    RESIZE_BUCKET = 'ksbysample-resize-bucket'

    def setUp(self):
        s3_client = boto3.client('s3')
        s3_client.create_bucket(Bucket=TestResizeService.UPLOAD_BUCKET)
        s3_client.create_bucket(Bucket=TestResizeService.RESIZE_BUCKET)

    def tearDown(self):
        s3 = boto3.resource('s3')
        upload_bucket = s3.Bucket(TestResizeService.UPLOAD_BUCKET)
        upload_bucket.objects.all().delete()
        upload_bucket.delete()
        resize_bucket = s3.Bucket(TestResizeService.RESIZE_BUCKET)
        resize_bucket.objects.all().delete()
        resize_bucket.delete()

    def test_resize(self):
        s3_client = boto3.client('s3')
        s3_client.upload_file('tests/sample.jpg', TestResizeService.UPLOAD_BUCKET, 'sample.jpg')

        with open('tests/s3_event.json', 'r') as f:
            event = json.load(f)

        handler.resize(event, None)

        thumb_object = s3_client.get_object(Bucket=TestResizeService.RESIZE_BUCKET,
                                            Key='sample_thumb.jpg')
        self.assertEqual(thumb_object['ResponseMetadata']['HTTPStatusCode'], 200)
        self.assertGreater(int(thumb_object['ResponseMetadata']['HTTPHeaders']['content-length']), 0)

        # 生成されたサムネイル画像をダウンロードすることも出来る(実際に作成される)
        # s3_client.download_file(TestResizeService.RESIZE_BUCKET, 'sample_thumb.jpg',
        #                         'tests/sample_thumb.jpg')

python -m unittest -v を実行してみると raise NoCredentialsError のエラーが出てテストが失敗しました。。。

f:id:ksby:20200613134030p:plain f:id:ksby:20200613134137p:plain

NoCredentialsError のエラーを解消する

Web で調べると AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY が設定されていないことが原因と書かれている記事を見かけるのですが、解決したという人としなかったという人を見かける上に、テストの方はモックの S3 に問題なくアクセスできていて今ひとつ良く分かりません。

試しに AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY をいくつかの方法で設定してみると以下の結果でした。

  • test_resize メソッド内で AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY の環境変数を設定するのは NG。 f:id:ksby:20200613134845p:plain
  • resize_service/handler.py で s3_client = boto3.client('s3')s3_client = boto3.client('s3', aws_access_key_id='test', aws_secret_access_key='test') に変更すると OK。 f:id:ksby:20200613135322p:plain
  • python -m unittest -v を実行するコマンドプロンプトAWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY の環境変数を設定すると OK。 f:id:ksby:20200613135536p:plain

何となく原因が分かりました。おそらく以下の動作になっているのでしょう。

  • test_resize.py が読み込まれる。
  • from resize_service import handler により resize_service/handler.py が読み込まれる。
  • resize_service/handler.py のグローバルで s3_client = boto3.client('s3') が実行される。この時は boto3 の s3 クライアントはまだモックではなく Credentials の情報がセットされない。
  • test_resize.py の TestResizeService.test_resize メソッドが実行される。この時は mock_s3 デコレータ により boto3 の s3 クライアントはモック化されて Credentials の情報がセットされる。
  • 結果として test_resize.py 内の処理ではモックの S3 に問題なくアクセスできるが、resize_service/handler.py 内の処理でモックの S3 にアクセスしようとすると raise NoCredentialsError のエラーが出る。

resize_service/handler.py の s3_client = boto3.client('s3') を記述する位置を resize 関数の最初に変更します。

..........

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def resize_image(image_path, resized_path):
    with Image.open(image_path) as image:
        image.thumbnail(thumbnail_size)
        image.save(resized_path)


def resize(event, context):
    s3_client = boto3.client('s3')

    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        ..........

python -m unittest -v を実行すると今度は成功しました。

f:id:ksby:20200613142047p:plain

IntelliJ IDEA から debug 実行してみる

test_resize.py 内の handler.resize(event, None) の行に breakpoint をセットしてから、IDEA のエディタの左側に表示されている矢印をクリックして Debug 'Unittests for test_r...' を選択します。

f:id:ksby:20200613143729p:plain

初回は FileNotFoundError: [WinError 3] 指定されたパスが見つかりません。: 'tests/sample.jpg' のエラーが出てエラーになります。

f:id:ksby:20200613144122p:plain

IDEA のメインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、「Python tests」の下に表示されている項目を選択してから画面右側の「Working directory」の設定を D:\project-serverless\ksbysample-serverless\resize-image-app-project\testsD:\project-serverless\ksbysample-serverless\resize-image-app-project に変更します(プロジェクトのルートディレクトリにする)。

f:id:ksby:20200613144459p:plain

再度 Debug 'Unittests for test_r...' を選択して debug 実行すると今度は breakpoint で止まり、「Step Into」ボタンを押せば resize_service/handler.py の resize 関数に進みます。

f:id:ksby:20200613144621p:plain

最後にディレクトリ構成を記載する

f:id:ksby:20200613145416p:plain

Python には標準で unittest モジュールが用意されていたり、moto という AWS Service をモック化するモジュールが存在したり、IntelliJ IDEA で Lambda のソースを debug 実行できたりして、Serverless は debug しにくいと思っていましたが結構開発しやすくて意外でした。

履歴

2020/06/13
初版発行。

IntelliJ IDEA で Java Flight Recorder を有効にして実行する

記事一覧はこちらです。

会社の IntelliJ IDEA で開発をしていた時に画面右上のボタンに Run with Java Flight Recorder のようなボタンを見かけた気がしていて、家に帰ってから IntellJ IDEA を起動して確認するとそんなボタンが見当たりません。

位置的には下の画像の赤枠のボタンのはずなのですが、「Run with Profiler」というラベルが出るだけでボタンが押せません。。。

f:id:ksby:20200611003734p:plain

調べてみると Profiling Tools and IntelliJ IDEA Ultimate のページが見つかりました。書かれたのは March 6, 2020 で最近ですね。

Settings ダイアログを開いて Java Profiler の設定を見ると中央のリストには何も表示されていません。ボタンが押せないはずです。

f:id:ksby:20200611004544p:plain

AdoptOpenJDK の Migration Guide を見ると Java Flight Recorder は OpenJDK 11 に入っているようなので試してみます。

Settings ダイアログの Java Profiler の設定の中央のリストで「+」ボタンを押すと「Java Flight Recorder」が出てきたので選択します。

f:id:ksby:20200611005244p:plain

中央のリストに「Java Flight Recorder」が追加されます。設定は「Default」のままにして「OK」ボタンを押してダイアログを閉じます。

f:id:ksby:20200611005256p:plain

画面右上の「Run with Profiler」のドロップダウンリストをクリックすると「Run 'Application' with 'Java Flight Recorder'」のメニューが表示されました。クリックして実行します。

f:id:ksby:20200611005532p:plain

Tomcat が起動して、更に初めて見る「Profiler」タブが表示されました。

f:id:ksby:20200611005912p:plain

「Profiler」タブをクリックすると以下の画面が表示されて、

f:id:ksby:20200611010123p:plain

「Stop Profiling and Show Results」リンクをクリックすると Profile 結果が表示されました。残念ながら見方が全然分かりません。。。

f:id:ksby:20200611010340p:plain f:id:ksby:20200611010433p:plain f:id:ksby:20200611010536p:plain f:id:ksby:20200611010837p:plain

Java Flight Recorder が Java 11 から OpenJDK でも使えるようになったというのは見かけていましたが、使うのが面倒そうだったので1度も試していなかったんですよね。IntelliJ IDEA から実行できるようになったのであれば試してみようと思います。