AWS - Nuxt.js SSR Super Lightweight Deploy(초 경량화 배포)!

2021. 4. 12. 00:24Develop/Javascript

728x90

AWS Lambda를 사용해서 Nuxt.js SSR(Server Side Rendering)의 배포 방식에 대해서는 게시글이 검색했을 때

생각보다 많지 않습니다. 대체로 진짜 아무것도 안 만든 hello World! 같은 프로젝트 생성한 것만 배포할 정도의 수준이죠

 

 

그 이유는 의외로 간단합니다. lambda에서 업로드 가능한 용량이 제한되기 때문입니다.

 

용량이 제한되니 많은 패키지 용량을 보유해야 하는 npm을 이용해 설치된 node_modules는 비상식적인 용량을 자랑합니다. 이러한 이유로 Nuxt.js 와 Next.js 등의 SSR 프레임워크를 사용하는 유저들은 Lambda@edge + Cloud Front + S3 조합을 포기하고 다른 방안을 찾는 시도를 하죠.

 

 

 

 

대체 방안은 다음과 같습니다.

  • AWS Amplify (SSR을 지원한다고 하였지만, 사실 백엔드를 지원하는 자사 AppSync를 사용하는 거 아니면 안 쓰는 게 정신건강에 이롭습니다.
  • AWS Fargate (ECS는 생각보다 자료가 있긴 한데 도커를 포함해서 컨테이너 배포 방식에 대한 러닝 커브도 있고 초기 설정이 상당히 귀찮습니다.)
  • AWS EC2 (서버리스 제품군보다는 사실 비용이 많이 나가고, 오토스케일을 해주면 되긴 하지만 그래도 서버 설정 및 유지보수에 대한 인건비용이 발생합니다. [딱히 필요하던가..?])
  • Google Cloud Functions (람다보단 쉽고 관리도 편하고 무료 한도도 높고 비용도 저렴한 데다가 용량 제한도 없지만, 도메인 붙이려면 Firebase랑 왔다 갔다 하는 게 그냥 귀찮습니다. / 스케줄러로 쓸 거라면 3개 이상은 유료...)
  • Google Cloud Run (AWS에 ECS 서비스에 비하면 진짜 설정하나는 편한데, 구글에서 구입한 도메인이 아닌 외부 도메인 붙이려면 리전을 미국으로 잡아줘야 하는 불편한 점이 있습니다. CDN이 자동이라면서 IP가 한국으로 안 잡히는 건 무슨 일..?)

 

그 외에도 정말 많은 방법이 있지만, 그럼에도 불구하고 왜 Lambda 나면 Google 제품군을 더 선호하긴 하지만 일단 자료가 없고 가격대가 한 달 이상 유치할 경우 얼마나 나오는지도 궁금해서 해보았습니다.

 

 

 

 

 

package.json

{
  "name": "nuxt-service",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "analyze": "nuxt build --analyze",
    "start": "nuxt-start",
  },
  "dependencies": {
    "aws-serverless-express": "^3.4.0",
    "express": "^4.17.1",
    "nuxt-start": "2.15.2"
  },
  "devDependencies": {
    // 의존성을 모두 개발 의존성으로 변경
  }
}

package.json에는 nuxt-start, express, aws-serverless-express를 사용합니다.

nuxt가 아닌 nuxt-start를 사용하는 이유는 nuxt 모듈은 60MB에 육박하지만, nuxt-start는 8MB밖에 되지 않습니다.

express나 aws-serverless-express는 lambda@edge를 배포하기 위해 작성해야 합니다.

 

의존성 패키지들을 모두 개발 의존성으로 작성하는 이유는

serverMiddleware와 같은 runtime 실행에 필요한 모듈이 아니라면, 모두 build 과정에서 standalone 옵션을 통해서 dist에 번들링 되어 포함할 수 있기 때문에, 굳이 많은 용량을 포함할 필요가 없습니다.

 

즉 Nuxt에서 API까지 처리할 때, 예를 들어 nanoid를 api에서 사용한다면, 의존성에 포함해야 하지만

API 서버가 분리된 서버에서 동작한다면 의존성에 포함될 필요가 없습니다.

 

 

 

 

 

nuxt.config.js

// nuxt.config.js

  // Modules for dev and build (recommended) (https://go.nuxtjs.dev/config-modules)
  buildModules: [
    // https://go.nuxtjs.dev/typescript
    '@nuxt/typescript-build',
    // https://go.nuxtjs.dev/vuetify
    '@nuxtjs/vuetify',
    '@nuxtjs/composition-api',
    '@nuxtjs/pwa', // https://go.nuxtjs.dev/pwa
    '@nuxtjs/device',
    '@nuxtjs/apollo',
    '@nuxtjs/style-resources',
    '@nuxtjs/auth-next',
    '@nuxtjs/axios',
    'cookie-universal-nuxt',
    '@aceforth/nuxt-optimized-images',
    'nuxt-client-init-module',
    [
      '@nuxtjs/dayjs',
      {
        locales: ['en', 'ko'],
        defaultLocale: 'ko',
      },
    ],
  ],

  // Modules (https://go.nuxtjs.dev/config-modules)
  modules: [],
  
  
   // Build Configuration (https://go.nuxtjs.dev/config-build)
  build: {
    standalone: true,
    // 생략
  }

이런 식으로 modules는 최대한 비어있어야 합니다.

또한 API 서버가 분리되어 있다면 modules는 위처럼 비어져 있어야 정상입니다.

 

buildModules에 작성을 하게 되면, nuxt build 과정 중에 패키지가 포함됩니다.

build 옵션에는 standalone 옵션을 설정하여 필수 모듈들을 번들링 과정에 포함시켜주도록 합니다.

 

 

 

app.js

// app.js (nuxt.config.js와 같은 경로에 위치합니다.)

'use strict'

const { createServer, proxy } = require('aws-serverless-express')
const express = require('express')
const { Nuxt } = require('nuxt-start')
const config = require('./nuxt.config.js')

const app = express()
const nuxt = new Nuxt({
  ...config,
  dev: false,
  _start: true,
})

app.use(async (req, res) => {
  if (nuxt.ready) {
    await nuxt.ready()
  }
  nuxt.render(req, res)
})

const server = createServer(app, void 0, [
  'application/javascript',
  'application/json',
  'application/manifest+json',
  'application/octet-stream',
  'application/xml',
  'font/eot',
  'font/opentype',
  'font/otf',
  'image/gif',
  'image/jpeg',
  'image/png',
  'image/svg+xml',
  'image/x-icon', // for favicon
  'text/comma-separated-values',
  'text/css',
  'text/html',
  'text/javascript',
  'text/plain',
  'text/text',
  'text/xml',
  'application/rss+xml',
  'application/atom+xml',
])

module.exports.handler = (event, ctx) => {
  proxy(server, event, ctx)
}

 

app.js 파일을 만들고 위 내용을 복붙 합니다.

aws-serverless-express와 express 패키지를 이용해서 nuxt를 렌더링 하는 간단한 express 코드입니다.

 

 

 

 

 

serverless.yml

service: nuxt-serverless

provider:
  name: aws
  runtime: nodejs12.x
  stage: ${opt:stage, self:provider.stage, 'dev'}
  region: us-east-1
  apiGateway:
    binaryMediaTypes:
      - '*/*'
  environment:
    # stops telemetry output from nuxt
    NUXT_TELEMETRY_DISABLED: 1
    NODE_ENV: production

functions:
  nuxt:
    timeout: 30
    handler: app.handler
    events:
      - http: ANY /
      - http: ANY /{proxy+}
    package:
      include:
        - .nuxt/dist/**
        - server-middleware/**
        - middleware/**
        - static/**
        - app.js
        - nuxt.config.js
        - node_modules/**
        - package.json

package:
  individually: true
  excludeDevDependencies: false
  exclude:
    - "**"

plugins:
  - serverless-offline
  - serverless-prune-plugin

custom:
  prune:
    automatic: true
    number: 10

serverless.yml입니다.

현재 글에서 안 중요한 부분은 없지만, 여기가 특출 나게 중요합니다. functions에서 아까 작성한 app.handler

hanlder로 지정합니다. package include에는 본인이 포함해야 하는 파일들을 포함시켜주어야 합니다.

 

여기서 필수 파일은 다음과 같습니다.

  • . nuxt/dist/**
  • app.js
  • nuxt.config.js
  • node_modules/**

 

환경변수에서 NUXT_TELEMETRY_DISABLED를 지정해주지 않으시면 배포 중에 빌드를 진행하는 과정에서

오류 정보수집 의견 제출  뭐 이러한 내용에 수락하겠습니까? (Y/N) 이걸 무시를 못합니다.

 

별도로 package를 통해 exclude로 모든 파일을 제외시킵니다.

저의 경우는 serverless-prune-plugin을 사용해서 최대 10개까지 유지시켜주도록 하였는데 별 도움이 안ㄷ..

 

 

 

Dockerfile

FROM node:14.15.5-alpine3.13 as base
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN export $(grep -v '^#' .env.production | xargs) && \
  apk add curl bash --no-cache && \
  curl -sfL https://install.goreleaser.com/github.com/tj/node-prune.sh | bash -s -- -b /usr/local/bin && \
  npm i -g modclean minify-all serverless serverless-prune-plugin serverless-offline && \
  npm i --production && \
  npm i @vue/apollo-composable@4.0.0-alpha.10 && \
  npm run build --production && \
  node-prune && \
  minify-all && \
  echo y | modclean -n default:safe,default:caution && \
  NODE_ENV=production npm prune --production && \
  serverless config credentials --provider aws --key $AWS_ACCESS_KEY_ID --secret $AWS_SECRET_ACCESS_KEY

CMD ["sls", "deploy", "--stage", "production"]

제가 배포하는 예시입니다. 각 라인마다 #1 이런 식으로 표기를 하겠습니다.

 

#1 node 14 alpine을 사용하였습니다. 12로 진행하셔도 무방하며, 개인차가 있을 수 있고 굳이 alpine이 아니어도 가능합니다.

 

#2 WORKDIR를 이용해서 /usr/src/app으로 경로를 변경합니다.

 

#3 현재 경로에 있는 모든 파일을 /usr/src/app 으로 복사합니다.

    (. dockerignore를 통해. gitignore처럼 복사에서 제외할 파일 및 폴더 등을 지정할 수 있습니다.)

 

#4 환경변수가 제대로 적용이 되지 않는 문제가 있어서 귀찮아서 shell로 박아 버렸습니다. 물론 해당 도커 파일은 node_modules 축소 후 배포를 하기 위함이기 때문에 전혀 문제없습니다. 

 

#5 alpine 리눅스에 curl과 bash를 설치합니다.

 

#6 curl 명령을 이용하여 node-prune를 다운로드합니다. alpine 리눅스에서는 node-prune를 npm 명령으로 설치하기가 쉽지 않기 때문입니다. 다운로드 후에 bash 명령으로 node-prune를 실행 명령에 등록합니다.

 

#7 npm 명령으로 전역(글로벌)에 배포에 필요로 하는 패키지들과, node_modules를 축소시키기 위한 패키지들을 설치합니다. 전역으로 설치하면 S3에 업로드되는 압축파일에 포함되지 않아서 배포 과정 중에 필요한 경우는 전역으로 설치를 진행하시면 됩니다. (서버리스 플러그인은 이곳에!)

 

#8 production 명령을 포함하여 package.json을 설치합니다. --production으로 안 해도 되는 이유가 COPY를 통해       

     node_modules를 이미 갖고 왔기 때문에 더 이상은 생략합니다...

    (모르시는 분들도 있을 테니, --production 옵션을 주게 되면 devDependencis에 정의한 패키지를 설치하지 않습니다.)

 

#9 nuxt.js에서 vue/apollo-composable이 오류가 있어서 alpha10으로 강제 설치합니다.

     package.json에서 ^표시를 제외하시면 굳이 이렇게 안 하셔도 됩니다.

 

#10 production으로 빌드를 진행합니다.

 

#11 node-prune 명령을 이용해서 불순물(사용하지 않는 굳이 없어도 되는?)들을 제거합니다.

 

#12 minify-all 명령을 이용해서 코드의 축소(공백 제거 등)를 진행합니다.

 

#13 modclean을 '경고 주의'까지 포함하여 진행합니다. 이 녀석도 생각보다 물건이라 용량 축소에 도움이 큽니다.

 

#14 마지막으로 확인사살을 위해 npm prune까지 진행합니다.

 

#15 전역으로 설치한 serverless와 환경변수를 이용해서 도커 이미지에 본인 계정을 설정합니다.

 

#16 serverless를 production stage에 배포합니다.

 

 

 

 

이 도커 파일의 이미지를 확인해 보면 생각보다 높은걸 확인할 수 있습니다. alpine의 경우 117MB인 점을 감안해도 생각보다 높게 나오실 겁니다. 전역으로 설치한 패키지들이 문제지만, #7에서 언급한 것처럼 전역 설치 패키지들은 용량에 포함되지 않습니다.

저의 경우 buildModules와 node-prune minify-all modclean 등을 통해서 S3 압축파일 용량이 251MB -> 10.75MB로 확연히 줄어든 것을 확인할 수 있었습니다.

 

 

드디어 끝입니다. 선행 러닝 커브로는 docker를 필요로 하지만 저와 같은 구성을 하신다면 그냥 복붙.... 하시면 됩니다.

 

Nuxt.jsLambda 배포는 맨 처음에 언급한 것처럼 용량 문제가 해결이 안 되는 방식으로 들 설명되어 있고

해외 자료들도 비슷하기 때문에 정말 도움이 안 됩니다. Nuxt.js 자체가 국내에서는 아직 많은 사용이 없어서 누군가에게는 큰 도움이 되기를 바랍니다.

 

약 2~3주가량 삽질하여 최고의 초 경량화 축소 방식을 고안해냈고, 도커를 통해서 언제나 새로 설치 후 새로 제거하는 과정을 통해 깔끔하게 초경량화된 nuxt.js를 만들어낼 수 있었습니다.

클라우드 프런트(CloudFront)는 서버리스에서 설정하는 것보다 그냥 콘솔에서 하는 게 편한 것 같은데 콘솔에서 진행하는 것으로 추후 작성 예정입니다.

728x90