エムティーアイ エンジニアブログ

株式会社エムティーアイのエンジニア達による技術ブログ

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を構築する際の鉄板構成だと思われる以下の構成で構築します。

f:id:vatscy:20171225180920p:plain

  • フロントエンド
    • 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のハンドラとして使う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で自動構築していきます。

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

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

Origin Access Identity周りはこちらを参考にさせていただきました。

www.h4a.jp

Origin Access IdentityがCloudFormationに対応していないため、Origin Access Identityを作成するLambdaを自分で用意しています。

ちなみに、Resource内からServerless Frameworkの文法で定義したLambda function等を参照する場合は、Serverless Frameworkの命名規則を理解して指定しないと失敗します。これで結構ハマりました。

serverless.com

  • serverless.yml
...
functions:
  createCloudFrontOriginAccessIdentity:
    handler: lambda/create-oai.handler
    timeout: 300
    role: CustomResourceFunctionRole

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"
                      - Fn::GetAtt:
                          - CloudFrontOriginAccessIdentity
                          - Id
              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
                      - Fn::GetAtt:
                          - CloudFrontOriginAccessIdentity
                          - Id
          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
    CloudFrontOriginAccessIdentity:
      Type: Custom::CloudFrontOriginAccessIdentity
      Properties:
        ServiceToken:
          Fn::GetAtt:
            - CreateCloudFrontOriginAccessIdentityLambdaFunction
            - Arn
        CloudFrontOriginAccessIdentityConfig:
          CallerReference:
            Ref: AWS::StackName
          Comment:
            Ref: AWS::StackName
    CustomResourceFunctionRole:
      Type: AWS::IAM::Role
      Properties:
        Path: /${self:service}/${opt:stage}/LambdaFunctionRole/
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Action: sts:AssumeRole
              Principal:
                Service: lambda.amazonaws.com
              Effect: Allow
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        Policies:
          - PolicyName: CloudFrontOriginAccessIdentity
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - cloudfront:GetCloudFrontOriginAccessIdentity
                    - cloudfront:CreateCloudFrontOriginAccessIdentity
                    - cloudfront:UpdateCloudFrontOriginAccessIdentity
                    - cloudfront:DeleteCloudFrontOriginAccessIdentity
                  Resource: "*"
                - Effect: Allow
                  Action:
                    - xray:PutTraceSegments
                    - xray:PutTelemetryRecords
                  Resource: "*"
  Outputs:
    StaticContentsCloudFrontUrl:
      Value:
        Fn::Join:
          - ""
          - - "https://"
            - Fn::GetAtt:
              - StaticContentsCloudFront
              - DomainName
  • Origin Access Identityを作成するためのLambda
'use strict';
// cf. http://www.h4a.jp/detail/31654

const AWSXray = require('aws-xray-sdk-core');
const CloudFront = require('aws-sdk/clients/cloudfront');
const cloudfront = AWSXray.captureAWSClient(new CloudFront());
const https = require('https');
const url = require('url');

exports.handler = (event, context, callback) => {
  const response = {
    SUCCESS: 'SUCCESS',
    FAILED: 'FAILED',
    send: (ev, cx, cb, responseStatus, responseData, physicalResourceId) => {
      const responseBody = JSON.stringify({
        Status: responseStatus,
        Reason: `See the details in CloudWatch Log Stream: ${cx.logStreamName}`,
        PhysicalResourceId: physicalResourceId || cx.logStreamName,
        StackId: ev.StackId,
        RequestId: ev.RequestId,
        LogicalResourceId: ev.LogicalResourceId,
        Data: responseData
      });

      const parsedUrl = url.parse(ev.ResponseURL);
      const options = {
        hostname: parsedUrl.hostname,
        port: 443,
        path: parsedUrl.path,
        method: 'PUT',
        headers: {
          'content-type': '',
          'content-length': responseBody.length
        }
      };
      const request = https.request(options, (response) => cb(null, {}));
      request.on('error', (error) => cb(null, {}));
      request.write(responseBody);
      request.end();
    }
  };
  const evType = event.RequestType,
    prop = event.ResourceProperties,
    physicalId = event.PhysicalResourceId;
  delete prop.ServiceToken;
  const fail = (err) => {
    console.log(err);
    response.send(event, context, callback, 'FAILED', {
      Error: err
    });
  };
  const succeed = (data, id) => {
    response.send(event, context, callback, 'SUCCESS', data, id);
  };
  const ignorable = (cd) => ((400 <= cd) && (cd < 500));
  const getOAI = (id, cb) => {
    cloudfront.getCloudFrontOriginAccessIdentity({
      Id: id
    }, cb);
  };
  const onCreate = () => {
    cloudfront.createCloudFrontOriginAccessIdentity(prop, (err, result) => {
      if (err) {
        return fail(err);
      }
      succeed({
        Id: result.CloudFrontOriginAccessIdentity.Id
      }, result.CloudFrontOriginAccessIdentity.Id);
    });
  };
  const onUpdate = () => {
    getOAI(physicalId, (err, oaiData) => {
      if (err) {
        return fail(err);
      }
      const param = {
        CloudFrontOriginAccessIdentityConfig: {
          CallerReference: oaiData.CloudFrontOriginAccessIdentityConfig.CallerReference,
          Comment: prop.CloudFrontOriginAccessIdentityConfig.Comment
        },
        Id: physicalId,
        IfMatch: oaiData.ETag
      };
      cloudfront.updateCloudFrontOriginAccessIdentity(param, (err, result) => {
        if (err) {
          return fail(err);
        }
        succeed({
          Id: physicalId
        }, physicalId);
      });
    });
  };
  const onDelete = () => {
    getOAI(physicalId, (err, oaiData) => {
      if (err && ignorable(err.statusCode)) {
        return succeed({}, physicalId);
      }
      if (err) {
        return fail(err);
      }
      const param = {
        Id: physicalId,
        IfMatch: oaiData.ETag
      };
      cloudfront.deleteCloudFrontOriginAccessIdentity(param, (err, result) => {
        if (err && !ignorable(err.statusCode)) {
          return fail(err);
        }
        succeed({}, physicalId);
      });
    });
  };
  switch (evType) {
    case 'Create':
      onCreate();
      break;
    case 'Update':
      onUpdate();
      break;
    case 'Delete':
      onDelete();
      break;
  }
};

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