tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

Serverless Framework で作る mono repo AWS プロジェクト構成

サーバーレスなアーキテクチャを実現するサービスは世の中いくつか存在します。その中でも Serverless Framework(serverless.com) はベンダーに依存せずサーバーレス構築を提供するユニークなサービスです。サポートするベンダーも AWS, Google, Azure といった代表的なものから kubeless や openwhisk など比較的若いサービスまで多岐に渡っています。

つい最近 Serverless Framework を使って AWS のサーバーレス環境(TypeScript)を構築したのですが、 mono repo にするにあたってレイヤの切り分けや、サービスをまたいで利用される共通機能や変数の扱い方について学びがあったので、ブログに残すことにしました。Serveless Framework に興味がある方、どういう構成でサーバレスなプロジェクトが作れるのか気になる方にとって参考になるかと思います!

プロジェクト概要

サインアップ、記事の登録ができるだけの小さなサービスです。 f:id:tomoima525:20210323160435g:plain:w400
f:id:tomoima525:20210323160530g:plain:w400

アーキテクチャ

f:id:tomoima525:20210323160335p:plain

サインアップ(認証)は Cognito User Pool で行い、プライベートな Lambda function へのアクセスは API Gateway を通して行われます。 フロントエンドは手抜きなので Serverless Framework の template で作られるものをそのまま利用しており、S3 に静的ホスティングしています。 以上を踏まえた上で詳細を追っていきます。
実際のコードが先に見たい人はこのレポジトリをご覧ください。

github.com

プロジェクトルート

通常の mono repo の場合、lerna など mono repo ツールを使って、TypeScript など共通で利用される dependencies をルートの package.json に記述し、各パッケージは依存関係をシンボリックリンクで解決するデザインを取ります。しかしながら Serverless Framework の場合、 sls deploy コマンドが serverless.yml より上のディレクトリから定義された依存関係を解決できないようで、パッケージ単位で依存関係を設定する必要がありました。そのため tsconfig.json だけおいてあり、各プロジェクトから参照するようになっています。

バックエンド

バックエンドのパッケージ構成は以下のようになっています。

.
├── backend
│   ├── services                # Services
│       └── notes/
│         └── serverless.yml    # Setting file for serverless framework (Lambda)
│         └── package.json      # Dependency specific for notes
│         └── functions/...     # functions
│   └── libs                    # Lambda shared code
│     └── api                   # Helper functions for api handling
│       └── apiGateway.ts       # API Gateway specific helpers
│       └── lambda.ts           # Lambda middleware
│     └── dynamoDB              # Helper functions for db access
│     └── models                # Abstruction layer for Data Access
├── package.json                # commonly used dependencies (e.g. aws-lamdba)
├── serverless.yml              # Set up for global usage (DB, API Gateway)

インフラレイヤ

backend 直下がバックエンドのインフラレイヤにあたります。ここの package.json には TypeScript や aws-lambda など共通して利用される依存関係を記述しています。

また serverless.yml は DynamoDB, Cognito の定義を記載しています。どの関数からも利用されるインフラはすべてここに定義していくことになります。この定義は CloudFormation の文法と全く同じなので、CloudFormation に慣れている人なら全く問題なく書けますね。

インフラレイヤに修正を追加した場合は、このディレクトリから sls deploy コマンドを呼びデプロイします。

libs には各関数が共通して利用できるヘルパー関数などがあります。
api パッケージには API Gateway にアクセスするための CORS のヘッダ設定などをまとめたユーティリティ関数などがまとまってます。 models パッケージは各関数がデータにアクセスするための抽象化レイヤを定義しています。

ところで serverless.yml は TypeScript でも書けます。しかし書き方に癖がある上にドキュメントが整備されていないのでちょっとしたことを書くのに苦労します。素直に serverless.yml を使うほうが楽です。

アプリレイヤ

Serverless Framework では関数のまとまりをserviceと呼びます。このまとまり単位にマイクロサービス(API)を作っていくことになります。今回のサービスは notes サービスしかないので、その中の構成を見ていきましょう。

├── notes
│   ├── functions               # Lambda configuration and source code folder
│       ├── getList
│       │   ├── handler.ts      # lambda source code
│       │   ├── index.ts        # lambda Serverless configuration
│       │   ├── schema.json     # lambda input parameter, if any, for local invocation
│       │   └── schema.ts       # lambda input event JSON-Schema
│       │
│       └── index.ts            # Import/export of all lambda configurations
├── package.json
├── serverless.yml              # Serverless service file
├── tsconfig.json               # Typescript compiler configuration
└── webpack.config.js           # Webpack configuration

package.json には この function で必要な依存関係のみ記載されています。ここでは親ディレクトリである backend/package.json の依存関係がそのまま利用できるので、 devDependencies などの設定は不要です。

このレイヤでは AWS Lambda にアップロードするコードをバンドルするために webpack を利用しています。 serverless-webpack という便利プラグインを使っています。

plugins:
  - serverless-webpack

custom:
  webpack:
    webpackConfig: "./webpack.config.js" # Name of webpack configuration file
    includeModules: true # Node modules configuration for packaging
    excludeFiles: src/**/*.test.js # Provide a glob for files to ignore

必要なモジュールのみバンドルするためのコツとして、 serverless.yml で includeModules: trueを設定し関数内で参照しているモジュールを含めつつ、 webpack.config.js の設定で node_modules に含まれる外部パッケージを省くことができます。

// webpack.config.js
var nodeExternals = require('webpack-node-externals')

module.exports = {
  // we use webpack-node-externals to excludes all node deps.
  // You can manually set the externals too.
  externals: [nodeExternals()],
}

serverless.yml は親ディレクトリの serverless.yml ファイルを参照することができます。

  environment:
    DYNAMODB_TABLE_NOTES: ${file(../../serverless.yml):provider.environment.DYNAMODB_TABLE_NOTES}

このプロジェクトでは設定してませんが、DynamoDB へのアクセス権限など、どの関数でも同じ場合は共通な yml ファイルを定義して参照するほうがボイラプレートを減らせますね。

フロントエンド

フロントエンドも Serverless Framework でデプロイ可能です。 以下のようにserverless-finchというプラグインをセットし、アップロードしたい環境を CloudFormation で記述します。

service: web

frameworkVersion: "2"
variablesResolutionMode: 20210219

plugins:
  - serverless-finch

# 以下 CloudFormationの設定
resources:
  Resources:
    S3Bucket:
      Type: AWS::S3::Bucket
      ...

認証およびバックエンドへのアクセスは Amplify を利用しています。認証周りや API アクセスなど複雑になりがちな処理を上手に抽象化してくれるので非常に便利です。

private API へのアクセス(IAM Permissions)

Amplify で提供される API クラスは IAM permission による API へのアクセス制御を提供してくれます。なお、もし Amplify を使いたくないという場合は Amazon Signature version 4 signingを実装してアクセス制御を行う必要があります。ここでは IAM Permissions を使う場合の設定を紹介します。

まずは authorizer: aws_iam をアクセス制御したい関数に設定します。

# services/notes/serverless.yml
functions:
  postNote:
    handler: functions/post/handler.postRequest
    events:
      - http:
          method: post
          path: note/post
          authorizer: aws_iam
          cors: true

Cognito Identity Pool で生成した id(user)に対してアクセス権限を付与するために IAM Role を定義し、それを Idenity Pool の authorized ユーザーに付与します。

# backend/serverless.yml
## 認可していないユーザーの権限
CognitoUnAuthorizedRole:
  Type: "AWS::IAM::Role"
  Properties:
    ...
    Policies:
      - PolicyName: "CognitoUnauthorizedPolicy"
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Action:
                - "mobileanalytics:PutEvents"
                - "cognito-sync:*"
              Resource: "*"
## 認可したユーザーの権限
CognitoAuthorizedRole:
  Type: "AWS::IAM::Role"
  Properties:
    AssumeRolePolicyDocument:
      ...
    Policies:
      - PolicyName: "CognitoAuthorizedPolicy"
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            ...
            - Effect: "Allow"
              Action:
                - "lambda:InvokeFunction"
              Resource: "*"
            - Effect: "Allow"
              Action:
                - "execute-api:Invoke"
              Resource: !Sub "arn:aws:execute-api:${AWS::Region}:*:*/*"

# Identity Pool に権限をマッピング
IdentityPoolRoleMapping:
  Type: "AWS::Cognito::IdentityPoolRoleAttachment"
  Properties:
    IdentityPoolId: !Ref CognitoIdentityPool
    Roles:
      authenticated: !GetAtt CognitoAuthorizedRole.Arn
      unauthenticated: !GetAtt CognitoUnAuthorizedRole.Arn

フロントエンド側は Amplify が IAM Permissions による認可をサポートしているのでそのまま API を呼び出すだけです。

import Amplify, { API, Auth } from "aws-amplify";
// routeのindex.js APIの configを設定
Amplify.configure({
  Auth: cognito,
  API: APIConfig,
});

API.configure();
import { API } from "aws-amplify";
const response = await API.get(apiName, path, requestParams);

まとめ: Serverless Framework に対する所感

さいごに Serverless Framework を触ってみた感想をまとめます。
設定ファイルはほどよく抽象化されており、コミュニティベースのプラグインも充実しており、成熟したプロダクトだという印象でした。すべてのインフラ設計が serverless.yml という一つの設定ファイルに記述できる点は、Amplify と比べても管理が非常にしやすいです。もちろん詳細な設定も可能なので、AWS について言えば CloudFormation を中心としたインフラの知識が強いエンジニアがいるチームならとても良い選択になるのではないでしょうか。

一方で気になった点としては、運営のロードマップが少々見通しがはっきりしてないところがあります。例えば、最近のアップデートで親ディレクトリにある serverless.yml を参照できなくなる deprecation warning が追加されました。これはインフラ層とアプリケーション層を分けつつ、共通する設定を共有したい場合、致命的な問題になります。この issueで議論されていますが、まだ解決策は確定していません。また色々なサーバレスサービスをカバーしていますが、それにより各サービスへの追従が間に合わなくなる気配もあります。

そんなところで少々懸念点もありますが、サーバーレスとはいえインフラをすべて把握して管理したいというニーズにも応えられるので十分選定する技術候補になると思います。