Merry Xmas!!! エンジニアの小林です。
この記事は Riot.js Advent Calendar 2017 25日目の記事です。
今年はRiot.jsによるフロントエンド開発と、Serverless Framework+AWSによるバックエンド開発が中心の1年でした。今回はそのノウハウをまとめてみます。
執筆当初はRiot.jsとServerless Frameworkの比率を1:1くらいで書く想定をしていましたが、ビックリするくらいほぼ同じ境遇で既に先駆者がおりました。。。非常に良くまとめらていますので是非御覧ください。
そこでここでは、主にServerless Frameworkでコードとインフラを管理するあたりについてまとめます。各ツールの基本的な使い方については省略しますのでご了承ください。
Riot.js Advent Calendarなのに上述の理由により申し訳程度しかRiot要素を載せられませんでした...すみません。フロントエンド開発者がバックエンドをサクッと構築したい場合に少しでも役に立てばなと思います。
システム構成
AWSでSPAを構築する際の鉄板構成だと思われる以下の構成で構築します。
- フロントエンド
- CloudFront + S3
- Origin Access IdentityによりCloudFront経由でのアクセスのみ許可
- バックエンド
- 認証: Cognito
- ビジネスロジック: API Gateway + Lambda + DynamoDB
今回はCognito周りは割愛します。そのうち書こうかな。
プロジェクト構成
Serverless Frameworkのプロジェクト構成にフロントエンド周りを突っ込む構成です。
. ├── 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で開発する際は、こちらのスタイルガイドを参考にしています。
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
というパッケージを使用しています。
$ 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周りはこちらを参考にさせていただきました。
Origin Access IdentityがCloudFormationに対応していないため、Origin Access Identityを作成するLambdaを自分で用意しています。
ちなみに、Resource内からServerless Frameworkの文法で定義したLambda function等を参照する場合は、Serverless Frameworkの命名規則を理解して指定しないと失敗します。これで結構ハマりました。
- 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でフロントエンドとバックエンドの全てを同時に管理することができました。プロトタイプや小規模開発であればこれくらいで十分ではないかと思います。