Lambdaを使ってみて

お疲れ様です。本記事は、AWS Lambda Advent Calendar 2017の3日目の記事になります。

さて、Lambdaをがっつり本番環境で動かしていますという話もチラホラ聞きますが、私はLambdaを使い始めて約1年半、まだいまひとつガッツリ本番な気分になっていません。

その理由の一つがローカル開発環境問題です。ありきたり!
少し複雑なコードを書くとなると、デバッグしながらになるのでローカルマシンの使い慣れたエディタでやりたいところ。
ではささっと環境構築してみましょう。

ローカル開発環境の整備

その :one: 各言語のSDKをインストール

Lambdaで使える言語は今のところnode.js, Python, C#, Java(今後Golang, .NET)がありますが、MacでLambdaの開発環境を構築するとなると、

  • nodejs用

    $ npm -g install aws-sdk
    
  • Python用

    $ pip install python-lambda-local
    
  • Java用
    AWS Toolkitをインストール

以下略

当然全言語を使うわけではないですが、用途によって適正言語を使おうとすると、それぞれランタイムが必要。これらを今後バージョン管理していくとなると、若干見通しが暗く。。

その :two: 必要なライブラリのインストール

さらに、モジュールをImportしたりすると、それらもそれぞれbuild && デプロイパッケージ(要はZIPで固めて)をLambdaにアップロードするという二手間作業の発生

その :three: invalid ELF header対策

Lambdaは実際にはEC2のAmazon Linuxを立ち上げて動くことになるので、macOS上でビルドされたライブラリでは動かないケースが有る。いわゆる invalid ELF header 問題である。
じゃあちょろっとEC2インスタンス立ち上げて、ビルドしてパッケージングすれば解決。

もうローカル環境じゃなくなっちゃった!

2016年まではこのツラみがありましたが、今はもうAmazon Linux Dockerイメージが出た!
これでEC2インスタンスを立ち上げるのはなんとか回避できるようになりました。

その :four: 実行&デプロイ(ざっくり)

  • nodejs用
    メイン処理を実行するファイル lambda_handler.jsを作る(ファイル名は任意で)

    $ node lambda_handler.js
    

    実装が完了したら、それらを固めてデプロイするためのgulp.taskを作る

  • Python用
    メイン処理を実行するファイル lambda_handler.pyを作る(ファイル名は任意で)

    $ python-lambda-local -l lib/ -f handler -t 30 lambda_handler.py event.json
    

以下略再び

というか、 サーバのプロビジョニングの必要がないのがLambdaの売りなので、 もうだいぶ前の時点で既に本末転倒なんじゃないか感があったのに気づかないふりをしていた自分。。

もっとこうスマートに何か!

こういった状況の一つのソリューションとしてServerless Frameworkがあります。

こちらメリットとしては、

  • slsコマンドでビルドやデプロイなど大抵のことが一発完了
  • 設定もymlでパッケージ管理しやすい

という点がありますが、一方で

  • npmインストールして使う以上、結局管理対象が増えるのでは?
  • AWSの管理者権限が付与されたIAMロールが必要 (なので、所属組織によっては開発者全員が使うのは難しいかも)
  • 上述のネイティブライブラリ問題は据え置き

といった点が個人的には引っかかりました。

ここまでで既に勘の良い人はお気づきかと思いますが

Dockerで管理する

「これDockerで全部やれるんじゃないか?」

先程このへんで触れたAmazon Linux Docker Image、これに諸々入れる分にはローカルのマシンを基本汚さずに済む。
そして、開発が終わったらコンテナごと廃棄ドボンしてスッキリ。

あとはまた、Lambdaでの開発が必要なときにそのつど docker buildするわけですが、うまいことDockerfileが保守されていれば理想的ですよね。

何より普段Dockerでアプリケーション開発してるんなら、Dockerでまとめてしまうのが見通しがよい。

そんなLambda用のDockerイメージを作れば良い

と思いましたが、lambci/docker-lambdaというLambda用Dockerイメージがありました。

Screen Shot 2017-12-04 at 2.50.05.png
https://speakerdeck.com/hihats/aws-lambdafalsejin-xian-zai?slide=18

LambciというOSSプロジェクトなので、自作より皆で保守していったほうが間違いない。
活発にアップデートされているので、Goへの対応なども楽しみです。

さっそく使ってみる

lambci/lambda:python3.6 イメージをベースにして実行

print_json.py
# 引数で渡されたJSONをそのまま出力するだけのPythonスクリプト
import json

def lambda_handler(event, context):
    print(event)
sample.json
  {
    "type": "products",
    "id": 123,
    "attributes": {
      "name": "Fun Toy",
      "description": "Toy for infants",
      "state": "in sale",
      "slug": "4b5366e5",
      "photo": "TOY.jpg"
    }
  } 

以下のように、(拡張子なしファイル名):関数名 をコマンドとしてイメージに渡してdocker runしてあげるだけで、実行環境のbuild(イメージがなければpullも)から関数実行までやってくれます

$ docker run -v "$PWD":/var/task lambci/lambda:python3.6 print_json.lambda_handler $(printf '%s' $(cat sample.json))

Unable to find image 'lambci/lambda:python3.6' locally
python3.6: Pulling from lambci/lambda
5aed7bd8313c: Pull complete
d60049111ce7: Pull complete
216518d5352c: Pull complete
47aa6025d0bc: Pull complete
9a82bb1662ac: Pull complete
Digest: sha256:3663b89bd1f4c4d1a4f06a77fc543422c1f0cbfc3a2f491c8c7bdc98cf9cf0b6
Status: Downloaded newer image for lambci/lambda:python3.6
START RequestId: 6cf5f6b5-bf62-49de-aaa4-f05b1148b67e Version: $LATEST
{'type': 'products', 'id': 123, 'attributes': {'name': 'FunToy', 'description': 'Toyforinfants', 'state': 'insale', 'slug': '4b5366e5', 'photo': 'TOY.jpg'}}
END RequestId: 6cf5f6b5-bf62-49de-aaa4-f05b1148b67e
REPORT RequestId: 6cf5f6b5-bf62-49de-aaa4-f05b1148b67e Duration: 109 ms Billed Duration: 200 ms Memory Size: 1536 MB Max Memory Used: 19 MB 

実行ファイルとコマンドワンライナーでいけました :exclamation::sushi:

build用イメージ lambci/lambda:build-python3.6 をベースにして環境をbuild

Lambdaがデフォルトで扱ってくれないライブラリが必要な処理のケースでは、ビルド & デプロイパッケージングするためのイメージ(lambci/lambda:build-python3.6)をベースにDockerfileでゴニョゴニョする

FROM lambci/lambda:build-python3.6
ENV LANG C.UTF-8
ENV AWS_DEFAULT_REGION ap-northeast-1

WORKDIR /var/task
ADD . .

RUN /bin/cp -f /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
  pip install -r requirements.txt -t /var/task

CMD zip -9 deploy_package.zip search.py && \
  zip -r9 deploy_package.zip *

必要なモジュールはrequirements.txtに羅列

requirements.txt
twitter
pymongo
numpy
requests_oauthlib
pytz

ビルドしてみる

$ docker build -t twitter_search .

Sending build context to Docker daemon  107.5MB
Step 1/7 : FROM lambci/lambda:build-python3.6
 ---> a895020ff4f5

<中略>

Successfully installed certifi-2017.11.5 chardet-3.0.4 idna-2.6 numpy-1.13.3 oauthlib-2.0.6 pymongo-3.5.1 pytz-2017.3 requests-2.18.4 requests-oauthlib-0.8.0 twitter-1.18.0 urllib3-1.22  

必要なモジュールがdockerに入っていき、実行環境が整いました。

既に上のDockerfileのCMD文にsearch.pyとファイル名が書かれていますが、今回は 「Twitterで特定のキーワード検索した結果をmongodb(ローカルのDockerコンテナ)にINSERTする」 Lambda関数を用意。

search.py
import os
from twitter import *
from requests_oauthlib import OAuth1Session
from requests.exceptions import ConnectionError, ReadTimeout, SSLError
import json, datetime, time, pytz, re, sys,traceback, pymongo, pprint
from pymongo import MongoClient
from collections import defaultdict
import numpy as np
import csv

def lambda_handler(event, context):
    pp = pprint.PrettyPrinter(indent=4)
    mongo_host = os.environ['MONGODB_HOST']
    secrets = {
        'consumer_key': event['CONSUMER_KEY'],
        'consumer_secret': event['CONSUMER_SECRET'],
        'access_token': event['ACCESS_TOKEN'],
        'access_token_secret': event['ACCESS_SECRET']
    }
    s_pa = [
        secrets["access_token"],
        secrets["access_token_secret"],
        secrets["consumer_key"],
        secrets["consumer_secret"]
    ]
    query = event["KEYWORD"]

    t = Twitter(auth=OAuth(s_pa[0], s_pa[1], s_pa[2], s_pa[3]))

    client = MongoClient(mongo_host, 27017)
    db = client.twitter_db
    tw_collection = db.tweets
    metadata_collection = db.metadata

    while(True):
        results = t.search.tweets(q=query, lang='ja', result_type='recent', count=100, max_id=0)
        metadata_collection.insert(results['search_metadata'])
        if len(results['statuses']) == 0:
            sys.stdout.write("statuses is none. ")
            break
        for tw in results['statuses']:
            if tw_collection.find_one({'id': tw['id']}) is None:
                tw_collection.insert(tw)

        print(len(results['statuses']))
        if not 'next_results' in results['search_metadata']:
            sys.stdout.write("no more results. ")
            break
        since_id = results['search_metadata']['since_id']

secret.jsonにTwitterAPIのKEYやらmongoのホストアドレスやら検索したいクエリやらをぶちこみ :arrow_down: 実行する

$ docker run -v "$PWD":/var/task lambci/lambda:python3.6 search.lambda_handler $(printf '%s' $(cat secret.json))

START RequestId: afc3baf7-09d1-4407-85b7-e012e759053b Version: $LATEST
30 recorded
no more results. END RequestId: afc3baf7-09d1-4407-85b7-e012e759053b
REPORT RequestId: afc3baf7-09d1-4407-85b7-e012e759053b Duration: 2070 ms Billed Duration: 2100 ms Memory Size: 1536 MB Max Memory Used: 38 MB

とれました :exclamation: :sushi: :sushi:

注意するのは、ビルド用と関数実行用のイメージは別(`lambci/lambda:build-python3.6`はあくまでビルド&パッケージングだけで使う)ということ

無事実装が終わればパッケージング

$ docker run -v "$PWD":/var/task --name twitter_search twitter_search:latest
$ ls deploy_package.zip
deploy_package.zip

deploy_package.zipができていることが確認できました。

結論

Docker最高ですね。

LambdaのAdvent calendarなのにDockerアゲになってしまいました :bow:

参考記事

Serverless Frameworkのプラグインを利用した外部モジュールの管理
AWS Lambda 用の python パッケージをクロスコンパイルして serverless で deploy した話