こんにちは。あかいです。
この記事は、勉強を兼ねてAPI Gateway + Lambda + LINE messaging API で食堂のメニュー通知Botを作ってみた際の備忘録です。
作成に当たって必要な要素を概観するようなものを目的とします。
AWSやLINEへの登録については、今回はスキップします。
#作成物
今回作成したのは以下のようなLINE Botです。
「今日」、「今週」などと入力すると、メニューが返信されます。
返信には「今日」「明日」「今週」「来週」のようなクイックリプライがついています。
#背景
今回のメニュー通知Botのもととなっているのは、私が利用している寮の食堂のメニュー通知です。
メニューを知るには、
- 専用のWebシステムにアクセス
- ログイン
- 今月のメニュー表(Excel)をダウンロード
- メニュー表を開いて確認
する必要があります。
以下はメニュー表ですが、これで毎回確認するのは、(個人的には)なかなかつらいものがあります。
これを簡単に知る手段はないか、というのが今回の動機です。
「今日のご飯なんだっけな?」と手軽に確認するのにLINEが最適と判断しました。
#構成
以下に構成を示します。
基本的には、LINEのMessaging APIからWebhookして、API Gatewayで受け付け、Lambdaで処理して返信する流れです。
Webhookのたびに食堂Webシステムにアクセスするのは非効率なので、メニューデータファイルをS3に保存しておきます。
Lambdaがキックされたとき、S3のメニューデータファイルにアクセスして該当するメニューがなければ、最新のメニュー表を食堂Webシステムから取得します。Excel形式のメニュー表を扱いやすいJSON形式のメニューデータファイルに変換してS3に保存します。
#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)
#作成の流れ
- Lambda関数作成
- AWS設定(S3、API Gateway、IAMロール、Lambda設定)
- テスト
#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で作成する場合は、最初に呼び出される関数(ハンドラ)を定義します。
デフォルトでは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内では、以下を実施します。
- LINEのmessaging_api用の初期処理
- LINEのメッセージテキストを取得
- メッセージテキストを解析
- メニューデータを取得
- LINEの返信用URLにレスポンスをPOST
- API Gatewayからのメッセージに返信
####LINEのmessaging_api用の初期処理
初期処理で実施するのは次です。
- 応答メッセージ用のURL, METHOD, HEADERSを設定
- eventからbody, headerを取得
- 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)
# 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
channelsecret
やchannel access token
はBotアカウントに紐づくセンシティブな情報です。
そのため、ソースコードに直書きは避けたいものです。
DBに格納する、S3にファイルで格納するなど考えられますが、ここでは、Lambdaの環境変数を使用できます。
環境変数へのアクセスは、通常の環境変数同様、os.environ['ACCESSTOKEN']
のように可能です。
####LINEのメッセージテキストを取得
本Botはメッセージテキストのみを受け付けます。
したがって、以下のようなリクエストボディを想定します。
"events": [
{
"type": "message",
"message": {
"type": "text",
"text": "今日"
}
}
]
(参考:https://developers.line.biz/ja/reference/messaging-api/#message-event)
次に示す関数では、上記に該当するメッセージを受け取った場合には、Botが受け取ったtextを、該当しない場合はNoneを返します。
# 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できた場合、開始日、終了日を満たすメニューデータが返されます。
# 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_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メッセージ作成のスクリプト
# 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
# 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からメニューデータファイルを取得して解析します。
ここではスクリプト例を載せませんが、以下がポイントとなります。
- S3へのアクセスはboto3を利用(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Bucket.download_file)
- lambdaでは/tmpを一時ファイル領域として利用可能(https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)
なお、/tmp領域を使用する場合は、環境が再利用されている可能性を考慮してLambda関数の開始・終了時に削除処理を入れる方がよいと思われます。
###③update_menu
update_menu関数では、Selenium
を使用して食堂Webシステムからメニュー表を取得します。
ポイントは以下です。
- Seleniumの組み込みはLambda Layerを利用する
- Seleniumの初期設定はクセがある
- 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
#!/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を使用します。
- cd 作業用ディレクトリ/test_dir
- 作業用ディレクトリ/test_dir/fn配下に任意の名前で検証対象のpythonファイルを作成する(ここではlambda_function.py)
- lambda_function.pyにlambda_handler関数を作成
- 以下コマンドでテスト実行
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を作成します。
- 統合タイプ:Lambda関数
- Lambdaプロキシ統合の仕様:チェック
- Lambda関数:作成した関数(ex. parse_message)
を指定し保存します。
##IAMロール設定
次のポリシーを作成します(簡単のため雑な指定になっていますが、必要に応じてAction、Resourceを絞るべきです)。
・CloudwatchLogsPolicy
・LambdaInvokePolicy
・S3AccessPolicy
以下のIAMロールを作成します。
・lambda-s3-role
・lambda-role
・s3-role
##Lambda設定
###レイヤーの作成
Lambda Layersの作成(ページ内リンク)で作成したZIPファイルごとにレイヤーを作成する。
- 名前:ZIPファイル名と同じ
- アップロード:Lambda Layersの作成(ページ内リンク)で作成したZIPファイル
- 互換性のあるランタイム:Python 3.7
###関数の作成
関数の作成を選択します。
オプション:1から作成
関数名:parse_messageなど
ランタイム:python3.7
次の通りロールを設定します。
Lambda関数 | IAMロール |
---|---|
parse_message | lambda-role |
get_menu | lambda-s3-role |
update_menu | s3-role |
アクセス情報を設定し関数の作成を押下します。 | |
実行ロール:既存のロールを使用する | |
既存のロール:表参照 | |
次の通りカスタムレイヤーを設定します。
Lambda関数 | カスタムレイヤー |
---|---|
parse_message | なし |
get_menu | なし |
update_menu | headless_chrome、selenium |
作成した関数を選択し、レイヤーを設定します。 | |
- レイヤー:カスタムレイヤー | |
- カスタムレイヤー:表参照 | |
- バージョン:最新のもの | |
#3. テスト
Lambda関数単体のテストは、個々のLambda関数から実行できます。
ただし、テストのためのリクエストを明記する必要があります。
API Gatewayを経由しての、LINEと連携したテストを行います。
IAMロール設定(ページ内リンク)で設定した、CloudWatchLogsPolicyをlambdaに付与しているため、CloudWatchからlambdaの出力を確認できます。
CloudWatch>CloudWatch Logs>Log groups>/aws/lambda/parse_message のようにログストリームを参照できます。
Lambda関数中でprintするとここに出力されます。