共有Dockerサーバーへのデプロイ
使用すべきタイミング
- サーバーが専有ではない:既に他の人のデプロイや他のプロジェクトが動作している。
- アプリにネイティブ依存関係(
better-sqlite3、sharp、canvas)またはブラウザ(Chromium)がある。 - 複数のドメインを公開するリバースプロキシ(Caddyやnginx)が既に存在する。
EADDRINUSE、OOM、またはディスク容量不足が他のスタックに影響を与える可能性がある。
基本ルール:新しいアプリが他のアプリをダウンさせる原因になってはいけない。 そのため、すべてを事前に制限し、計測します。
0. ビルド前の準備:ディスクの計測と解放
Chromiumを使ったビルドは約2GBのイメージをダウンロードします。ディスク使用率が78%の場合、ビルドが失敗するか、サーバーのディスク容量が危険な状態になり、次のpruneで本番イメージが削除される可能性があります。
ssh SERVIDOR 'docker system df; df -h /'
一般的に、安全に解放できるもの:
docker builder prune -af # ビルドキャッシュ(通常これが一番大きい)
docker image prune -af # 未使用イメージ
docker container prune -f # 停止中のコンテナ
実行前後に、コンテナ数をカウントして何も停止していないことを確認:
docker ps -q | wc -l # 数を記録し、後で比較
実際の事例で計測:78% → 32%回復、本番の41コンテナは無傷。
1. ネイティブモジュールとNodeのバージョン
異なるNodeのメジャーバージョン用にコンパイルされた.nodeファイルは、明確なエラーでは失敗せず、内部アサーションと無意味なネイティブスタックトレースでプロセスが異常終了します。
典型的な症状:
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
原因:better-sqlite3@11とNode 24の組み合わせ。サポートはbetter-sqlite3@12で追加されました。
ルール:
- 統合前に、ネイティブモジュールのバージョンをNodeのメジャーバージョンと照合する。
- ビルド用の
Dockerfileでは、プリコンパイル済みバイナリが存在しない場合にその場でコンパイルできるよう、ツールチェーン(python3 make g++)をインストールする。 - ランタイムでは、ツールチェーンは不要と仮定する:より小さな最終イメージを目指す。
- モジュールがワークスペース内の複数のパッケージからインポートされる場合、他の処理を行わないプロセスでテストする:遅延GCによるクラッシュが数時間後に発生する可能性があるため。
2. 重い処理とレスポンスを担当する処理の分離
ChromiumとネイティブモジュールをHTTPサーバーと同じプロセスで実行すると、クラッシュが発生し、さらにロングジョブ中にイベントループがブロックされます。
api : HTTP + SSE、低メモリ、HTTPヘルスチェック
worker : キュー + ブラウザ、高メモリ、ポートなし、HTTPヘルスチェックなし
これらは共有ストレージ(SQLite、Redis、Postgres)を介して通信します。APIはワーカーが生きているかどうかを知る必要はありません。
ヘルスチェックはHTTPを提供するサービスのみに定義します。 Dockerfileからワーカーにヘルスチェックを継承すると、ワーカーはポートを開かないため、常にunhealthy状態になります:
worker:
healthcheck:
disable: true
3. サービスごとのメモリ制限
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromiumは/dev/shmを使用します;64MBでは動作しません
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
さらに、コード内で作業量を減らします:スクレイパーはサーバー上ではローカルよりも低いDETAIL_MAX / MAX_PAGESを設定する必要があります。もしサーバーでRAMが不足した場合、原因はほとんどの場合その数値であり、Dockerではありません。
実際の検索の前後で計測:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' <api> <worker>
free -m | head -2
4. ビルド:ステージと有効なキャッシュ
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # バージョンをnpmパッケージに固定
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- イメージのバージョンをインストールされたnpmパッケージのバージョンに固定します。一致しない場合、Playwrightがブラウザを期待する場所に見つけることができません。
- 最初に
package.jsonをコピーし、その後コードをコピーします。これにより、COPY . .がinstallレイヤーを無効化するのを防ぎます。 - ランタイムでは、起動スクリプトが
pnpm/npmを使用する場合、それらが必要です →corepack enable。 node_modulesがビルドからコピーされ、パーミッションが一致しない場合、pnpmは起動時に毎回「修復」を試み、失敗します。これはイメージ内の.npmrcファイルでverify-deps-before-run=falseを設定することで無効化できます。- ランタイムではroot以外のユーザーを使用します。Playwrightイメージには既に
pwuserが含まれています。
pnpm-lock.yamlはコミットする必要があり、ビルドのinstallは--frozen-lockfileオプションを付けて実行します。
5. 既存のプロキシを介した公開
既に複数のドメインを公開するCaddyが存在する場合、別のプロキシを起動したり、別のパブリックポートを開いたりしないでください:既存のCaddyfileにブロックを追加し、Dockerネットワークに参加させます。
services:
api:
networks: [edge_proxy] # 外部ネットワーク、既に作成済み
networks:
edge_proxy:
external: true
name: edge_proxy
ネットワークとCaddyfileの場所を確認:
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
ファイルをホスト上で編集します:バインドマウントがrw=falseの場合、コンテナ内で書き込もうとするとResource busyエラーが発生するか、再起動時に変更が失われます。
安全な手順(決して盲目的に編集しない):
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. バックアップ
# 2. ブロックを追加
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. バリデーション
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. リロード(ダウンタイムなし)
validateが失敗した場合、バックアップを復元します。リロードは既存の接続を切断しません。
Basic認証
prospector.ejemplo.dev {
basic_auth {
leo $2a$14$... # bcrypt、絶対に平文で保存しない
}
reverse_proxy api:3000 {
flush_interval -1 # SSEには必須
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- ハッシュはサーバー上で生成します:
docker exec <proxy> caddy hash-password --plaintext '...'。 - パスワードの平文は、root専用のファイル(
/root/<app>-password.txt、パーミッション600)にのみ保存し、復元やローテーションが可能なようにします。絶対にリポジトリに保存しないでください。 - アプリがSSEを使用する場合、Cloudflareプロキシを前面に使用しないでください:100秒のタイムアウトとバッファリングにより進行が中断されます。DNS-only(グレークラウド)にすると、オリジンが実際の証明書を発行できるようになります。
basic_authはプロキシを通過するトラフィックのみを保護します。アプリが別のポートでも公開されている場合、保護されないままになります:これをドキュメントに明記してください。
6. Windowsからのデプロイ
rsyncはWindowsには存在しません。Windowsでも動作するクロスプラットフォームな方法は、SSHパイプ経由のtarです:
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
Windows上でpnpm(.cmdファイル)を解決するには、shell: trueを指定したspawnが必須です。ただし、この場合、出力をリダイレクトするためのファイルハンドルを渡すことはできません:環境変数LOG_FILEを使用し、アプリケーション自体にログを書き込ませてください。
7. 共有サーバーを操作する前の確認リスト
-
docker ps -q | wc -lを記録し、最後に比較する - 解放後の空きディスク容量が十分であること
- すべての新しいサービスに
mem_limitを設定 - 重い処理は独自のコンテナ(ポートなし)に分離
- ヘルスチェックはHTTPを提供するサービスのみに設定
- プロキシの外部ネットワークを宣言(新しいネットワークではない)
- Caddyfileのバックアップとリロード前の
caddy validate - ログはファイルに出力(プロセスはコンソールを持たない)
- デプロイスクリプトは冪等性を持つ:
check / push / build / up / logs / stats / backup - バックアップボリュームがテスト済みかつ復元済みであること(作成されただけではない)
クイック診断
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /