Hisakeyのブログ

エンジニアが色々呟くブログです。

さくらVPSへのCD環境を作成してみた~~バックエンド~~

はじめに

さくらのVPSで動かしているバックエンド(Docker)を、GitHub Actions の手動トリガー(workflow_dispatch)でデプロイできるCD環境にしました。

コンテナイメージは GHCR(GitHub Container Registry) に push、VPS には SSH + Docker Compose で反映します。

前回のフロントエンド記事に続き、バックエンドもCD構築できたので、構成と手順をまとめます。

環境

モノレポ構成

Go + Gin

フロントエンドは別途CD構築済

背景・動機

「一から最小構成でCDを組みたい」という動機で、フロントエンド/バックエンドともにさくらVPSへデプロイできる仕組みを用意しました。

前回はフロントエンドをCD化 → デプロイまで確認。今回は同じ思想でバックエンドをGitHub ActionsでCD化しています。

実例・やってみたこと

実際に作成したworkflowは以下になります。必要に応じてプロジェクト名・レジストリ名など置き換えてください)

name: Deploy Backend (manual, GHCR)

on:
  workflow_dispatch:
    inputs:
      ref:
        description: 'Git ref to build (branch/tag/SHA)'
        default: 'main'
        required: true
      image_tag:
        description: 'Image tag to deploy (empty = current commit SHA)'
        default: ''
        required: false
      dry_run:
        description: 'dry run(no pull/up)'
        type: boolean
        default: false

env:
  IMAGE_NAME: ghcr.io/${{ secrets.GHCR_USERNAME }}/device-platform-backend

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ secrets.GHCR_USERNAME }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Compute tag
        id: tag
        run: |
          t="${{ inputs.image_tag }}"
          [ -z "$t" ] && t="${GITHUB_SHA}"
          echo "value=$t" >> $GITHUB_OUTPUT

      - name: Build & push backend image
        uses: docker/build-push-action@v6
        with:
          context: ./backend
          file: ./backend/Dockerfile.prod
          push: true
          platforms: linux/amd64
          tags: |
            ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.value }}
            ${{ env.IMAGE_NAME }}:latest

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    steps:
      - name: Start ssh-agent (load deploy key)
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.VPS_SSH_KEY }}

      # ====== (deploy用) リポジトリをチェックアウト:SCPで配布するため ======
      - name: Checkout (for deploy)
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}
          sparse-checkout: |
            backend/compose.prod.yml
          sparse-checkout-cone-mode: false

      # ====== compose.prod.yml をVPSへ毎回配布 ======
      - name: Push compose to VPS
        if: ${{ !inputs.dry_run }}
        run: |
          set -euo pipefail
          scp -o StrictHostKeyChecking=accept-new backend/compose.prod.yml \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/compose.prod.yml.new
          ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
            set -euo pipefail
            mkdir -p /opt/myapp/backend
            if [ -f /opt/myapp/backend/compose.prod.yml ]; then
              cp /opt/myapp/backend/compose.prod.yml /opt/myapp/backend/compose.prod.yml.bak.$(date +%Y%m%d%H%M%S)
            fi
            mv /tmp/compose.prod.yml.new /opt/myapp/backend/compose.prod.yml
          '

      #        # ===== Dry-run(計画表示のみ・サーバ状態は変更しない) =====
      - name: Plan (dry-run)
        if: ${{ inputs.dry_run }}
        env:
          TAG: ${{ inputs.image_tag || github.sha }}
        run: |
          set -Eeuo pipefail
          ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
            set -Eeuo pipefail
            cd /opt/myapp/backend

            echo "[plan] services:"; docker compose -f compose.prod.yml config --services

            echo "[plan] image to use:"
            IMAGE_TAG='"$TAG"' docker compose -f compose.prod.yml config \
              | awk "/image:/ {print \"  \" \$2}" | sort -u

            echo "[plan] verify GHCR manifest"
            echo "***" | docker login ghcr.io -u "'"${{ secrets.GHCR_USERNAME }}"'" --password-stdin >/dev/null 2>&1 || true
            if docker manifest inspect "'"${{ env.IMAGE_NAME }}:$TAG"'" >/dev/null 2>&1; then
              echo "[ok] exists: $TAG"
            else
              echo "[error] not found: "'"${{ env.IMAGE_NAME }}:$TAG"'" ; exit 1
            fi

            echo "[plan] current app container:"
            docker compose -f compose.prod.yml ps app || true

            echo "[plan] would run:"
            echo "  IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml run --rm migrator"
            echo "  IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml pull app"
            echo "  IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml up -d app"
          '

      # ===== 実デプロイ =====
      - name: Deploy on VPS (migrate, pull & up)
        if: ${{ !inputs.dry_run }}
        env:
          TAG: ${{ inputs.image_tag || github.sha }}
        run: |
          set -euo pipefail
          ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
            set -Eeuo pipefail
            cd /opt/myapp/backend

            # compose に migrator があるか
            if ! docker compose -f compose.prod.yml config --services | grep -qx migrator; then
              echo "[ERROR] migrator service not found in compose.prod.yml"; exit 1
            fi

            # GHCR login (pull用)
            echo "'"${{ secrets.GHCR_TOKEN }}"'" | docker login ghcr.io -u "'"${{ secrets.GHCR_USERNAME }}"'" --password-stdin

            # 1) マイグレーション(冪等)
            IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml run --rm migrator

            # 2) イメージpull(appのみ)
            IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml pull app

            # 3) 切替
            IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml up -d app
          '

      - name: Readiness check
        if: ${{ !inputs.dry_run }}
        run: |
          set -e
          for i in $(seq 1 30); do
            if ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
              "curl -fsS http://127.0.0.1:3000/api/v1/health | grep -qi '^ok$'"; then
              echo "ready"; exit 0
            fi
            sleep 2
          done
          echo "App not ready after deploy"
          ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
            "cd /opt/myapp/backend && docker compose -f compose.prod.yml logs --tail=120 app || true"
          exit 1

compose.ymlは以下になります。(実際のファイル名は、workflowに合わせています)

name: device_platform
services:
  app:
    image: ghcr.io/tomitahisaki/device-platform-backend:${IMAGE_TAG:-stable}
    env_file: [ ./.env.prod ]
    environment:
      APP_ENV: production
      DB_URL: postgres://${DB_USER}:${DB_PASSWORD}@db:${DB_PORT:-5432}/${DB_NAME}?sslmode=disable
    ports: ["127.0.0.1:3000:3000"]
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16
    env_file: [ ./.env.prod ]
    volumes:
      - pgdata:/var/lib/postgresql/data
    expose: ["5432"] 
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${DB_USER:-postgres} -h 127.0.0.1 -p $${DB_PORT:-5432} || exit 1"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  migrator:
    # app と同じイメージ(中に /app/migrations と migrate バイナリが入っている)
    image: ghcr.io/tomitahisaki/device-platform-backend:${IMAGE_TAG:-stable}
    depends_on:
      db:
        condition: service_started
    env_file: [ ./.env.prod ]
    # 実行コマンドをcommandにおくと、配列として読み取られ、エラーになるためentrypointに追記
    entrypoint:
      - /bin/sh
      - -ceu
      - |-
        DB_URL="postgres://$${DB_USER}:$${DB_PASSWORD}@db:$${DB_PORT:-5432}/$${DB_NAME}?sslmode=disable"
        echo "[migrator] which migrate: $(command -v migrate)"
        echo "[migrator] list /app/migrations:"; ls -1 /app/migrations
        exec migrate -verbose -path=/app/migrations -database "$$DB_URL" up

volumes:
  pgdata:

コードを貼り付けても、一体何をしているか分かりづらいので、順を追って行きたいと思います。

build-and-pushでやっていること

  • GHCRにログイン
    • ログインするために、usernameとtokenが必要です。
    • usernameは、GitHubの名前
    • tokenは、GHCRにログインするためのパスワード。Personal Access Tokensで設定
      • package: write(pushするため), package: read(サーバーでpullするため)
  • QEMU/Buildxセットアップして、異なるアーキテクチャ(例:amd64/arm64)をビルド可能にする土台づくり
  • タグ設定 image_tag 未指定なら GITHUB_SHA を採用
  • ./backend の Dockerfile.prod でビルド
  • IMAGE_NAME: と :latest を GHCR に push

用語説明 * GHCRとは、GitHub Container Registryです。GitHubが提供するコンテナイメージの保管する場となります。ghcr.io/ユーザー/リポジトリになる。 * QEMUとは、CPUエミュレータ。Buildxと合わせて、ホストと異なるアーキテクチャのイメージをビルド可能します。 * docker/build-push-actionとは、GitHub ActionsでDockerビルド&レジストリpushを行えるアクション。

deployでやっていること

  • ssh-agentでVPSへ接続
  • compose.ymlをVPSに配布
  • deployする
    • ssh接続
    • IMAGE_TAG指定で、migratorの実行
    • appのイメージを新タグで取得し、コンテナ切り替え

この構成できをつけること

  • .env.prod はVPSに配置(Git管理しない)

compose.prod.yml から読み込まれるため、/opt/myapp/backend/.env.prod を用意(権限は 600 推奨)

  • VPSからGHCRにpullできる状態に

ログイン状態の破損でハマりやすい。いったんログアウト→再ログインが手早いです。(結構、理解に時間がかかりました...)

docker logout ghcr.io || true

echo "<GHCR_PAT>" | docker login ghcr.io -u <YOUR_GH_USER> --password-stdin

学び・気づき

まずは、どのようにCDを作るべきかを迷っていましたが、このような方法があることを知らなかったことが学びです。

  • イメージを作って、配布する→VPS側でイメージをpullして、起動する

Go + gin + golang-migrateを使用しているのですが、DBとの接続に苦戦してしまい、今回の趣旨とは異なるところで沼ってしまいました。(Railsならサクッと終わらせられる自信が何処かにある)

まとめ

GHCR を使った最小構成のCDで、さくらVPSへ安全にデプロイできるようになりました。

手間はかかりましたが、GitHub Actions がグリーンになる瞬間はやっぱり嬉しいですね。

どなたかの参考になれば幸いです。最後までお読みいただきありがとうございました。

参考リンク