メインコンテンツへスキップ
  1. Posts/

GCP Docker デプロイ & 運用ガイド

NineKoo9
著者
NineKoo9
目次

チームプロジェクトでGCP VMにDocker Composeでサービスをデプロイする中で経験したトラブルと解決過程を、Claudeと共にまとめた記事です。 公式ドキュメントでは見落としやすい、初心者がはまりやすいポイント(Debian/Ubuntuの混同、ECDSA証明書の互換性問題、ディスク容量不足など)を中心に記録しています。


1. 目標
#

GCP仮想サーバー(VM)1台にDB・バックエンド・フロントエンド・NginxをDocker Composeでまとめて起動し、 外部からHTTPSでアクセスできるようにします。iOSモバイルアプリ開発者も同じバックエンドを使用します。


2. 最終構成図
#

ブラウザ / iOSアプリ (https://ainiinu.kr)
┌──────────────────────────────────────┐
│ GCP VM (Debian 12 bookworm)          │
│                                      │
│  Docker Compose                      │
│  ┌────────────────────────────────┐  │
│  │ nginx (:80/:443 外部公開)      │  │
│  │  ├─ /            → frontend   │  │
│  │  ├─ /api/v1/*    → backend    │  │
│  │  └─ /ws/*        → backend    │  │
│  ├────────────────────────────────┤  │
│  │ frontend (:3000 内部のみ)      │  │
│  ├────────────────────────────────┤  │
│  │ backend  (:8080 内部のみ)      │  │
│  ├────────────────────────────────┤  │
│  │ postgres (:5432 内部のみ)      │  │
│  └────────────────────────────────┘  │
│                                      │
│  ホストにインストール:                │
│  - certbot (SSL証明書の発行/更新)    │
│  - /etc/letsencrypt → nginxにマウント│
└──────────────────────────────────────┘
  • 外部にはNginxの80/443ポートのみを公開
  • その他のサービスはDockerの内部ネットワークでのみ通信
  • NginxがリバースプロキシとSSLターミネーションを担当
  • SSL証明書はホストでcertbotを使って直接管理(Docker certbotコンテナは使用しない)

3. 事前準備
#

3.1 GCP Compute Engineインスタンスの作成
#

項目内容
GCP VMCompute Engineインスタンスを作成
OSDebian 12 (bookworm) — GCPデフォルトイメージ
ディスク最小20GB以上を推奨(デフォルトの10GBはDockerビルド時に容量不足)
固定IP外部IPを「静的外部IP」に昇格(VM再起動してもIP維持)
SSHアクセスGCPコンソールの「SSH」ボタンをクリック(ブラウザからすぐターミナルが開く)

ディスクサイズを必ず20GB以上に設定しましょう!

GCPのデフォルトディスクは10GBですが、Dockerイメージのビルド時に以下の容量が必要です:

  • Dockerエンジン + イメージレイヤーキャッシュ: ~2GB
  • バックエンドビルド(Gradle + JDK + 依存関係): ~3GB
  • フロントエンドビルド(Node.js + node_modules): ~2GB
  • PostgreSQLデータ: ~500MB
  • OS + システム: ~2GB

10GBではビルド中に no space left on device エラーが発生します。 インスタンス作成時に20〜30GBに設定すると安心です。

既に作成したインスタンスのディスクを拡張する方法は10.5節を参照。

3.2 ドメインのDNS設定
#

ドメイン購入後、GCP VMの外部IPにDNS Aレコードを紐付けます。 DNSプロバイダーはどこでも構いません(Route 53、Cloudflare、お名前.comなど)。

ainiinu.kr → 34.47.72.28 (GCP VM外部IP)

確認方法(VMで):

sudo apt install -y dnsutils   # nslookupがなければインストール
nslookup ainiinu.kr            # ドメインがどのIPに解決されるか確認
curl -s ifconfig.me            # VMの外部IPを確認

nslookupDNS検索ツールです。
簡単に言えば「このドメイン名が今どのIPアドレスを指しているか?」をDNSサーバーに問い合わせるコマンドです。

このコマンドが重要な理由は、ドメイン設定がまだVMに反映されていないのに、サーバー設定の問題だと勘違いしやすいからです。
たとえばNginxの設定・ファイアウォール・certbotのコマンドがすべて正常でも、DNS AレコードがまだIPを指していなければ、外部アクセスやSSL発行は失敗します。

nslookup ainiinu.krを実行したとき、下部のAddressの値が自分のGCP VMの外部IPと一致している必要があります。

Server:         169.254.169.254
Address:        169.254.169.254#53

Non-authoritative answer:
Name:   ainiinu.kr
Address: 34.47.72.28
  • 上のServer / Addressは検索に使ったDNSサーバーの情報
  • 下のName / Addressが実際にドメインが解決された結果

つまりこの段階では、下の結果のIPだけ確認すれば大丈夫です。
この値がVMの外部IPと異なる場合は、DNSレコードが間違っているか、まだ伝播(propagation)が完了していない状態です。

3.3 GCPファイアウォールの設定
#

これを忘れると外部からアクセスできず、SSL証明書の発行も失敗します!

GCPコンソール → VPCネットワーク → ファイアウォール → ファイアウォールルールを作成:

項目
名前allow-http-https
タイプ上り(Ingress)
ターゲットすべてのインスタンス
送信元IPアドレス範囲0.0.0.0/0
プロトコル/ポートTCP: 80, 443
アクション許可
優先度1000

デフォルトで存在するdefault-allow-internalはGCP内部通信用なので外部アクセスには関係ありません。 default-allow-ssh(TCP 22)はSSHアクセス用なので触らないでください。

3.4 プライベートOrganizationリポジトリのクローン
#

OrganizationのプライベートリポジトリはふつうのGit cloneでは失敗します。GitHub Personal Access Token (PAT) が必要です。

トークン生成手順:

  1. GitHub → 右上プロフィール → Settings
  2. Developer settingsPersonal access tokensTokens (classic)
  3. Generate new token (classic) をクリック
  4. Select scopesrepo にチェック — これは「このトークンがプライベートリポジトリにアクセスできる権限」を意味します
  5. 生成後、ghp_xxxx...形式のトークンをコピー(この画面を離れると再確認できません!)

repoスコープとは? GitHubトークンは実行できる操作の範囲(scope)を指定します。 repoをチェックするとプライベートリポジトリへの読み書き権限が付与されます。 cloneだけするならこれ一つにチェックすれば十分です。

Organization権限の付与(重要!):

Personalトークンを作成しても、Organizationのリポジトリにはすぐにアクセスできません。 トークン作成後、追加の承認が必要です:

  1. GitHub → Settings → Developer settings → Personal access tokens
  2. 作成したトークンをクリック
  3. 下部の Organization access セクションで該当orgの横の “Grant” または “Authorize” をクリック

Grantボタンが表示されない場合は、Organization設定でPATアクセスを許可する必要があります:

  • GitHub → Organizationページ → SettingsThird-party accessPersonal access tokens → ポリシーを “Allow” に変更

cloneコマンド:

git clone https://ghp_xxxx@github.com/<org>/<repo>.git

4. Docker & Docker Composeのインストール
#

4.1 GCP VMのOS確認
#

GCP Compute EngineのデフォルトイメージはubuntuではなくDebianです! これを知らないとDockerのインストールではまります。

lsb_release -cs
# → bookworm (Debian 12)

lsb_releaseとは? Linux Standard Base release。OSディストリビューション情報を確認するコマンドです。 -cはコードネーム(codename)、-sは短い出力(short)。bookwormが出てくればDebian 12です。

4.2 Dockerインストール時の注意点
#

よくあるミスとエラー:

試みた方法結果原因
sudo apt install docker.ioDockerはインストールされるがdocker composeコマンドがないdocker.ioはDebian/Ubuntuのデフォルトパッケージで、composeプラグインが含まれない
sudo apt install docker-compose-pluginUnable to locate packageDocker公式リポジトリが登録されていない状態
Ubuntu用Dockerリポジトリを追加後にインストールPackage 'docker-ce' has no installation candidateGCP VMがUbuntuではなくDebianなのにUbuntuのリポジトリを追加してしまった

Docker Desktopはインストール不可 — GCP VMはGUIのないサーバーなので、Docker Desktop(Mac/Windows デスクトップ用)は使用できません。

docker.iodocker-ceの違い:

  • docker.io — Debian/Ubuntuの公式パッケージリポジトリから提供されるDocker。バージョンが古くcomposeプラグインが含まれない。
  • docker-ce — Docker公式リポジトリから提供されるCommunity Edition。最新版 + composeプラグイン付き。
  • 結論: 常にdocker-ceを使いましょう。

4.3 正しいインストール方法(Debian 12 bookworm)
#

# 1. 必須ツールのインストール
sudo apt update
sudo apt install -y ca-certificates curl gnupg

# 2. Docker公式GPGキーの追加
#    GPGキー = パッケージが本当にDockerが作ったものか検証する署名キー
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# 3. Debian用Dockerリポジトリを追加(Ubuntuではないことに注意!)
#    この行がaptに「DockerのパッケージはこのURLからダウンロードしろ」と伝える
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 4. Docker + Composeプラグインのインストール
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# 5. 現在のユーザーにdocker実行権限を付与
#    これをしないと毎回 sudo docker ... で実行しなければならない
sudo usermod -aG docker $USER

# 6. SSH再接続(権限の適用 — 再接続しないとグループ変更が反映されない)
exit
# GCPコンソールから再度SSH接続

# 7. インストール確認
docker --version          # Dockerバージョンを表示
docker compose version    # Docker Composeバージョンを表示

各パッケージの役割:

  • docker-ce — Dockerエンジン(コンテナ実行の核心)
  • docker-ce-clidockerコマンド(CLIツール)
  • containerd.io — コンテナランタイム(Docker内部で実際にコンテナを管理)
  • docker-buildx-plugindocker buildxコマンド(マルチプラットフォームイメージビルドに対応)
  • docker-compose-plugindocker composeコマンド(複数コンテナを一括管理)

4.4 AWS EC2基準(参考)
#

Ubuntu基準:

# Docker公式リポジトリを追加後インストール(docker.ioではなくdocker-ceを使用)
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# SSH再接続後に適用

Amazon Linux 2023基準:

sudo yum update -y
sudo yum install -y docker
sudo systemctl enable --now docker
sudo usermod -aG docker $USER

# Composeプラグインのインストール
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# SSH再接続後に適用

注意: docker-compose(ハイフン、v1)は2023年7月にEOLとなりました。 現在はdocker compose(スペース、v2プラグイン)を使う必要があります。 Ubuntuでもdocker.io + docker-composeの代わりに、Docker公式リポジトリのdocker-ce + docker-compose-pluginをインストールしましょう。

Dockerインストール以降の全工程(git clone、docker compose up、Nginx、SSLなど)はGCP/AWS共通です。


5. プロジェクトで作成したファイル
#

5.1 フロントエンドDockerfile
#

aini-inu-frontend/Dockerfile:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_PUBLIC_ENABLE_MSW=false
ENV NEXT_PUBLIC_API_PROXY_TARGET=http://backend:8080
ENV NEXT_PUBLIC_WS_URL=ws://backend:8080

RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

マルチステージビルドとは? 一つのDockerfile内で複数のステージ(FROM)を経るビルド方式です。 ビルドに必要なツール(node_modules全体、ビルドツールなど)はbuilderステージだけで使い、 最終イメージ(runner)には実行に必要な最小限のものだけコピーします。 結果: イメージサイズが数GB → 数百MBに削減されます。

npm cinpm installの違い:

  • npm installpackage.jsonを基準に依存関係を解決し、package-lock.jsonを修正することがある
  • npm cipackage-lock.json正確に従います。より速く再現性が高い。CI/Docker環境に適しています。

standaloneビルドの注意点: Next.jsのoutput: 'standalone'はサーバーコードのみを含みます。 .next/static(JS/CSSバンドル)とpublic(静的ファイル)は自動で含まれないため、必ず別途COPYする必要があります。 これを忘れるとページは表示されるがCSSがなかったり画像が表示されなかったりします。

5.2 バックエンドDockerfile
#

aini-inu-backend/Dockerfile:

FROM gradle:8.14.3-jdk21 AS builder

WORKDIR /workspace
COPY . .
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:21-jre

WORKDIR /app
COPY --from=builder /workspace/build/libs/*.jar /app/app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "/app/app.jar"]

バックエンドもマルチステージビルドを使用しています。

builderステージ — Gradle + JDK 21全体を含むイメージ(gradle:8.14.3-jdk21)でbootJarを実行します。 bootJarはSpring Bootアプリを一つの実行可能な.jarファイルにパッケージングするGradleタスクです。 --no-daemonはGradleデーモンプロセスを起動しないオプションです。Dockerビルドのような一回限りの実行ではデーモンが不要なのでメモリを節約できます。

runnerステージeclipse-temurin:21-jreはJava実行環境(JRE)のみを含む軽量イメージです。 builderで作った.jarファイル一つだけコピーしてくるので、Gradle・ソースコード・依存関係キャッシュなどのビルドツールは最終イメージに含まれません。 結果: ビルドイメージ(~2.5GB)→ 実行イメージ(~300MB)に軽量化されます。

-Duser.timezone=Asia/Seoul — JVMのデフォルトタイムゾーンを日本時間に合わせる場合はAsia/Tokyoに変更してください。 これを設定しないとコンテナ内がUTC(+0時間)で動作し、DB保存時刻・ログ時刻がずれます。

5.3 フロントエンド .dockerignore
#

aini-inu-frontend/.dockerignore:

node_modules
.next
.env*
.git
.gitignore
.claude
*.md
npm-debug.log*
.DS_Store

.dockerignoreとは? Dockerビルド時のコンテキスト(ビルドに送るファイル群)から除外するパターンを指定します。 .gitignoreと同じ文法です。node_modulesを除外するとビルドコンテキストの送信が数秒 → 数ミリ秒に短縮されます。

5.4 Nginx設定ファイル
#

nginx/nginx.conf:

# Rate Limiting: IPあたりの毎秒リクエスト数を制限
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ws:10m rate=5r/s;

# --- HTTP → HTTPSリダイレクト ---
server {
    listen 80;
    server_name ${DOMAIN};

    # Let's Encrypt証明書発行/更新用
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# --- HTTPSメインサーバー ---
server {
    listen 443 ssl;
    server_name ${DOMAIN};

    # SSL証明書(ホストの/etc/letsencryptをマウント)
    ssl_certificate     /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;

    # SSLセキュリティ設定
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # 共通プロキシヘッダー
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # Swagger UI → backend(APIドキュメント)
    location /swagger-ui/ {
        proxy_pass http://backend:8080;
    }
    location /v3/api-docs {
        proxy_pass http://backend:8080;
    }

    # REST API → backend
    location /api/v1/ {
        limit_req zone=api burst=20 nodelay;
        proxy_pass http://backend:8080;
        client_max_body_size 10m;   # 画像アップロード用
    }

    # WebSocket → backend
    location /ws/ {
        limit_req zone=ws burst=10 nodelay;
        proxy_pass http://backend:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;   # WebSocket接続の維持
        proxy_send_timeout 3600s;
    }

    # その他すべて → frontend
    location / {
        proxy_pass http://frontend:3000;
    }
}

Nginx設定の解説:

limit_req_zone — 悪意のあるリクエスト攻撃への防御。IPごとに毎秒許可するリクエスト数を制限します。

  • $binary_remote_addr — クライアントIPアドレス(バイナリ形式、メモリ効率が良い)
  • zone=api:10m — “api"という名前の10MB共有メモリ領域(約16万IPを追跡可能)
  • rate=10r/s — 毎秒10リクエストを許可
  • burst=20 — 瞬間的に20個まで許可(超過時は429 Too Many Requests)
  • nodelay — burst範囲内のリクエストは遅延なしで即時処理

proxy_pass — リクエストを他のサーバーに転送(リバースプロキシの核心)。 http://backend:8080backendはDocker Composeのサービス名 = Docker内部DNSで自動解決されます。

WebSocketプロキシの必須ヘッダー:

  • proxy_http_version 1.1 — WebSocketはHTTP/1.1が必須
  • Upgrade: websocket — 「この接続をWebSocketにアップグレードして」
  • Connection: upgrade — 「接続を維持しながらプロトコルを変更して」
  • この3行がないとWebSocket接続がすぐに切れます。

proxy_read_timeout 3600s — Nginxはデフォルト60秒間応答がないと接続を切ります。 WebSocketは長時間維持される接続なので1時間(3600秒)に延長しています。

client_max_body_size 10m — リクエストbodyの最大サイズ。デフォルトは1MBなので 画像アップロード時に413 Request Entity Too Largeエラーが出ます。10MBに設定しています。

${DOMAIN}環境変数の置換: nginx.confを/etc/nginx/templates/default.conf.templateとしてマウントすると NginxのDockerイメージが起動時に${DOMAIN}のような環境変数を自動置換してくれます。 おかげで設定ファイルにドメインをハードコードしなくて済みます。

5.5 ルートdocker-compose.yml
#

services:
  postgres:
    image: pgvector/pgvector:pg16
    container_name: aini-inu-postgres
    restart: unless-stopped
    environment:
      TZ: Asia/Seoul
      POSTGRES_DB: ${POSTGRES_DB:-ainiinu}
      POSTGRES_USER: ${POSTGRES_USER:-ainiinu}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu}
    volumes:
      - aini_inu_postgres_data:/var/lib/postgresql/data
      - ./aini-inu-backend/docker/postgres/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ainiinu} -d ${POSTGRES_DB:-ainiinu}"]
      interval: 5s
      timeout: 5s
      retries: 30

  backend:
    build:
      context: ./aini-inu-backend
      dockerfile: Dockerfile
    container_name: aini-inu-backend
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    env_file:
      - .env.docker
    environment:
      TZ: Asia/Seoul
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-ainiinu}
      SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-ainiinu}
      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu}
      COMMUNITY_STORAGE_LOCAL_BASE_DIR: /app/var/uploads
      COMMUNITY_STORAGE_PUBLIC_BASE_URL: https://${DOMAIN}
    volumes:
      - aini_inu_backend_uploads:/app/var/uploads

  frontend:
    build:
      context: ./aini-inu-frontend
      dockerfile: Dockerfile
    container_name: aini-inu-frontend
    restart: unless-stopped
    depends_on:
      - backend

  nginx:
    image: nginx:alpine
    container_name: aini-inu-nginx
    restart: unless-stopped
    depends_on:
      - frontend
      - backend
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - certbot_webroot:/var/www/certbot:ro
    environment:
      DOMAIN: ${DOMAIN}

volumes:
  aini_inu_postgres_data:
  aini_inu_backend_uploads:
  certbot_webroot:

docker-compose.ymlの用語解説:

image: pgvector/pgvector:pg16 — Docker Hubからイメージをダウンロードして使用。 build: context: ./aini-inu-backend — 該当ディレクトリのDockerfileで直接ビルド。

restart: unless-stopped — コンテナが死んだら自動再起動。ただしdocker compose stopで手動停止した場合は再起動しません。

depends_on + condition: service_healthy — postgresがhealthcheckを通過してからbackendが起動します。 これがないとDBがまだ準備できていないのにbackendが先に起動して接続失敗します。

env_file: .env.docker — このファイルの環境変数をコンテナ内に注入します。 注意: docker compose自体が読む.envとは別物です。.envはcomposeファイル内の${変数}置換用、env_fileはコンテナ内部の環境変数注入用です。

${POSTGRES_DB:-ainiinu}.envファイルにPOSTGRES_DBがあればその値、なければデフォルト値のainiinuを使用。

volumes(サービスレベル):

  • aini_inu_postgres_data:/var/lib/postgresql/data — DBデータをDockerボリュームに永続保存。 コンテナを削除してもデータは維持されます。
  • ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro — ホストのファイルをコンテナ内に読み取り専用(:ro)でマウント。
  • /etc/letsencrypt:/etc/letsencrypt:ro — ホストのSSL証明書ディレクトリをnginxにマウント。

ports: "80:80" — ホストの80ポート → コンテナの80ポートにマッピング。外部からアクセス可能になります。 portsがないサービス(postgres、backend、frontend)はDocker内部からのみアクセス可能です。

volumes(最下部、トップレベル): ここで宣言されたボリュームはDockerが管理する永続ストレージです。 docker compose downしても維持されます。docker compose down -vで削除されます(注意!)。

5.6 環境変数ファイル
#

.env.docker.example(コミット対象 — テンプレート):

# ドメイン
DOMAIN=your-domain.com

# DB(Docker内部通信用)
POSTGRES_DB=ainiinu
POSTGRES_USER=ainiinu
POSTGRES_PASSWORD=change-me-in-production

# JWT
JWT_SECRET=your-super-secret-key-must-be-at-least-32-characters-long-for-hs256-algorithm

# Gemini AI
GEMINI_API_KEY=
GEMINI_EMBEDDING_MODEL=gemini-embedding-001

# Lost Pet AI(任意)
LOSTPET_AI_VECTOR_TOP_K=50
LOSTPET_SEARCH_SESSION_TTL_HOURS=24
LOSTPET_SEARCH_TOP_N=20

# PgVector(任意)
SPRING_AI_PGVECTOR_INITIALIZE_SCHEMA=false
SPRING_AI_PGVECTOR_TABLE_NAME=lostpet_vector_store
SPRING_AI_PGVECTOR_SCHEMA_NAME=public
SPRING_AI_PGVECTOR_DIMENSIONS=768
SPRING_AI_PGVECTOR_DISTANCE_TYPE=COSINE_DISTANCE
SPRING_AI_PGVECTOR_INDEX_TYPE=HNSW

# Community Storage(任意)
COMMUNITY_STORAGE_PRESIGNED_EXPIRES_SECONDS=300

重要: .env.docker.env.gitignoreに追加して、絶対にコミットしないようにしましょう。

.env.env.dockerenv_fileの関係:

ファイル誰が読む用途
.envdocker compose CLIcomposeファイル内の${変数}置換(例: ${DOMAIN}${POSTGRES_DB}
.env.dockerbackendコンテナenv_file:ディレクティブでコンテナ内部に環境変数を注入(JWT_SECRET、GEMINI_API_KEYなど)

このプロジェクトでは両者の内容が同じなのでcp .env.docker .envでコピーします。 .envを作らないとWARN: The "DOMAIN" variable is not setという警告が発生します。


6. VMでデプロイする(実践の順序)
#

6.1 リポジトリのクローン
#

git clone https://ghp_xxxx@github.com/<org>/aini-inu-monorepo.git
cd aini-inu-monorepo

6.2 環境変数の設定
#

cp .env.docker.example .env.docker
nano .env.docker    # 実際の値で編集(DOMAIN、JWT_SECRET、GEMINI_API_KEYなど)
cp .env.docker .env # docker composeが.envをデフォルトで読むのでコピーが必要

nanoエディタのショートカットは12.2節を参照。

6.3 SSL証明書の発行(初回のみ)
#

必ずdocker compose upの前に実行してください!(80ポートが空いている必要があります)

certbotLet’s Encrypt証明書を発行・更新するACMEクライアントツールです。
簡単に言えば、HTTPSに必要なSSL証明書を自動で作成し、期限前に更新まで処理してくれるプログラムです。

ここではcertbotがLet’s Encryptサーバーと通信しながら 「このドメインを本当にあなたが管理していますか?」を検証した後、/etc/letsencrypt/以下に証明書と秘密鍵を保存します。
その後nginxがこのファイルを読み取ってhttps://ドメインへのアクセスを処理します。

この記事でDocker certbotコンテナの代わりにホストに直接インストールする理由はシンプルです。

  • 設定がより直感的でデバッグしやすい
  • /etc/letsencryptパスをそのままnginxにマウントして使いやすい
  • standaloneモードで実行するときの80ポート占有問題を把握しやすい
# 1. certbotのインストール
sudo apt install -y certbot

# 2. 全コンテナの停止(80ポートを確保)
docker compose down

# 3. 証明書の発行(必ず --key-type rsa を含めること!)
#    certbot 2.0.0からデフォルトのキータイプがECDSA(secp256r1)に変更されました。
#    特定のOpenSSLバージョン + cipher組み合わせで互換性問題が発生する可能性があるため、
#    互換性問題が出た場合はRSAに切り替えると解決します。(10.7節参照)
sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --key-type rsa -m your@email.com

certbotのオプション説明:

  • certonly — 証明書の発行のみ(Webサーバーの設定は自動変更しない)
  • --standalone — certbotが一時的なWebサーバーを直接起動してLet’s Encryptの検証を実行
  • -d ainiinu.kr — 証明書を発行するドメイン
  • --agree-tos — Let’s Encryptの利用規約に自動同意
  • -m — 証明書の有効期限通知を受け取るメールアドレス

成功すると:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/ainiinu.kr/fullchain.pem
Key is saved at:          /etc/letsencrypt/live/ainiinu.kr/privkey.pem
This certificate expires on 2026-06-07.

SSL証明書の発行ができない場合のチェックリスト:

症状原因解決
certbotコマンド自体がCreatingで止まるDocker certbotコンテナ方式の問題ホストに直接sudo apt install certbot後、standaloneモードを使用
certbotが無限待機80ポートをnginxが占有しているdocker compose downで全コンテナ停止後に再試行
Let’s Encrypt検証失敗GCPファイアウォールで80ポートが未許可VPCファイアウォールにTCP 80、443インバウンドルールを追加
DNS検証失敗ドメインがVMのIPを指していないnslookup ainiinu.krの結果がVMの外部IPと一致するか確認

6.4 全サービスの起動
#

docker compose up -d --build

オプション説明:

  • -d(detached)— バックグラウンドで実行。これがないとターミナルにログが出続け、ターミナルを閉じるとサービスも終了します。
  • --build — イメージを再ビルド。コード変更後は必要。変更がなければキャッシュを使うので速いです。

初回のビルドは時間がかかります(バックエンドのGradleビルド + フロントエンドのnpm build)。

6.5 状態の確認
#

# サービスの状態確認 — 4つすべてUpなら成功
docker compose ps

# 問題があればログを確認
docker compose logs -f --tail=50

# 特定サービスのログだけ見る
docker compose logs -f backend
docker compose logs -f nginx
docker compose logs -f frontend

ログオプションの説明:

  • -f(follow)— リアルタイムで新しいログが出続けます。tail -fと同じ概念。
  • --tail=50 — 最近50行から表示。これがないと最初から全部出力されてスクロール爆発。
  • ログを見て抜けるにはCtrl+C
  • 長いログが画面に収まらないとき: スクロールではなくless形式のページャーが出ることがあります → qを押して抜けます

6.6 アクセスの確認
#

https://ainiinu.kr                      → フロントエンド(Webページ)
https://ainiinu.kr/api/v1/              → バックエンドAPI
https://ainiinu.kr/swagger-ui/index.html → Swagger UI(APIドキュメント)
wss://ainiinu.kr/ws/                    → WebSocket

ポート番号なしでドメインだけでアクセス可能(nginxが80/443の標準ポートを使用)。

Swagger UIが表示されない場合: Nginxのルーティングルールを追加する必要があります。詳細は10.6節を参照。

なぜポート番号が不要なのか? HTTPのデフォルトポートは80、HTTPSのデフォルトポートは443です。 ブラウザはhttps://ainiinu.krhttps://ainiinu.kr:443に自動解釈します。 nginxが443ポートを占有しているのでポート指定なしで直接アクセス可能です。

6.7 証明書の自動更新
#

certbotをsudo apt installでインストールするとsystemdタイマーで自動更新が設定されます。

注意: standaloneモードで発行したため、更新時にも80ポートが必要です。 nginxが80を占有していると自動更新が失敗します。

解決方法1 — renewal hookの設定(推奨): certbotが更新前後に自動でnginxを停止/起動するようにhookを設定します。

# 更新設定ファイルにhookを追加
sudo nano /etc/letsencrypt/renewal/ainiinu.kr.conf
# [renewalparams]セクションの下に以下の2行を追加:
pre_hook = docker compose -f /home/<user>/aini-inu-monorepo/docker-compose.yml stop nginx
post_hook = docker compose -f /home/<user>/aini-inu-monorepo/docker-compose.yml start nginx

解決方法2 — webroot方式に切り替える: nginxを止めずに更新できます。nginx.confに既に/.well-known/acme-challenge/パスが設定されているので:

sudo certbot certonly --webroot -w /var/lib/docker/volumes/<project>_certbot_webroot/_data -d ainiinu.kr --force-renewal --key-type rsa

手動更新が必要な場合:

# nginx停止 → 更新 → nginx再起動
docker compose stop nginx
sudo certbot renew
docker compose start nginx

Let’s Encrypt証明書は90日有効。certbotは期限30日前から自動更新を試みます。


7. ソースコード確認結果
#

7.1 CORS — 問題なし
#

  • WebConfig.java: setAllowedOriginPatterns("*") + allowCredentials(true)
  • SecurityConfig.java: Securityフィルターレベルでも同じCORS設定を適用
  • WebSocketConfig.java: WebSocketもsetAllowedOriginPatterns("*")
  • Docker/GCPデプロイでドメインが変わってもコード修正不要

7.2 WebSocket(チャット)— 問題なし
#

  • STOMPエンドポイント: /ws/chat-rooms/{roomId}
  • 認証: STOMP CONNECT時にAuthorization: Bearer <JWT>ヘッダーで処理
  • メッセージブローカー: インメモリ(/topic/queue
  • イベント発行: /topic/chat-rooms/{roomId}/events
  • フロントエンド: NEXT_PUBLIC_WS_URL環境変数でWebSocket URLを決定
  • プロトコル自動検知: HTTPS → wss://、HTTP → ws://
  • 失敗時のHTTPポーリングフォールバックが実装済み
  • Nginxで/ws/*をプロキシする際にupgradeヘッダーだけ設定すれば動作します

7.3 画像アップロード/表示 — 問題なし(注意事項あり)
#

動作方式:

  1. フロントエンド → POST /api/v1/images/presigned-url(presigned URL要求)
  2. フロントエンド → PUT /api/v1/images/presigned-upload/{token}(ファイルアップロード)
  3. DBに相対パスを保存: /api/v1/images/local?key=community/post/...
  4. 画像取得: GET /api/v1/images/local?key=...(認証不要、@Public

Docker環境:

  • application.propertiesのデフォルト値: ../common-docs/storage(ローカル開発用相対パス)
  • Docker composeでCOMMUNITY_STORAGE_LOCAL_BASE_DIR=/app/var/uploadsにオーバーライド
  • Dockerボリュームで永続化 — 正しく処理されています

注意: 画像URLが相対パス

  • バックエンドが/api/v1/images/local?key=...形式の相対パスを返す
  • Web: Next.js rewrites → Nginxを通じて正常動作
  • iOS: ベースURL(https://ainiinu.kr)を先頭に付けて組み合わせる必要あり

8. iOSモバイルアプリ開発者連携ガイド
#

アクセス方式
#

iOSアプリ → https://ainiinu.kr/api/v1/... → Nginx → backend
iOSアプリ → wss://ainiinu.kr/ws/...       → Nginx → backend

Nginxが/api/v1/*/ws/*をバックエンドにルーティングするので、 iOSアプリはWebフロントエンドと同じドメインの同じパスでAPIを呼び出せます。

考慮事項
#

項目内容
HTTPS必須iOS ATS仕様でHTTPはブロック(HTTPSデプロイなので問題なし)
CORS不要ネイティブアプリはCORS制約なし
APIドキュメント(Swagger)https://ainiinu.kr/swagger-ui/index.html — ブラウザで直接確認可能
APIドキュメント(JSON)https://ainiinu.kr/v3/api-docs — OpenAPI JSONスペックをダウンロード
APIドキュメント(ファイル)common-docs/openapi/openapi.v1.json — リポジトリ内のスナップショット
認証JWTトークン発行/更新フローを同様に実装
画像URLバックエンドレスポンスの相対パスにベースURLを組み合わせる必要あり
WebSocketwss://ainiinu.kr/ws/chat-rooms/{roomId} + STOMP + JWTヘッダー
プッシュ通知必要になったらバックエンドにAPNs連携を追加

9. デバッグガイド
#

9.1 502 Bad Gateway
#

ブラウザで502 Bad Gatewayが出る = Nginxがバックエンド/フロントエンドに接続できていない状態です。

デバッグの順序:

# 1. 全サービスの状態確認
docker compose ps
# → STATUSが"Up"ではないサービスがあればそれが原因

# 2. 死んでいるサービスのログを確認
docker compose logs --tail=100 backend   # またはfrontend

# 3. リアルタイムログを見ながらリクエストを再試行
docker compose logs -f backend
# → 別のブラウザタブで会員登録などのリクエストを試みる
# → ログにエラーが出ているか確認

# 4. バックエンドログに何も出ていなければnginxの問題
docker compose logs --tail=50 nginx

実際に経験した事例:

バックエンドがApplication run failedで起動失敗 → nginxがbackend:8080に接続できない → 502を返す。 原因: 削除されたSQLシードファイル(10_core_sample_seed.sql)をapplication.propertiesでまだ参照していた。 解決: spring.sql.init.data-locationsから該当ファイルへの参照を削除。

9.2 コンテナが再起動し続けるとき
#

# 再起動回数の確認
docker compose ps
# → RESTARTSカラムが増え続けていたら起動失敗 + 自動再起動を繰り返している

# 最近のログからエラーを探す
docker compose logs --tail=200 backend | grep -i "error\|exception\|failed"

grep -iとは? 大文字小文字を区別せず(-i = ignore case)パターン検索します。 |(パイプ)= 前のコマンドの出力を後のコマンドの入力に渡します。 \| = grepでのOR条件(“error"または"exception"または"failed”)。

9.3 特定サービスだけ再ビルド/再起動
#

# バックエンドだけ再ビルド + 再起動(他のサービスは触らない)
docker compose up -d --build backend

# nginx設定変更後にnginxだけ再起動
docker compose restart nginx

# フロントエンドだけ再ビルド
docker compose up -d --build frontend

9.4 コンテナ内に入って直接確認
#

# backendコンテナ内でbashを実行
docker compose exec backend sh

# コンテナ内で環境変数を確認
env | grep SPRING
env | grep JWT

# コンテナ内から出る
exit

docker compose execdocker compose runの違い:

  • exec — 既に実行中のコンテナ内でコマンドを実行
  • run — 新しいコンテナを作成してコマンドを実行(使い捨て)

9.5 DBへの直接アクセス
#

# postgresコンテナでpsqlを実行
docker compose exec postgres psql -U ainiinu -d ainiinu

# SQL実行例
SELECT count(*) FROM member;
\dt                    -- テーブル一覧
\d member              -- memberテーブルの構造
\q                     -- psqlの終了

9.6 ネットワーク/ポート関連
#

# 80/443ポートを誰が占有しているか確認
sudo ss -tlnp | grep ':80\|:443'

# Docker内部ネットワークの確認(コンテナ間通信の問題時)
docker network ls
docker network inspect aini-inu-monorepo_default

ss -tlnpの説明:

  • ss — socket statistics(ネットワークソケット情報、netstatの現代的な代替)
  • -t — TCPのみ
  • -l — LISTEN状態のみ(接続待ちポート)
  • -n — 数字で表示(名前解決しない、速い)
  • -p — どのプロセスが占有しているか表示

9.7 ディスク/リソース関連
#

# ディスク使用量の確認
df -h

# Dockerが使うディスク容量
docker system df

# 未使用のイメージ/コンテナ/ボリュームの整理(空き容量確保)
docker system prune -f          # 停止したコンテナ、未使用イメージを削除
docker image prune -a -f        # 全未使用イメージを削除(注意: ビルドキャッシュも削除される)

VMのディスクが一杯になったら? Dockerイメージとビルドキャッシュがすぐに溜まります。 docker system dfで確認し、docker system pruneで整理します。 ビルドするたびに古いイメージが残るので定期的に整理が必要です。

9.8 DBスキーマの直接変更(DDLマイグレーション)
#

エンティティからフィールドを削除したがDBテーブルにはカラムが残っている場合、Docker再起動なしで実行中のPostgreSQLに直接SQLを実行できます。

なぜ必要なのか? Hibernateのddl-auto=updateカラムの追加のみ行い、カラムの削除はしません。 そのためJavaエンティティからフィールドを削除しても、DBには該当カラムがNOT NULL制約とともに残り、 INSERTのときnull value in column "xxx" violates not-null constraintエラーが発生します。

方法1: 一行コマンドでSQLを実行(推奨)

# docker execで実行中のpostgresコンテナにSQLを直接実行
docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c "ALTER TABLE pet DROP COLUMN IF EXISTS is_certified;"

コマンド解説:

  • docker exec -i — 実行中のコンテナ内でコマンドを実行(-iはstdinを維持)
  • aini-inu-postgres — コンテナ名(docker-compose.ymlcontainer_name
  • psql -U ainiinu -d ainiinu — ainiinuユーザーでainiinuデータベースに接続
  • -c "SQL文" — SQLを一行実行後に終了

方法2: DDLファイルをまとめて実行

プロジェクトのdb/ddl/ディレクトリにマイグレーションSQLファイルがある場合:

# ローカルのSQLファイルをコンテナのpsqlにパイプ
docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu < aini-inu-backend/src/main/resources/db/ddl/13_pet_certification_removal.sql

<(リダイレクション)の説明: ファイルの内容をコマンドの入力(stdin)として渡します。 つまりSQLファイル内のすべてのSQL文が順番にpsqlで実行されます。

方法3: psqlの対話セッションで実行

# 1. postgresコンテナのpsqlに接続
docker exec -it aini-inu-postgres psql -U ainiinu -d ainiinu

# 2. SQLを直接入力
ALTER TABLE pet DROP COLUMN IF EXISTS is_certified;
ALTER TABLE pet DROP COLUMN IF EXISTS certification_number;

# 3. 変更の確認
\d pet    -- petテーブルの構造確認(削除されたカラムがなければ正常)

# 4. 終了
\q

-i-itの違い:

  • -i(interactive)— stdinを開いておく。パイプ(<)で入力を渡すときに使用。
  • -it(interactive + tty)— ターミナルセッションを開いて直接タイピングするときに使用。
  • ファイル実行は-i、対話接続は-itを使います。

実行前の確認 / 実行後の検証:

# 実行前: 該当カラムが存在するか確認
docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c "\d pet" | grep is_certified

# 実行後: カラムが削除されたか確認(出力がなければ成功)
docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c "\d pet" | grep is_certified

IF EXISTSを必ず付けましょう: DROP COLUMN IF EXISTSはカラムがなくてもエラーになりません。 既に適用したDDLを誤って再実行しても安全です。


10. トラブルシューティングまとめ
#

10.1 Dockerインストール関連
#

Q: sudo apt install docker.ioしたがdocker composeコマンドがない?

docker.ioはDebian/Ubuntuのデフォルトパッケージで、Docker Composeプラグインが含まれていません。 Docker公式リポジトリからdocker-ce + docker-compose-pluginをインストールする必要があります。(4章参照)

Q: Unable to locate package docker-compose-plugin?

Docker公式リポジトリがシステムに登録されていない状態です。4.3節のGPGキー + リポジトリ追加の手順を先に実行してください。

Q: Package 'docker-ce' has no installation candidate?

Ubuntu用のリポジトリを追加したが実際のOSがDebianの場合です。lsb_release -csでOSを確認し、結果がbookwormであればDebian 12なので、リポジトリURLをhttps://download.docker.com/linux/debianに変更してください。

Q: Docker Desktopをインストールしてはいけないのか?

GCP VMはGUIのないサーバーなのでDocker Desktopはインストールできません。Docker DesktopはMac/Windowsのデスクトップ環境専用です。

10.2 プライベートリポジトリのクローン関連
#

Q: Organizationのプライベートリポジトリをcloneすると権限エラーになる?

GitHub PAT(Personal Access Token)を生成し、Organization accessで該当orgの**“Grant”**ボタンをクリックする必要があります。Personalトークンを作成しても自動的にorgのリポジトリにアクセスできるわけではありません。

10.3 環境変数関連
#

Q: WARN: The "DOMAIN" variable is not set. Defaulting to a blank string.?

docker composeはデフォルトでプロジェクトルートの.envファイルを読みます。.env.dockerだけ作って.envを作らないとこの警告が発生します。

cp .env.docker .env

10.4 SSL証明書関連
#

Q: docker compose run --rm certbot ...がCreatingで止まる?

Docker certbotコンテナ方式は様々な理由で止まることがあります。ホストに直接certbotをインストールするのが最も確実です:

docker compose down              # 80ポートを解放
sudo apt install -y certbot      # ホストに直接インストール
sudo certbot certonly --standalone -d ainiinu.kr --agree-tos -m your@email.com

Q: certbot standaloneも止まる?

GCPファイアウォールでTCP 80ポートが開いているか確認してください。Let’s EncryptサーバーがHTTPでの検証リクエストを送るので、ファイアウォールでブロックされると無限待機になります。

10.5 ディスク容量不足(no space left on device
#

症状: Dockerビルド中にエラーが発生:

target backend: failed to solve: ResourceExhausted: failed to copy files:
copy file range failed: no space left on device

原因: GCPのデフォルトディスクが10GBですが、Gradleビルド(JDK + 依存関係)とNext.jsビルド(node_modules)が同時に進むとディスクが一杯になります。

即時解決 — Dockerキャッシュの整理:

# 未使用のイメージ、ビルドキャッシュ、停止したコンテナをすべて削除
docker system prune -a -f

# 整理された容量 + 残り容量を確認
df -h

docker system prune -a -fの説明:

  • prune — 未使用リソースを整理
  • -a(all)— 現在実行中でない全イメージも削除(タグされたイメージを含む)
  • -f(force)— 確認なしで即削除
  • 注意: 次回docker compose up --buildのときは最初からビルドし直すので時間がかかります

根本的な解決 — ディスクサイズを拡張する:

  1. GCPコンソール → Compute Engineディスクメニュー
  2. VMに接続されたディスクをクリック → 上部の編集ボタン
  3. サイズを20GB以上に変更 → 保存
  4. VMでパーティションを拡張(VM再起動なしで可能):
# 1. パーティションの拡張(growpartがなければインストール)
sudo apt install -y cloud-guest-utils
sudo growpart /dev/sda 1

# 2. ファイルシステムの拡張(ext4基準)
sudo resize2fs /dev/sda1

# 拡張の確認
df -h
# → /dev/sda1 のSizeが増えているか確認

growpartとは? ディスクのパーティションテーブルを拡張するコマンドです。 GCPでディスクサイズを増やしてもパーティションサイズは自動で変わらないので、 growpartでパーティションを先に拡張してからresize2fsでファイルシステムを拡張します。

resize2fsとは? ext4ファイルシステムのサイズをパーティションに合わせて拡張するコマンドです。 growpartなしでresize2fsだけ実行するとパーティションサイズがそのままなので効果がない場合があります。 データ損失なしにオンライン(サービス停止なしに)拡張可能です。

ディスク容量の監視:

# 全体のディスク使用量
df -h

# Dockerが使う容量の詳細
docker system df

# どのディレクトリが容量を多く使っているか確認
sudo du -sh /* 2>/dev/null | sort -rh | head -10

df -hの読み方:

Filesystem  Size  Used  Avail  Use%  Mounted on
/dev/sda1    20G  3.8G   15G   21%   /
  • Size — 全体のディスクサイズ
  • Used — 使用中の容量
  • Avail — 残り容量
  • Use% — 使用率(80%を超えたら注意、90%を超えたら危険
  • Mounted on/がルートパーティション(最重要)

ビルドごとの予想ディスク使用量:

項目容量
OS + システムパッケージ~2GB
Dockerエンジン~500MB
バックエンドビルドイメージ(Gradle + JDK)~2.5GB
フロントエンドビルドイメージ(Node.js + npm)~1.5GB
最終実行イメージ(backend + frontend + nginx + postgres)~1.5GB
PostgreSQLデータ~500MB(データ量に応じて増加)
アップロード画像(ユーザーデータ)可変
合計(余裕を含む)最小15GB、推奨20GB

10.6 Swagger UIが表示されずフロントエンドページが出る
#

症状: https://ainiinu.kr/swagger-ui/index.htmlにアクセスするとSwaggerではなくフロントエンドの空白ページが出る。

原因: Nginxのlocation /がマッチしない全パスをフロントエンドに送ります。/swagger-ui/もフロントエンドに行ってしまいます。

解決: nginx.confにSwaggerパスをバックエンドにルーティングするルールを追加:

location /swagger-ui/ {
    proxy_pass http://backend:8080;
}
location /v3/api-docs {
    proxy_pass http://backend:8080;
}

教訓: バックエンドが直接サービングするパスが新たに追加されたら、nginx.confにも該当パスのルーティングルールを必ず追加してください。 Nginxは最も具体的な(longest prefix)locationからマッチするので、/swagger-ui//より優先してマッチします。

10.7 ERR_SSL_PROTOCOL_ERROR(ECDSA証明書の互換性問題)
#

症状: ブラウザでhttps://ainiinu.krにアクセスするとERR_SSL_PROTOCOL_ERRORエラーが表示される。 Nginxログに以下のエラーが繰り返される:

SSL_do_handshake() failed (SSL: error:0A000119:SSL routines:ssl_do_handshake:no suitable signature algorithm)

iOSアプリからのAPI呼び出しは正常なのに、Webブラウザからだけ SSL接続が失敗する状況。

原因:

certbot 2.0.0(2023年1月リリース)から証明書を発行するときデフォルトのキータイプがECDSA secp256r1(P-256)に変更されました。

RSAとECDSAとは?

SSL証明書は内部に「キー(key)」を持っており、このキーを作る暗号化方式が2つあります:

方式説明特徴
RSA古くからある標準方式。ほぼすべての環境でサポート。互換性最高、キーサイズが大きい(2048〜4096 bit)
ECDSA新しい楕円曲線方式。より短いキーで同等のセキュリティレベル。パフォーマンス良好、一部の環境で互換性問題

例えるなら: RSAはすべての鍵穴に合う万能鍵、ECDSAは最新のスマートロック — より効率的だが古い扉には合わないことがある。

ECDSA証明書が問題を引き起こす理由:

  • nginx:alpineイメージのOpenSSLバージョンとECDSA P-256キー + SHA384署名の組み合わせで一部のクライアントと署名アルゴリズムのネゴシエーションに失敗
  • エラーメッセージのno suitable signature algorithm = 「サーバーとクライアントが合意できる署名方式がない」という意味
  • iOSのネイティブHTTPクライアントはこの組み合わせを処理できるが、一部のブラウザでハンドシェイク失敗

SSLハンドシェイクとは?

ブラウザがHTTPSサーバーに接続するときに最初に行う「握手」の過程:

  1. ブラウザ: 「私はこんな暗号化方式をサポートしています」(サポートリストを送信)
  2. サーバー: 「その中からこれにしましょう」(方式を選択)
  3. 両者が合意したら → 暗号化された接続が確立
  4. 合意失敗 → ERR_SSL_PROTOCOL_ERROR

診断方法:

# 1. 現在の証明書のキータイプを確認
sudo certbot certificates
# → Key Type: ECDSA ← これが問題の原因

# 2. 証明書の詳細情報を確認
sudo openssl x509 -in /etc/letsencrypt/live/ainiinu.kr/fullchain.pem -text -noout | head -20
# → Signature Algorithm: ecdsa-with-SHA384 ← ECDSA署名を確認
# → Public Key Algorithm: id-ecPublicKey  ← ECDSAキーを確認

# 3. SSL接続テスト(VMから直接)
openssl s_client -connect ainiinu.kr:443 -servername ainiinu.kr 2>&1 | head -30
# → verify return:1 であれば証明書自体は有効だが、ブラウザの互換性問題

解決 — RSAキータイプで証明書を再発行:

# 1. nginxを停止(80ポートを解放 — certbot standaloneが80ポートを必要とする)
docker compose stop nginx

# 2. RSAキータイプで証明書を強制再発行
sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --force-renewal --key-type rsa

# 3. nginxコンテナを完全に再作成(新しい証明書を反映)
docker compose up -d --force-recreate nginx

各オプションの説明:

  • --force-renewal — 既存の証明書が有効でも強制的に新規発行
  • --key-type rsaこれがポイント! ECDSAの代わりにRSAキーで証明書を生成
  • --force-recreate — コンテナを削除してから新規作成(単純なrestartより確実に証明書を再読み込みする)

再発行後の確認:

# キータイプがRSAに変更されたか確認
sudo certbot certificates
# → Key Type: RSA ← こうなれば正常

# nginxログにSSLエラーがないか確認
docker compose logs --tail=10 nginx

ブラウザキャッシュの問題: 証明書を交換してもブラウザが以前のSSLセッションをキャッシュしていることがあります。

  • シークレット/プライベートモードで先にアクセステスト
  • またはブラウザキャッシュを削除(Cmd+Shift+DeleteまたはCtrl+Shift+Delete)後に再試行

教訓: certbot 2.0.0からデフォルトのキータイプがECDSAに変わりました。ECDSA自体に問題があるわけではありませんが、特定のサーバー/クライアントの組み合わせで互換性問題が発生することがあるので、互換性の問題が出た場合は--key-type rsaに切り替えると解決します。 今後の証明書更新時もRSAを維持するには:

# /etc/letsencrypt/renewal/ainiinu.kr.conf ファイルに以下があるか確認
key_type = rsa

--key-type rsaで発行するとcertbotが自動的に更新設定にもRSAを記録するので、 以降の自動更新でもRSAで更新されます。

10.8 バックエンド起動失敗
#

Q: No data scripts found at location 'classpath:db/seed/xxx.sql'?

application.propertiesspring.sql.init.data-locationsで参照しているSQLファイルが実際に存在しない場合です。 ファイルが削除/移動されていたら設定からも該当の参照を削除してください。

Q: バックエンドのログにApplication run failedが見える?

Spring Bootの起動に失敗しています。ログのCaused by:またはErrorを探すと根本原因が出ます:

docker compose logs backend | grep -i "caused by\|error creating bean\|application run failed"

11. HTTPS方式の比較(参考)
#

方法メリットデメリット
Nginx + Let’s Encrypt(採用)composeで一緒に管理、自動更新可能Nginx設定が必要
Caddy設定2〜3行、証明書の自動発行/更新Nginxより馴染みが薄いかも
GCP Load Balancerインフラレベルで処理、コンテナ変更不要GCP費用追加、設定が複雑
Cloudflare ProxyDNS変更だけで完了、無料SSL外部サービスへの依存

NginxをDocker Composeに含める方式を採用した理由:

  • VMにDocker + certbot以外にインストールするものがない
  • docker compose up -d一度で全サービス起動
  • サーバー移転時はcomposeファイルだけ持っていけば完了
  • コンテナ間の通信がDockerの内部ネットワークで整理されている

12. Linux/ターミナル必須コマンド
#

GCP VMにSSHで接続するとターミナル(CLI)しか使えません。 よく使うコマンドをまとめます:

12.1 ファイル/ディレクトリ
#

ls                 # 現在のディレクトリのファイル一覧
ls -la             # 隠しファイル含む詳細情報(権限、サイズ、日付)
pwd                # 現在の位置(Print Working Directory)
cd /path/to/dir    # ディレクトリ移動
cd ..              # 上位ディレクトリ
cd ~               # ホームディレクトリ

cat filename       # ファイルの内容をすべて出力
less filename      # ファイルの内容をページ単位で見る(qで終了)
head -n 20 file    # 最初の20行だけ見る
tail -n 20 file    # 最後の20行だけ見る
tail -f file       # ファイルに追加される内容をリアルタイムで見る(ログ監視)

12.2 ファイル編集
#

nano filename      # nanoエディタ(初心者向け、下部にショートカット表示)
vi filename        # viエディタ(慣れると速い)

nanoのショートカット: Ctrl+OEnter = 保存、Ctrl+X = 終了、Ctrl+K = 行削除、Ctrl+W = 検索

vi最小サバイバルガイド:

  • i → 入力モード(文字入力可能)
  • Esc → コマンドモード(入力終了)
  • :wq + Enter → 保存して終了
  • :q! + Enter → 保存せず終了
  • /検索語 + Enter → 検索、nで次の結果

12.3 プロセス/システム
#

ps aux                   # 実行中のすべてのプロセス
ps aux | grep docker     # docker関連プロセスだけフィルター
kill <PID>               # プロセスの終了
sudo systemctl status docker  # Dockerサービスの状態確認

12.4 ネットワーク
#

curl https://ainiinu.kr              # URLにHTTPリクエストを送る
curl -I https://ainiinu.kr           # レスポンスヘッダーだけ見る
curl -s ifconfig.me                  # 自分の外部IPを確認
nslookup ainiinu.kr                  # DNSの確認
sudo ss -tlnp                        # 開いているポートの確認

12.5 ターミナル制御
#

Ctrl+C    # 実行中のコマンドを強制停止(ログ表示、サーバー実行などを止めるとき)
Ctrl+D    # 入力終了 / ターミナルセッション終了
Ctrl+L    # 画面を消去(clearと同じ)
q         # less、git logなどのページャーから抜ける
↑/↓       # 以前に入力したコマンドの履歴を遡る
Tab       # ファイル名/コマンドの自動補完

qで抜ける場面:

  • docker compose logs(ページャーモードのとき)
  • git log(コミット履歴が長いとき)
  • lessコマンドでファイルを見るとき
  • manコマンドでマニュアルを見るとき

画面に:または(END)が表示されたらqを押して抜けられます。 Ctrl+Cが効かなくて画面が止まって見える場合は大体qで抜けられます。

12.6 便利な組み合わせ
#

# パイプ(|)— 前のコマンドの出力を後のコマンドの入力に渡す
docker compose logs backend | grep ERROR     # ログからERRORだけフィルター
ps aux | grep nginx                          # nginxプロセスを探す

# && — 前のコマンドが成功した場合のみ後のコマンドを実行
git pull && docker compose up -d --build     # pull成功時のみビルド開始

# ; — 前のコマンドの結果に関わらず後のコマンドも実行
docker compose down; docker compose up -d    # 常に両方実行

# > — 出力をファイルに保存(上書き)
docker compose logs backend > backend.log    # ログをファイルに保存

# >> — 出力をファイルに追記
echo "メモ" >> notes.txt

13. 運用コマンドチートシート
#

コピペ用の素早い参照。各コマンドの詳細説明は括弧内の節を参照。

# ── 日常運用(6.4節)──
docker compose up -d --build              # 全サービス起動(ビルド含む)
docker compose down                       # 全サービス停止
docker compose ps                         # サービス状態確認
git pull && docker compose up -d --build  # コード更新後に再デプロイ

# ── ログ確認(6.5節)──
docker compose logs -f --tail=50          # 全ログ(最近50行からリアルタイム)
docker compose logs -f backend            # 特定サービスのログ
docker compose logs backend | grep -i error  # エラーだけフィルター

# ── サービス管理(9.3節)──
docker compose restart nginx              # 特定サービスだけ再起動
docker compose up -d --build backend      # 特定サービスだけ再ビルド

# ── SSL証明書(6.7節)──
sudo certbot certificates                 # 証明書の有効期限確認
docker compose stop nginx && sudo certbot renew && docker compose start nginx  # 手動更新

# ── データ管理 ──
docker compose down && docker compose up -d --build  # DB維持、コンテナ再作成
docker compose down -v                    # すべてを初期化(DBデータ含む削除 — 注意!)

# ── ディスク整理(9.7節)──
docker system df                          # Dockerのディスク使用量確認
docker system prune -f                    # 未使用リソースの整理
docker image prune -a -f                  # 全未使用イメージ削除(ビルドキャッシュ含む)

14. まとめ
#

  • 現在のソースコードでコード修正は最小限(next.config.tsにoutput: 'standalone'の一行)でDockerデプロイが可能
  • プロジェクトに追加したファイル: フロントエンドDockerfile、.dockerignore、nginx.conf、ルートdocker-compose.yml、.env.docker.example
  • GCP VM(Debian 12)にDocker公式リポジトリからインストール、SSLはホストのcertbotで発行
  • docker compose up -d --buildで全サービスを起動
  • https://ainiinu.krでWebとiOS両方から同じドメインでアクセス可能