サイトアイコン

toLog

SAMとGitHubActionsでNuxtをSSR構成で構築したい

  • 更新日:
  • 投稿日:
サムネイル

この記事は最終更新日から3年以上が経過しています。

はじめに

先日、個人開発で「ローカりんぐ」という全国のローカルメディアの良質なコンテンツを収集して、一覧化するサイトを作りました。

その際、やってみたいという理由で 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 のアクセスキー ID
    • AWS_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 の初期表示の速さは全く感じられません 😓 ウォームアップにしたいのですが、コストが大きく掛かる可能性があり、導入できていません。 個人開発の辛みですかね。

参考文献


プロフィール画像

canji

とにかく私的にサービスを作りたい発作を起こしている。お腹はペコペコ。

  • toLog Tools icon
  • dots icon