LoginSignup
12
8

More than 3 years have passed since last update.

API Gateway + Lambda + LINE messaging API で食堂のメニュー通知Botを作ってみた

Last updated at Posted at 2021-02-01

こんにちは。あかいです。
この記事は、勉強を兼ねてAPI Gateway + Lambda + LINE messaging API で食堂のメニュー通知Botを作ってみた際の備忘録です。
作成に当たって必要な要素を概観するようなものを目的とします。
AWSやLINEへの登録については、今回はスキップします。

作成物

今回作成したのは以下のようなLINE Botです。
「今日」、「今週」などと入力すると、メニューが返信されます。
返信には「今日」「明日」「今週」「来週」のようなクイックリプライがついています。
line_movie1.gif

背景

今回のメニュー通知Botのもととなっているのは、私が利用している寮の食堂のメニュー通知です。
メニューを知るには、

  1. 専用のWebシステムにアクセス
  2. ログイン
  3. 今月のメニュー表(Excel)をダウンロード
  4. メニュー表を開いて確認

する必要があります。
以下はメニュー表ですが、これで毎回確認するのは、(個人的には)なかなかつらいものがあります。
menu.png

これを簡単に知る手段はないか、というのが今回の動機です。
「今日のご飯なんだっけな?」と手軽に確認するのにLINEが最適と判断しました。

構成

以下に構成を示します。
基本的には、LINEのMessaging APIからWebhookして、API Gatewayで受け付け、Lambdaで処理して返信する流れです。
Webhookのたびに食堂Webシステムにアクセスするのは非効率なので、メニューデータファイルをS3に保存しておきます。
Lambdaがキックされたとき、S3のメニューデータファイルにアクセスして該当するメニューがなければ、最新のメニュー表を食堂Webシステムから取得します。Excel形式のメニュー表を扱いやすいJSON形式のメニューデータファイルに変換してS3に保存します。
構成概要1.png

LINE Messaging APIについて

今回使用するのはLINE Messaging APIの応答メッセージです。

ポイントは5点です。

 1. Botがメッセージを受け取ると、Webhookが送られる
 2. Webhookのリクエストをx-line-signatureおよびchannelsecretで検証する
 3. Webhookの呼び出しもとには200で空オブジェクト{}のレスポンスを返す
 4. Botが受け取ったメッセージへの返信は、返信用URLにPOSTする
 5. Botの返信のPOSTにはchannel access tokenおよびreply tokenを利用する

(Webhookについて:https://developers.line.biz/ja/reference/messaging-api/#webhooks)
(x-line-signatureの検証について:https://developers.line.biz/ja/reference/messaging-api/#signature-validation)
(応答メッセージについて:https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)

作成の流れ

  1. Lambda関数作成
  2. AWS設定(S3、API Gateway、IAMロール、Lambda設定)
  3. テスト

1. Lambda関数作成

関数

作成したLambda関数は3つです。すべてpythonで作成しました。

# Lambda関数名 内容
1 parse_message API Gatewayから呼び出される関数。メッセージを受け取り返信する。
2 get_menu parse_messageから呼び出される関数。S3のメニューデータファイルから必要な情報を取得する。
3 update_menu get_menuから呼び出される関数。get_menuでメニューを取得できなかった場合に食堂Webシステムからメニュー表を取得し、JSON形式に変換してメニューデータファイルとしてS3に格納する。

①parse_message

プロセスの呼び出し

Lambdaをpythonで作成する場合は、最初に呼び出される関数(ハンドラ)を定義します。
image.png
デフォルトではlambda_handler(event, context)で、今回はデフォルトのままとしました。

引数名 内容
event イベントデータ
context ランタイム情報

(参考:https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html)

lambda関数が実行されると、lambda_handlerが呼ばれます。
parse_messageでの呼び出しの様子を示します。併せて必要なライブラリをimportしています。

プロセスの呼び出し
import json
import boto3
import re
import datetime
import urllib
import os
import base64
import hashlib
import hmac
import copy
def process(event):
    # Response to the request source.
    res = { 
        "isBase64Encoded": False,
        "statusCode": 200,
        "headers": {},
        "body": "{}"
        }
    # Init for messaging api.
    tmp = init_messaging_api(event)
    event = tmp
    if event is None:
        # Initできなければ何もせずリターン
        return res
    # Get text.
    text = get_text(event)
    # Parse text.
    s_date, e_date = parse_text(text)
    if s_date is None or e_date is None:
        text = "認識できません。\n以下から選択してください。"
        message = {
            "type": "text",
            "text": text
        }
    else:
        # Get menu.
        menu = get_menu(s_date, e_date)
        # Make message.
        message = make_message(menu, "flex")
        if message is None:
            text = "データが存在しません。\n以下から選択してください。"
            message = {
                "type": "text",
                "text": text
            }
    # Set quick reply.
    message = set_quick_reply(message)
    # Make return params.
    params = make_params([message], event)
    if params is not None:
        # Send response to the reply URI of messaging api.
        response(params)
    return res

def lambda_handler(event, context):
    return process(event)

parse_menu関数では、ハンドラからprocess(event)なる関数を呼び出しています。
process内では、以下を実施します。

  1. LINEのmessaging_api用の初期処理
  2. LINEのメッセージテキストを取得
  3. メッセージテキストを解析
  4. メニューデータを取得
  5. LINEの返信用URLにレスポンスをPOST
  6. API Gatewayからのメッセージに返信

LINEのmessaging_api用の初期処理

初期処理で実施するのは次です。

  1. 応答メッセージ用のURL, METHOD, HEADERSを設定
  2. eventからbody, headerを取得
  3. bodyから計算したsignatureとheaderのx-line-signatureが等しいことを確認する

lambda_handlerに渡されるeventから、リクエストのヘッダとボディは次のように取り出せます。
ヘッダ=event[headers]
ボディ=event[body]
(参考:https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format)

LINE Messaging APIからのリクエストボディには、Webhookイベントのリスト(events)が含まれています。
ですので、1つのWebhookイベントは次のように取り出せます。
Webhookイベント=json.loads(event[body])['events'][0]
(参考:https://developers.line.biz/ja/reference/messaging-api/#request-body)

LINEのmessaging_api用の初期処理
# Init setting and parse event.
def init_messaging_api(event):
    # Set vars for Bot reply.
    global URL
    global METHOD
    global HEADERS
    URL = f"https://api.line.me/v2/bot/message/reply"
    METHOD = "POST"
    HEADERS = {
        'Authorization': "Bearer " + os.environ['ACCESSTOKEN'],
        'Content-Type': 'application/json'
    }
    # raw event: event that api gateway wrap was removed.
    raw_event = None
    # Output event from api gateway.
    print("Event from API Gateway: ", event)
    # Get raw json data from event.
    body = event['body'] # String
    header = event['headers'] # Json
    # Get signature from body with channelsecret.
    hash = hmac.new(os.environ['CHANNELSECRET'].encode('utf-8'), \
        body.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash)
    # Get signature from header.
    if  'X-Line-Signature' in header:
        line_signature = header['X-Line-Signature'].encode('utf-8')
    if  'x-line-signature' in header:
        line_signature = header['x-line-signature'].encode('utf-8')
    # Compare signature and if matched, get raw_data as body in json.
        if line_signature == signature:
            raw_event = json.loads(body)['events'] # In webhook payload, there are events list.
            # If events not empty, get event.
            if len(raw_event) > 0:
                raw_event = raw_event[0]
    print("Event from Line:", raw_event)
    return raw_event

channelsecretchannel access tokenはBotアカウントに紐づくセンシティブな情報です。
そのため、ソースコードに直書きは避けたいものです。
DBに格納する、S3にファイルで格納するなど考えられますが、ここでは、Lambdaの環境変数を使用できます。
image.png

環境変数へのアクセスは、通常の環境変数同様、os.environ['ACCESSTOKEN']のように可能です。

LINEのメッセージテキストを取得

本Botはメッセージテキストのみを受け付けます。
したがって、以下のようなリクエストボディを想定します。

受け付けるWebhookイベントのリクエストボディ(一部略)
"events": [
  {
    "type": "message",
    "message": {
      "type": "text",
      "text": "今日"
    }
  }
]

(参考:https://developers.line.biz/ja/reference/messaging-api/#message-event)

次に示す関数では、上記に該当するメッセージを受け取った場合には、Botが受け取ったtextを、該当しない場合はNoneを返します。

LINEのメッセージテキストを取得
# Get message.
def get_text(raw_event):
    if raw_event is None:
        return None
    rc = 0
    text = None
    # Get type.
    if "type" in raw_event:
        type = raw_event["type"]
    else:
        rc = 8
    # Get message.
    if rc == 0:
        if type == "message":
            message = raw_event['message']
        else:
            rc = 8
    # Get text.
    if rc == 0:
        if "text" in message:
            text = message['text']
        else:
            rc = 8
    return text

メッセージテキストを解析

「今日」や「今週」などがtextに含まれている場合に、メニュー取得の開始日と終了日を取得します。
それ以外の場合はNoneを返します。

メッセージテキスト解析
# Parse text. This returns the start date and end date of the menu.
TODAY = datetime.datetime.utcnow() + datetime.timedelta(hours=9)
def parse_text(text):
    if text is None:
        return None, None
    if re.search("今日", text) or re.search("きょう", text):
        s_date = TODAY
        e_date = TODAY
    elif re.search("明日", text) or re.search("あした", text) or re.search("あす", text):
        s_date = TODAY+ datetime.timedelta(days=1)
        e_date = TODAY+ datetime.timedelta(days=1)
    elif re.search("明後日", text) or re.search("あさって", text):
        s_date = TODAY+ datetime.timedelta(days=2)
        e_date = TODAY+ datetime.timedelta(days=2)
    elif re.search("今週", text) or re.search("こんしゅう", text):
        s_date = TODAY- datetime.timedelta(days=TODAY.weekday())
        e_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=6)
    elif re.search("来週", text) or re.search("らいしゅう", text):
        s_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=7)
        e_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=13)
    else:
        return None, None
    s_date = datetime.datetime.strftime(s_date, '%Y/%m/%d')
    e_date = datetime.datetime.strftime(e_date, '%Y/%m/%d')
    print(s_date, e_date)
    return s_date, e_date

メニューデータを取得

別のLambda関数、get_menuを呼び出します。
Lambdaでは、AWS SDKがデフォルトで使用可能です。
pythonであれば、boto3が使えます。
以下では、boto3でget_menuなる別途定義したLambda関数をinvokeしています。
正常にinvokeできた場合、開始日、終了日を満たすメニューデータが返されます。

(参考:https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html#Lambda.Client.invoke)

メニューデータを取得
# Get menu between start date and end date.
# return: menu
#         None(Cannot get menu)
def get_menu(s_date, e_date):
    if s_date is None or e_date is None:
        return None
    client = boto3.client('lambda')
    response = client.invoke(
        FunctionName='get_menu',
        InvocationType='RequestResponse',
        Payload = json.dumps({"start_date": s_date, "end_date": e_date}))
    res_from_called_function = "no"
    if response['StatusCode'] == 200:
        res_from_called_function = response['Payload'].read().decode('utf-8')
        res_from_called_function = json.loads(res_from_called_function)
        print("this is res_from_called_function : ", res_from_called_function)
        if res_from_called_function['statusCode'] == 200:
            menu = json.loads(res_from_called_function['body'])['message']
            menu = json.loads(menu) if menu != "" else None
        else:
            menu = None
    return menu

LINEの返信用URLにレスポンスをPOST

レスポンスを返すにあたって、メッセージを作成する必要があります。
メッセージはFlex Messageで作成します。
(参考:https://developers.line.biz/ja/reference/messaging-api/#flex-message)

Flex Messageは以下のページから簡単に作成できます(LINEアカウントが必要です)。
https://developers.line.biz/flex-simulator/

メニューを表示するためのFlex Messageのテンプレートを定義して、メニューに応じてreplaceします。

Flex Messageのテンプレート
Flexメッセージテンプレート
FLEX_TEMPLATE = \
{
  "type": "box",
  "layout": "vertical",
  "contents": [
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": "type1",
          "size": "md",
          "style": "italic",
          "weight": "bold",
          "decoration": "underline",
          "color": "#ffffff"
        }
      ],
      "backgroundColor": "color0"
    },
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {"type": "text","text": "menu0","size": "xs"},
        {"type": "text","text": "menu1","size": "xs"},
        {"type": "text","text": "menu2","size": "xs"},
        {"type": "text","text": "menu3","size": "xs"},
        {"type": "text","text": "menu4","size": "xs"},
        {"type": "text","text": "menu5","size": "xs"},
        {"type": "text","text": "menu6","size": "xs"},
        {"type": "text","text": "menu7","size": "xs"},
        {"type": "text","text": "menu8","size": "xs"},
        {"type": "text","text": "menu9","size": "xs"}
      ],
      "backgroundColor": "color1"
    },
    {
      "type": "separator",
      "color": "#808080"
    },
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "energy : energy1","size": "xxs"}
          ]
        },
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "protein : protein1","size": "xxs"},
            {"type": "text","text": "lipid : lipid1","size": "xxs"}
          ]
        },
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "carbohydrate : carbohydrate1","size": "xxs"},
            {"type": "text","text": "salt : salt1","size": "xxs"}
          ]
        }
      ],
      "backgroundColor": "color2"
    },
    {
      "type": "spacer"
    }
  ]
}
FLEX_COLORS_DAY = ["#ff8c00", "#fffbe6", "#fffbe6"]
FLEX_COLORS_NIGHT = ["#000080", "#e6fbff", "#e6fbff"]
FLEX_BG_TODAY_COLOR = "#ffd1ff"
FLEX_BG_COLOR = "#ffffff"

次の通りに日ごとにFlexメッセージの要素(bubble)を作成して、レスポンス用にまとめます。

Flexメッセージ作成のスクリプト
Flexメッセージ作成
# Replace or delete target value in json.
def process_json(json, action, value, new_value = None):
    key = ""
    if type(json) == dict:
        for k, v in json.items():
            if v == value:
                    key = k
                    break
            if type(v) == dict or type(v) == list:
                process_json(v, action, value, new_value)
    elif type(json) == list:
        for i in range(len(json)):
            j = json[i]
            if j == value:
                key = i
            process_json(j, action, value, new_value)
    else:
        return
    if key != "":
        if action == "delete":
            del json[key]
        if action == "replace":
            json[key] = new_value
    return

# Parse menu per day.
def parse_menu(menu_per_day, template, data_type="flex", options=[]):
    if "date" not in menu_per_day:
        return None
    else:
        if data_type == "flex":
            bubble = \
            {
              "type": "bubble",
              "body": {
                "type": "box",
                "layout": "vertical",
                "contents": [
                  {
                    "type": "box",
                    "layout": "vertical",
                    "contents": [
                      {
                        "type": "text",
                        "text": menu_per_day["date"],
                        "size": "xl",
                        "weight": "bold",
                        "style": "italic",
                        "decoration": "underline"
                      }
                    ]
                  }
                ]
              }
            }
        if "todayColor" in options:
            color = options["todayColor"]
            bubble["styles"]={"body": {"backgroundColor": color}}
    if "all" not in menu_per_day:
        return None
    keys = ["type", "menu", "energy", "protein", "lipid", "carbohydrate", "salt"]
    for menu in menu_per_day["all"]:
        # Check keys.
        if set(keys) != set(menu.keys()):
            return None
        # Get information.
        day_type = "Morning" if menu["type"] == "morning" else "Supper" if menu["type"] == "supper" else None
        if day_type is None:
            return None
        menu_list = menu["menu"]
        energy = menu["energy"]
        protein = menu["protein"]
        lipid = menu["lipid"]
        carbohydrate = menu["carbohydrate"]
        salt = menu["salt"]
        # Insert template.
        if data_type == "flex":
            tmp = copy.deepcopy(template)
            for i in range(10):
                if i < len(menu_list):
                    value = {"type": "text","text": "menu"+str(i),"size": "xs"}
                    new_value = {"type": "text","text": menu_list[i],"size": "xs"}
                    process_json(tmp, "replace", value, new_value = new_value)
                else:
                    value = {"type": "text","text": "menu"+str(i),"size": "xs"}
                    process_json(tmp, "delete", value)
            process_json(tmp, "replace", "type1", new_value = day_type)
            process_json(tmp, "replace", "energy : energy1", new_value = "energy : " + energy)
            process_json(tmp, "replace", "protein : protein1", new_value = "protein : " + protein)
            process_json(tmp, "replace", "lipid : lipid1", new_value = "lipid : " + lipid)
            process_json(tmp, "replace", "carbohydrate : carbohydrate1", new_value = "carbohydrate : " + carbohydrate)
            process_json(tmp, "replace", "salt : salt1", new_value = "salt : " + salt)

            for i in range(len(FLEX_COLORS_DAY)):
                if menu["type"] == "morning":
                    color = FLEX_COLORS_DAY[i]
                elif menu["type"] == "supper":
                    color = FLEX_COLORS_NIGHT[i]
                process_json(tmp, "replace", "color"+str(i), new_value=color)
            bubble["body"]["contents"].append(tmp)
    return bubble 

# Make bubbles.
def make_bubbles(menu):
    bubbles = [parse_menu(menu_per_day, FLEX_TEMPLATE, options={"todayColor": FLEX_BG_TODAY_COLOR}) \
               if datetime.datetime.strptime(menu_per_day["date"], '%Y/%m/%d').date() == TODAY.date() \
               else parse_menu(menu_per_day, FLEX_TEMPLATE) for menu_per_day in menu]
    bubbles = [bubble for bubble in bubbles if not bubble is None]
    return bubbles

# Make message.
def make_message(menu, type):
    if menu is None or type is None:
        return None
    if type == "flex":
        message['altText'] = "flex"
        contents = {}
        bubbles = make_bubbles(menu)
        if bubbles is not None:
            if len(bubbles) == 1:
                contents = bubbles[0]
            else:
                contents['type'] = "carousel"
                contents['contents'] = bubbles
            message['contents'] = contents
        else:
            return None
    return message

以下でクイックリプライを付与します。

クイックリプライを付与
クイックリプライ付与
def set_quick_reply(message):
    quickReply = {"items": []}
    item = {
        "type": "action",
        "action": {
            "type": "message",
            "label": "", 
            "text": ""
        }
    }
    labels = ["今日", "明日", "今週", "来週"]
    for label in labels:
        temp = copy.deepcopy(item)
        temp["action"]["label"] = label
        temp["action"]["text"] = label
        quickReply["items"].append(temp)
    message["quickReply"] = quickReply
    return message

レスポンスは形式が決まっており、それに従って作成しPOSTします。(LINE Messaging APIについて(ページ内リンク))
POSTはurllibで行います。

レスポンスを作成してPOST
レスポンスを作成してPOST
# Make return params.
def make_params(messages, raw_event):
    if messages is None or raw_event is None:
        return None
    if "replyToken" not in raw_event:
        return None
    params = {
        "replyToken": raw_event['replyToken'],
        "messages": messages
    }
    print("params : ", params)
    return params

# Response by urllib.
def response(params):
    request = urllib.request.Request(URL, json.dumps(params).encode("utf-8"), method=METHOD, headers=HEADERS)
    try:
        with urllib.request.urlopen(request) as res:
            body = res.read()
    except urllib.error.HTTPError as err:
        print("Error Response: ", err.code)
    except urllib.error.URLError as err:
        print("Error Respones: ", err.reason)

②get_menu

get_menuではS3からメニューデータファイルを取得して解析します。
ここではスクリプト例を載せませんが、以下がポイントとなります。

  1. S3へのアクセスはboto3を利用(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Bucket.download_file)
  2. lambdaでは/tmpを一時ファイル領域として利用可能(https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)

なお、/tmp領域を使用する場合は、環境が再利用されている可能性を考慮してLambda関数の開始・終了時に削除処理を入れる方がよいと思われます。

③update_menu

update_menu関数では、Seleniumを使用して食堂Webシステムからメニュー表を取得します。
ポイントは以下です。
1. Seleniumの組み込みはLambda Layerを利用する
2. Seleniumの初期設定はクセがある
2. Seleniumの依存関係に注意を払う

1は後述します(Lambda Layerの作成(ページ内リンク))。
2について、Seleniumの設定には癖があるようです。動いている設定を以下に示します。

from selenium import webdriver
def init_driver():
    options = webdriver.ChromeOptions()
    options.binary_location = "/opt/python/headless_chrome/bin/headless-chromium"
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--single-process")
    options.add_experimental_option("prefs", {"download.default_directory" : DOWNLOAD_DIR})
    driver = webdriver.Chrome(
        executable_path="/opt/python/headless_chrome/bin/chromedriver",
        chrome_options=options
    )
    driver.implicitly_wait(10)
    driver = enable_download_headless(driver, DOWNLOAD_DIR)
    return driver

def enable_download_headless(driver, dir):
    driver.command_executor._commands["send_command"] = ("POST", '/session/$sessionId/chromium/send_command')
    params = {'cmd': 'Page.setDownloadBehavior', 'params': {'behavior': 'allow', 'downloadPath': dir}}
    driver.execute("send_command", params)
    return driver

3について、以下で動きました。
SERVERLESS_CHROME_URL=https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-55/stable-headless-chromium-amazonlinux-2017-03.zip
CHROME_DRIVER_URL=https://chromedriver.storage.googleapis.com/2.41/chromedriver_linux64.zip
RUNTIME=Python 3.7

(参考:https://qiita.com/konomochi/items/eb5ff389a405a665430e)

Lambda Layersの作成

LambdaではZIPファイルで作成したLambda関数をアップロードできます。
ここにはライブラリを含めることができますが、容量制限があります。
そこで、ライブラリはLambda Layersに設定します。
ローカルで作成したLambda Layers用ディレクトリをZIPしてアップロードします。
docker-lambdaでの検証も考慮して、以下のような階層を作成します(検証時に必要な形にしておきます)。
作業はWSL2(Ubuntu)で実施していますが、Linux環境であれば問題ないものと思います。

作業用ディレクトリ
├── fn
│   └── lambda_function.py
└── layers
    └── python
        ├── headless_chrome
        │   └── bin
        │       ├── chromedriver
        │       └── headless-chromium
        └── lib
            └── python3.7
                └── site-packages

①cd 作業用ディレクトリ
②以下のget-binaries.shを実施(参考に記載のkonomichi様の記事をもとにしました。)

get-binaries.sh
get-binaries.sh
#!/bin/bash -x

SERVERLESS_CHROME_URL=https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-37/stable-headless-chromium-amazonlinux-2017-03.zip
CHROME_DRIVER_URL=https://chromedriver.storage.googleapis.com/2.37/chromedriver_linux64.zip

# get headless_chrome
HEADLESS_LIB=./headless_chrome/python/headless_chrome/bin/
rm -r ${HEADLESS_LIB}
mkdir -p ${HEADLESS_LIB}

wget $SERVERLESS_CHROME_URL
unzip stable-headless-chromium-amazonlinux-2017-03.zip -d ${HEADLESS_LIB}
rm stable-headless-chromium-amazonlinux-2017-03.zip

wget $CHROME_DRIVER_URL
unzip chromedriver_linux64.zip -d ${HEADLESS_LIB}
rm chromedriver_linux64.zip

# get selenium
#  /opt/python, または /opt/python/lib/python3.7/site-packages に展開されるように配置する
rm -r ./selenium

"""
# python3.6
PYTHON_VER="python3.6"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
cp ./requirements.txt ./selenium/
docker run --rm \
    -v ${PWD}/selenium:/opt \
    "lambci/lambda:build-${PYTHON_VER}" \
    /bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}
"""

# python3.7
PYTHON_VER="python3.7"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
docker run --rm \
    -v ${PWD}/selenium:/opt \
    "lambci/lambda:build-${PYTHON_VER}" \
    /bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}

"""
# python3.8
PYTHON_VER="python3.8"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
docker run --rm \
    -v ${PWD}/selenium:/opt \
    "lambci/lambda:build-${PYTHON_VER}" \
    /bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}
"""
# make zip file
cd ./headless_chrome
zip -r headless_chrome.zip python > /dev/null
cd ../selenium
zip -r selenium.zip python > /dev/null
cd ../
mv ./headless_chrome/headless_chrome.zip ./
mv ./selenium/selenium.zip ./
rm -R ./headless_chrome
rm -R ./selenium

# make test
DIR=test_dir
rm -R ./${DIR}/layers
mkdir -p ./${DIR}/layers
cp ./headless_chrome.zip ./${DIR}/layers
cp ./selenium.zip ./${DIR}/layers
cd ./${DIR}/layers
unzip headless_chrome.zip > /dev/null
unzip selenium.zip > /dev/null
sudo rm headless_chrome.zip
sudo rm selenium.zip
mkdir ./fn

③headless_chrome.zipファイル、selenium.zipファイル、test_dirディレクトリが存在することを確認する

(参考:https://qiita.com/konomochi/items/eb5ff389a405a665430e,
    https://www.yamamanx.com/aws-lambda-layer-chrome-headless-layer/,
    https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-layer-simulated-docker/)

Lambda関数のローカル検証

まずは試しに作成した関数をローカルで検証しました。
ローカルでの検証には、docker-lambdaを使用しました。
(参考:https://qiita.com/anfangd/items/bb448e0dd30db3894d92)

ローカル検証時には、Lambda Layersの環境を使用するため、Lambda Layersの作成(ページ内リンク)で作成したtest_dirを使用します。

  1. cd 作業用ディレクトリ/test_dir
  2. 作業用ディレクトリ/test_dir/fn配下に任意の名前で検証対象のpythonファイルを作成する(ここではlambda_function.py)
  3. lambda_function.pyにlambda_handler関数を作成
  4. 以下コマンドでテスト実行

docker run --rm -v "\$PWD"/fn:/var/task -v "\$PWD"/layers:/opt lambci/lambda:python3.7 lambda_function.lambda_handler

―vオプションで、コンテナ環境のディレクトリをローカルのディレクトリと対応付けています。
すなわち、Lambdaでは(pythonの場合は)、実行時に以下のように動作すると思われます。

  • /opt/pythonにLambda Layers内のライブラリを展開
  • /var/taskでLambda関数を実行

2. AWS設定(S3、API Gateway、IAMロール、Lambda設定)

S3設定

S3は任意のバケットを作成するのみです。
なお、アクセスポリシーでパブリックアクセスはブロックします。
作成時のバケット名、オブジェクト名(フォルダまで固定値)は、Lambda関数での呼び出し値に合わせます。

API Gateway設定

REST APIを作成します。
image.png
image.png
image.png

ルートにPOSTメソッドを追加します。
image.png
image.png

  • 統合タイプ:Lambda関数
  • Lambdaプロキシ統合の仕様:チェック
  • Lambda関数:作成した関数(ex. parse_message)

を指定し保存します。

image.png

IAMロール設定

次のポリシーを作成します(簡単のため雑な指定になっていますが、必要に応じてAction、Resourceを絞るべきです)。
・CloudwatchLogsPolicy
image.png
・LambdaInvokePolicy
image.png
・S3AccessPolicy
image.png

以下のIAMロールを作成します。
・lambda-s3-role
image.png
・lambda-role
image.png
・s3-role
image.png

Lambda設定

レイヤーの作成

Lambda Layersの作成(ページ内リンク)で作成したZIPファイルごとにレイヤーを作成する。
 - 名前:ZIPファイル名と同じ
 - アップロード:Lambda Layersの作成(ページ内リンク)で作成したZIPファイル
 - 互換性のあるランタイム:Python 3.7
image.png

関数の作成

関数の作成を選択します。
 オプション:1から作成
 関数名:parse_messageなど
 ランタイム:python3.7
image.png

次の通りロールを設定します。

Lambda関数 IAMロール
parse_message lambda-role
get_menu lambda-s3-role
update_menu s3-role

アクセス情報を設定し関数の作成を押下します。
 実行ロール:既存のロールを使用する
 既存のロール:表参照
image.png

次の通りカスタムレイヤーを設定します。

Lambda関数 カスタムレイヤー
parse_message なし
get_menu なし
update_menu headless_chrome、selenium

作成した関数を選択し、レイヤーを設定します。
 - レイヤー:カスタムレイヤー
 - カスタムレイヤー:表参照
 - バージョン:最新のもの
image.png

3. テスト

Lambda関数単体のテストは、個々のLambda関数から実行できます。
ただし、テストのためのリクエストを明記する必要があります。
image.png

API Gatewayを経由しての、LINEと連携したテストを行います。
IAMロール設定(ページ内リンク)で設定した、CloudWatchLogsPolicyをlambdaに付与しているため、CloudWatchからlambdaの出力を確認できます。

CloudWatch>CloudWatch Logs>Log groups>/aws/lambda/parse_message のようにログストリームを参照できます。
Lambda関数中でprintするとここに出力されます。
image.png

12
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
8