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

当記事は、半年以上前に投稿されたものです。そのため、古い技術や情報をもとに書かれている可能性があります。参照する際は十分に注意していただければです。

はじめに

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

その際、やってみたいという理由で 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 と相性が良く、コードも簡潔に書けるので慣れるとグッと効率が上がりそうだなとも思っています emoji-+1

そんなこんなで、今回は SAM で Nuxt.js を SSR するコードを書いてみたので残しておきたいと思います。

※ 個人的な感想ですがピーキーな構成なので、通常は Fargate 等で SSR した方が無難だなと思っています emoji-sweat

TL; DR

nuxt-ssr-with-samで GitHub に開発環境一式を置いています。

開発環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2

$ node --version
v12.16.0

$ docker --version
Docker version 19.03.13, build 4484c46d9d

$ aws --version
aws-cli/1.18.39 Python/3.7.4 Darwin/19.6.0 botocore/1.17.63

$ sam --version
SAM CLI, version 1.4.0

前提条件

  • Route 53 や ACM は、インフラコード化していないので、予めドメインや証明書周りはご自身でリソースを設定する必要あり。簡単に検証をするだけなら、無料ドメインの freenom 等がおすすめです。
  • 証明書の識別子は、template.ymlのパラメータに設定する必要があるため、メモしておく。
  • あくまでも個人開発で利用しているインフラ構成なので、一切の動作保証も、損害も受け入れられないので、自己責任でお願いします emoji-bow

構成とフロー

  • SSR x Serverless x AWS
  • API Gateway + Lambda 環境で Express のミドルウェアとして Nuxt.js をレンダリング
  • S3 に静的なアセット(画像や.jsなど)を押し込めて高速化を図る、ただし直アクセスは禁止したいため Origin Access Identity (OAI) を構成
  • SAM でインフラコードを閉じ込めて、GitHub Actions で CICD を構成することで、インフラとアプリケーションのコードを一元管理化
  • Route 53 や ACM はコード管理するのは怖いため、コンソール画面で設定することにしています

構成図

コード (主要なファイルのみ)

# ディレクトリ構成
$ tree . -L 1
.
├── README.md
├── node_modules
├── nuxt-app
├── nuxt.config.js
├── package-lock.json
├── package.json
├── render
└── template.yml

3 directories, 5 files

template.yml

  • node_modules を閉じ込めるために Lambda Layer を利用
  • CloudFront の Behoviors は、古い書き方になっています。何故か新しい書き方ではデプロイできず emoji-confused
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Description: >
  Server Side Rendering and Build static Hosting.

Parameters:
  ServiceName:
    Type: String
    Default: hogehoge
  Environment:
    Type: String
    Default: prod
  SubDomain:
    Type: String
    Default: www
  NakedDomain:
    Type: String
    Default: hogehoge.com
  CFSSLCertificateId:
    Type: String
    NoEcho: true

Globals:
  Function:
    Runtime: nodejs12.x
    Environment:
      Variables:
        ENVIRONMENT: !Ref Environment

Resources:
  ServerlessApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub ${ServiceName}-${Environment}-ssr
      StageName: !Ref Environment
      OpenApiVersion: 3.0.2
      BinaryMediaTypes:
        - '*/*'

  RenderLambdaLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: !Sub ${ServiceName}-${Environment}-render
      ContentUri: .layer/render
      CompatibleRuntimes:
        - nodejs12.x
      RetentionPolicy: Delete

  NuxtLambdaLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: !Sub ${ServiceName}-${Environment}-nuxt
      ContentUri: .layer/nuxt
      CompatibleRuntimes:
        - nodejs12.x
      RetentionPolicy: Delete

  RenderFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${ServiceName}-${Environment}-ssr-nuxt
      CodeUri: render/
      Handler: app.lambdaHandler
      Layers:
        - !Ref RenderLambdaLayer
        - !Ref NuxtLambdaLayer
      Timeout: 30
      MemorySize: 256
      Events:
        RenderEvent:
          Type: Api
          Properties:
            RestApiId: !Ref ServerlessApi
            Path: /
            Method: GET
        RenderProxyEvent:
          Type: Api
          Properties:
            RestApiId: !Ref ServerlessApi
            Path: /{proxy+}
            Method: GET

  StaticAssetsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !Sub ${ServiceName}-${Environment}-static-assets
      PublicAccessBlockConfiguration:
        BlockPublicAcls: false
        BlockPublicPolicy: false
        IgnorePublicAcls: false
        RestrictPublicBuckets: true

  StaticAssetsBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticAssetsBucket
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - s3:GetObject
              - s3:ListBucket
            Resource:
              - !Sub arn:aws:s3:::${StaticAssetsBucket}/*
              - !Sub arn:aws:s3:::${StaticAssetsBucket}
            Principal:
              CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId

  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub access-identity-${StaticAssetsBucket}

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        # Generail - Distribution Settings
        PriceClass: PriceClass_All
        Aliases:
          - !Sub ${SubDomain}.${NakedDomain}
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2019
          AcmCertificateArn: !Sub arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CFSSLCertificateId}
        HttpVersion: http2
        Enabled: true
        # Origins and Origin Groups
        Origins:
          # API Origin
          - DomainName: !Sub ${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com
            OriginPath: !Sub /${Environment}
            Id: !Sub Custom-${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}
            CustomOriginConfig:
              HTTPPort: 80
              HTTPSPort: 443
              OriginProtocolPolicy: https-only
          # S3 Origin
          - DomainName: !GetAtt StaticAssetsBucket.DomainName
            Id: !Sub S3origin-${StaticAssetsBucket}
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
        # Behaviors
        # API Gateway Behavior
        DefaultCacheBehavior:
          TargetOriginId: !Sub Custom-${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          DefaultTTL: 0
          MaxTTL: 0
          MinTTL: 0
          Compress: true
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: true
        # Static S3 Behavior
        CacheBehaviors:
          - PathPattern: '*.png'
            TargetOriginId: !Sub S3origin-${StaticAssetsBucket}
            ViewerProtocolPolicy: redirect-to-https
            AllowedMethods:
              - GET
              - HEAD
            CachedMethods:
              - GET
              - HEAD
            DefaultTTL: 0
            MaxTTL: 0
            MinTTL: 0
            Compress: true
            ForwardedValues:
              Cookies:
                Forward: none
              QueryString: false
          - PathPattern: '_nuxt/*'
            TargetOriginId: !Sub S3origin-${StaticAssetsBucket}
            ViewerProtocolPolicy: redirect-to-https
            AllowedMethods:
              - GET
              - HEAD
            CachedMethods:
              - GET
              - HEAD
            DefaultTTL: 0
            MaxTTL: 0
            MinTTL: 0
            Compress: true
            ForwardedValues:
              Cookies:
                Forward: none
              QueryString: true

render/app.js

  • API Gateway + Lambda 上で Node.js の Express を動かせるようにする aws-serverless-express という OSS があり、この serverless-express 上で Nuxt.js を ミドルウェアとして動かすことで SSR を実現します
  • こちらの Keisuke69 様の方の記事を大いに参考にしていますので、詳しくはご一読願います
'use strict'

const path = require('path')
const { loadNuxt } = require('nuxt')

const express = require('express')
const app = express()

const awsServerlessExpress = require('aws-serverless-express')
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')

app.use(awsServerlessExpressMiddleware.eventContext())
app.use(
  '/_nuxt',
  express.static(path.join(__dirname, '.nuxt', 'dist', 'client'))
)

async function start() {
  const nuxt = await loadNuxt('start')
  app.use(nuxt.render)
  return app
}

let server
exports.lambdaHandler = (event, context) => {
  start().then((app) => {
    if (server === undefined) {
      server = awsServerlessExpress.createServer(app)
    }
    awsServerlessExpress.proxy(server, event, context)
  })
}

nuxt.config.js

  • コードを見やすくするためにも srcDirでソース一式を別ディレクトリにしています
export default {
  srcDir: 'nuxt-app',
  head: {
    title: 'nuxt-app',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },
  css: [],
  plugins: [],
  components: true,
  buildModules: ['@nuxtjs/eslint-module'],
  modules: ['@nuxtjs/axios'],
  axios: {},
  build: {},
}

.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 に追加してください
name: Deployment for SSR Nuxt

on:
  pull_request:
    branches:
      - master
    types: [closed]

env:
  ENVIRONMENT: ${{ (github.base_ref == 'master' && 'prod') || 'stg' }}
  SUB_DOMAIN: ${{ (github.base_ref == 'master' && 'www') || 'stg' }}

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Set up Python
        uses: actions/setup-python@v1
        with:
          python-version: 3.7

      - name: Install SAM
        run: |
          python -m pip install --upgrade pip
          pip install aws-sam-cli

      - name: Set up Node.js
        uses: actions/setup-node@v2-beta
        with:
          node-version: 12

      - name: Linter and Formetter JS and Vue
        run: |
          npm install
          npm run lint
          npm run lintfix

      - name: Build Nuxt App
        run: |
          npm run build

      - name: Install npm packages for render lambda layer
        run: |
          rsync render/package.json .layer/render/nodejs
          cd .layer/render/nodejs
          npm install --production

      - name: Install npm packages for nuxt lambda layer
        run: |
          rsync package.json .layer/nuxt/nodejs
          cd .layer/nuxt/nodejs
          npm install --production

      - name: Copy to lambda for requirement files
        run: |
          rsync -Rr .nuxt/dist/server render/
          rsync -Rr nuxt-app render/
          rsync nuxt.config.js render/

      - name: Build by SAM
        run: |
          sam build

      - name: Packaging by SAM
        run: |
          sam package \
            --template-file template.yml \
            --s3-bucket ${{ secrets.CFN_TEMPLATES_BUCKET }} \
            --output-template-file deploy.yml

      - name: Deploy by SAM
        run: |
          sam deploy \
            --template-file deploy.yml \
            --stack-name nuxt-ssr \
            --capabilities CAPABILITY_NAMED_IAM \
            --parameter-overrides \
                Environment=$ENVIRONMENT \
                SubDomain=$SUB_DOMAIN \
                CFSSLCertificateId=${{ secrets.CFSSL_CERTIFICATE_ID }}

      - name: Deploy static assets to S3
        run: |
          aws s3 sync nuxt-app/static s3://localing-clinet-$ENVIRONMENT-static-assets --delete
          aws s3 sync .nuxt/dist/client s3://localing-clinet-$ENVIRONMENT-static-assets/_nuxt --delete

      - name: Delete production cloudfront cache
        if: github.base_ref == 'master'
        run: |
          aws cloudfront create-invalidation --distribution-id ${{ secrets.PROD_CLOUDFRONT_ID }} --paths '/*'

おわりに

こういうピーキーな構成って何故かロマンというか妙な面白味を感じてしまいます。 あくまでも私個人がピーキーだと勝手に感じているだけです。 もちろん、ちゃんとチューニングして運用される方もたくさんいます。 単純に私がまだ未熟なだけですね。

私の構築したサイトでは、Lambda のコールドスタートを考慮できていないので、SSR の初期表示の速さは全く感じられません emoji-sweat ウォームアップにしたいのですが、コストが大きく掛かる可能性があり、導入できていません。 個人開発の辛みですかね。

参考文献


Canji

クラウド周りをちょこまかしたい注意散漫人間。個人開発を楽しんでいたあの頃。