AWS Lambda と連携するマイクロフレームワーク Chalice を使ってみる

# はじめに

Serverless アプリケーションのフレームワークとしては [Serverless Framework](https://serverless.com/) が有名だが、以下の点で個人的なユースケースにちょっと合わないところを感じていた。

- Python で開発したい！
    - Serverless Framework 自体は様々な言語の開発にも対応しているが、Node.js/npm ベースであり、これらの知識が多少要求される
    - たくさんの言語で対応できているので、悪く言うと情報が散らかっていて情報収集結果の読み替えが少し大変
        - JSの場合の解決策は提示されているが、これのPythonの場合は...と考える時間がそこそこあるという経験談から
- マルチプラットフォーム対応は必要ない
    - AWS 特化さえしてくれてれば今回は問題ない

そこで改めて情報を調べていると [Chalice](https://github.com/aws/chalice) というマイクロフレームワークがAWS公式のGithubリポジトリから出ていることを確認。
チュートリアルとかを読む限り、私には Serverless Framework よりも向いてる気がしたので、改めて色々と触ってみました。


# Chalice とは

以下がGithubページからの概要の引用です。

> Chalice is a microframework for writing serverless apps in python. It allows you to quickly create and deploy applications that use AWS Lambda. It provides:
> 
> - A command line tool for creating, deploying, and managing your app
> - A decorator based API for integrating with Amazon API Gateway, Amazon S3, Amazon SNS, Amazon SQS, and other AWS services.
> - Automatic IAM policy generation
> 
> 引用 - https://github.com/aws/chalice

できることとしては、

- コマンドラインツールを使ってのアプリケーションのデプロイ管理
- AWS Lambda とそれ以外のAWSリソース連携を前提としたコーディング
    - API Gateway と連携させることで serverless アプリケーションを作成できる
    - CloudWatch Evnetsによる定期実行や、SNS・SQSのイベントを発火起因としてLambdaを起動させる設定がプログラムで書ける！
- デプロイに合わせて適切なIAMポリシーを自動生成してくれる

となり、Serverless 以外にもその他のAWSリソースとの連携をプログラムの中に書くことができる、というのは非常に大きいです。 
特にAPI Gateway以外の連携ができる、というのは、システム全体としてバッチ・遅延処理などを書く際には非常に重宝します。


## Chalice ことはじめ

ChaliceではAWSへのデプロイを行い、更にIAMを書き換えたりします。
そのため、それらが実現可能なIAMのアクセスキー・シークレットアクセスキーが必要です。
…で、ドキュメントからどんなポリシーが必要なのかを見てたのですが、全く記載なし。
[問題視こそされているようですが……](https://github.com/aws/chalice/issues/59)。

ただ、IAMを編集できるってことは、基本的にそのアカウントに何でもできることに近くはあるので、ここではAdministratorAccessのポリシーを付与したアクセスキー・シークレットアクセスキーを利用しているとして進めます（本当は適切なポリシーを付与したいのですが、何を入れればよいか分からないので…）

最初はこれらの設定ですが、READMEを見ると credential ファイルを自分で作成する方法が書いてありますが、正直 aws-cli の configure を使って設定する方が楽と思います。
awscli をインストールした後、以下の方法で入力できます。

```bash:awscliを利用
# プロファイルを指定せずに利用する場合
$ aws configure
AWS Access Key ID [********************]: (アクセスキーを入力)
AWS Secret Access Key [********************]: (シークレットアクセスキーを入力)
Default region name [ap-northeast-1]: (通常利用するリージョンを入力)
Default output format [None]: (何も入力せずEnterでOK)

# 別のプロファイルを利用する場合
$ aws configure --profile chalice
AWS Access Key ID [********************]: (アクセスキーを入力)
AWS Secret Access Key [********************]: (シークレットアクセスキーを入力)
Default region name [ap-northeast-1]: (通常利用するリージョンを入力)
Default output format [None]: (何も入力せずEnterでOK)
```

## プロジェクトの作成

以後、仮想環境マネージャとして pipenv を利用して進めます。
pipenv が利用できない場合、 `pip install pipenv` でグローバルインストールしてください。

```bash:プロジェクトの作成
# Lambda 対応としては最新の Python 3.7 をインストール
$ pipenv install --python 3.7

# chalice をインストールする
$ pipenv install chalice

# プロジェクト hello-world を作成
$ pipenv run chalice new-project hello-world

# プロジェクト以下のディレクトリ表示
$ tree -a
.
├── .chalice
│   └── config.json
├── .gitignore
├── app.py
└── requirements.txt

```

プロジェクトを作成すると、カレントディレクトリの下にプロジェクト名のディレクトリが作成されます。 ここに `app.py` と `requirements.txt` が作成されます。

`requirements.txt` に書き込まれたライブラリは Lambda のアップロード時に同時にアップロードされる依存ライブラリとなります。
pipenv でインストールしたライブラリを書き出したい場合、`pipenv lock -r` で現在インストールされているライブラリ群を標準出力できるので、これを適切に `requirements.txt` に書き出します。

## app.py の中身

生成された `app.py` は以下の通りです。

```python:app.py
from chalice import Chalice

app = Chalice(app_name='hello-world')


@app.route('/')
def index():
    return {'hello': 'world'}


# The view function above will return {"hello": "world"}
# whenever you make an HTTP GET request to '/'.
#
# Here are a few more examples:
#
# @app.route('/hello/{name}')
# def hello_name(name):
#    # '/hello/james' -> {"hello": "james"}
#    return {'hello': name}
#
# @app.route('/users', methods=['POST'])
# def create_user():
#     # This is the JSON body the user sent in their POST request.
#     user_as_json = app.current_request.json_body
#     # We'll echo the json body back to the user in a 'user' key.
#     return {'user': user_as_json}
#
# See the README documentation for more examples.
#
```

`@app.route` といったURIをデコレータに書き、関数と結び付けるやり方は Python の Flask や bottle などのWebフレームワークを使っていると非常に見覚えがある形式です。

この時点で `/` に対する関数は紐づけられていますので、デプロイは可能です。


## アプリケーションのデプロイ

カレントディレクトリをプロジェクトに移したうえで、`chalice deploy` を行うことでデプロイ可能です。 
もし別のプロファイル設定を利用したい場合、`--profile [profile name]` オプションを書いて下さい。 以後のサンプルでは `--profile chalice` を利用します。

```bash:hello-worldのデプロイ(一部マスクをかけています)
$ cd hello-world
$ pipenv run chalice deploy --profile chalice
Creating deployment package.
Creating IAM role: hello-world-dev
Creating lambda function: hello-world-dev
Creating Rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:ap-northeast-1:************:function:hello-world-dev
  - Rest API URL: https://**********.execute-api.ap-northeast-1.amazonaws.com/api/

$ curl https://**********.execute-api.ap-northeast-1.amazonaws.com/api/
{"hello":"world"}
```

これだけで API サービスを作成できてしまいました。


### AWS リソースの状況

この時点で Lambda にデプロイされたソースコードを見てみると以下のようになっていました。

![デプロイ直後のソースコード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/06e71e5c-efd4-c51f-5901-f59b3196f32e.png)

ここから以下のことが分かります。

- 自分で書いたプログラムはトランスパイラなどを通さず、そのままアップロードされてLambda内で解決されるようです
    - アップロード時に変換するかも、と思っていたのでこれを確認した
- ハンドラ(Lambdaのエントリーポイント)は `app.app` , つまり Chalice クラス内の `__call__` が呼ばれるようになっています
- `requirements.txt` には chalice を記載していませんでしたが、これは自動的にアップロードしてくれるようです


## エンドポイントの追加

`app.py` を以下のように修正して、`/` だけではなく `/hello/{contents}` でもアクセス可能にします。

```python:app.py
from chalice import Chalice

app = Chalice(app_name='hello-world')

@app.route('/')
def index():
    return {'hello': 'world'}

@app.route('/hello/{name}')
def hello_name(name):
   return {'hello': name}
```

これを修正した後、再度 `pipenv run chalice deploy --profile chalice` してからそれぞれのエンドポイントにアクセスします。

ただし、**デプロイ後、実際にデプロイが反映されるまでにはタイムラグがあります** 。デプロイ直後にアクセスしたとしても、まだその変更が反映されていない場合がある点に注意してください。

```bash:エンドポイントにアクセス
$ curl https://************.execute-api.ap-northeast-1.amazonaws.com/api/hello/nya
{"hello":"nya"}
```

## ローカル環境での検証

今回はいきなりAWSにデプロイしましたが、実際にはローカル環境での検証を行ってからデプロイするべきです。 Chalice にはローカル環境で Serverless アプリケーションを動作させるためのコマンドも用意されています。

```bash:ローカル環境での動作
$ pipenv run chalice local --port 8080

# デーモン化はされないので、別のターミナルで実行
$ curl http://localhost:8080/
{"hello":"world"}
```

portはデフォルトだと 8000 番です。 また、localコマンド実行中にソースコードの変更が発見されると自動的にreloadされます。


### 環境の判定

ローカル環境の場合とAWS環境とでリソースへのアクセス方法を変更したい場合があります。具体的には、

- AWS環境ではAWS上の DynamoDB サービスを使い、ローカル環境ではローカルに立てた DynamoDB を利用したい (デプロイはデプロイサーバーが担当するためリソースにアクセスするためのクレデンシャルを開発者が持っていないような場合)
- ログ出力を変えたい (ローカルログをファイルに出力したい場合など)

といった場合です。
この件については記事を書いてくださっている方がいました。

[AWS Chaliceでchalice localしているかどうかを判定する - Qiita](https://qiita.com/nikotan/items/b847f8036397f5debb24)

結論だけ言うと、`config.json` 内の stage 設定で、実行時にステージ単位の環境設定をすることができますが、local用のステージング設定を作成・利用する、という方法で対処できるようです。

なお、`config.json` ですが、プロジェクトを作成するとその下に `.chalice/config.json` というファイルが作成されます。

### AWSリソースの状況

この場合、API Gateway に新たなエンドポイントを受け付ける設定がデプロイされています。
そして、それらのエンドポイントから呼び出される Lambda 関数は同じ Lambda 関数が呼び出されています。
今回作成された Lambda 関数名は `hello-world-dev` でした。エンドポイントが増えただけでは、新たなLambdaは作成されませんでした。


## Chalice クラスでデコレートできるイベント


リファレンスとして以下のページが利用できます。

- https://chalice.readthedocs.io/en/latest/topics/events.html
- https://chalice.readthedocs.io/en/latest/api.html

抜粋すると、以下のようなイベントに対して Python プログラムを書くだけでサービスを連携させることができます。

- schedule: CloudWatch Events のスケジュール。 Lambda関数を定期実行したい場合などに
- on_cw_event: CloudWatch で発生したイベント。 何かのエラーを検知した場合にプログラムを動かしたい場合などに便利
- on_s3_event: S3で発生したイベント。 引数の `events` で `events=['s3:ObjectCreated:*']` のようにして、特定のイベントに絞り込むことができる
    - ただし `chalice package` コマンドを利用する場合はこの機能は使えないなど注意がある
    - `chalice package` って何？ と思ったら、おそらく AWS の SAM (Serverless Application Model) 形式への変換用のコマンド（だと思う）
- on_sns_message: SNSトピックに対するイベントに対してLambda関数を動かす。 通知系のインタフェースとして SNS を挟んでいる場合などに便利
- on_sqs_message: SQSの特定キューのメッセージ１つ１つに対してLambda関数を動かす。 キューと言えば polling をしなくてはいけないが、これをしなくても良い。

１つのサーバーだけで完結できないような遅延処理サービスなどを実現したり、運用業務を考える場合は非常に有用な機能となっている。


### 一例: schedule

Scheduled Events と連携して、一定時間ごと、あるいはcronライクなフォーマットで Lambda を起動させることができます。

この機能を利用して簡易的なバッチ処理を記載することができます。
が、AWS Lambda関数の最大実行可能時間は5分です。 
それ以上の処理を行う場合はSNS/SQSなどと連携する必要があるでしょう。

具体例として、定期的に ChatWork まで投稿を飛ばしてくれる `@app.schedule` を実装すると以下のようになります。

```json:.chalice/config.jsonにChatWork投稿用の情報を記述
{
  "version": "2.0",
  "app_name": "hello-world",
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
      "environment_variables": {
        "CHATWORK_ROOM": "**********",
        "CHATWORK_APIKEY": "*********************************"
      }
    }
  }
}
```

`environment_variables` に記述した内容は Lambda 内の環境変数として設定されます。 そして、プログラム内では `os.environ.get` で取得できるようになります。

```python:app.py
#!/usr/bin/python 
# -*- coding: utf-8 -*-

import os
import datetime
import requests

from chalice import Chalice

app = Chalice(app_name='hello-world')

def _notify_to_chatwork(message):
    ''' message を特定のチャットに送付します '''
    room = os.environ.get('CHATWORK_ROOM')
    apikey = os.environ.get('CHATWORK_APIKEY')
    url = f'https://api.chatwork.com/v2/rooms/{room}/messages'
    headers = { 'X-ChatWorkToken': apikey }
    data = dict(body=message)
    return requests.post(url, headers=headers, data=data)


@app.schedule('rate(1 minute)')
def notify_to_chatwork(event):
    ''' 検証用: 1分おきにChatWorkへ連絡する '''
    _notify_to_chatwork(datetime.datetime.now())
```

```requiements.txt
requests==2.23.0
```

本当ならば、「cronをどこで動かそう…」とか「cronって1台だから、そのサーバーがダウンした場合はどうしよう…」とかを考える必要があるのですが、冗長化構成になっているCloudWatch Events のスケジューラとLambdaを組み合わせることによって、簡易的なジョブ実行機能が得られました。

ちなみに、schedule の場合は Lambda は別名でデプロイされるようです。 これは、イベントの通知元がServerlessの場合とは異なるためだと思います。


## リソースの削除

`chalice delete` コマンド１つでできる。
この時、利用しなくなったIAM Policyなどもまとめて消してくれるので非常に助かる。
なお、deploy時に利用しなくなったリソースがある場合 (例: `@app.route` が app.py からひとつもなくなった場合など）には deploy 時にその関連リソースは消去される。

```bash:リソースの削除
$ pipenv run chalice delete --profile chalice
Deleting function: arn:aws:lambda:ap-northeast-1:************:function:hello-world-dev-notify_to_chatwork
Deleting IAM role: hello-world-dev
```

# まとめ

イベントソース + Lambda の構成は色々と「やればできる」ことは知っていたのですが、ライブラリひとつ＋プログラムレベルでここまでサポートしてくれているのは非常に使いやすい、という印象を持ちました。　
AWS公式で提供しているというのも個人的には評価が高いです。

あと、コマンドの詳細が分からなかったので探していると、[ワークショップのページが公開されている](https://chalice-workshop.readthedocs.io/en/latest/index.html) ようなので、こちらも読む予定です。


# 検証中に分かったその他のメモ（個人用）

## HEADリクエストはデフォルトでは通らない

個人的な作業の癖で、うまくリクエストが返ってこない場合は `curl -I` を使うことが多いのですが、今回の場合はそれが仇になりました。
リクエストの正常性をチェックする場合、ちゃんと自分で指定した種類（デフォルトはGETのみ）のHTTPリクエストを利用しましょう。