この記事で扱う課題
ローカルで動かして確認してから本番に上げる、という当たり前の開発サイクルは、構成要素がすべて自分の手元で動くうちは難しくない。問題は、認証もデータ層もキャッシュも AWS のマネージドサービスに乗っている場合だ。
このプロジェクトの構成要素は以下のとおり。
- Cognito — ユーザー認証(ログイン・サインアップ)
- AppSync — GraphQL API(Cognito 認証)
- DynamoDB — データストア
- ElastiCache (Redis) — サーバーサイドセッション
- Next.js — 本番では ECS Fargate 上で動くアプリケーション本体
このうちアプリ本体(Next.js)はローカルで npm run dev すれば動く。だが、それが依存する Cognito・AppSync・Redis をローカルでどう用意するかが悩みどころになる。選択肢は大きく分けて 3 つある。
- すべてをエミュレータ・モックでローカルに再現する
- すべてを共有の 1 つの dev 環境に集約し、全員がそれを叩く
- AWS マネージドサービスは本物を使い、ローカルで等価に再現できるものだけコンテナで立てる
このプロジェクトは 3 を選んだ。以下、その判断と具体的な仕組みを説明する。
方針 1: AWS マネージドサービスは「個人用の本物」を CDK で立てる
Cognito や AppSync をローカルでエミュレートしようとすると、認証トークンの細かい挙動や GraphQL リゾルバーの解決順序など、本番との差異が必ずどこかで顔を出す。「ローカルでは通るのに本番で落ちる」を生む典型的な原因だ。
そこでこのプロジェクトは、Cognito・AppSync・DynamoDB は本物の AWS リソースを使う。ただし共有の 1 つを全員で叩くのではなく、1 つの開発用アカウントの中に、開発者ごとの専用 dev スタックを立てる。アカウントは共有でも、リソースは個人ごとに分かれている状態をつくる。
リソースは CDK スタックとしてソースコードに定義されているので、誰でも 1 コマンドで個人用 dev 環境を作れる。同じアカウント内でスタック名が衝突しないよう、スタック名に開発者を識別する名前を含める。
// infra/bin/infra.ts
// Dev: Cognito + AppSync + DynamoDB のみ (ローカル開発用)
// devName は開発者ごとに異なる識別子(環境変数などで渡す)
const devName = process.env.DEV_NAME;
const devAuth = new AuthStack(app, `AppName-dev-${devName}-Auth`, devConfig);
new BackendStack(app, `AppName-dev-${devName}-Backend`, {
...devConfig,
userPool: devAuth.userPool,
});
prod スタック(VPC・ElastiCache・ECS・CodePipeline …)とは別に、dev は認証とバックエンドだけの軽量な 2 スタックに切り出してある。ローカル開発で必要なのはここまでで、ネットワークやコンテナ基盤は要らないからだ。
cd infra
DEV_NAME=alice npx cdk deploy AppName-dev-alice-Auth AppName-dev-alice-Backend
これで開発用アカウントの中に、自分専用の Cognito User Pool と AppSync API ができる。アカウントは共有でもリソースは個人ごとに分かれているので、他人のデータやスキーマ変更に邪魔されずに並行開発できるのがこの方式の利点だ。共有の 1 つの dev を全員で叩くと「誰かがスキーマを壊すと全員止まる」が起きるが、それを構造的に避けられる。
方針 2: ローカルで等価に再現できるものは docker-compose で立てる
すべてを「本物の AWS」にすると、今度は Redis が問題になる。本番の ElastiCache は VPC 内のプライベートサブネットに置かれ、外部からは到達できない。セキュリティ上それが正しい姿だが、ローカルの Next.js からは繋げない。
ここで効くのが、Redis はステートレスなキャッシュ/セッションストアであり、ローカルに立てた素の Redis が ElastiCache とまったく同じプロトコル・同じ操作を提供するという性質だ。Cognito や AppSync と違い、ローカルのコンテナと本番のマネージドサービスとの間に意味のある差が生じない。
だから Redis だけは docker-compose でローカルに立てる。
# docker-compose.yml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
docker compose up -d
判断基準はシンプルで、**「ローカルのコンテナが本番と同じ振る舞いを保証できるか」**だ。保証できるもの(Redis)はコンテナ、保証できないもの(Cognito・AppSync)は本物の dev リソースを使う。この線引きによって「何がローカルで、何が AWS か」が一目で分かり、環境差に起因するデバッグを最小化できる。
| コンポーネント | ローカル開発での扱い | 理由 |
|---|---|---|
| Cognito | 本物(個人用 dev スタック) | 認証挙動をエミュレータで再現しきれない |
| AppSync / DynamoDB | 本物(個人用 dev スタック) | リゾルバー・スキーマの挙動を本番と揃えたい |
| Redis | ローカルコンテナ(docker-compose) | プロトコルが同一で、本番と等価に再現できる |
方針 3: ローカルとサーバーの切り替えは「環境変数」だけで行う
ここまでの構成で、接続先は「ローカルの Redis」「個人用 dev の Cognito/AppSync」「本番の ElastiCache/Cognito/AppSync」と環境ごとに変わる。これをコードの分岐で書くと if (isLocal) が散らばって破綻する。
このプロジェクトは接続先をすべて環境変数に追い出し、アプリのコードは接続先を一切知らない形にしている。Redis クライアントは REDIS_URL が指す先がローカルか ElastiCache かを意識しない。
// apps/web/src/app/lib/redis.ts
client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
AppSync の呼び出しも同様に、URL を環境変数から受け取るだけだ。
// apps/web/src/app/lib/appsync.ts
const APPSYNC_URL = process.env.APPSYNC_URL!;
ローカルの .env.local はこうなる。Redis だけ localhost を指し、Cognito と AppSync は dev スタックの出力値を入れる。
# apps/web/.env.local
# Cognito (dev: cdk deploy AppName-dev-Auth で出力される値)
NEXT_PUBLIC_USER_POOL_ID=ap-northeast-1_XXXXXXXXX
NEXT_PUBLIC_USER_POOL_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXX
# AppSync (dev: cdk deploy AppName-dev-Backend で出力される値)
APPSYNC_URL=https://XXXXXXXXXX.appsync-api.ap-northeast-1.amazonaws.com/graphql
# Redis (ローカル: docker compose up)
REDIS_URL=redis://localhost:6379
本番(ECS タスク)では同じ環境変数に、ElastiCache のエンドポイントと prod の AppSync URL を入れる。**コードは 1 行も変わらない。**ログインからセッション保存、AppSync 呼び出しまで、ローカルと本番で完全に同じコードパスが走る。これが「ローカルで確認したものがそのまま本番で動く」ことの根拠になる。
ひとつ注意: ビルド時に焼き込む値とランタイムで渡す値は分けている
環境変数といっても、Next.js では渡すタイミングが 2 種類ある。この区別を曖昧にすると、同じ Docker イメージを環境ごとに使い回せなくなる。
-
サーバー専用の接続情報(
APPSYNC_URL・REDIS_URL・SESSION_SECRET)はランタイムで渡す。サーバーサイドでしか参照されないので、ビルド済みイメージにそのまま注入できる。 -
ブラウザに露出するクライアント設定(
NEXT_PUBLIC_USER_POOL_IDなど Cognito の ID)は、Next.js の仕様上ビルド時にバンドルへ焼き込まれる。そのため Dockerfile ではARGとして受け取っている。
# apps/web/Dockerfile (builder ステージ)
ARG NEXT_PUBLIC_USER_POOL_ID
ARG NEXT_PUBLIC_USER_POOL_CLIENT_ID
ENV NEXT_PUBLIC_USER_POOL_ID=$NEXT_PUBLIC_USER_POOL_ID
ENV NEXT_PUBLIC_USER_POOL_CLIENT_ID=$NEXT_PUBLIC_USER_POOL_CLIENT_ID
RUN npm run build --workspace=apps/web
つまり「環境差はすべてランタイム環境変数で吸収する」が理想だが、NEXT_PUBLIC_* だけはブラウザに露出する性質上ビルド時に確定する。サーバー側の接続先(DB・キャッシュ・API)はランタイム、クライアント公開値はビルド時、という線引きを意識しておくと混乱しない。
まとめ
AWS マネージドサービスを前提にしつつ「ローカルで確認してからサーバーに上げる」を成立させるための 3 つの方針はこうだ。
- エミュレートが難しいマネージドサービス(Cognito・AppSync・DynamoDB)は、1 つの開発用アカウントの中に開発者ごとの本物のリソースを立てる。 アカウントは共有でもリソースは個人ごとに分かれるので、並行開発で互いに壊し合わない。
- ローカルで本番と等価に再現できるもの(Redis)だけ docker-compose で立てる。 「コンテナが本番と同じ振る舞いを保証できるか」を線引きの基準にする。
-
ローカルとサーバーの切り替えはコード分岐ではなく環境変数で行う。 アプリは接続先を知らず、同一コードパスがどちらでも走る。ただしクライアント公開値(
NEXT_PUBLIC_*)だけはビルド時に確定する点に注意する。
この 3 つを守ると、「ローカルで通ったのに本番で落ちる」の主要因である環境差が、設計の段階で構造的に潰れる。