GHCRでCI/CDの中途半端なプロジェクトを再構成してみた

個人開発で使っているNext.js + Express + React Viteの構成、デプロイまわりがかなり場当たり的になっていたので、GHCRを軸にCI/CDを整理し直すことにした。進捗あれば随時追記していく。

目次

そもそもDockerイメージビルド(GHCR)を使用したデプロイとは

Dockerイメージは、ランタイム(Node.jsなど)、依存パッケージ(node_modules)、ソースコード、ビルド成果物(.nextやdistなど)を1つのファイルにまとめたもの。このイメージさえあれば、どの環境でも同じようにアプリが動く。

GHCRを使ったデプロイでは、GitHub Actionsでこのイメージをビルドし、ghcr.io(GitHubのコンテナレジストリ)にpushする。Lightsail側ではそのイメージをpullしてdocker compose up -dするだけでアプリが起動する。本番サーバー上でのビルドが一切不要になるため、サーバーリソースの消費やビルド失敗のリスクがなくなる。

これまでの構成

ローカル環境

Gitについては、下記の4リポジトリ

  • express-mysql-docker(親)— docker-compose, initdb, db/.envなどインフラ設定のみ。子リポジトリはgitignoreで除外
  • backend(子)— Express API
  • next-basic(子)— Next.js SSR ユーザー側フロントエンド
  • vite-react-0206(子)— React Vite 管理画面

Next.jsはnpm run devで直接起動、React Viteも同様に直接起動している。BackendとMySQLだけdocker-composeで動かしていた。つまりローカルではDockerに寄せきれておらず、フロント系は素で動かしている状態。

Lightsail(本番)

/var/www/app/にリポジトリをクローンし、docker-compose.prod.ymlでbackend・Next.js・MySQLをDockerコンテナとして動かしている。React Vite(管理画面)はビルド済みの静的ファイルを/var/www/app/backend/public/に配置し、ホスト側のNginxから配信。Nginxはリバースプロキシとして各サービスの前段に立っている。

何が問題だったか As-Is → To-Be

Next.jsの二重ビルド問題

GitHub Actionsでビルド → scpでファイル転送 → Lightsail上でさらにDocker再ビルド、という流れになっていた。せっかくCIでビルドしているのに、本番側でもう一度ビルドが走る無駄な構成。

またLightsail上でビルドするフローのため、AWSコンソール画面でメトリクスでバーストキャパシティの枯渇を確認しました、、Next.jsのビルド中にメモリ不足で502エラーが発生しており、デプロイが不安定な状態です。

Next.jsの環境変数について

NEXT_PUBLIC_API_BASE_URLはGitHub Secretsにあったが、Next.jsのapi.tsではprocess.env.INTERNAL_API_URL(SSR用、docker-compose内部通信)とprocess.env.NEXT_PUBLIC_API_BASE_URL(クライアント用)の2つを使っている。INTERNAL_API_URLはdocker-compose.prod.ymlのenvironmentで渡すのでGHCR化しても問題ない。NEXT_PUBLIC_系はビルド時にインライン化されるのでDockerビルド時にARGで渡す必要がある。

Backend:Lightsailでビルドが走る

Lightsail上でgit pullしてからdocker buildしている。ビルドがサーバー上で走るので、CI/CDとしては中途半端。サーバーのリソースも食うし、ビルドの再現性も担保しにくい。

React Vite:同じくサーバー上でビルド

Lightsail上でgit pullnpm run build。これも本番サーバーでビルドしているので同じ問題。

共通しているのは「ビルドをどこでやるか」が統一されていないこと。GitHub Actionsがあるのに活かしきれていなかった。

現状はViteのvite.config.tsoutDir: '../backend/public'としてbackendリポジトリにビルド出力しているが、新構成ではadminは独立したnginxコンテナになるので、このbuild出力先は変更が必要。ホスト側Nginxの/admin/のルーティングも、staticファイルのaliasからコンテナへのproxy_passに変わる。

3つのリポジトリは別々?それとも1つのモノレポ?

As-Is

親リポジトリはnext-basic/, vite-react-0206/, backend/をgitignoreしていて、それぞれが独立したリポジトリ。

親リポジトリにはdocker-compose.yml, initdb/, db/.envなどの共通設定だけがある。

To-Be 推奨する構成変更

親リポジトリ(express-mysql-docker)は不要にできる。各リポジトリが自分のDockerイメージをGHCRにpushし、Lightsail側にはdocker-compose.prod.ymlとNginx設定だけ置く。

[GitHub]
  backend repo      → ghcr.io/あなた/backend:latest
  next-basic repo   → ghcr.io/あなた/nextjs:latest
  vite-react repo   → ghcr.io/あなた/admin:latest (nginx+静的ファイル)

[Lightsail]
  /var/www/app/
    docker-compose.prod.yml   ← これだけ管理
    initdb/                   ← 初期化SQL(既存のまま)
    db/.env                   ← MySQL認証情報
    backend/.env              ← Backend認証情報
  
  Nginx (ホスト側) → 今とほぼ同じ
  
  docker compose pull && docker compose up -d  ← デプロイはこれだけ

GHCRを使った新しい構成

方針はシンプルで、GitHub Actionsでイメージをビルドしてghcr.ioにpush → Lightsailではpullしてdocker compose up -dするだけにする。

GitHub Actions(ビルド & push)
  → ghcr.io/<user>/backend:latest
  → ghcr.io/<user>/nextjs:latest
  → ghcr.io/<user>/admin:latest(nginx + 静的ファイル)

Lightsail(pull & run)
  docker compose -f docker-compose.prod.yml up -d
  + Nginx(ホスト側、既存のまま)

これでLightsail上ではビルドが一切走らなくなる。デプロイはdocker compose pull && docker compose up -dだけ。

┌─────────────────────────────────────────────────────────┐
│  ローカルPC                                              │
│                                                         │
│  ┌──────────────┐  ┌──────────────┐                     │
│  │  Next.js     │  │  React Vite  │                     │
│  │  npm run dev │  │  npm run dev │                     │
│  │  :3000       │  │  :5173       │                     │
│  └──────┬───────┘  └──────┬───────┘                     │
│         │                 │                             │
│         │  http://localhost:8888/api                    │
│         │                 │                             │
│  ┌──────┴─────────────────┴──────────────────────────┐  │
│  │  docker-compose.yml                               │  │
│  │                                                   │  │
│  │  ┌──────────────┐      ┌──────────────┐           │  │
│  │  │  Backend     │      │  MySQL       │           │  │
│  │  │  Express     ├──────┤  :3306       │           │  │
│  │  │  :8888       │      │              │           │  │
│  │  └──────────────┘      └──────────────┘           │  │
│  └───────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│  Lightsail                                                      │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  Nginx (ホスト)                                            │  │
│  │  :443 (SSL) / :80 → 301 redirect                         │  │
│  │                                                           │  │
│  │  /           → proxy_pass http://localhost:3000 (nextjs)  │  │
│  │  /api/       → proxy_pass http://localhost:8888 (backend) │  │
│  │  /admin/     → proxy_pass http://localhost:8080 (admin)   │  │
│  │  /uploads/   → alias /var/www/app/backend/uploads/        │  │
│  └───────────────────────────────────────────────────────────┘  │
│         │              │              │                          │
│  ┌──────┴──────────────┴──────────────┴───────────────────────┐ │
│  │  docker-compose.prod.yml                                   │ │
│  │                                                            │ │
│  │  ┌────────────┐ ┌────────────┐ ┌────────┐ ┌────────────┐  │ │
│  │  │  nextjs    │ │  backend   │ │  admin  │ │  mysql     │  │ │
│  │  │  :3000     │ │  :8888     │ │  :8080  │ │  :3306     │  │ │
│  │  │            │ │            │ │  nginx  │ │            │  │ │
│  │  │  ghcr.io/  │ │  ghcr.io/  │ │ ghcr.io/│ │  mysql:    │  │ │
│  │  │  idw-coder/│ │  idw-coder/│ │ idw-    │ │  8.4.0     │  │ │
│  │  │  nextjs    │ │  backend   │ │ coder/  │ │            │  │ │
│  │  │            │ │            │ │ admin   │ │            │  │ │
│  │  └────────────┘ └─────┬──────┘ └────────┘ └─────┬──────┘  │ │
│  │                       │                          │         │ │
│  │                       └──────────────────────────┘         │ │
│  │                         mysql:3306 (内部通信)               │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  volumes:                                                       │
│    mysql_data (既存データ維持)                                    │
│    /var/www/app/backend/uploads/ (バインドマウント)                │
└─────────────────────────────────────────────────────────────────┘
┌──────────────┐     ┌──────────────────────┐     ┌──────────────┐
│  GitHub      │     │  GitHub Actions      │     │  Lightsail   │
│              │     │                      │     │              │
│  git push    ├────►│  1. docker build     ├────►│  3. docker   │
│  to main     │     │  2. push to ghcr.io  │     │     compose  │
│              │     │                      │ SSH │     pull     │
│              │     │                      ├────►│  4. docker   │
│              │     │                      │     │     compose  │
│              │     │                      │     │     up -d    │
└──────────────┘     └──────────────────────┘     └──────────────┘

各リポジトリが独立してこのフローを持つ:
  backend      → ghcr.io/idw-coder/backend:latest
  next-basic   → ghcr.io/idw-coder/nextjs:latest
  vite-react   → ghcr.io/idw-coder/admin:latest
GitHub
├── backend           (Express API)
│   ├── Dockerfile
│   └── .github/workflows/deploy.yml
│
├── next-basic        (Next.js ユーザー向けフロント)
│   ├── Dockerfile
│   └── .github/workflows/deploy.yml
│
├── vite-react-0206   (React Vite 管理画面)
│   ├── Dockerfile
│   ├── nginx.conf
│   └── .github/workflows/deploy.yml
│
└── express-mysql-docker (インフラ設定のみ ※デプロイには使わない)
    ├── docker-compose.yml          (ローカル開発用)
    ├── docker-compose.prod.yml     (本番用 → Lightsailに配置)
    ├── initdb/
    └── db/.env

参考

https://github.com/growilabs/growi-unique-ogp/blob/master/.github/workflows/release.yml

GHCR以降手順

GitHub リポジトリの設定

作業場所
パッケージ公開権限の確認リポジトリ Settings → Actions → General → Workflow permissions
packages: write 権限の付与↑ 「Read and write permissions」にチェック

GITHUB_TOKEN に packages: write 権限がないと、GHCRへのpushが失敗します。deploy.yml 内で permissions を明示的に書く方法もあります

PAT(Personal Access Token)の作成

  • read:packages(イメージのpullに必要)

Lightsail インスタンス上での初回セットアップ

echo "<PAT>" | docker login ghcr.io -u <GitHubユーザー名> --password-stdin

成功すると Login Succeeded と表示されます。

この作業は初回のみで、ログイン情報は ~/.docker/config.json に保存されるため、以降のデプロイでは不要です。

CI/CD関連ファイルを編集しプッシュ

next.jsのディレクトリ/.github/workflows/deploy.yml

name: Deploy Next.js to Lightsail via GHCR

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          build-args: |
            NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
            NEXT_PUBLIC_ADSENSE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_ADSENSE_CLIENT_ID }}
            NEXT_PUBLIC_ADSENSE_SLOT=${{ secrets.NEXT_PUBLIC_ADSENSE_SLOT }}
            NEXT_PUBLIC_GOOGLE_AUTH_URL=${{ secrets.NEXT_PUBLIC_GOOGLE_AUTH_URL }}

      - name: Deploy to Lightsail
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.LIGHTSAIL_HOST }}
          username: ${{ secrets.LIGHTSAIL_USER }}
          key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
          script: |
            cd /var/www/app
            docker compose -f docker-compose.prod.yml pull nextjs
            docker compose -f docker-compose.prod.yml up -d nextjs
            docker image prune -f

/docker-compose.prod.yml

name: Deploy Next.js to Lightsail via GHCR

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          build-args: |
            NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
            NEXT_PUBLIC_ADSENSE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_ADSENSE_CLIENT_ID }}
            NEXT_PUBLIC_ADSENSE_SLOT=${{ secrets.NEXT_PUBLIC_ADSENSE_SLOT }}
            NEXT_PUBLIC_GOOGLE_AUTH_URL=${{ secrets.NEXT_PUBLIC_GOOGLE_AUTH_URL }}

      - name: Deploy to Lightsail
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.LIGHTSAIL_HOST }}
          username: ${{ secrets.LIGHTSAIL_USER }}
          key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
          script: |
            cd /var/www/app
            docker compose -f docker-compose.prod.yml pull nextjs
            docker compose -f docker-compose.prod.yml up -d nextjs
            docker image prune -f

lightsail上のNext.jsのディレクトリはGit管理していましたが、もはやGHCRからイメージをpullしてコンテナ起動なので不要になりましたので削除

rm -rf /var/www/app/next.jsのディレクトリ

MySQLのデータ移行

既存のdocker volumeをそのまま使えば問題ないはず。コンテナを差し替えてもvolumeは維持される。

今後の予定

全体のステップ一覧

  1. backend — Dockerfile修正 + GitHub Actions workflow作成 既存のDockerfileはマルチステージでdev/prodを分けているが、GHCR用には本番ビルドだけのシンプルなものに書き換える。GitHub Actionsでイメージをビルドしてghcr.ioにpush、その後SSHでLightsailに入ってdocker compose pull backend && up -dするワークフローを作る。backendにTypeScriptのビルドステップがある前提で進めるので、もし純JSなら教えてほしい。
  2. next-basic — Dockerfile修正 + GitHub Actions workflow作成 既存のDockerfileも同様にprod専用に書き換える。NEXT_PUBLIC_系の環境変数はビルド時にインライン化されるので、DockerfileでARGとして受け取りGitHub Actionsのbuild-argsで渡す構成にする。INTERNAL_API_URLは実行時にdocker-composeのenvironmentから渡すので変更不要。
  3. vite-react-0206 — Dockerfile新規作成 + vite.config.ts修正 + GitHub Actions workflow作成 node:alpineでビルドしてnginx:alpineに静的ファイルをコピーする2ステージDockerfileを新規作成する。vite.config.tsoutDir: '../backend/public'はコンテナ内では意味がないのでdistに変更する。admin画面は/admin/パスで配信するのでnginx設定ファイルも同梱する。
  4. Lightsail側 docker-compose.prod.yml — GHCR pull構成に書き換え 現在はbuild:でローカルビルドしているが、image: ghcr.io/idw-coder/xxx:latestに置き換えてpullするだけの構成にする。adminコンテナを新たに追加し、MySQLとbackendは既存のvolume・env_file設定をそのまま維持する。Next.jsのINTERNAL_API_URL=http://backend:8888もenvironmentに残す。
  5. Lightsail側 Nginx設定 — /admin/のルーティング変更 現在はalias /var/www/app/backend/public/でホスト上の静的ファイルを直接配信しているが、adminがコンテナになるのでproxy_passに変更する。adminコンテナは適当なポート(例: 8080)を公開し、Nginxからproxy_pass http://localhost:8080/admin/で転送する。他のlocation(/api/, /, /uploads/)は変更なし。
  6. Lightsail側でGHCRへのログイン設定 Lightsailからghcr.ioのプライベートイメージをpullするために、GitHub Personal Access Token(read:packages権限)を作成してサーバー上でdocker login ghcr.ioを一度実行する。もしくはリポジトリをpublicにすればログイン不要。認証情報は~/.docker/config.jsonに保存されるので一度やればOK。
  7. 動作確認・切り替え 既存のコンテナをdocker compose downで停止し(volumeは削除しない)、新しいdocker-compose.prod.ymlでdocker compose up -dする。MySQLのvolumeはmysql_dataという名前付きvolumeなので、composeファイルで同じ名前を使えばデータはそのまま引き継がれる。URL・パス構成が変わっていないことを確認して完了。

1〜3は独立して進められる。4と5はまとめてやれる。6は1コマンド。7は最後に一気にやる。

構成が固まり次第、GitHub Actionsのワークフロー作成から着手する。進捗があれば追記予定。


最終更新:2025年2月

目次