Serverless Frameworkを使ってAWSにSPAを構築する

Merry Xmas!!! ヘルスケア部門エンジニアの小林です。

この記事は Riot.js Advent Calendar 2017 25日目の記事です。

今年はRiot.jsによるフロントエンド開発と、Serverless Framework+AWSによるバックエンド開発が中心の1年でした。フロントからバックまで自由にいじらせてもらえる環境を頂けたことに大変感謝しております。今回はそのノウハウをまとめてみます。

執筆当初はRiot.jsとServerless Frameworkの比率を1:1くらいで書く想定をしていましたが、ビックリするくらいほぼ同じ境遇で既に先駆者がおりました。。。非常に良くまとめらていますので是非御覧ください。

qiita.com

そこでここでは、主にServerless Frameworkでコードとインフラを管理するあたりについてまとめます。各ツールの基本的な使い方については省略しますのでご了承ください。

Riot.js Advent Calendarなのに上述の理由により申し訳程度しかRiot要素を載せられませんでした...すみません。フロントエンド開発者がバックエンドをサクッと構築したい場合に少しでも役に立てばなと思います。

システム構成

AWSでSPAを構築する際の鉄板構成だと思われる以下の構成で構築します。

  • フロントエンド
    • CloudFront + S3
    • Origin Access IdentityによりCloudFront経由でのアクセスのみ許可
  • バックエンド
    • 認証: Cognito
    • ビジネスロジック: API Gateway + Lambda + DynamoDB

今回はCognito周りは割愛します。そのうち書こうかな。

プロジェクト構成

Serverless Frameworkのプロジェクト構成にフロントエンド周りを突っ込む構成です。

serverless.com

.
├── lambda
│   ├── api
│   │   └── xxx.js
│   └── async
│       └── xxx.js
├── lib
│   └── xxx.js
├── static
│   ├── src
│   │   ├── assets                               
│   │   │   └── index.html
│   │   ├── constant
│   │   │   └── xxx.json
│   │   ├── entries
│   │   │   └── xxx.js
│   │   └── tags
│   │       ├── components
│   │       │   ├── xxx.README.md
│   │       │   └── xxx.tag.html
│   │       └── xxx
│   │           ├── xxx.README.md
│   │           └── xxx.tag.html
│   ├── .babelrc
│   └── webpack.config.babel.js
├── package.json
├── serverless.yml
└── README.md
  • lambda
    • AWS Lambdaのhandlerとして使うJSコードはこちらに格納
    • api (WebAPI用途のもの)と async (それ以外) で分ける
  • static
    • フロントエンド周りはこちらに格納
    • Riot.js + webpack + babel の構成
    • Riotタグについては、使い回しの効く汎用タグ(components)とそれ以外で分けている

命名がテキトーなのは大目に見てください。

フロントエンド実装

前述の通り、Riot.js + webpack + babel の構成です。

Riot.jsで開発する際は、こちらのスタイルガイドを参考にしています。

qiita.com

webpack.config.babel.js

import path from 'path';
import webpack from 'webpack';

export default function(env, argv) {
  return [{
      entry: {
        xxx: './src/entries/xxx.js'
      },
      output: {
        path: path.join(__dirname, './.deploy', env, 'scripts'),
        filename: '[name].js'
      },
      module: {
        rules: [{
            test: /\.tag.html$/,
            exclude: /node_modules/,
            enforce: 'pre',
            use: 'riot-tag-loader'
          },
          {
            test: /\.js$|\.tag.html$/,
            exclude: /node_modules/,
            use: 'babel-loader'
          }
        ]
      },
      resolve: {
        extensions: ['*', '.js', '.tag.html']
      },
      plugins: [
        new webpack.ProvidePlugin({
          riot: 'riot'
        }),
        new webpack.optimize.UglifyJsPlugin()
      ]
    }
  ];
}
  • $ webpack --env [dev/prod] で開発環境と本番環境をビルドし分けられるように
  • .deploy/[env]/ にビルド結果を出力

s3-deploy

フロントエンドのデプロイ、つまりS3へのファイル配置には s3-deploy というパッケージを使用しています。

www.npmjs.com

$ s3-deploy [対象ファイル] --cwd [ルートディレクトリ] --bucket [S3バケット名] --profile [AWSプロファイル名] --region [リージョン] --private

バックエンド実装

API Gateway + Lambda + DynamoDBを基本構成としてバックエンドAPIを実装しつつ、その他必要なインフラ群もAWS CloudFormationで自動構築していきます。

バックエンドAPIの実装については、Serverless FrameworkのGet Started的な内容なのでここでは省略させていただきます。

フロントエンド用のインフラを構築

CloudFront+S3をServerless Frameworkで構築してしまいます。Serverless FrameworkはAWS CloudFormationのラッパーなので、CloudFormationの文法に沿って serverless.yml に定義していきます。

  • S3
    • HTML、CSS、JS等の静的コンテンツをここにホスト
  • CloudFront
    • S3の前段に置くCDN
  • OriginAccessIdentity
    • これを設定すると、CloudFront経由でしかS3にアクセスできなくなる

serverless.yml

...
resources:
  Resources:
    StaticContentsS3:
      Type: AWS::S3::Bucket
      DeletionPolicy: Retain
      Properties:
        AccessControl: Private
        BucketName: [バケット名]
        Tags:
          - Key: Name
            Value: ${self:service}-${opt:stage}
    StaticContentsS3Policy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket:
          Ref: StaticContentsS3
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                AWS:
                  Fn::Join:
                    - " "
                    - - "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity"
                      - Ref: StaticCloudFrontOriginAccessIdentity
              Action: s3:GetObject
              Resource:
                Fn::Join:
                  - "/"
                  - - Fn::GetAtt:
                        - StaticContentsS3
                        - Arn
                    - "*"
    StaticContentsCloudFront:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Enabled: true
          Comment: "Delivery static contents"
          PriceClass: PriceClass_200
          DefaultRootObject: index.html
          Origins:
            - Id: S3Origin
              DomainName:
                Fn::GetAtt:
                  - StaticContentsS3
                  - DomainName
              S3OriginConfig:
                OriginAccessIdentity:
                  Fn::Join:
                    - "/"
                    - - origin-access-identity/cloudfront
                      - Ref: StaticCloudFrontOriginAccessIdentity
          DefaultCacheBehavior:
            AllowedMethods:
              - HEAD
              - GET
            CachedMethods:
              - HEAD
              - GET
            Compress: true
            DefaultTTL: 900
            MaxTTL: 1200
            MinTTL: 600
            ForwardedValues:
              QueryString: true
            SmoothStreaming: false
            TargetOriginId: S3Origin
            ViewerProtocolPolicy: https-only
    StaticCloudFrontOriginAccessIdentity:
      Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
      Properties:
        CloudFrontOriginAccessIdentityConfig:
          Comment:
            Ref: AWS::StackName
  Outputs:
    StaticContentsCloudFrontUrl:
      Value:
        Fn::Join:
          - ""
          - - "https://"
            - Fn::GetAtt:
              - StaticContentsCloudFront
              - DomainName

バックエンドのデプロイ

インフラ、API共に下記コマンドでまとめて展開・デプロイができます。マジ卍。

$ sls deploy -s [env]

package.json

最終的に package.json はこうなりました。gulp等のタスクランナーは使わず、npm-scriptsを駆使しています。

{
...
  "scripts": {
    "setup": "npm i",
    "deploy:dev": "npm run setup && ./node_modules/.bin/sls deploy -s dev",
    "deploy:prod": "npm run setup && ./node_modules/.bin/sls deploy -s prod",
    "build:dev": "npm run setup && cd static && mkdir -p .deploy && rm -rf .deploy/dev && cp -r ./src/assets ./.deploy/dev && ../node_modules/.bin/webpack --env dev",
    "build:prod": "npm run setup && cd static && mkdir -p .deploy && rm -rf .deploy/prod && cp -r ./src/assets ./.deploy/prod && ../node_modules/.bin/webpack --env prod",
    "upload:dev": "npm run build:dev && ./node_modules/.bin/s3-deploy './static/.deploy/dev/**' --cwd './static/.deploy/dev/' --bucket [S3バケット名] --profile [AWSプロファイル名] --region ap-northeast-1 --private",
    "upload:prod": "npm run build:prod && ./node_modules/.bin/s3-deploy './static/.deploy/prod/**' --cwd './static/.deploy/prod/' --bucket [S3バケット名] --profile [AWSプロファイル名] --region ap-northeast-1 --private"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    "aws-sdk": "^2.174.0",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-es2015-riot": "^1.1.0",
    "riot": "^3.7.4",
    "riot-tag-loader": "1.0.0",
    "s3-deploy": "^0.8.0",
    "serverless": "1.25.0",
    "webpack": "^3.10.0"
  }
...
}
  • $ npm run deploy:[env] でバックエンドのデプロイ
  • $ npm run upload:[env] でフロントエンドのデプロイ

うーん、命名テキトーなので言葉ややこし。

まとめ

Serverless Frameworkでフロントエンドとバックエンドの全てを同時に管理することができました。プロトタイプや小規模開発であればこれくらいで十分ではないかと思います。