docs(ops): 起動スクリプトをリポジトリ管理下に置き、本番障害の罠を記録 (#7) #213
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy to VPS | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: {} | |
| concurrency: | |
| group: deploy-vps | |
| cancel-in-progress: false | |
| jobs: | |
| deploy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| needs: [] | |
| defaults: | |
| run: | |
| working-directory: site | |
| env: | |
| DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} | |
| DEPLOY_USER: ${{ secrets.DEPLOY_USER }} | |
| DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # 218ツール×17言語=約7,800ページのSSGでランナーのディスクが枯渇する(ENOSPC)。 | |
| # ビルド前に未使用のプリインストールSDKを削除して約30GB確保する。 | |
| - name: Free disk space | |
| working-directory: / | |
| run: | | |
| echo "Before:"; df -h / | tail -1 | |
| sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ | |
| /usr/local/lib/android /usr/local/share/boost \ | |
| /usr/local/share/powershell /usr/share/swift "$AGENT_TOOLSDIRECTORY" || true | |
| sudo docker image prune -af || true | |
| echo "After:"; df -h / | tail -1 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "24" | |
| cache: "npm" | |
| cache-dependency-path: site/package-lock.json | |
| - name: Install dependencies | |
| run: npm ci --no-fund --no-audit | |
| - name: Type check | |
| run: npm run typecheck | |
| - name: i18n / SEO audits | |
| run: | | |
| npm run audit:i18n | |
| npm run audit:seo | |
| - name: Unit tests | |
| run: npm test | |
| - name: Production build (standalone) | |
| run: npm run build | |
| env: | |
| NEXT_PUBLIC_SITE_URL: https://toolify365.com | |
| NEXT_PUBLIC_ORG_NAME: Toolify | |
| NEXT_PUBLIC_CONTACT_EMAIL: app.develop.sk@gmail.com | |
| NEXT_PUBLIC_ADSENSE_CLIENT: ca-pub-4927026308242118 | |
| NEXT_PUBLIC_FC_ID: "" | |
| NEXT_PUBLIC_BING_SITE_VERIFICATION: ${{ secrets.BING_SITE_VERIFICATION }} | |
| NEXT_PUBLIC_YANDEX_SITE_VERIFICATION: ${{ secrets.YANDEX_SITE_VERIFICATION }} | |
| # AdSense ad unit slot IDs (set after AdSense approval; empty = placeholder shown) | |
| NEXT_PUBLIC_ADSENSE_SLOT_BANNER: ${{ secrets.ADSENSE_SLOT_BANNER }} | |
| NEXT_PUBLIC_ADSENSE_SLOT_INARTICLE: ${{ secrets.ADSENSE_SLOT_INARTICLE }} | |
| NEXT_PUBLIC_ADSENSE_SLOT_STICKY: ${{ secrets.ADSENSE_SLOT_STICKY }} | |
| NEXT_PUBLIC_ADSENSE_SLOT_BELOWRESULT: ${{ secrets.ADSENSE_SLOT_BELOWRESULT }} | |
| NEXT_PUBLIC_ADSENSE_SLOT_INFEED: ${{ secrets.ADSENSE_SLOT_INFEED }} | |
| NEXT_PUBLIC_ADSENSE_SLOT_ANCHOR: ${{ secrets.ADSENSE_SLOT_ANCHOR }} | |
| NEXT_PUBLIC_GA_ID: ${{ secrets.GA_ID }} | |
| NEXT_PUBLIC_CLARITY_PROJECT_ID: ${{ secrets.CLARITY_PROJECT_ID }} | |
| # 楽天市場 価格比較ツール (/api/price-search) — サーバーサイドのみ | |
| RAKUTEN_APP_ID: ${{ secrets.RAKUTEN_APP_ID }} | |
| RAKUTEN_ACCESS_KEY: ${{ secrets.RAKUTEN_ACCESS_KEY }} | |
| RAKUTEN_AFFILIATE_ID: ${{ secrets.RAKUTEN_AFFILIATE_ID }} | |
| # 楽天アプリ登録のApplication URLと一致させる | |
| RAKUTEN_ORIGIN_URL: ${{ secrets.RAKUTEN_ORIGIN_URL }} | |
| # Amazon アソシエイト (承認後に設定) | |
| NEXT_PUBLIC_AMAZON_PARTNER_TAG: ${{ secrets.AMAZON_PARTNER_TAG }} | |
| - name: Set up SSH | |
| run: | | |
| mkdir -p ~/.ssh | |
| echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null | |
| - name: Write runtime env file for VPS | |
| env: | |
| RAKUTEN_APP_ID: ${{ secrets.RAKUTEN_APP_ID }} | |
| RAKUTEN_ACCESS_KEY: ${{ secrets.RAKUTEN_ACCESS_KEY }} | |
| RAKUTEN_AFFILIATE_ID: ${{ secrets.RAKUTEN_AFFILIATE_ID }} | |
| RAKUTEN_ORIGIN_URL: ${{ secrets.RAKUTEN_ORIGIN_URL }} | |
| run: | | |
| printf 'RAKUTEN_APP_ID=%s\nRAKUTEN_ACCESS_KEY=%s\nRAKUTEN_AFFILIATE_ID=%s\nRAKUTEN_ORIGIN_URL=%s\n' \ | |
| "$RAKUTEN_APP_ID" "$RAKUTEN_ACCESS_KEY" "$RAKUTEN_AFFILIATE_ID" "$RAKUTEN_ORIGIN_URL" \ | |
| > .next/standalone/.env.local | |
| - name: rsync standalone artifacts to VPS | |
| run: | | |
| rsync -az --delete \ | |
| -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \ | |
| .next/standalone/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" | |
| rsync -az --delete \ | |
| -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \ | |
| .next/static/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/.next/static/" | |
| rsync -az --delete \ | |
| -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \ | |
| public/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/public/" | |
| - name: Clear ISR / full-route cache on VPS | |
| run: | | |
| ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \ | |
| "$DEPLOY_USER@$DEPLOY_HOST" \ | |
| "rm -rf \"$DEPLOY_PATH/.next/cache/\" && echo 'cache cleared'" | |
| - name: Reload toolify on VPS via PM2 (zero-downtime) | |
| run: | | |
| ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \ | |
| "$DEPLOY_USER@$DEPLOY_HOST" \ | |
| "pm2 reload toolify --update-env && pm2 save" | |
| # `pm2 reload` は再起動を"シグナルした"時点で返る(2026-09-17の実障害では134msで復帰)。 | |
| # standalone の Next.js が約7,800ページのルートマニフェストを読み終えてポートを | |
| # listen するまでには数秒かかり、その間 nginx は上流不在で 502 を返す。 | |
| # PM2の復帰を待つのではなく、アプリ自身が応答するまでをここで待つ。 | |
| - name: Wait for app to accept connections | |
| run: | | |
| set -e | |
| for i in $(seq 1 60); do | |
| code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ | |
| https://toolify365.com/en/tools/bmi-calculator || echo 000) | |
| if [ "$code" = "200" ]; then | |
| echo "✓ アプリ復帰 (${i}回目, HTTP $code)" | |
| exit 0 | |
| fi | |
| echo " 起動待ち ${i}/60 (HTTP $code)" | |
| sleep 2 | |
| done | |
| echo "✗ 120秒待ってもアプリが200を返さない → 起動失敗の可能性" | |
| exit 1 | |
| - name: Smoke test live URL | |
| run: | | |
| set -e | |
| fail=0 | |
| code=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 \ | |
| https://toolify365.com/en/tools/bmi-calculator || echo 000) | |
| if [ "$code" = "200" ]; then | |
| echo "✓ ツールページ 200" | |
| else | |
| echo "✗ ツールページが HTTP $code" | |
| fail=1 | |
| fi | |
| if curl -sSf --max-time 10 https://toolify365.com/feed-ja.xml | grep -q "<rss"; then | |
| echo "✓ feed-ja.xml が RSS を返す" | |
| else | |
| echo "✗ feed-ja.xml が RSS を返さない" | |
| fail=1 | |
| fi | |
| exit "$fail" | |
| # Pinterest RSS自動投稿の回帰ガード(2026-07-21の実障害): | |
| # feedのenclosure画像が全item同一になると、Pinterestの画像ハッシュ重複排除で | |
| # ピン化が2枚で無言停止する(/api/og がクエリを読まなくなる等の回帰で再発しうる)。 | |
| # 先頭3itemの画像を実取得しMD5が2種以上あることを検証。失敗=workflow赤で | |
| # 朝のCI監視ルーチンが検知する。 | |
| - name: Guard feed pin images are unique (Pinterest dedupe) | |
| run: | | |
| set -e | |
| urls=$(curl -sSf --max-time 15 https://toolify365.com/feed.xml \ | |
| | grep -o 'enclosure url="[^"]*"' | sed 's/enclosure url="//;s/"$//;s/\&/\&/g' | head -3) | |
| [ -n "$urls" ] || { echo "✗ feedにenclosureが無い"; exit 1; } | |
| md5s="" | |
| while IFS= read -r u; do | |
| curl -sSf --max-time 30 "$u" -o /tmp/pin.png | |
| bytes=$(wc -c < /tmp/pin.png | tr -d ' ') | |
| h=$(md5sum /tmp/pin.png | cut -d' ' -f1) | |
| echo " $h ${bytes}B $u" | |
| # 2026-08-14の実障害: /api/og が edge ランタイムでフォントを読めず、 | |
| # 全リクエストが 70B の 1×1 透明PNG フォールバックに落ちていた。 | |
| # 「200が返る」だけでは検知できないのでバイト数で門番する。 | |
| if [ "$bytes" -lt 20000 ]; then | |
| echo "✗ ピン画像が ${bytes}B しかない → /api/og が空白PNGフォールバックに落ちている" | |
| exit 1 | |
| fi | |
| md5s="$md5s$h\n" | |
| done <<< "$urls" | |
| uniq_count=$(printf "%b" "$md5s" | sort -u | grep -c .) | |
| if [ "$uniq_count" -lt 2 ]; then | |
| echo "✗ feedのピン画像が全て同一(MD5 1種) → Pinterest重複排除でRSS投稿が止まる回帰" | |
| exit 1 | |
| fi | |
| echo "✓ ピン画像 unique ($uniq_count 種/3件)" | |
| - name: Cleanup SSH key | |
| if: always() | |
| run: rm -f ~/.ssh/deploy_key |