AWS+ServerlessFrameworkを使ってPythonバックエンドを構築する

ここしばらく個人でスマホアプリを作ってみたいと思い色々とバックエンド側の構築方法を探っていました。Amplifyを使おうかと思っていたのですが、Flutterではdatastore周りが少し安定していない感がありまして、噂に聞いていたServerlessFrameworkを使ってみることにしました

Flutter的にはバックエンドはFirebaseがメジャーだと思いますが、私のスキルセット的にAWS、LambdaもPythonで構築することにしました

# この記事でできること
* ServerlessFramework、AWSを使ったバックエンドの構築
* APIGateway + Lambda + S3 + DynamoDBの構成
* APIKeyを使ったREST APIのアクセス管理
* S3やDynamoDBに対する、アクセスロールの設定
* ルートディレクトリに集まりがちなfunctionを関数ごとにディレクトリ分け

と、バックエンドを構築するときの一通りのことができるのではないかと思います。また、ServerlessFrameworkそのものの導入方法は割愛します。すでに詳しく解説されている記事がありますので

# 環境
```
Framework Core: 2.57.0
Plugin: 5.4.4
SDK: 4.3.0
Components: 3.16.0
```

# 初期化
```
% serverless create --template aws-python3 --name hello-world <<< Serverlessサービスを作る
```

ServerlessFrameworkはサービスという単位で環境を作るそうです。上記のコマンドひとつで、Lambda1つを含む基本的なテンプレートが生成されます

```serverless.yml
service: hello-world
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
functions: << これ以下を消すと、作ったLambdaを削除することもできる
  hello:
    handler: handler.hello <<< hander.pyとの紐付け
```

```handler.py
import json

def hello(event, context):
    body = {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "input": event
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(body)
    }

    return response
```

このテンプレートをベースにガリガリ触っていきます。ちなみに

- デフォルトだとバージニア北部リージョンにできる
- Lambdaは[サービス名]-[環境変数]-[serverless.ymlで指定した名前]でできる
- 実行ログのCloudWatch吐き出しもデフォルトで設定できるようである

Amplifyと比べて、余計なファイルもできないし、デプロイもほぼ待ち時間はありません。全体的にシンプルで良いですね

# APIGateway > Lambda > DynamoDBの流れを作る
```serverless.yml
service: hello-world
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  # ///////// ↓DynamoDBへのアクセス権限を追加↓ ///////////#
  iam:
    role:
      statements:
        - Effect: 'Allow'
          Action:
            - 'dynamodb:*'
          Resource:
            - "arn:aws:dynamodb:us-east-1:xxxxxxxxxx:table/*"
            # xxxxxxxxにはAWSのアカウント番号が入る
  # ///////// ↑DynamoDBへのアクセス権限を追加↑ ///////////#
functions:
  hello:
    handler: handler.hello
# //////////　↓ここから下でAPIGatewayとDynamoDBを追加↓ ///////// #
    events:
      - httpApi:
          path: /users/create
          method: get
resources:
  Resources:
    DynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
          - AttributeName: name
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
          - AttributeName: name
            KeyType: RANGE
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: hello-dynamodb
```
この時、Lambdaからはboto3を使ってDynamoDBにアクセスします

```handler.py
def hello(event, context):
    dynamodb = boto3.resource('dynamodb')
    result = dynamodb.Table('hello-dynamodb').put_item(
        Item = {
            'id': "hoge_id",
            'name': 'hoge_name'
        }
    )

    response = {
        "statusCode": 200,
        "body": json.dumps('success!')
    }

    return response
```

![スクリーンショット 2021-11-09 8.24.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/924658/697989e3-6cda-9d9c-fa3b-b369cdf68413.png)

デプロイをして再度実行すると、DynamoDBにも無事レコードが追加されました。Amplifyでは自動でcreated_at, updated_at, versionなどが付加されていましたが、今回は定義したもの以外は全くなく、非常にシンプルでした。

生成されたURLをブラウザで開いてみると、「success!」とだけ表示されました。成功ですね

# APIKeyを使ったREST APIのアクセス管理
このままだとAPIは全世界に公開されていて、URLを知ってさえいれば誰でもアクセスできてしまいます。これはこれで良いのですが、コール数を管理したり制限するときにはAPI Key（AWS的には使用量プラン）をつけます

```serverless.yml
# 関連しないところは省略しています
service: hello-world
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  stage: dev # api-keyがstageできりかわるようにするために追加
  
  # ////////// ↓全function共通で使用するAPIKeyを追加↓ ////////// #
  apiGateway:
    apiKeys: # API Keyの設定
      - mobileApp: 
        - name: ${self:provider.stage}-app-key
          value: # お好みのAPI Key
    usagePlan: # 使用量プラン
      - mobileApp:
          quota:
            limit: 1000
            offset: 0
            period: DAY
          throttle:
            rateLimit: 100
            burstLimit: 100
# ////////// ↑全function共通で使用するAPIKeyを追加↑ ////////// #
functions:
  hello:
    handler: handler.hello
    events:
      - http: # httpApi -> http
          path: /users/create
          method: get
          private: true # ApiKeyをつけたいものにprivateをつける
```

![スクリーンショット 2021-11-28 8.40.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/924658/8a5c1a45-cf86-9343-47fc-aaaae865219b.png)
これで画像のようにAPI KeyをHeaderにつけてAPIコールしないと動かなくなります。（API Key無しだとForbidden）

# S3の作成と、functionごとへにアクセスロールの設定
```serverless.yml
custom:
  backetName: hello-serverless-bucket-${self:provider.stage}

resources:
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.backetName}
    S3AccessRole:
      Type: AWS::IAM::Role
      DependsOn: Bucket
      Properties:
        Path: /
        RoleName: hello-serverless-lambda-role-with-s3
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: hello-serverless-lambda-policy-with-s3
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                  Resource:
                    - 'Fn::Join':
                        - ':'
                        -
                          - 'arn:aws:logs'
                          - Ref: 'AWS::Region'
                          - Ref: 'AWS::AccountId'
                          - 'log-group:/aws/lambda/*:*:*'
                - Effect: "Allow"
                  Action:
                    - "s3:PutObject"
                    - "s3:GetObject"
                    - "s3:ListBucket"
                  Resource:
                    - 'Fn::Join':
                        - ''
                        -
                          - "arn:aws:s3:::"
                          - ${self:custom.backetName}
                    - 'Fn::Join':
                        - ''
                        -
                          - "arn:aws:s3:::"
                          - ${self:custom.backetName}
                          - "/*"
```

* LambdaごとにAWSの各リソースに対するアクセス権限を付与しています
* Bucket名は定数化して環境ごとに切り替わるようにしています

```hendler.py
def hello(event, context):
    
    filepath = '/tmp/data.csv'
    with open(filepath, 'w') as f:
        writer = csv.writer(f)
        writer.writerow(["a", "b", "c"])

    s3 = boto3.resource('s3')
    s3.meta.client.upload_file(filepath, 'hello-serverless-bucket-dev', 'hgoe.csv')
    

    response = {
        "statusCode": 200,
        "body": json.dumps('success!')
    }

    return response
```

Lambdaのコードは上記のようになります

# functionのディレクトリ分けと、外部ライブラリのアップロード
functionは数十個になりうるのですが、このままだとServerlessのルートディレクトリに全て並べないといけなくなります。また、Lambdaがデフォルトでサポートしていない外部ライブラリを使う場合にアップロードする方法に迷いました

これらを解決してくれるのが、ServerlessFrameworkのライブラリ`serverless-python-requirements`です。導入方法は[公式](https://www.serverless.com/plugins/serverless-python-requirements)を参照していただくのが良いかと思います。ちなみにDockerが必要です

## ディレクトリ構成
```
/
├── serverless.yml
├── functions/
│   ├── hello/
│   │   ├── handler.py
│   │   └── requirements.txt
```

こんな感じでfunctionごとにディレクトリを切ることができます。ちなみに、各ディレクトリ中のrequirements.txtには利用したい外部ライブラリを列挙するのですが、**外部ライブラリを使わない場合でも必要です**

## Serverless定義
```serverless.yml
service: hello-world
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  stage: dev

plugins: # 追加
  - serverless-python-requirements # 追加

custom:
  backetName: hello-serverless-bucket-${self:provider.stage}
  pythonRequirements:  # 追加
    dockerizePip: true # 追加

package: # 追加
  individually: true # 追加

functions:
  hello:
    role: S3AccessRole
    module: functions/hello # 追加
    handler: handler.hello
    events:
      - http:
          path: /users/create
          method: get
          private: true
```

# まとめ
ServerlessFrameworkはやや情報が少ないのか、一度ハマると解決するのが結構大変でした（requirement.txtが必須とか）。今回の記事で、ServerlessFrameworkで、AWSを使って、Pythonを使う場合の基本的な内容はカバーできた気がします