コンテナを使わずGitLab CI/CDでNext.jsを環境変数を渡しつつ自動デプロイする。

Next.jsを環境変数を渡しつつ自動デプロイするようにCDを構築してみたので雑ですがまとめてみました。

# 構成

- どっかに配置
  - GitLab
  - GitLab Runner
  - MySQL
- デプロイ用サーバ
  - Next.js (Systemd)

# デプロイ用ホストの設定

## ユーザー

sudoをパスワードなしで実行できるユーザーを作成

```bash
$ useradd -m deploy -s /usr/bin/bash -G sudo
```

```bash
$ visudo
```

```
...

deploy ALL=(ALL) NOPASSWD: ALL
 ```
 
## Next.js用のUnitファイル
 
```bash
$ vim /lib/systemd/system/nextjs@.service
```
 
```
[Unit]
Description=NodeJS server, NextJS frontend
After=network.target

[Service]
Type=simple
Restart=on-failure
RestartSec=10
EnvironmentFile=/etc/default/%i
WorkingDirectory=/home/deploy/%i/
ExecStartPre=/usr/bin/yarn --production
ExecStartPre=/usr/bin/yarn build
ExecStart=/usr/bin/yarn start -- -p ${PORT}

[Install]
WantedBy=multi-user.target
```

```bash
$ systemctl daemon-reload
```

# Next.jsアプリケーション

事前にGitLabのリポジトリとGitLab Runner(shell executor)を登録しておく

※リポジトリはpublicにする必要がある。

ここからローカルで作業

```bash
$ npm install -g yarn npx
$ npx create-next-app@latest next-app --typescript
$ cd next-app
$ git remote add origin <gitlab_remote_url>
$ git push origin HEAD
```

# .gitlab-ci.yml

Next.jsからMySQLへのアクセスとJWTトークンの操作をするとして環境変数を用意しています。

```yml=.gitlab-ci.yml
variables:
  SOURCE_URL: "$CI_PROJECT_URL/-/archive/$CI_COMMIT_REF_NAME/$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME.tar.gz"

  DEPLOY_HOST: "$DEPLOY_HOST"
  DEPLOY_HOST_SSH_USER: "$DEPLOY_HOST_SSH_USER"
  DEPLOY_HOST_SSH_PORT: "$DEPLOY_HOST_SSH_PORT"
  SSH_PRIVATE_KEY: "$SSH_PRIVATE_KEY"
  DEPLOY_PORT: "$DEPLOY_PORT"
  MYSQL_HOST: "$MYSQL_HOST"
  MYSQL_PORT: "$MYSQL_PORT"
  MYSQL_USERNAME: "$MYSQL_USERNAME"
  MYSQL_PASSWORD: "$MYSQL_PASSWORD"
  JWT_SECRET: "$JWT_SECRET"

  BETA_DEPLOY_HOST: "$BETA_DEPLOY_HOST"
  BETA_DEPLOY_HOST_SSH_USER: "$BETA_DEPLOY_HOST_SSH_USER"
  BETA_DEPLOY_HOST_SSH_PORT: "$BETA_DEPLOY_HOST_SSH_PORT"
  BETA_SSH_PRIVATE_KEY: "$BETA_SSH_PRIVATE_KEY"
  BETA_DEPLOY_PORT: "$BETA_DEPLOY_PORT"
  BETA_MYSQL_HOST: "$BETA_MYSQL_HOST"
  BETA_MYSQL_PORT: "$BETA_MYSQL_PORT"
  BETA_MYSQL_USERNAME: "$BETA_MYSQL_USERNAME"
  BETA_MYSQL_PASSWORD: "$BETA_MYSQL_PASSWORD"
  BETA_JWT_SECRET: "$BETA_JWT_SECRET"

stages: # List of stages for jobs, and their order of execution
  - build
  - test
  - deploy

build-job:
  stage: build
  before_script:
    - sudo apt install -y nodejs
    - npm install -g yarn
    - yarn
  script:
    - echo 'MYSQL_PASSWORD=$MYSQL_PASSWORD\nJWT_SECRET=$JWT_SECRET' > ./.env.local
    - yarn build

# unit-test-job:   # This job runs in the test stage.
#   stage: test    # It only starts when the job in the build stage completes successfully.
#   script:
#     - echo "Running unit tests."

lint-test-job:
  stage: test
  before_script:
    - sudo apt install -y nodejs
    - npm install -g yarn
    - yarn
  script:
    - yarn lint

beta-deploy-job:
  stage: deploy
  only:
    - release
  before_script:
    - sudo apt install -y openssh-client
    - eval "$(ssh-agent -s)"
    - echo "$BETA_SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo -e "CI_PROJECT_NAME=$CI_PROJECT_NAME\nSOURCE_URL=$SOURCE_URL\nCI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME\nDEPLOY_PORT=$BETA_DEPLOY_PORT\nMYSQL_HOST=$BETA_MYSQL_HOST\nMYSQL_PORT=$BETA_MYSQL_PORT\nMYSQL_USERNAME=$BETA_MYSQL_USERNAME\nMYSQL_PASSWORD=$BETA_MYSQL_PASSWORD\nJWT_SECRET=$BETA_JWT_SECRET" > deploy_env
    - scp -P "$BETA_DEPLOY_HOST_SSH_PORT" deploy_env "$BETA_DEPLOY_HOST_SSH_USER"@"$BETA_DEPLOY_HOST":~/.ssh/environment.beta
    - >
      ssh "$BETA_DEPLOY_HOST_SSH_USER"@"$BETA_DEPLOY_HOST" -p "$BETA_DEPLOY_HOST_SSH_PORT" 'export $(cat ~/.ssh/environment.beta | grep -v ^#) &&
      sudo systemctl stop nextjs@"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".service &&
      sudo rm -r "$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME"* 2> /dev/null || true'
  script:
    - >
      ssh "$BETA_DEPLOY_HOST_SSH_USER"@"$BETA_DEPLOY_HOST" -p "$BETA_DEPLOY_HOST_SSH_PORT" 'export $(cat ~/.ssh/environment.beta | grep -v ^#) &&
        curl -LO "$SOURCE_URL" &&
        tar xf "$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".tar.gz &&
        echo 'MYSQL_HOST=$MYSQL_HOST\nMYSQL_PORT=$MYSQL_PORT\nMYSQL_USERNAME=$MYSQL_USERNAME\nMYSQL_PASSWORD=$MYSQL_PASSWORD\nJWT_SECRET=$JWT_SECRET' > ./"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME"/.env.local &&
        echo 'PORT=$DEPLOY_PORT' > /etc/default/"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME" &&
        sudo systemctl start nextjs@"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".service'

deploy-job: # This job runs in the deploy stage.
  stage: deploy # It only runs when *both* jobs in the test stage complete successfully.
  only:
    - main
  before_script:
    - sudo apt install -y openssh-client
    - eval "$(ssh-agent -s)"
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo -e "CI_PROJECT_NAME=$CI_PROJECT_NAME\nSOURCE_URL=$SOURCE_URL\nCI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME\nDEPLOY_PORT=$DEPLOY_PORT\nMYSQL_HOST=$MYSQL_HOST\nMYSQL_PORT=$MYSQL_PORT\nMYSQL_USERNAME=$MYSQL_USERNAME\nMYSQL_PASSWORD=$MYSQL_PASSWORD\nJWT_SECRET=$JWT_SECRET" > deploy_env
    - scp -P "$DEPLOY_HOST_SSH_PORT" deploy_env "$DEPLOY_HOST_SSH_USER"@"$DEPLOY_HOST":~/.ssh/environment
    - >
      ssh "$DEPLOY_HOST_SSH_USER"@"$DEPLOY_HOST" -p "$DEPLOY_HOST_SSH_PORT" 'export $(cat ~/.ssh/environment | grep -v ^#) &&
      sudo systemctl stop nextjs@"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".service &&
      sudo rm -r "$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME"* 2> /dev/null || true'
  script:
    - >
      ssh "$DEPLOY_HOST_SSH_USER"@"$DEPLOY_HOST" -p "$DEPLOY_HOST_SSH_PORT" 'export $(cat ~/.ssh/environment | grep -v ^#) &&
        curl -LO "$SOURCE_URL" &&
        tar xf "$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".tar.gz &&
        echo 'MYSQL_HOST=$MYSQL_HOST\nMYSQL_PORT=$MYSQL_PORT\nMYSQL_USERNAME=$MYSQL_USERNAME\nMYSQL_PASSWORD=$MYSQL_PASSWORD\nJWT_SECRET=$JWT_SECRET' > ./"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME"/.env.local &&
        echo 'PORT=$BETA_DEPLOY_PORT' > /etc/default/"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME" &&
        sudo systemctl start nextjs@"$CI_PROJECT_NAME"-"$CI_COMMIT_REF_NAME".service'
```

## GitLabのリポジトリに設定する変数

- `main`用 (本番環境)
  - DEPLOY_HOST
  - DEPLOY_HOST_SSH_USER
  - DEPLOY_HOST_SSH_PORT
  - SSH_PRIVATE_KEY
  - DEPLOY_PORT
  - MYSQL_HOST
  - MYSQL_PORT
  - MYSQL_USERNAME
  - MYSQL_PASSWORD
  - JWT_SECRET
- `release`用 (β版用)
  - BETA_DEPLOY_HOST
  - BETA_DEPLOY_HOST_SSH_USER
  - BETA_DEPLOY_HOST_SSH_PORT
  - BETA_SSH_PRIVATE_KEY
  - BETA_DEPLOY_PORT
  - BETA_MYSQL_HOST
  - BETA_MYSQL_PORT
  - BETA_MYSQL_USERNAME
  - BETA_MYSQL_PASSWORD
  - BETA_JWT_SECRET

## deployステージで行っていること

- デプロイ用のホストで利用する環境変数のファイル作成・scpでの送信
- デプロイ用のホストにSSH接続
- リポジトリからソースをダウンロード
- Next.js内で利用する環境変数(DBのホスト・接続ユーザー情報等)用のファイルを作成 (`.env.local`)
- Next.jsをビルド
- Next.jsに割り当てるポートを環境変数として指定 (`/etc/default/<project_name>-<branch-name>`)
- systemdでNext.jsをデーモンで起動

今回の構成ではgit-flowでの開発を想定して`release`ブランチをβ版、`main`ブランチを本番環境としてそれぞれブランチが更新されたときに自動デプロイするように作成しています。

# あとがき

## 今考えている改善点

- curlでソースコードをDLしている部分をgitlab-runnerからscpで渡すようにすればリポジトリをprivateにしても動作しそう。
- Ansibleを介してデプロイさせたら綺麗にできそう。(gitlab-runnerからansibleを実行する。)
- デプロイ先のサーバーがリバースプロキシを介して公開している場合のnginxのコンフィグ切り替えもできたら良さそう。
- デプロイ用のサーバのAisibleのplaybookを用意すればちょっとは使えそう。(コンテナを使わない場合は特に)
- gitlab-runnerもAnsibleにまとめてしまえばもっと良くなる？

## 注意点

- 自動デプロイを実行するブランチをGitLabのリポジトリ設定でプロテクトしておかないと事故る可能性がある。
- GitLab上で登録する環境変数をProtectedとかMaskedにして環境変数を見れる範囲を制限しないと危険。
  - sshキーとかシークレットが覗けてしまう。

## 参考

- [systemd の Unit定義ファイル で 変数 を使用する方法](https://qiita.com/yasushi-jp/items/97057509e96919c35f64)
- [Next.js Environment Variables](https://nextjs.org/docs/basic-features/environment-variables)
- [The .gitlab-ci.yml file](https://docs.gitlab.com/ee/ci/yaml/gitlab_ci_yaml.html)
- [GitLab CI/CD variables](https://docs.gitlab.com/ee/ci/variables/)