はじめに
先日、個人開発で「ローカりんぐ」という全国のローカルメディアの良質なコンテンツを収集して、一覧化するサイトを作りました。
その際、やってみたいという理由で SSR を選択。 コストも抑えたかったので関数単位で課金が発生する API Gateway + Lambda で Nuxt.js を SSR することにしました。
Nuxt.js を Lambda で SSR する文献は多くあるのですが、そのほとんどが Serverless Framework (sls) を用いたものです。 ところが、SAM や CloudFormation に戯れてきた身としてはどうも sls は取っつきづらく、インフラコードが SAM、CloudFormation、sls に分散するのは避けたいものがあります。 と言いつつも、Node.js で書かれている sls は、同じく js で書かれている Nuxt.js と相性が良く、コードも簡潔に書けるので慣れるとグッと効率が上がりそうだなとも思っています 👍
そんなこんなで、今回は SAM で Nuxt.js を SSR するコードを書いてみたので残しておきたいと思います。
※ 個人的な感想ですがピーキーな構成なので、通常は Fargate 等で SSR した方が無難だなと思っています 😓
TL; DR
nuxt-ssr-with-samで GitHub に開発環境一式を置いています。
開発環境
1$ sw_vers
2ProductName: Mac OS X
3ProductVersion: 10.15.7
4BuildVersion: 19H2
5
6$ node --version
7v12.16.0
8
9$ docker --version
10Docker version 19.03.13, build 4484c46d9d
11
12$ aws --version
13aws-cli/1.18.39 Python/3.7.4 Darwin/19.6.0 botocore/1.17.63
14
15$ sam --version
16SAM CLI, version 1.4.0
前提条件
- Route 53 や ACM は、インフラコード化していないので、予めドメインや証明書周りはご自身でリソースを設定する必要あり。簡単に検証をするだけなら、無料ドメインの freenom 等がおすすめです。
- 証明書の識別子は、template.yml のパラメータに設定する必要があるため、メモしておく。
- あくまでも個人開発で利用しているインフラ構成なので、一切の動作保証も、損害も受け入れられないので、自己責任でお願いします 🙇♂️
構成とフロー
- SSR x Serverless x AWS
- API Gateway + Lambda 環境で Express のミドルウェアとして Nuxt.js をレンダリング
- S3 に静的なアセット(画像や.js など)を押し込めて高速化を図る、ただし直アクセスは禁止したいため Origin Access Identity (OAI) を構成
- SAM でインフラコードを閉じ込めて、GitHub Actions で CICD を構成することで、インフラとアプリケーションのコードを一元管理化
- Route 53 や ACM はコード管理するのは怖いため、コンソール画面で設定することにしています
コード (主要なファイルのみ)
1# ディレクトリ構成
2$ tree . -L 1
3.
4├── README.md
5├── node_modules
6├── nuxt-app
7├── nuxt.config.js
8├── package-lock.json
9├── package.json
10├── render
11└── template.yml
12
133 directories, 5 files
template.yml
- node_modules を閉じ込めるために Lambda Layer を利用
- CloudFront の Behoviors は、古い書き方になっています。何故か新しい書き方ではデプロイできず 😕
1AWSTemplateFormatVersion: 2010-09-09
2Transform: AWS::Serverless-2016-10-31
3
4Description: >
5 Server Side Rendering and Build static Hosting.
6
7Parameters:
8 ServiceName:
9 Type: String
10 Default: hogehoge
11 Environment:
12 Type: String
13 Default: prod
14 SubDomain:
15 Type: String
16 Default: www
17 NakedDomain:
18 Type: String
19 Default: hogehoge.com
20 CFSSLCertificateId:
21 Type: String
22 NoEcho: true
23
24Globals:
25 Function:
26 Runtime: nodejs12.x
27 Environment:
28 Variables:
29 ENVIRONMENT: !Ref Environment
30
31Resources:
32 ServerlessApi:
33 Type: AWS::Serverless::Api
34 Properties:
35 Name: !Sub ${ServiceName}-${Environment}-ssr
36 StageName: !Ref Environment
37 OpenApiVersion: 3.0.2
38 BinaryMediaTypes:
39 - "*/*"
40
41 RenderLambdaLayer:
42 Type: AWS::Serverless::LayerVersion
43 Properties:
44 LayerName: !Sub ${ServiceName}-${Environment}-render
45 ContentUri: .layer/render
46 CompatibleRuntimes:
47 - nodejs12.x
48 RetentionPolicy: Delete
49
50 NuxtLambdaLayer:
51 Type: AWS::Serverless::LayerVersion
52 Properties:
53 LayerName: !Sub ${ServiceName}-${Environment}-nuxt
54 ContentUri: .layer/nuxt
55 CompatibleRuntimes:
56 - nodejs12.x
57 RetentionPolicy: Delete
58
59 RenderFunction:
60 Type: AWS::Serverless::Function
61 Properties:
62 FunctionName: !Sub ${ServiceName}-${Environment}-ssr-nuxt
63 CodeUri: render/
64 Handler: app.lambdaHandler
65 Layers:
66 - !Ref RenderLambdaLayer
67 - !Ref NuxtLambdaLayer
68 Timeout: 30
69 MemorySize: 256
70 Events:
71 RenderEvent:
72 Type: Api
73 Properties:
74 RestApiId: !Ref ServerlessApi
75 Path: /
76 Method: GET
77 RenderProxyEvent:
78 Type: Api
79 Properties:
80 RestApiId: !Ref ServerlessApi
81 Path: /{proxy+}
82 Method: GET
83
84 StaticAssetsBucket:
85 Type: AWS::S3::Bucket
86 DeletionPolicy: Retain
87 Properties:
88 BucketName: !Sub ${ServiceName}-${Environment}-static-assets
89 PublicAccessBlockConfiguration:
90 BlockPublicAcls: false
91 BlockPublicPolicy: false
92 IgnorePublicAcls: false
93 RestrictPublicBuckets: true
94
95 StaticAssetsBucketPolicy:
96 Type: AWS::S3::BucketPolicy
97 Properties:
98 Bucket: !Ref StaticAssetsBucket
99 PolicyDocument:
100 Statement:
101 - Effect: Allow
102 Action:
103 - s3:GetObject
104 - s3:ListBucket
105 Resource:
106 - !Sub arn:aws:s3:::${StaticAssetsBucket}/*
107 - !Sub arn:aws:s3:::${StaticAssetsBucket}
108 Principal:
109 CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId
110
111 CloudFrontOriginAccessIdentity:
112 Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
113 Properties:
114 CloudFrontOriginAccessIdentityConfig:
115 Comment: !Sub access-identity-${StaticAssetsBucket}
116
117 CloudFrontDistribution:
118 Type: AWS::CloudFront::Distribution
119 Properties:
120 DistributionConfig:
121 # Generail - Distribution Settings
122 PriceClass: PriceClass_All
123 Aliases:
124 - !Sub ${SubDomain}.${NakedDomain}
125 ViewerCertificate:
126 SslSupportMethod: sni-only
127 MinimumProtocolVersion: TLSv1.2_2019
128 AcmCertificateArn: !Sub arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CFSSLCertificateId}
129 HttpVersion: http2
130 Enabled: true
131 # Origins and Origin Groups
132 Origins:
133 # API Origin
134 - DomainName: !Sub ${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com
135 OriginPath: !Sub /${Environment}
136 Id: !Sub Custom-${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}
137 CustomOriginConfig:
138 HTTPPort: 80
139 HTTPSPort: 443
140 OriginProtocolPolicy: https-only
141 # S3 Origin
142 - DomainName: !GetAtt StaticAssetsBucket.DomainName
143 Id: !Sub S3origin-${StaticAssetsBucket}
144 S3OriginConfig:
145 OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
146 # Behaviors
147 # API Gateway Behavior
148 DefaultCacheBehavior:
149 TargetOriginId: !Sub Custom-${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}
150 ViewerProtocolPolicy: redirect-to-https
151 AllowedMethods:
152 - GET
153 - HEAD
154 CachedMethods:
155 - GET
156 - HEAD
157 DefaultTTL: 0
158 MaxTTL: 0
159 MinTTL: 0
160 Compress: true
161 ForwardedValues:
162 Cookies:
163 Forward: none
164 QueryString: true
165 # Static S3 Behavior
166 CacheBehaviors:
167 - PathPattern: "*.png"
168 TargetOriginId: !Sub S3origin-${StaticAssetsBucket}
169 ViewerProtocolPolicy: redirect-to-https
170 AllowedMethods:
171 - GET
172 - HEAD
173 CachedMethods:
174 - GET
175 - HEAD
176 DefaultTTL: 0
177 MaxTTL: 0
178 MinTTL: 0
179 Compress: true
180 ForwardedValues:
181 Cookies:
182 Forward: none
183 QueryString: false
184 - PathPattern: "_nuxt/*"
185 TargetOriginId: !Sub S3origin-${StaticAssetsBucket}
186 ViewerProtocolPolicy: redirect-to-https
187 AllowedMethods:
188 - GET
189 - HEAD
190 CachedMethods:
191 - GET
192 - HEAD
193 DefaultTTL: 0
194 MaxTTL: 0
195 MinTTL: 0
196 Compress: true
197 ForwardedValues:
198 Cookies:
199 Forward: none
200 QueryString: true
render/app.js
- API Gateway + Lambda 上で Node.js の Express を動かせるようにする aws-serverless-express という OSS があり、この serverless-express 上で Nuxt.js を ミドルウェアとして動かすことで SSR を実現します
- こちらの Keisuke69 様の方の記事を大いに参考にしていますので、詳しくはご一読願います
1"use strict";
2
3const path = require("path");
4const { loadNuxt } = require("nuxt");
5
6const express = require("express");
7const app = express();
8
9const awsServerlessExpress = require("aws-serverless-express");
10const awsServerlessExpressMiddleware = require("aws-serverless-express/middleware");
11
12app.use(awsServerlessExpressMiddleware.eventContext());
13app.use(
14 "/_nuxt",
15 express.static(path.join(__dirname, ".nuxt", "dist", "client"))
16);
17
18async function start() {
19 const nuxt = await loadNuxt("start");
20 app.use(nuxt.render);
21 return app;
22}
23
24let server;
25exports.lambdaHandler = (event, context) => {
26 start().then((app) => {
27 if (server === undefined) {
28 server = awsServerlessExpress.createServer(app);
29 }
30 awsServerlessExpress.proxy(server, event, context);
31 });
32};
nuxt.config.js
- コードを見やすくするためにも
srcDir
でソース一式を別ディレクトリにしています
1export default {
2 srcDir: "nuxt-app",
3 head: {
4 title: "nuxt-app",
5 meta: [
6 { charset: "utf-8" },
7 { name: "viewport", content: "width=device-width, initial-scale=1" },
8 { hid: "description", name: "description", content: "" },
9 ],
10 link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
11 },
12 css: [],
13 plugins: [],
14 components: true,
15 buildModules: ["@nuxtjs/eslint-module"],
16 modules: ["@nuxtjs/axios"],
17 axios: {},
18 build: {},
19};
.github/workflows/main.yml
- GitHub Secrets で環境変数は隠匿化
AWS_ACCESS_KEY_ID
: AWS のアクセスキー IDAWS_SECRET_ACCESS_KEY
: AWS のシークレットアクセスキーCFN_TEMPLATES_BUCKET
: SAM 等のテンプレートを保存するバケット名(s3://は不要)CFSSL_CERTIFICATE_ID
: ACM で発行した証明書の識別子PROD_CLOUDFRONT_ID
: CloudFront のリソース ID
- プルリクのみで発火
- 高速化を目的に一つの job にまとめています、お好みで分割して最適化してください
- 初めての Action では、CloudFront のリソース ID が分からないので、最後の step で失敗します。CloudFront が作成され次第、Secrets に追加してください
1name: Deployment for SSR Nuxt
2
3on:
4 pull_request:
5 branches:
6 - master
7 types: [closed]
8
9env:
10 ENVIRONMENT: ${{ (github.base_ref == 'master' && 'prod') || 'stg' }}
11 SUB_DOMAIN: ${{ (github.base_ref == 'master' && 'www') || 'stg' }}
12
13jobs:
14 deploy:
15 runs-on: ubuntu-latest
16
17 steps:
18 - name: Checkout
19 uses: actions/checkout@v2
20
21 - name: Configure AWS credentials
22 uses: aws-actions/configure-aws-credentials@v1
23 with:
24 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
25 aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
26 aws-region: ap-northeast-1
27
28 - name: Set up Python
29 uses: actions/setup-python@v1
30 with:
31 python-version: 3.7
32
33 - name: Install SAM
34 run: |
35 python -m pip install --upgrade pip
36 pip install aws-sam-cli
37
38 - name: Set up Node.js
39 uses: actions/setup-node@v2-beta
40 with:
41 node-version: 12
42
43 - name: Linter and Formetter JS and Vue
44 run: |
45 npm install
46 npm run lint
47 npm run lintfix
48
49 - name: Build Nuxt App
50 run: |
51 npm run build
52
53 - name: Install npm packages for render lambda layer
54 run: |
55 rsync render/package.json .layer/render/nodejs
56 cd .layer/render/nodejs
57 npm install --production
58
59 - name: Install npm packages for nuxt lambda layer
60 run: |
61 rsync package.json .layer/nuxt/nodejs
62 cd .layer/nuxt/nodejs
63 npm install --production
64
65 - name: Copy to lambda for requirement files
66 run: |
67 rsync -Rr .nuxt/dist/server render/
68 rsync -Rr nuxt-app render/
69 rsync nuxt.config.js render/
70
71 - name: Build by SAM
72 run: |
73 sam build
74
75 - name: Packaging by SAM
76 run: |
77 sam package \
78 --template-file template.yml \
79 --s3-bucket ${{ secrets.CFN_TEMPLATES_BUCKET }} \
80 --output-template-file deploy.yml
81
82 - name: Deploy by SAM
83 run: |
84 sam deploy \
85 --template-file deploy.yml \
86 --stack-name nuxt-ssr \
87 --capabilities CAPABILITY_NAMED_IAM \
88 --parameter-overrides \
89 Environment=$ENVIRONMENT \
90 SubDomain=$SUB_DOMAIN \
91 CFSSLCertificateId=${{ secrets.CFSSL_CERTIFICATE_ID }}
92
93 - name: Deploy static assets to S3
94 run: |
95 aws s3 sync nuxt-app/static s3://localing-clinet-$ENVIRONMENT-static-assets --delete
96 aws s3 sync .nuxt/dist/client s3://localing-clinet-$ENVIRONMENT-static-assets/_nuxt --delete
97
98 - name: Delete production cloudfront cache
99 if: github.base_ref == 'master'
100 run: |
101 aws cloudfront create-invalidation --distribution-id ${{ secrets.PROD_CLOUDFRONT_ID }} --paths '/*'
おわりに
こういうピーキーな構成って何故かロマンというか妙な面白味を感じてしまいます。 あくまでも私個人がピーキーだと勝手に感じているだけです。 もちろん、ちゃんとチューニングして運用される方もたくさんいます。 単純に私がまだ未熟なだけですね。
私の構築したサイトでは、Lambda のコールドスタートを考慮できていないので、SSR の初期表示の速さは全く感じられません 😓 ウォームアップにしたいのですが、コストが大きく掛かる可能性があり、導入できていません。 個人開発の辛みですかね。