docker-lambdaでAWS Lambda環境をお手軽に動かす

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 した話

続きを読む

AWS+Reactアプリ作成入門(DynamoDB編)

AWS+Reactアプリ作成入門(Cognito編)
AWS+Reactアプリ作成入門(S3編)
AWS+Reactアプリ作成入門(DynamoDB編)
AWS+Reactアプリ作成入門(IAM Role編)
AWS+Reactアプリ作成入門(ログイン後のAdmin編)

今回作成したアプリ ==>久喜SNS

 DynamoDBはAWSが提供するDBサービスです。MongoDBと比べるとちょっと癖がありますし、私も全体的な理解が十分ではないので、説明は今回アプリの利用に限定することとして、断定的に記述していきたいと思います。偏見や偏りがあるかもしれないという事ですが、ご容赦ください。

  1. テーブルの検索はqueryとscanがあります。
  2. scanは無条件に全テーブルをサーチしコストが高いので使わないことにします。
  3. queryはindexに対して検索条件を与えて検索するものです。
  4. テーブル作成時にprimary indexを設定するのが必須です
  5. indexキーは2つの項目を指定します。partition key とsort keyと呼びます(partition keyのみでもok)
  6. partition keyはテーブルを分割して、検索時に分割された領域だけをサーチするようにする、イメージですかね。
  7. query検索時にはpartition keyを指定して(equal条件)、sort keyでフィルタします。
  8. query検索時は自動的にsort keyでソートされます。
  9. primary keyでないものを条件として検索したい時は、secondary indexを明示的に作成します(課金される)
  10. secondary indexでも検索したい条件に合わせて、partition key とsort keyを定義します。

1.投稿のpost

 以下のようなコードで、画像掲示板のテーブルへを投稿します。

src/views/Admin.jsの一部
    var docClient = new AWS.DynamoDB.DocumentClient();
    var params = {
        TableName: tablename,
        Item:{
             identityId: identityId, // ★prime partition key
             email: _self.state.email,
             username: _self.state.username,
             filename: filepath,
             thumbnail: thumbnail,
             type: fileType,
             title: title,
             story: story,
             imageOverwrite: _self.state.imageOverwrite,
             mapUse: _self.state.mapUse,
             position: _self.state.position,
             uploadTime: uploadTime, // ★prime & secondary sort key
             uploadDate: uploadDate,
             partitionYear: partitionYear, //★secondary partition key
             refCounter: 0
        }
    };
    docClient.put(params, function(err, data) {
        if(err) {
            console.log("Err: table put :" +err);
        } else {
            console.log("Success: table put ok");
        }
    });

 docClient.put()で投稿をテーブルに挿入します。

 このテーブルは、トップ画面で最新投稿を検索するのと、管理画面で自分の最新投稿を検索する2つの種類の検索があります。どちらも最新順のリストを取得します。

1. 管理画面で自分の最新順の投稿リストを検索

primary indexを以下のキーで作成
partition key : identityId (文字列) 
sort key : uploadTime (数値)

 identityIdはユーザIDとして使っているもので、Cognitoでのログイン時にAWS.config.credentials.identityIdに値が設定されるものです。uploadTimeは投稿時間でunixtimeです。

2. トップ画面で最新順の投稿リストを検索

primary indexを以下のキーで作成
partitionYear: partitionYear (数値)
sort key : uploadTime (数値)

partitionYearはuploadTimeを年数字に変換したものです。2017とかの数字です。uploadTimeは投稿時間です。

ちなみに昔はAWS.DynamoDB()が使われていたようですが、検索結果に不要の型(SとかNとか)が含まれとても使いにくいので、ここではAWS.DynamoDB.DocumentClient()を使っています。

2.投稿削除

 以下のようなコードで、画像掲示板のテーブルから投稿を削除します。

src/views/Admin.jsの一部
    const docClient = new AWS.DynamoDB.DocumentClient();
    const params3 = {
        TableName: tablename,
        Key: {
           identityId: item.identityId, // ★partition key
           uploadTime: item.uploadTime  // ★sort key
        }
    };
    docClient.delete(params3, function (err, res) {
      if (err) {
          console.log("### delete table err:"+err); // an error occurred
      } else{
          console.log("### delete table ok"); // successful response
          if(addCallback) { //編集 => 削除 then 追加
              addCallback();
          }
      }
    });

 partition keyとsort keyを指定し、docClient.delete()で投稿を削除しています。

3.投稿編集

 編集は、古いものを削除して新しいものを挿入する、という考えで実装しています。上の削除のコードで、削除が成功した時にaddCallback()を呼んでいるのがそれに当たります。単に削除だけを行いたい場合はaddCallback=nullとしてこの関数を呼びます。

4.カウンター

 投稿のページにはそれぞれカウンターを設け参照数をカウントしています。投稿のpostで示したコードのrefCounterがそれに当たります。今回はReactのCounterコンポーネントを作成し、参照されるごとにアトミックにインクリメントするコードを書きました。

src/views/Counter.js
import React from 'react';
import AWS from "aws-sdk";
import appConfig from '../appConfig';
import {bucketname,tablename}  from '../appConfig';

//http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GettingStarted.NodeJs.03.html
export default class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        counter: 0
    };
  }

  componentWillMount() {
    const _self=this;
    const dynamo = new AWS.DynamoDB.DocumentClient();
    var params = {
        TableName:tablename,
        Key:{
            "identityId": this.props.identityId,
            "uploadTime": this.props.uploadTime
        },
        UpdateExpression: "set refCounter = refCounter + :val",
        ExpressionAttributeValues:{
            ":val":1
        },
        ReturnValues:"UPDATED_NEW"
    };
    console.log("Updating the item...");
    dynamo.update(params, function(err, data) {
        if (err) {
            console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2));
        } else {
            console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2));
            _self.setState( {counter: data.Attributes.refCounter} );
        }
    });
  }

  render () {
    return (
        <div>{this.state.counter}</div>
    );
  }
}

 Counterコンポーネントは親コンポーネントからidentityIdとuploadTimeを渡され、this.propsで参照しています。カウンターは dynamo.update()でアトミックにインクリメントされます。

 今回はこれで終わりです。次回以降にIAMのRoleについて述べたいと思います。

続きを読む

DockerでRocket.chatを構築し、hubotも連携させる

備忘録のために載せておきます。
※Dockerやhubotは他に詳しい記事があるので触れません。

Rocket.chatって?

Slackライクなチャットツール。
https://rocket.chat/

一年ほど前、コミュニケーションツールとしてチャットをプロジェクトで導入する際に
サーバインストール型のチャットツールを探してこれに行きつきました。
周りで誰も使ったことがありませんでしたが、とりあえずお試しで入れたのが経緯。

なんだかんだで1年半ほどプロジェクト内で運用しています。

Dockerレシピ

rocketchat:
  image: rocketchat/rocket.chat:latest
  environment:
    - MONGO_URL=mongodb://mongodb/rocketchat
    - ROOT_URL=http://localhost:80
  links:
    - mongodb
  ports:
    - 80:3000

hubot:
  image: rocketchat/hubot-rocketchat
  environment:
    - PORT=5080
    - ROCKETCHAT_URL=[任意のドメイン]:80
    - ROCKETCHAT_ROOM=
    - LISTEN_ON_ALL_PUBLIC=true
    - ROCKETCHAT_USER=[hubot用ユーザID]
    - ROCKETCHAT_PASSWORD=[hubot用パスワード]
    - BOT_NAME=[任意のhubot名]
    - EXTERNAL_SCRIPTS=hubot-help,hubot-seen,hubot-links,hubot-diagnostics,hubot-reddit,hubot-bofh,hubot-bookmark,hubot-shipit,hubot-maps,hubot-cron,hubot-jenkins-notifier
    - HUBOT_JENKINS_URL=[連携するjenkinsのURL]
    - HUBOT_JENKINS_AUTH=[jenkinsのアカウント:パスワード]
  volumes:
    - /usr/local/share/hubot/scripts:/home/hubot/scripts
    - /etc/localtime:/etc/localtime:ro
  links:
    - rocketchat:rocketchat
  ports:
    - 3001:5080

mongodb:
   image: mongo
   ports:
     - 27017
   volumes:
     - /srv/docker/mongodb/db:/data/db

一部解説

下記はJenkinsのジョブをhubot内から実行するための設定です。

    - EXTERNAL_SCRIPTS=hubot-help,hubot-seen,hubot-links,hubot-diagnostics,hubot-reddit,hubot-bofh,hubot-bookmark,hubot-shipit,hubot-maps,hubot-cron,hubot-jenkins-notifier
    - HUBOT_JENKINS_URL=[連携するjenkinsのURL]
    - HUBOT_JENKINS_AUTH=[jenkinsのアカウント:パスワード]

下記はRocket.chatのログはmongoDB内に格納されるため
コンテナを消してもログを残すためにマウントの設定を入れています。
ホスト側のパスはもちろん任意です。

   volumes:
     - /srv/docker/mongodb/db:/data/db

運用する中で起きた問題

ID管理

開発環境には他サービスも並行して動いており
導入したチャットでも新たにID管理するのは面倒だから何とかならない?
といったことがありました。

対処として、Rocket.Chatには特定サービスとのoAuth認証機能が備わっていましたので
今回のケースではバージョン管理として既に使用していたGitlabに集約することに。

キャプチャ.PNG

hubotコンテナが落ちる

hubotには導入したプラグイン次第で様々なコマンドを使用させることができます。
メンバーの利用状況を見ていると、通知機能(タスク登録)がよくつかわれていたようです。
Cron形式で登録することができ、お昼や定例などのアラーム代わりに使っているのが見受けられました。

ある日突然、hubotが反応しなくなりdocker ps -a で状態を見ると、hubotコンテナが落ちていました。
docker logs にてログを出力したところ、チャットの全ルームの過去ログが流れ出しました。

原因はおそらく、割り当てたメモリ枯渇ではないかなと推測しています。
ちなみにDockerはAWSのEC2上で起動しております。

対処として、docker rm [コンテナ名] でコンテナ削除を行い、docker-compuse up -d
で起動しました。
hubotが持っているログは不要なのでばっさり切り捨て。

ただこの方法だと、上記にありました通知機能で登録したタスクがすべてなくなってしまいますので
コアな運用に用いている場合はhubotコンテナもホストにマウントするなど、一工夫必要かなと思います。

続きを読む

ドキュメント共有ツール Crowi をAmazonLinuxで構築する

ドキュメント共有ツール Crowi をAmazonLinuxで構築する

Crowiとはオープンソースで、ドキュメントを色々溜め込むことができるツールです。
ずっと、RedmineのWikiを使っていましたが、どうしても好きになれなかったんですよね・・
(マークダウンで書けない、タグサーチができなくて検索不便等々・・・)
おそらくプラグインをいれたら上記は解決できるかと思いますが、そこまでやるのは面倒だということで、他にないか探していたらCrowiを発見しました!

今回は、導入と設定らへんを書いていきたいと思います。

構築準備

  • AmazonLinux
  • Node.js v8.0
  • Mongo v3.4.2
  • Nginx v1.6.2

構築方法

まずはサクッとNode.jsとNginxのインストールをおこないます。

Node.jsのインストール

こちらを参考にしてインストールしてみてください。
http://qiita.com/sinmetal/items/154e81823f386279b33c

Nginxのインストール

$ sudo yum install -y nginx

こちらでサクッとインストール&起動しておきましょう!

MongoDBのインストール

/etc/yum.repos.d/mongodb-org-3.4.repo
[mongodb-org-3.4]
name=MongoDB Repository
baseurl=https://repo.mongodb.org/yum/amazon/2013.03/mongodb-org/3.4/x86_64/
gpgcheck=1
enabled=1
gpgkey=https://www.mongodb.org/static/pgp/server-3.4.asc

このファイルを作成しておきます。
作成が完了したら、

$ sudo yum install -y mongodb-org
$ sudo service mongod start

これで、起動が完了です。

各設定

  • Nginxの設定

3001番ポートにプロキシする設定を書きます。

/etc/nginx/vhost.d/crowi.conf
server {
  listen   *:80;
  server_name crowi.hoge.com;

  access_log /opt/nginx/logs/crowi_access.log;
  error_log  /opt/nginx/logs/crowi_error.log;

  location / {
    proxy_pass http://127.0.0.1:3001;
  }
}
  • Mongoの設定
$ mongo
> use crowidb
switched to db crowidb
>  db.createUser({user: "crowi", pwd: "crowi", roles: [{role: "userAdmin", db: "crowidb"}]})
Successfully added user: {
    "user" : "crowi",
    "roles" : [
        {
            "role" : "userAdmin",
            "db" : "crowidb"
        }
    ]
}

Crowiをインストールする

$ git clone --depth 1 https://github.com/crowi/crowi.git
$ cd crowi
$ git checkout v1.6.0
$ sudo npm install

これで、Crowiの設定は完了です。

次に、プロセスを起動しますが、ここはforeverを用いて、プロセスを常に起動できるようにしておきます。
その前に、環境変数の設定をしておきます。

$ export PASSWORD_SEED=なんでもOK
$ export MONGO_URI=mongodb://crowi:パスワード@localhost/crowidb
$ export PORT=3001
$ export NODE_ENV=production
$ npm install forever -g

foreverのインストールが完了したので、プロセスを起動していきます。

$ forever start app.js
$ forever list
ここにプロセスの起動情報が書いてある

これでプロセスの起動が完了したので、設定したドメインにアクセスすればCrowiが表示されると思います。

その他設定

他にもCrowiにはこういった機能があります

  • 画像やファイルをアップロードできるようにする
  • 認証をGoogleアカウント認証に変更できる
  • 全文検索ができるようになる

ここはまたのちほど紹介していきたいと思います。

では、よいドキュメント共有ライフを!

続きを読む