はじめに
前回の記事「初の個人開発 #Vue.js」 で公開した、プロ野球の投手 × 打者の個人対戦成績を検索できるサービス Pitcher-vs-Batter を、Heroku → AWS にフルリプレイスしました。
本記事では:
- なぜ移行したのか
- Before / After の構成
- やったこと(DB移行・スクレイパー移植・SEO・コールドスタート対策など)
- つまずいたところ
- コスト比較
を実体験ベースでまとめます。同じく Heroku 個人開発から AWS 移行を検討している方の参考になれば幸いです。
TL;DR
| 項目 | Before(Heroku) | After(AWS) |
|---|---|---|
| App サーバ | Heroku Basic Dyno(常時稼働) | AWS Lambda(API Gateway 経由) |
| 静的配信 | Heroku 内 Spring Boot で配信 | S3 + CloudFront |
| DB | RDS for MySQL | Neon (PostgreSQL, サーバレス) |
| スクレイパー | Heroku Scheduler | EventBridge Scheduler + Lambda |
| シークレット管理 | Heroku Config Vars(平文) | AWS Secrets Manager |
| IaC | なし | Terraform |
| CI/CD | 手動 | GitHub Actions(OIDC) |
| 月額コスト | 約 $25〜30 | 約 $1〜2 |
90% 以上のコスト削減 + 構成の自動化に成功しました。
なぜ移行したのか
1. コスト
Heroku の Basic Dyno($7)+ RDS(t2.micro でも $15〜)+ ストレージ等で 月 $25〜30 かかっていました。月数千円とはいえ、個人開発のサービスでこの金額が常時発生するのは重い。
2. インフラ知識のアップデート
前回の記事でも書いた通り、インフラ知識不足が課題でした。仕事でも AWS を触るようになったので、自分のサービスを練習台にして「IaC + サーバレス + マネージド DB」を一通り経験したかった。
3. 平文シークレット管理が気持ち悪い
Heroku Config Vars に DB 接続文字列が平文で入っていて、ダッシュボードで誰でも見られる状態。チームでなく個人開発でも、ベストプラクティスに寄せたかった。
4. Heroku の値上げ・サポート方針の変化
Heroku 無料枠廃止以降、個人開発の選択肢としては微妙になっていた、というのも一因です。
Before:Heroku 時代の構成
問題点:
- Dyno は 常時起動でコスト発生(リクエスト 0 でも課金)
- Spring Boot プロセスが API もスクレイピングも全部担当して責務が混在
- Scheduler の実行ログが Heroku CLI でしか見えなくて運用しづらい
- Dyno が 30 分アイドルだと sleep する(Basic で回避していた)
- Config Vars に DB URL が 平文 で保存されている
After:AWS 構成
ポイント:
- API と Scraper を Lambda に分離。リソース要件もデプロイサイクルも分けられる
- Secrets Manager で DB 接続文字列を暗号化管理(環境変数は「シークレット名」だけ)
- Neon(サーバレス PostgreSQL)の無料枠で DB コスト 0
- Terraform で全リソース管理 → 再現性確保
- GitHub Actions + OIDC でアクセスキー不要のデプロイ
実施した移行作業
ざっくり以下を順番にやりました。
1. コードの DB ベンダー切替(MySQL → PostgreSQL)
DB は RDS for MySQL → Neon (PostgreSQL) に変えました。理由は:
- Neon の 無料枠が太い(0.5 GB ストレージ + 月 191.9 時間 compute)
- サーバレス PostgreSQL なのでアイドル時に自動停止 → コスト 0
- ブランチング機能で開発用 DB を簡単に作れる
- マイグレーション対象データが 64 万行程度しかなく、無料枠で十分
ただし MySQL → PostgreSQL は表面的なドライバ差し替えだけでは済みません。
コード側で必要だった変更
| 観点 | MySQL | PostgreSQL |
|---|---|---|
| JDBC ドライバ | mysql-connector-java |
org.postgresql:postgresql |
| URL スキーム | jdbc:mysql:// |
jdbc:postgresql:// |
| Hibernate dialect | MySQLDialect |
PostgreSQLDialect |
| 日付関数 | YEAR(date) |
EXTRACT(YEAR FROM date) |
| 文字列関数 | SUBSTRING_INDEX(s, '?', 1) |
SPLIT_PART(s, '?', 1) |
| AUTO_INCREMENT | AUTO_INCREMENT |
BIGSERIAL / IDENTITY
|
| 識別子の引用 | バッククォート `
|
ダブルクォート "
|
| 大文字/小文字 | デフォルト大文字小文字を区別しない | デフォルト識別子は小文字 |
特に詰まったのは VIEW。MySQL で作っていた V_AT_BAT_GAME_DETAILS などを PostgreSQL の構文で書き直す必要があり、SUBSTRING_INDEX を SPLIT_PART に置換、COALESCE(end_date, '9999-12-31') の日付リテラル形式の差異などを吐き出しながら手動修正しました。
-- MySQL
SELECT SUBSTRING_INDEX(player_nm, '?', 1) AS name FROM ...
-- PostgreSQL
SELECT SPLIT_PART(player_nm, '?', 1) AS name FROM ...
-- MySQL
WHERE EXTRACT(YEAR FROM g.GAME_DATE) = ? -- これは両DB共通でOK
WHERE YEAR(g.GAME_DATE) = ? -- MySQL専用 → 要書き換え
-- PostgreSQL
WHERE EXTRACT(YEAR FROM g.GAME_DATE) = ?
Neon の接続文字列が独特
Neon は libpq 形式 の接続文字列をくれます。
postgresql://user:pass@host.neon.tech/dbname?sslmode=require&channel_binding=require
このまま spring.datasource.url に入れても JDBC ドライバは受け付けません。jdbc: プレフィックスが必要 + user:pass@host 形式は分離が必要です。
SecretsManagerInitializer 内で URL を分解 → JDBC 形式に変換する処理を入れています:
// 入力: postgresql://user:pass@host/db?sslmode=require
// 出力: jdbc:postgresql://host/db?sslmode=require + username/password 別プロパティ
private Map<String, Object> convertToJdbcProperties(String rawUrl) {
URI uri = URI.create(rawUrl.replaceFirst("^postgresql://", "http://"));
Map<String, Object> props = new HashMap<>();
String jdbcUrl = "jdbc:postgresql://" + uri.getHost() + uri.getPath();
if (uri.getQuery() != null) jdbcUrl += "?" + uri.getQuery();
props.put("DATABASE_URL", jdbcUrl);
String[] userInfo = uri.getUserInfo().split(":", 2);
props.put("spring.datasource.username", userInfo[0]);
props.put("spring.datasource.password", userInfo[1]);
return props;
}
2. Spring Boot を Lambda に乗せる
- AWS Lambda Web Adapter を使うと、Spring Boot のコードをほぼそのまま Lambda で動かせる
- Dockerfile に 1 行
COPYを入れるだけ:
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.0 /lambda-adapter /opt/extensions/lambda-adapter
- API Gateway HTTP API → Lambda で公開
3. Secrets Manager 統合
DB 接続文字列を平文の環境変数で渡すのを止めて、起動時に Secrets Manager から取得する仕組みを Spring Boot 側に実装。
public class SecretsManagerInitializer implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
String secretName = env.getProperty("DATABASE_URL_SECRET_NAME");
if (secretName == null) return; // ローカルではスキップ
try (SecretsManagerClient client = SecretsManagerClient.create()) {
String url = client.getSecretValue(...).secretString();
// libpq形式 (postgresql://user:pass@host/db) → JDBC形式に変換
Map<String, Object> props = convertToJdbcProperties(url);
env.getPropertySources().addFirst(new MapPropertySource("secretsManager", props));
}
}
}
EnvironmentPostProcessor は Spring Boot の起動初期に動くので、spring.datasource.url=${DATABASE_URL} の解決前に値を注入できます。
4. Terraform で全部 IaC 化
20 ほどのリソースを Terraform 化:
- Lambda(API / Scraper)+ ECR
- API Gateway + 統合
- CloudFront + S3 + ACM 証明書
- Secrets Manager + IAM ポリシー
- EventBridge Scheduler(日次スクレイピング + 並列 warmup)
- GitHub Actions 用 OIDC Provider + IAM Role
5. データ移行(RDS MySQL → Neon PostgreSQL)
mysqldump → CSV/TSV → psql COPY で移行。日本語の文字コードでハマったので --default-character-set=utf8mb4 を付ける必要がありました。
# 各テーブルを TSV でエクスポート
mysql --default-character-set=utf8mb4 -h <RDS_HOST> -u admin -p<PW> baseball \
--batch --raw -e "SELECT * FROM BASEBALL_PLAYER" > BASEBALL_PLAYER.tsv
# Neon に投入
psql "$NEON_URL" -c "COPY BASEBALL_PLAYER FROM STDIN" < BASEBALL_PLAYER.tsv
レコード数:
| テーブル | 件数 |
|---|---|
| BASEBALL_TEAM | 12 |
| BASEBALL_PLAYER | 1,811 |
| BASEBALL_GAME | 8,444 |
| AT_BAT_RESULT | 635,537 |
| BASEBALL_PLAYER_HISTORY | 2,377 |
6. CI/CD(GitHub Actions + OIDC)
# .github/workflows/deploy-api.yml の主要部分
permissions:
id-token: write # OIDC トークン発行に必要
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }} # アクセスキー不要
aws-region: ap-northeast-1
- name: Build & Push
run: |
docker buildx build -f Dockerfile.lambda \
--platform linux/arm64 \
--provenance=false \
-t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
--push .
ポイント:
- OIDC を使うので AWS のアクセスキーを GitHub に保存する必要なし
- arm64 でビルドしてコスト削減
つまずいたポイント
1. --provenance=false 必須
docker buildx build のデフォルトはマニフェストリストを生成しますが、Lambda はこれを受け付けず:
InvalidParameterValueException: The image manifest, config or layer media type
for the source image ... is not supported.
--provenance=false を付けると単一マニフェストになり Lambda で動くようになります。
2. Lambda Web Adapter のイメージ名変更
過去のドキュメントだと public.ecr.aws/awsguru/aws-lambda-web-adapter ですが、現在は public.ecr.aws/awsguru/aws-lambda-adapter:1.0.0 に変わっています。最初の web- がなくなった点に注意。
3. EnvironmentPostProcessor が読まれない
Spring Boot 3 では META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports が標準ですが、Spring Boot fat JAR の build 結果では BOOT-INF/classes/META-INF/spring/ 配下に展開されず読まれない事象に遭遇。後方互換の META-INF/spring.factories にしたら動きました。
4. CloudFront から API Gateway に届かない
Origin Request Policy で headers: allViewer にしていると、ビューワの Host: <CloudFrontドメイン> がそのまま API Gateway に転送されて拒否される。headers: none にして API Gateway 自身のホストを使わせるのが正解。
5. 日次スクレイパーの重複エラー
NPB の試合一覧ページで取得する team1/team2 と試合詳細ページの home/away が必ずしも一致せず、既存データを「重複なし」と判定 → INSERT で UNIQUE 制約違反。クエリを順序非依存に修正しました:
@Query("SELECT bg FROM BaseballGame bg WHERE bg.gameDate = :gameDate "
+ "AND ((bg.homeTeamId = :homeTeamId AND bg.awayTeamId = :awayTeamId) "
+ "OR (bg.homeTeamId = :awayTeamId AND bg.awayTeamId = :homeTeamId))")
6. Lambda コールドスタート問題
Spring Boot は起動に 25〜40 秒かかります。一方 API Gateway HTTP API のタイムアウトは 最大 30 秒(変更不可)。
試した対策
- メモリを 1024 → 2048 MB に増強(CPU も比例して上がる)→ 起動 38s → 25s
- 不要な
@Bean CommandLineRunnerの混入を排除(API Lambda にバッチ処理が紛れ込んでいた) - EventBridge Scheduler で 10 分おきに warm up
並列 warmup(重要)
Lambda は 1 コンテナ = 1 リクエスト。フロントから複数 API を並列で叩くと、warm 済みのコンテナ 1 つで足りずコールドスタートします。
→ EventBridge Scheduler を 5 個 並べて、同じ cron 式で同時発火。warmup エンドポイントを 2 秒スリープにして AWS に「5 並列」と認識させ、5 コンテナを warm 維持しています。
resource "aws_scheduler_schedule" "api_warmup" {
count = 5
name = "baseball-api-warmup-${count.index + 1}"
schedule_expression = "cron(0/10 * * * ? *)"
target {
arn = aws_lambda_function.api.arn
input = jsonencode({ rawPath = "/baseball/api/warmup", ... })
}
}
これでフロント並列リクエストでも全部 warm で応答 ⚡
ローディング UX 改善
コールドスタート時に最大 30 秒近くローディングする可能性があるので、ユーザに「壊れた?」と思われないように 5 秒経過したら追加メッセージを表示するようにしました。
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p>読み込み中...</p>
<p v-if="showSlowLoadingMessage">
初回の読み込みには時間がかかることがあります(最大30秒程度)
</p>
</div>
watch: {
isLoading(newVal) {
if (newVal) {
this.slowLoadingTimer = setTimeout(() => {
this.showSlowLoadingMessage = true;
}, 5000);
} else {
clearTimeout(this.slowLoadingTimer);
this.showSlowLoadingMessage = false;
}
},
},
地味だけど、コールドスタートで離脱されるリスクをかなり減らせます。
コスト比較(実測)
| サービス | Heroku 時代 | AWS 移行後 |
|---|---|---|
| App | $7(Basic Dyno) | $0(無料枠内) |
| DB | $15〜(RDS MySQL t2.micro) | $0(Neon Free) |
| 静的配信 | $0(同 Dyno) | $0(CloudFront 無料枠) |
| API Gateway | - | $0(無料枠) |
| Secrets Manager | - | $0.40 |
| ECR | - | $0.5 |
| CloudWatch Logs | - | ~$0.5 |
| 合計 | $22〜$30 | $1〜$2 |
EventBridge × 5 並列の warmup を入れても、Lambda 月 100 万リクエスト + 40 万 GB 秒の無料枠に余裕で収まります。
今後
- ロングテール SEO 対応として、選手 × 選手の対戦結果ページを SSR or プリレンダリングで生成
- Google Analytics の数値を見て人気選手特集ページ追加
- まだ Performance スコア 78 なので Bootstrap-Vue の tree-shaking など、フロントのバンドルサイズ削減
これから移行する人へのチェックリスト
同じく Heroku → AWS 移行を検討している方向けに、この順番でやれば事故りにくい手順を残しておきます。
ハマりどころ早見表:
| やること | ハマりどころ |
|---|---|
| Docker buildx で push |
--provenance=false 必須(Lambda が単一マニフェストしか受け付けない) |
| Lambda Web Adapter 採用 | イメージ名が aws-lambda-adapter に変更(旧 aws-lambda-web-adapter は無い) |
Spring Boot で EnvironmentPostProcessor
|
META-INF/spring.factories で登録(.imports だと fat JAR で読まれない場合あり) |
| Lambda 環境変数の Bean 重複 |
application-prod.properties に spring.main.allow-bean-definition-overriding=true
|
| CloudFront → API Gateway | Origin Request Policy で Host を転送しない |
| API Gateway HTTP API のタイムアウト | 最大 30 秒固定。Spring Boot コールドスタート対策が必須 |
まとめ
個人開発の小規模サービスでも:
- Heroku → AWS Lambda + Neon はコストを 90% 削減できる
- Terraform + GitHub Actions OIDC で「再現性 + 鍵の漏洩リスク 0」を達成
- Lambda + Spring Boot はコールドスタートが課題。warmup × 並列 + メモリ増強で実用ラインに乗せられる
- SEO:SPA でも noscript と JSON-LD で十分戦える
ハマりどころは多いですが、サーバレスは個人開発と相性が良いことを実感しました。同じく Heroku から脱出を考えている方の参考になれば嬉しいです!
参考になったら LGTM・ストックお願いします 🙏
サービス: Pitcher-vs-Batter
前回の記事: 初の個人開発 #Vue.js