👋

Container-Optimized OS に Nx で作成したWEBアプリを自動デプロイ

2022/07/18に公開

概要

ユーザーが数人でデータ量も少ない NX アプリケーションを簡単に安くデプロイ・運用したい。Container-Optimized OS で簡単にデプロイできそうだったので試した。

  • DB と nginx は VM 上に Docker で用意
  • https 化は Let's Encrypt を利用
  • Github に push して自動デプロイ

前提

  • Container-Optimized OS バージョン : cos-stable-97-16919-103-10
  • VM の SSH 鍵を用意しておく

アプリケーション構成

アプリケーションは 2 つ。

  • api : NestJS
  • web : Next.js

メールアドレスによるユーザー登録、アイテムの登録・更新・削除・一覧表示・アイテム名検索だけのシンプルなアプリケーション。mui を使っているので多少重い。

api, web の project.json の build 設定 generatePackageJson を true にしておく。

apps/
  api/
  web/
docker/
  api/Dockerfile
  web/Dockerfile
  db/Dockefile, my.cnf
docker-compose.prod.yml

Dockefile

docker/api/Dockerfile
FROM node:16-slim
WORKDIR /app
COPY dist/apps/api .
ENV PORT=3333
EXPOSE ${PORT}
RUN yarn install
RUN yarn add mysql2
CMD node ./main.js
docker/web/Dockerfile
FROM node:16-slim
WORKDIR /app
COPY dist/apps/web .
ENV PORT=4200
EXPOSE ${PORT}
RUN yarn install
CMD yarn start
docker/db/Dockerfile
FROM mysql:8
COPY my.cnf /etc/mysql/conf.d/my.cnf
RUN chmod 644 /etc/mysql/conf.d/my.cnf

nginx の設定と https 化

nginx で api と web を振り分ける(ドメインは同じ。/ を web に、/api を api に振り分け)。Root53 を使用していたので certbot/dns-route53 (docs) によりSSL証明書作成。

/var/www/data/nginx/conf.d/default.conf
server {
    listen 80;
    listen 443 ssl;

    server_name example.com;
    ssl_certificate     /etc/nginx/certs/example.com/fullchain1.pem;
    ssl_certificate_key /etc/nginx/certs/example.com/privkey1.pem;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location /api {
        proxy_pass http://api:3333;
    }

    location / {
        proxy_pass http://web:4200;
    }
}

VM のディレクトリ構成

後述の github action で app/ に dist や Dockerfile を転送。

/var/www/
  app/
  data/
    db/db-prod/*
    nginx/
      certs/example.com/fullchain1.pem, privkey1.pem
      conf.d/default.conf
  docker-compose.prod.yml

Github Action

  1. api, web をそれぞれビルド
    • web は apps/web/.env で NEXT_PUBLIC_* を定義しているため env を指定
      NEXT_PUBLIC_WEB_URL=$WEB_URL
      NEXT_PUBLIC_API_URL=$API_URL
      
  2. 必要なファイルを VM に転送
  3. VM に SSH で入って docker-compose up する
    • .env.prod は事前に VM 上に設置しておく
.github/workflows/deploy.yml
name: deploy-prod

on:
  push:
    branches: [ deploy ]

jobs:
  deploy:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
      - uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          cache-name: cache-node-modules
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-
      - if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        run: yarn install
      - run: yarn nx run api:build:production
      - run: yarn nx run web:build:production
        env:
          WEB_URL: https://example.com
          API_URL: https://example.com
      - name: copy file via ssh key
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          source: "dist/*,docker/*,docker-compose.prod.yml"
          target: "/var/www/app"
          rm: true
      - uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          script_stop: true
          script: |
            cd /var/www
            mv app/docker-compose.prod.yml ./
            docker run --rm \
              -v /var/run/docker.sock:/var/run/docker.sock \
              -v "$(pwd):$(pwd)" \
              -w "$(pwd)" \
              docker/compose:1.29.2 --env-file .env.prod -f docker-compose.prod.yml up -d --build

docker-compose.prod.yml

docker-compose.prod.yml
version: "3"
services:
  api:
    build:
      context: app
      dockerfile: docker/api/Dockerfile
    container_name: api-prod
    env_file:
      - .env.prod
  web:
    build:
      context: app
      dockerfile: docker/web/Dockerfile
    container_name: web-prod
    env_file:
      - .env.prod
  db:
    build: app/docker/db
    container_name: db-prod
    ports:
      - ${DB_PORT}:3306
    volumes:
      - ./data/db-prod:/var/lib/mysql
    command: "--general_log=0 --slow_query_log=0"
    cap_add:
      - SYS_NICE
    environment:
      MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD
      MYSQL_DATABASE: $MYSQL_DATABASE
      MYSQL_PASSWORD: $MYSQL_PASSWORD
      MYSQL_USER: $MYSQL_USER
      TZ: 'Asia/Tokyo'
  nginx:
    image: nginx:1.23.0-alpine
    container_name: nginx-prod
    ports:
      - "443:443"
    volumes:
      - ./data/nginx/certs:/etc/nginx/certs
      - ./data/nginx/conf.d:/etc/nginx/conf.d
    depends_on:
      - api
      - web

まとめ

e2-micro, e2-small だと Dockerfile のビルド時に CPU 使用率が 100% を軽く超えてしまった。
ブートディスクの容量が 10GB でも実際に使える容量は 5.8GB 程度しかなく、シンプルなアプリケーションなのに no space left on device になってしまった。

e2-medium, 20~30GB 程度にすれば問題なさそうだが、Cloud Build + Artifact Registry でやった方がビルドも速く、価格も安くなるかもしれない(ビルドした後、docker save & 転送 & docker load すれば Artifact Registry 不要でより安く済むかも)。

e2-medium, 20GB の月間予測:

余談

Console 上で容量増やしたあと、sudo /usr/share/cloud/resize-stateful を叩くだけでパーティションを拡大できたが、ドキュメントにこの方法の記載がなさそう。
https://stackoverflow.com/questions/57997607/how-to-increase-root-disk-partition-using-containeros-within-google-cloud


追記 2022-07-24

Cloud Build を構成し、ローカル環境でビルド実行した後、VM 上で pull && docker-compose up することに。
(そもそも個人開発で滅多にリリースもしないので、とりあえず自動でなくていい)

api と web のイメージは合わせて 2GB 弱あるので、Artifact Registry のイメージはリリース後に手動で削除(0.5GBまで無料、$0.10/GB/月)。
古いイメージが残るのでほかっておくと容量がどんどん増えてしまう。
Artifact Registry の料金

Cloud Build のビルドは15~19分程度(デフォルトのマシンタイプで、$0.003/ビルド分。1ヶ月に20分*10回=200分使っても、$0.6≒81円)。
マシンタイプを上げれば速度改善するぽいけど1ついいのにすると約5倍 の $0.016 という価格に。
Cloud Build の料金
Increase vCPU for builds

ドキュメントの Best practices for speeding up builds を試したけれど、イメージのキャッシュ、node_modules のキャッシュともに速度改善しなかった。

ビルドを Cloud Build に移したので VM は e2-small から e2-micro に変更。ストレージは 20 GB のまま。空き 13 GB もあるからもっと少なくてよかったかも。

以下手順。1~3 の自動化が出来ればいいのだけれど難しそう。ビルドはトリガーで出来ても、VM 上でコマンド叩くやり方がよくわからなかった。

  1. ローカル環境でビルド実行:
gcloud builds submit --config cloudbuild.yaml
  1. VM 上で叩くコマンド:
docker pull asia-northeast1-docker.pkg.dev/PROJECT_ID/REPO_NAME/api && \
  docker pull asia-northeast1-docker.pkg.dev/PROJECT_ID/REPO_NAME/web && \
  docker image prune -f && \
  cd /var/www && \
  docker-compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
  1. Artifact Registry のイメージを削除(latest 含め全部削除):
gcloud artifacts docker images delete asia-northeast1-docker.pkg.dev/PROJECT_ID/REPO_NAME/api --quiet
gcloud artifacts docker images delete asia-northeast1-docker.pkg.dev/PROJECT_ID/REPO_NAME/web --quiet

Cloud Build 構成ファイル:

cloudbuild.yaml
steps:
  - name: node
    entrypoint: yarn
    args: [ 'install' ]
  - name: node
    entrypoint: yarn
    args: [ 'nx', 'run', 'api:build:production' ]
  - name: node
    entrypoint: yarn
    args: [ 'nx', 'run', 'web:build:production' ]
    env:
      - 'WEB_URL=https://example.com'
      - 'API_URL=https://example.com'
  - name: 'gcr.io/cloud-builders/docker'
    args: [ 'build', '-f', './docker/api/Dockerfile', '.',  '-t', 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/REPO_NAME/api' ]
  - name: 'gcr.io/cloud-builders/docker'
    args: [ 'build', '-f', './docker/web/Dockerfile', '.',  '-t', 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/REPO_NAME/web' ]
images:
  - 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/REPO_NAME/api'
  - 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/REPO_NAME/web'
timeout: 1200s

Discussion