LightsailにDockerizeされたNuxt SSR アプリケーションを自動デプロイする

普段はバックエンドと最近フロントエンドも始めたエンジニアです。その他にも前からインフラの技術に興味があり、NuxtアプリケーションをVPS環境(Lightsail)へ自動デプロイが行えるか試してみました。世の中には、[Netlify](https://www.netlify.com/)・[Firebase Hosting](https://firebase.google.com/docs/hosting/)・[zeit](https://zeit.co/)があり、VPSへのデプロイは少しニッチですが、なにかの参考になればと思います。
最終的なプロジェクト構成やCIの設定情報は[GitHub](https://github.com/inkusu/nuxt-sample-project)にあります。

## 全体フロー

![Untitled Diagram.png](https://qiita-image-store.s3.amazonaws.com/0/35291/1da744ab-a795-d7d0-39e9-1c8f50bf8566.png)

1. GitHubにpushを行う
2. CircleCIが起動する
3. 単体テストを実行する
4. DockerHubにNuxtアプリケーションをPushする
5. Lightsail環境にデプロイを行う
6. Nuxtアプリケーションが自動で公開される

## 自動デプロイ環境の構築

はじめにデプロイしたいNuxtアプリケーションの準備を行います。

### MacにNodeのインストール

Nodeの[オフィシャルサイト](https://nodejs.org/en/)より`11.x`をダウンロードしインストールを完了してください。ダウンロードしたファイルをダブルクリックし、指示に従うとインストールが完了します。

ターミナルを起動し、バージョンが表示されればNodeのインストールが完了しています。

```bash

node --version 
# v11.2.0
```

### Nuxtのインストール

[公式の設置手順](https://nuxtjs.org/guide/installation/)に沿ってインストールを行います。

```bash

npx create-nuxt-app nuxt-sample-project
```

いくつか質問が来るので、以下は回答例です。実際は自分のプロジェクトやスキルに合わして回答して貰えればと思います。

```bash
? Project name nuxt-sample-project
? Project description My phenomenal Nuxt.js project
? Use a custom server framework none
? Use a custom UI framework none
? Choose rendering mode Universal
? Use axios module no
? Use eslint no
? Use prettier no
? Author name lnkusuin
? Choose a package manager npm
```


### 開発環境の起動

以下コマンドで開発用サーバを起動します

```bash
cd nuxt-sample-project
npm run dev
```

サーバが立ち上がると、[http://localhost:3000](http://localhost:3000)でアクセスできるようになります。

![b0e9a7f3191e780803dca720d64ce2d0.png](https://qiita-image-store.s3.amazonaws.com/0/35291/1c24178d-cbd9-6139-3a4c-e6e8d3ced75c.png)

以下のページが表示されれば成功です。

![1d398388d44bb34c8923ee308d716359.png](https://qiita-image-store.s3.amazonaws.com/0/35291/64a926b3-7511-4954-d61d-9c43ff5f3d3f.png)

### 単体テストの用意

CircleCI上で単体テストを実行するために、簡単なテストを用意します。

まずはテストに必要なパッケージをインストールします。

```
npm install --save-dev jest babel-jest "babel-core@^7.0.0-bridge.0" @babel/core @vue/test-utils --save regenerator-runtime
```

npmスクリプトからテストが実行できるように`package.json`の`script`の箇所に`test`を追加します。

```json

"scripts": {
    ...
    "test": "NODE_PATH=. jest"
  },
```

Jest実行時にbabelが有効でないとシンタックスエラーでテストが落ちるため、`package.json`にbabelの設定を追加します。

```json

  "babel": {
    "presets": [
      [
        "@babel/preset-env",
        {
          "targets": {
            "node": "current"
          }
        }
      ]
    ]
  }

```


### Storeの単体テスト

`store/index.js`にテスト対象ファイルを作成します。

```js:index.js
export const state = () => ({
  counter: 0
})

export const mutations = {
  increment (state) {
    state.counter++
  }
}
```

`__tests__/store/test.js`にテストコードを用意します。

```js

import { createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import { state, mutations } from 'store'

const localVue = createLocalVue()
localVue.use(Vuex)

let store = new Vuex.Store({
  state, mutations
})

test('カウンターが動作していることを確認する', () => {
  store.commit('increment')
  expect(store.state.counter).toBe(1)

  store.commit('increment')
  expect(store.state.counter).toBe(2)
});

```

`npm run test`でテストを実行し、

![f067596029e6e5d4ed88326b23638adf.png](https://qiita-image-store.s3.amazonaws.com/0/35291/29c07fa6-be34-8fce-d571-08a12cf36a02.png)

テストが通ることを確認しテストの準備は終了

### NuxtアプリケーションのDockerize

簡単なテストが通るNuxtアプリケーションが準備できたので、これをDockerizeし本番に展開できるようにします。
[DockerHub](https://hub.docker.com/_/node/)に`node:11-alpine`があります。このイメージをベースにして、Nuxtアプリケーション用のDockerfileを用意します。

![7c0813acab4b21bbc51844b083694842.png](https://qiita-image-store.s3.amazonaws.com/0/35291/1359a00b-c7e4-ae82-25af-059a089408c5.png)

Dockerfileでは、日本時刻の設定・ソースコードのコピー・npmのプロダクション用インストール・nuxtのbuild or startを行っています。

```dockerfile:Dockerfile
FROM node:11-alpine
MAINTAINER lnkusuin

# 日本時刻の設定
RUN apk --update add tzdata && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apk del tzdata && \
    rm -rf /var/cache/apk/*

# ポート解放
EXPOSE 3000

COPY app /home/node/app

WORKDIR /home/node/app

RUN rm -rf node_modules .nuxt package-lock.json

# NODEアプリケーションのプロジェクトパスを設定
ENV NODE_PATH .
# プロジェクトルート
ENV APP_ROOT /home/node/app

RUN npm install --production
RUN npm run build

CMD npm run start
```

### DockerHubの準備

先程作成したDocker イメージファイルをDocker Hubに保存し、Circle CI上から保存、本番環境から取得できるようにします。
Docker Hubにログインし、右上の`Create`から、`Create Repository`を選択し、`namespace`と`repository name`を入力し`Create`を選択します。

![6c83a153a9583205463a55bcc1f546f6.png](https://qiita-image-store.s3.amazonaws.com/0/35291/a61a73c0-f1f7-22a7-8830-4bd591b10345.png)

Docker Hubでの作業は以上です。

### Lightsailの用意

次はデプロイ先である本番環境の用意を行います。今回は安価で使えるVPSサーバのLigjtsailを使用します。AWS ConsoleよりLightsailにログインを行い、インスタンスの作成を選択します。

![3a8eee73400c4a3e3d0aad08dee1534f.png](https://qiita-image-store.s3.amazonaws.com/0/35291/f4b7d8ec-f13f-483d-e6a9-d919f303144e.png)

次にインスタンスロケーション・インスタンスイメージを選択します。

* インスタンスロケーションを`東京、ゾーンA`
* インスタンスイメージを`Linux/Unix`の`Ubuntu`を選択

![912e2e3c23121246d44e507bc7620c77.png](https://qiita-image-store.s3.amazonaws.com/0/35291/30cdcb0f-0575-d636-8736-c3c880dea3cf.png)

インスタンス作成時に、CIからデプロイ可能にするために`docker swarm`を構成します。以下に用意している起動スクリプトを貼り付けます。

![eec08637034d7de3db9b33c4f0d20d23 (1).png](https://qiita-image-store.s3.amazonaws.com/0/35291/b04e8ca2-e2e3-62ce-7c19-4e9a9b5fb79d.png)


#### 起動スクリプト

```bash:run.sh

apt-get update
apt-get install -y \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"
apt-get update
apt-get install -y docker-ce
usermod -aG docker ubuntu

mkdir /app
mkdir /app/nginx
cat <<EOL > /app/nginx/default.conf
server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://app:3000;
        proxy_set_header Host \$host;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    }
}
EOL

cat <<EOL > /app/docker-compose.yml
version: '3.1'
services:

  app:
    image: wakachan/nuxt-sample-project:release
    working_dir: /home/node/app
    deploy:
      replicas: 2

  nginx:
    image: nginx:1.15
    ports:
      - "80:80"
    volumes:
      - ./nginx:/etc/nginx/conf.d
    depends_on:
      - app
EOL

docker swarm init
docker stack deploy -c /app/docker-compose.yml sample
```

CircleCIからSSHでアクセスしデプロイ作業を可能にするために、SSHキーをダウンロードします。

![7db4988e9866217a6ccb4cbee0ab9c44.png](https://qiita-image-store.s3.amazonaws.com/0/35291/d96ebec8-fbce-6f79-3672-ed697e575374.png)

インスタンスの作成を選択します。

![3decf7a54810ad88a01b1aa1e72cf70c.png](https://qiita-image-store.s3.amazonaws.com/0/35291/3f02f1e8-19a0-2a84-ffcd-95a5130cd6d0.png)


#### IPアドレスをメモ

サーバが起動するとIPアドレスが割り振られます。CircleCI上からデプロイ先サーバを識別するために使用します。

![e294ebbef57a22c3cdbc0f4500b93d6f.png](https://qiita-image-store.s3.amazonaws.com/0/35291/6428be77-a0fd-61d3-949b-61d640c0d40f.png)

サーバの準備は完了です。

### CircleCIの設定

ここまでに準備した、単体テスト・Dockerfile・Docker Hub・本番環境の各構成要素をCircleCIを使って自動デプロイを構成します。ますは、CircleCIにGitHubアカウントでログインします。


![f21231a0263e9a792bab2d7cd317cfd7.png](https://qiita-image-store.s3.amazonaws.com/0/35291/391ff9bb-e3f8-9bfc-e459-dba90fb52da7.png)

ログインが完了するとダッシュボードが表示されます。

![704bc1a9bcd89a315995ad99828f3020.png](https://qiita-image-store.s3.amazonaws.com/0/35291/d5a5c0d3-717a-2adb-ed1b-af9ca6dbb280.png)

`Add Project`よりプロジェクト追加画面に移動し

![5fbd919ff5469dcfc37bf0e22626438b.png](https://qiita-image-store.s3.amazonaws.com/0/35291/8b1cf005-1ce6-16ba-9dae-368d69fbe019.png)

`nuxt-sample-project`の`Set Up Project`を選択します。

![46cbad0471c09c0400d28ca3ffe9f4fa.png](https://qiita-image-store.s3.amazonaws.com/0/35291/e508bc49-1273-7730-e730-9663dba0697a.png)

`Operating System`に`Linux`を`Language`に`Node`を選択し、とりあえず`start build`を押します。まだ、`nuxt-sample-project`にはCircleciの設定ファイルが存在しないため、ビルドは失敗します。とりあえずこれでGitHubとCircleCIの連携はこれで行えるようになり、次回Pushを行ったときに処理されるビルドスクリプトを用意します。

#### 各種環境変数とSSHキーの設定

CircleCI上でNuxtアプリケーションのビルドを行った後、Docker Hubにイメージファイルをpushするためには先に、CircleCI上でDocker Hubへログインする必要があります。またCircleCI上からデプロイ先のサーバにSSH接続しデプロイ作業を行うためにデプロイ先のIPの登録も行います。これらの情報はCircleCIの環境変数設定で登録を行います。

Setting -> Followed Projects -> inkusu nuxt-project　横にある設定アイコンを選択します。 

![4f0179382fe934e02cd93da99ce984bb.png](https://qiita-image-store.s3.amazonaws.com/0/35291/1ba19953-1d4d-a704-9167-5b5fbf57e840.png)


Build Setting -> Environment Variables より環境変数の設定画面を開きます
Add Variableから、`DOCKER_USER`・`DOCKER_PASS`・`STATIC_IP_ADDRESS`を登録します。

![c117414cff4c2ad9ae8045711b32fee7.png](https://qiita-image-store.s3.amazonaws.com/0/35291/aa4cc254-2b49-fad0-9142-e7d0cc417587.png)

デプロイ先のサーバとSSHで接続してデプロイ処理を行うため鍵の登録をします。
PERMISSIONS -> SSH Permissions より行います。

![8fcd25dc373700e406e6bbb22891c702.png](https://qiita-image-store.s3.amazonaws.com/0/35291/5e8d21fc-928c-d578-9fc5-7a7c3591f7f4.png)

ここで発行されるFingerprint情報を使用してSSH接続を行います。


#### CIのコードを作成

ソースコードに戻り`.circleci/config.yml`を作成し以下のコードを貼り付けます。

```yml

version: 2

defaults: &defaults
  working_directory: ~/project
  docker:
    - image: banian/node-headless-chrome

jobs:

  setup:
    <<: *defaults
    steps:
      - checkout

      # Restore cache
      - restore_cache:
          key: package-{{ checksum "nuxt-sample-project/package-lock.json" }}

      - run:
          name: Install Dependencies
          command: NODE_ENV=dev npm install
          working_directory: nuxt-sample-project

      # Keep cache
      - save_cache:
          key: package-{{ checksum "nuxt-sample-project/package-lock.json" }}
          paths:
            - "nuxt-sample-project/node_modules"

      # Persist files
      - persist_to_workspace:
          root: ~/project
          paths:
            - .

  check:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/project
      - run:
          name: unit test
          command: npm run test
          working_directory: nuxt-sample-project

  build:
    machine: true
    steps:
      - attach_workspace:
          at: ~/project
      - run:
          name: docker build
          command: docker build --no-cache -t wakachan/nuxt-sample-project:release -f Dockerfile .
      - run:
          name: docker login
          command: docker login --username=$DOCKER_USER --password=$DOCKER_PASS
      - run:
          name: docker push
          command: docker push wakachan/nuxt-sample-project:release

  deploy:
    machine: true
    steps:
      - add_ssh_keys:
          fingerprints:
            - "7e:8b:cc:15:65:0e:a2:fa:5c:f3:18:42:34:19:51:df"
      - run:
          name: delpoy
          command: >
            ssh ubuntu@$STATIC_IP_ADDRESS "cd /app/ & docker service update --image wakachan/nuxt-sample-project:release sample_app"
            
workflows:
  version: 2
  test-build-deploy:
    jobs:
      - setup

      - check:
          requires:
            - setup

      - build:
          requires:
            - check

      - deploy:
          requires:
            - build
```


## デプロイ

これでNuxtアプリケーションを自動デプロイする準備が整いました。実際にコードをpushすることにより、Lightsail(VPS)環境に展開がされます。

### デプロイの様子

実際のデプロイされる風景はCircleCIの管理画面から確認することができます。

![b907730a07e7c982127009b1430ed45f.png](https://qiita-image-store.s3.amazonaws.com/0/35291/e2e79f71-fc80-5115-44ca-9aad0918aa94.png)

## まとめ

すごくざっくりとですが、VPS環境にNuxt SSRアプリケーションを自動デプロイする構成を組むことができました。
また他にも

* Circleci上でカバレッジ情報をcodecovにアップロード
* Nightwatchを使用して、CI上でe2eテストの実行
* sequelizeでのDBのマイグレーション

といったことも可能で、[NuxtのCircle CI](https://github.com/nuxt/nuxt.js/blob/dev/.circleci/config.yml)の設定情報が参考になります。

## 参考リソース

* [Nuxt本家のCircleCIの設定](https://github.com/nuxt/nuxt.js/blob/dev/.circleci/config.yml)
* [Circle CIのドキュメント](https://circleci.com/docs/)
* [Nuxt SSRのDockerファイル作成参考](https://dev.to/vuevixens/dockerise-your-nuxt-ssr-app-like-a-boss-a-true-vue-vixens-story-4mm6)
* [A Nuxt.js VPS production deployment.md](https://gist.github.com/DreaMinder/e9fc988295378d8373521a93c3ca6d18)
* [Testing a Vuex store](http://slides.com/eddyerburgh/testing-a-vuex-store#/)