Next.js 1 つで動いていたサイトを、Go のマイクロサービス + React(Vite)+ 自宅 Mac mini の Kubernetes に置き換えた。
このドキュメントでは、何をどう作ったかを図を使って順番に説明する。
図は Mermaid という書き方で描いてあり、GitHub や VS Code のプレビューで絵として表示される。
目次
| 回 | 分野 | テーマ |
|---|---|---|
| 0 | 全体 | 何から何に置き換えたか |
| 1 | バックエンド | 通信の約束事:Protocol Buffers・Connect・gRPC |
| 2 | バックエンド | 層の分け方:DDD+クリーンアーキテクチャ |
| 3 | バックエンド | 自作のコード生成・エラー・ログイン・Redis |
| 4 | バックエンド | サービス同士の通信・外部 API・テスト |
| 5 | インフラ | Docker と Kubernetes の基本、kind |
| 6 | インフラ | 入口:Cloudflare・Envoy Gateway・HTTPRoute |
| 7 | インフラ | Redis・Secret・NetworkPolicy |
| 8 | インフラ | CI/CD と GitOps(dev01〜03 を含む) |
| 9 | インフラ | カナリアリリース:Argo Rollouts+Datadog |
| 10 | フロント | Next.js から React+Vite へ、FSD |
| 11 | フロント | データの取り方と状態 |
| 12 | フロント | 見た目と動き、配信 |
| 付録 | – | 用語集・ドキュメントと実装の食い違い |
第0回 全体像:何から何に置き換えたか
置き換え前:Next.js 1 つ
Next.js は「画面(React)」と「サーバーの処理(API)」を 1 つのプロジェクトにまとめて書ける。手軽だが、全部がくっついているので、一部を直しても全体を作り直して入れ替えることになる。
置き換え後:役割ごとに分割
| 部分 | 前 | 後 |
|---|---|---|
| 画面 | Next.js |
React + Vite(frontend/) |
| サーバー処理 | Next.js の API routes(TypeScript) |
Go のマイクロサービス 8 つ(backend/) |
| 通信の形 | 普通の JSON API |
Connect / gRPC(.proto で型を決める) |
| 動かす場所 | 自宅の Kubernetes | 自宅 Mac mini の kind を、クラウド(GKE)に近い構成で |
| リリース | 手作業寄り | GitHub Actions → Argo CD(GitOps)→ Argo Rollouts(カナリア) |
| 環境 | 本番 + dev | dev01〜03(PR ごと)→ stg → 本番 |
| 監視 | Datadog(一部) | Datadog で全部を見張り、悪ければ自動で元に戻す |
8 つの Go サービス
1 回のアクセスを追う:「勤務場所」ページ
リプレイス全体の方針
- 見た目と作りは変えるが、内容は 1 文字も変えない:文言・URL・メール文面・localStorage のキーは旧版のまま
-
URL を変えない:Google に登録済みのログイン用 URL(
/api/auth/*・/api/calendar-sync/*)は旧版と同じ - 旧版と同じ答えを出すことをテストで守る:旧 TypeScript を Node で動かして作った正解データ(golden)と照合する
- いきなり全員に出さない:dev → stg → 本番の順。本番は 10% の利用者から少しずつ切り替える
第1回 通信の約束事:Protocol Buffers・Connect・gRPC
なぜ必要か
画面(TypeScript)とサーバー(Go)は別の言語で書かれている。「関数の名前」「渡すもの」「返ってくるもの」の約束がずれると動かない。
そこで約束を 1 つのファイル(.proto) に書き、両方のコードをそこから自動で作る。
用語
| 用語 | ひとことで |
|---|---|
| Protocol Buffers(protobuf) | Google が作った「データの形」と「関数の一覧」を書く言語。.proto ファイル |
| gRPC | protobuf で決めた関数を、ネットワーク越しに呼ぶ仕組み。速いがブラウザから直接は使いにくい |
| Connect | gRPC と同じ約束で、ブラウザからも普通の HTTP + JSON で呼べるようにした仕組み(Buf 社製) |
| buf |
.proto の書き方チェック(lint)とコード生成をまとめて行う道具 |
| h2c | 暗号化なしの HTTP/2。クラスターの中のサービス同士の gRPC で使う |
約束事の例(backend/proto/taramanji/worklocation/v1/worklocation.proto)
service WorkLocationService {
rpc ListWorkLocations(ListWorkLocationsRequest) returns (ListWorkLocationsResponse) {} // 公開
rpc SetWorkLocations(SetWorkLocationsRequest) returns (SetWorkLocationsResponse) {} // 管理者
rpc DeleteWorkLocation(DeleteWorkLocationRequest) returns (DeleteWorkLocationResponse) {}
rpc GetWorkLocation(GetWorkLocationRequest) returns (GetWorkLocationResponse) {} // 内部(reservation から)
}
// @entity
message WorkLocation {
// @pk
string date = 1; // YYYY-MM-DD(JST)
// @required
string location = 2;
}
// @entity などのコメントは自作の生成器(第3回)への指示。
URL の形
RPC 1 つが URL 1 つになる:/<package>.<Service>/<RPC名>
POST /taramanji.worklocation.v1.WorkLocationService/ListWorkLocations
content-type: application/json
{}
手元なら curl でも呼べる(backend/README.md)。
1 つのサーバーで 3 種類の呼ばれ方を受ける
backend/pkg/util/server/server.go で、1 つのポート(:8080)が HTTP/1 と h2c の両方を受けるように設定している。
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
設定ファイル
-
backend/buf.yaml:lint はSTANDARD(空のレスポンスを許すため 1 ルールだけ除外) -
backend/buf.gen.yaml:3 つの生成器(protoc-gen-go・protoc-gen-connect-go・mss-protoc-gen) -
frontend/buf.gen.yaml:protoc-gen-es。notification は内部専用なので画面側には生成しない
第2回 層の分け方:DDD + クリーンアーキテクチャ
考え方
- DDD(ドメイン駆動設計):業務の言葉(勤務場所・予約・お問い合わせ)で区切って作る。区切りを「コンテキスト」と呼び、ここでは 1 コンテキスト = 1 サービス
- クリーンアーキテクチャ:コードを層に分け、依存(import)は外側から内側への一方通行にする。業務ルールが Redis や HTTP の都合に引きずられない
ポイントは、内側は interface(「こういう関数があるはず」という約束)だけを知っていること。本物の Redis かテスト用の偽物かは、起動時(cmd/<service>/main.go)に差し込む。これを DI(依存性の注入) という。
worklocation のファイル一覧
| ファイル | 書き方 | 役割 |
|---|---|---|
domain/entity/work_location.gen.go |
生成 | WorkLocation 型と検証(@required) |
domain/repository/work_location_repository.gen.go |
生成 | 「読む・書く・消す」の interface |
domain/repository/mock/...gen.go |
生成 | テスト用の偽物 |
domain/service/work_location_service.go |
手書き | 業務ルール(過去を消す・未定で埋める) |
usecase/work_location_usecase_interface.gen.go |
生成 | Usecase の interface と入出力の型 |
usecase/work_location_usecase.go |
手書き | 入力チェックとエラーの種類決め |
handler/work_location_handler.gen.go |
生成 | Connect の入口 |
infra/repository/work_location_redis_repository.gen.go |
生成 | Redis の実装 |
dto/work_location.gen.go |
生成 | 外に返すための型 |
di/handlers.gen.go |
生成 | ハンドラを URL に登録 |
人が書くのは業務ルールと usecase だけで、残りは proto から生成される。
1 リクエストが層を通る順番
手書き部分の中身
業務ルール(domain/service/work_location_service.go)。旧 Next.js の src/app/api/location/route.ts と同じ動きを Go で書き直したもの。
const Undecided = "未定(お問い合わせください)"
// ListPublished は過去の日を消し、今日から 2 ヶ月先まで未登録の日を Undecided で埋めて返す。
today := jst.Date(now)
// ... 過去の日は BulkDelete
// JS の setMonth(+2) と同じく、月末の繰り越しも Go の AddDate と一致する
last := jst.Date(now.In(jst.Location).AddDate(0, publishMonths, 0))
usecase(usecase/work_location_usecase.go):
if len(input.Dates) == 0 || input.Location == "" {
return nil, errs.NewCodedError(errs.ErrorTypeBadRequest, "missing_fields", "日付と勤務場所を指定してください")
}
起動(cmd/worklocation/main.go):部品を組み立てるだけ。
rdb, _ := redisx.New(serviceName)
svc := service.NewWorkLocationService(infrarepo.NewRedisWorkLocationRepository(rdb), nil)
handlers := di.NewHandlers(usecase.NewWorkLocationUsecase(svc))
adminOnly := auth.AdminInterceptor([]string{ /* Set と Delete */ }, nil, "")
server.Run(serviceName, func(mux *http.ServeMux) {
handlers.Register(mux, connect.WithInterceptors(adminOnly))
})
データの持ち方の変化
第3回 自作のコード生成・エラー・ログイン・Redis
mss-protoc-gen(backend/cmd/mss-protoc-gen/)
protoc の プラグイン(生成器)を自作したもの。proto のコメント(アノテーション)を読んで、層ごとの雛形を作る。
| アノテーション | 作られるもの |
|---|---|
// @entity |
Entity・Repository interface・Mock・Redis 実装・DTO |
// @pk |
主キー(SelectByPK / Delete / BulkDelete) |
// @unique |
SelectBy<項目>(Redis では索引キー ...:idx:<項目>:<値>) |
// @required / // @email
|
検証 |
// @timestamp |
int64 を time.Time に |
// @paging |
ページ送り |
buf.gen.yaml で渡しているオプション:contexts=true(internal/<context>/ に出し分け)、infra=redis、handler=connect。
cd backend
make install-tools # buf と生成器を入れる
make proto-gen # proto を変えたら必ず実行
*.gen.go と gen/ は手で直さない。直したいときは proto を変えて生成し直す(CI でも「生成し直して差分が出ないか」を見ている)。
エラーの扱い(backend/pkg/util/errs/)
| 種類 | HTTP 相当 | Connect のコード |
|---|---|---|
| Validation / BadRequest | 422 / 400 | InvalidArgument |
| NotFound | 404 | NotFound |
| Duplicate | 409 | AlreadyExists |
| Unauthorized | 401 | Unauthenticated |
| Forbidden | 403 | PermissionDenied |
| Upstream(外部 API の失敗)/ Concurrency | 502 / 503 | Unavailable |
| Internal | 500 | Internal(中身は返さずログだけ) |
code(invalid_email など)には旧 API のエラー文字列をそのまま使う。フロントはそれを見て旧版と同じメッセージを出す。
ログイン(backend/pkg/util/auth/)
-
JWT:中身(メールアドレスなど)に署名を付けた文字列。署名の鍵
AUTH_SECRETは全サービスで共通なので、identity に問い合わせなくても各サービスが自分で確かめられる - Interceptor:全 RPC の手前に挟まる関所。管理者用の RPC だけを止める
- 5 分ごとの同期(CronJob)は人ではないので、Cookie の代わりに
Authorization: Bearer <トークン>で通す
Redis の分け方
- 1 つの Redis を、DB 番号と**ユーザー(ACL)**で区切って使う
- DB 0 = worklocation、1 = content(キャッシュ)、2 = calendarsync
- 各サービスのユーザーは自分のキー(例
workLocations:*)しか触れず、危険なコマンドも使えない
- 実際の区切りは
REDIS_URL(redis://<ユーザー>:<パスワード>@<ホスト>:6379/<DB番号>)で決まり、その値はdeploy/k8s/scripts/make-secrets.shが作る
その他の共通部品(backend/pkg/util/)
| 部品 | 役割 |
|---|---|
server |
起動、/healthz・/readyz、終了時に 5 秒待ってから止める(通信を切らないため) |
observability |
Datadog(トレース・メトリクス) |
logger |
slog。本番・stg は JSON、ログに trace_id を付ける |
env |
環境変数。必須のものが無ければ起動しない |
jst |
日付は必ず日本時間で判定する |
httpx |
外部 HTTP 用のクライアント(Datadog で計測済み) |
第4回 サービス同士の通信・外部 API・テスト
サービス同士:interface を挟んで gRPC
// backend/internal/inquiry/infra/gateway/notification_client.go
notificationv1connect.NewNotificationServiceClient(
server.InternalHTTPClient(), baseURL, server.InternalClientOptions()...) // WithGRPC()
reservation → worklocation(その日の勤務場所を聞く)も同じ形。
外部 API の担当
| サービス | つなぐ先 |
|---|---|
| identity | Google OAuth(PKCE)、ユーザー情報 |
| notification | Amazon SES(鍵が無いときは送らずに成功扱い=開発用) |
| reservation | GAS のウェブフック、Google Calendar、iCal、Google マップの短縮 URL |
| content | Qiita API とページ、connpass、ORCID、OGP(キャッシュは Redis) |
| calendarsync | Google Calendar(複数アカウント)、OAuth |
| analytics | Datadog(web.pageviews を数える) |
外部の URL を勝手に取りに行かないよう、Qiita とマップは許可したホストだけを通す(IsQiitaURL・IsMapsURL)。
旧 API との対応
| 旧(Next.js) | 新 |
|---|---|
/api/location・/api/admin/location
|
worklocation ListWorkLocations / SetWorkLocations / DeleteWorkLocation
|
/api/reserve・/api/ical/busy・/api/maps/resolve
|
reservation CreateReservation / GetBusy / ResolveMapsUrl など |
/api/contact・/api/questionnaire・/api/admin/send-email
|
inquiry → notification |
/api/qiita*・/api/connpass・/api/orcid・/api/ogp
|
content |
/api/calendar-sync/* |
calendarsync(connect・callback は同じ URL の HTTP) |
/api/auth/[...nextauth] |
identity(/api/auth/* は同じ URL の HTTP) |
/api/metrics/pageview |
analytics(同じ URL の HTTP) |
テスト
| 種類 | 何を守るか | 例 |
|---|---|---|
| ユニットテスト(Mock) | 業務ルール。時刻も注入して固定 |
internal/worklocation/domain/service/work_location_service_test.go(12/31 + 2 か月 = 3/3 を JS と合わせる) |
| golden テスト | メール文面・同期予定の ID が旧版と同じ |
internal/inquiry/.../golden_test.go、internal/calendarsync/.../golden_test.go
|
| miniredis | 本物の Redis 無しで Redis を使う処理を試す | internal/calendarsync/usecase/calendar_sync_usecase_test.go |
live テスト(-tags live) |
本番の旧 API と結果を比べる | internal/reservation/usecase/live_parity_test.go |
コンテナイメージ(backend/Dockerfile)
1 つの Dockerfile で、--build-arg SERVICE=reservation のように 8 サービスを作り分ける。
第5回 Docker と Kubernetes の基本、kind
コンテナと Kubernetes
| 用語 | ひとことで |
|---|---|
| コンテナ / イメージ | プログラムと必要なものを詰めた箱 / その箱の設計図 |
| Pod | Kubernetes で動かす最小単位 |
| Deployment | 「この Pod を N 個保つ」。落ちたら作り直す、新しい版へ少しずつ入れ替える |
| Service | Pod が入れ替わっても変わらない宛先(名前) |
| namespace | クラスターの中の区画(taramanji・taramanji-dev01 など) |
| マニフェスト | 「こうなっていてほしい」を書いた YAML |
kind で Mac mini の中にクラスターを作る
kind = Kubernetes in Docker。Docker のコンテナ 1 つを 1 台のマシンに見立てる。
-
deploy/kind/cluster.yaml:1 + 2 台。GKE と同じ「ゾーン」のラベルを付け、Pod を 2 台に散らす練習ができる - データ置き場を Mac のフォルダに出しているので、クラスターを作り直してもデータが残る(StorageClass
standard-rwo、Retain) -
deploy/kind/up.sh:起動(Mac の起動時に launchd が実行)。cloud-provider-kind で LoadBalancer に IP を付ける -
deploy/kind/platform.sh:GKE なら最初からあるもの(metrics-server・Envoy Gateway・StorageClass)を入れる
Go サービス 1 つ分のマニフェスト(deploy/k8s/base/apps/worklocation.yaml)
| 設定 | 意味 |
|---|---|
replicas: 2 + topologySpread |
2 個をノード・ゾーンに散らす。1 台止まっても動く |
RollingUpdate maxUnavailable: 0
|
入れ替え中も減らさない |
| startup / readiness / liveness probe | 起動した?通信を受けてよい?生きている?を /readyz・/healthz で確かめる |
| resources | CPU 10m・メモリ 24Mi を予約、上限 64Mi |
| securityContext | root で動かさない、ファイルを書き換えられない、権限を全部外す |
PDB minAvailable: 1
|
メンテナンス中も最低 1 個は残す |
| HPA(web・reservation・content) | CPU 70% を超えたら 2 → 最大 4 個に増やす |
第6回 入口:Cloudflare・Envoy Gateway・HTTPRoute
ルーターのポートを開けずに公開する
- Cloudflare Tunnel:家の中の cloudflared が外へつなぎに行く。外から家へ入る穴(ポート開放)が要らない
- トンネルは 2 本
| トンネル | 通すホスト | Access(ログイン必須) |
|---|---|---|
| prod(taramanji-onprem) | taramanji.com・www・gws | なし(公開) |
| staging(taramanji-kind) | dev01〜03・dev01〜03-gws・stg・stg-gws・rollouts |
あり(deploy/cloudflare/access.py) |
- ホスト名が
dev01-gws.taramanji.comのようにハイフンなのは、Cloudflare の無料証明書*.taramanji.comが 1 段しか効かないから
Envoy Gateway と HTTPRoute
Gateway API は Kubernetes の「入口」の標準の書き方。Envoy Gateway はその実装。
- notification には HTTPRoute が無い → 外からは絶対に届かない
- 各ルールは
<svc>(重み 100)と<svc>-canary(重み 0)の 2 つに向いている。カナリアのとき Argo Rollouts がこの重みを書き換える(第9回) - ホスト名は base では仮の値で、環境ごと(overlay)に差し替える
第7回 Redis・Secret・NetworkPolicy
Redis(deploy/k8s/base/data/redis.yaml)
-
StatefulSet:Pod に固定の名前(
redis-0)と専用のディスクを付ける。データを持つものに使う - AOF(
appendonly yes、1 秒ごとに書き出し)で、再起動してもデータが残る -
maxmemory 200mb・noeviction:あふれたら勝手に消さずにエラーにする
Secret:公開リポジトリに鍵を置く方法
- 暗号を解く鍵はクラスターの中にしかないので、暗号文は public リポジトリに置いてよい
- dev01〜03 は同じ
secrets/dev/を、namespace だけ変えてそれぞれ暗号化する(SealedSecret は namespace ごとに別の暗号になるため)
deploy/k8s/scripts/make-secrets.sh dev
for e in dev01 dev02 dev03; do deploy/k8s/scripts/seal.sh $e; done
- CI(lint)は、平文の
kind: Secretがコミットされていないかも確かめる
NetworkPolicy:Pod 同士の通信も「許可したものだけ」
まず全部を拒否(default-deny-ingress)し、必要な道だけを開ける。
第8回 CI/CD と GitOps(dev01〜03 を含む)
用語
| 用語 | ひとことで |
|---|---|
| CI | push のたびに自動で lint・テスト・ビルドする |
| CD | できたものを自動で環境に出す |
| GHCR | GitHub のコンテナイメージ置き場 |
| GitOps | 「クラスターのあるべき姿」を Git に書き、Git を正とする。人がクラスターを直接いじらない |
| Argo CD | Git を 3 分ごとに見て、クラスターをそれに合わせる道具(Pull 型なので家への入口が要らない) |
| Kustomize | 共通の YAML(base)に、環境ごとの差分(overlay)を重ねる仕組み |
環境は 5 つ
| 環境 | きっかけ | URL | namespace |
|---|---|---|---|
| dev01〜03 | main への PR/手動 | devNN.taramanji.com・devNN-gws.taramanji.com | taramanji-devNN |
| stg | main への push(マージ)/手動 | stg.taramanji.com・stg-gws.taramanji.com | taramanji-stg |
| prod | タグ v* の push |
taramanji.com・gws.taramanji.com | taramanji |
全体の流れ
変わったサービスだけ作り直す(deploy/k8s/scripts/changed-services.py)
dev01〜03:PR ごとに枠を割り当てる(今回の変更)
以前の dev は 1 つで、PR が来るたびに上書きされていた。今は 3 つの枠があり、deploy/k8s/scripts/pick-dev.sh が出す先を決める。
例:PR が A・B・C・D の順に来た場合
| dev01 | dev02 | dev03 | |
|---|---|---|---|
| A を出す | A | – | – |
| B を出す | A | B | – |
| C を出す | A | B | C |
| D を出す(一番前に使われたのは dev01) | D | B | C |
| A をもう一度 push | D | B | C → A(一番前に使われた dev03 へ) |
- 「まず stg にそろえる」のは、前にその枠を使っていた PR の版が残らないようにするため
- 全部の反映ジョブは同じ concurrency グループ(
deploy-manifests)なので、2 つの PR が同じ枠を同時に取り合うことはない - 今どこに何が出ているか:PR のコメント、
git log --grep '^deploy(dev0'、Argo CD の画面(taramanji-dev01〜03)
dev・stg の共通部分(deploy/k8s/components/nonprod/)
- dev・stg も Rollout(カナリアの手順なし)にしたので、rollouts.taramanji.com で namespace を taramanji-devNN / taramanji-stg に切り替えれば入れ替わりの様子が見える
- Rollouts の画面は、開いたときに taramanji の一覧が出るよう
--namespace=taramanjiを付けた(deploy/gitops/rollouts-values.yaml) - dev・stg の Redis はそれぞれの namespace に 1 つずつ。予約・お問い合わせは本物の GAS・SES に届くので、試すときは自分宛てに
Argo CD の構成
-
ApplicationSet:1 つの雛形から複数の Application を作る仕組み。ここでは
{env}に dev01〜03 を入れて 3 つ作る - 自動同期(
prune:Git から消したら消す、selfHeal:手でいじったら戻す) - 家の回線は遅いので、git の待ち時間を 300 秒に伸ばしている
-
deploy/gitops/bootstrap.shの順番:Sealed Secrets → Argo Rollouts → Argo CD → リポジトリの鍵 → root
今回の修正:
deploy/gitops/argocd-values.yamlにapplicationSet: enabled: falseと書いてあったが、使っているチャート(argo-cd 10.9.6)にはこの項目が無く、ApplicationSet コントローラーは最初から動いていた(だから dev01〜03 が作られている)。書いてあることと実際が逆で紛らわしいので、replicas: 1と説明コメントに直した。helm templateで比べて、クラスターに入る中身は変わらないことを確認済み。
本番リリース
# stg で確かめたコミットにタグを付ける
git tag v1.2.3 <コミット> && git push origin v1.2.3
戻すとき(Git が正なので、Git を戻す):
deploy/k8s/scripts/set-version.sh prod v1.2.2 reservation
git commit -am "deploy(prod): rollback reservation to v1.2.2" && git push
第9回 カナリアリリース:Argo Rollouts + Datadog
カナリアとは
新しい版を一部の利用者にだけ先に出し、問題がなければ広げる。炭鉱のカナリアが由来。
仕組み
- 新版だけを判定できるのは、各 Pod に
versionタグ(=イメージのタグ)が付いているから。Kustomize のreplacementsで、イメージのタグを Datadog のラベルと判定の引数に自動でコピーしている - 判定に入れないもの
-
ヘルスチェック:数秒ごとに来るので、混ぜると数字が実態から離れる(
server.goでトレースしない) -
外部 API への呼び出し:自分の失敗ではないので、サーバー側の span だけを見る(
DD_TRACE_SPAN_ATTRIBUTE_SCHEMA=v1) - RunSync の遅さ:カレンダー同期はもともと長いので p95 の判定から外す
-
ヘルスチェック:数秒ごとに来るので、混ぜると数字が実態から離れる(
- web は一度に切り替える:旧版の HTML から新版の JS を取りに行くと 404 になるため、混ぜない
操作
kubectl argo rollouts --context kind-taramanji -n taramanji get rollout reservation --watch # 様子
kubectl argo rollouts --context kind-taramanji -n taramanji promote reservation # 10% から先へ
kubectl argo rollouts --context kind-taramanji -n taramanji abort reservation # 中止して戻す
画面なら https://rollouts.taramanji.com(Cloudflare Access で管理者だけ)。
監視(deploy/k8s/observability/)
-
monitors.py:5xx 率・p95・再起動・トンネルの接続数・Redis のメモリ・カレンダー同期の失敗・予約やメールの失敗・証明書の期限・Argo の異常など -
dashboard.py:入口から Redis まで、上から順に見られるダッシュボード - dev・stg のログは取らない(お金がかかるため)。トレースは届く
第10回 Next.js から React + Vite へ、FSD
何が変わったか
| 用語 | ひとことで |
|---|---|
| SPA | 最初に 1 枚の HTML と JS を読み込み、あとはブラウザの中で画面を切り替える |
| Vite | 開発サーバーとビルドの道具。保存するとすぐ画面に反映される |
| React Router | URL ごとにどの画面を出すか決める |
- サーバー側の処理(API・ログイン)は全部 Go へ移ったので、フロントは画面だけになった
-
内容は 1 文字も変えないのがルール。文言・リンク・検証メッセージ・localStorage のキーは旧版のまま。
/privacyと/calendar-syncは Google の審査に登録済みなので特に厳密
主な設定ファイル
-
frontend/vite.config.ts:@をsrcの別名に。開発時は API(/taramanji.*と/api)を kind の Gateway へ転送し、Host をdev01.taramanji.comにする(=手元の開発は dev01 のデータを使う)。GATEWAY_URLで変更可 -
frontend/src/shared/config/site.ts:今いるホスト名から環境を判断(dev01〜03・stg・prod)。dev・stg ではタブのタイトルに[DEV01]〜[DEV03]・[STG]が付く -
frontend/src/app/routes/router.tsx:ページは必要になってから読み込む(lazy)。gws のホストでは/adminだけを出す(旧middleware.tsの役目)
FSD(Feature-Sliced Design):フォルダの分け方
ルール(npm run lint:fsd = scripts/check-fsd.mjs が CI で検査):
- import は上から下へだけ
- 同じ層の別のスライスは import しない(例:features/contact から features/reserve は ✕)
- 他のスライスは
index.tsで公開したものだけを使う
例:src/entities/work-location/index.ts
export * from "./model/locations";
export { useWorkLocations } from "./api/use-work-locations";
第11回 データの取り方と状態
API の呼び出し:connect-es
関数のように呼べて、引数や戻り値の型を間違えるとビルドで止まる。エラーは toApiError(err)(src/shared/api/errors.ts)で { code, message, detail, fields } にし、code を見て旧版と同じ文言を出す。
状態の置き場所
サーバーのデータ(src/entities/work-location/api/use-work-locations.ts):
export function useWorkLocations() {
return useQuery({
queryKey: ["work-locations"],
queryFn: async () => (await workLocationApi.listWorkLocations({})).locations,
});
}
フォームの入力(src/features/contact/model/state.ts):
const ttlString = createTTLStorage<string>(10 * 60 * 1000);
export const contactEmailAtom = atomWithStorage<string>("contact_email", "", ttlString, { getOnInit: true });
入力チェック:zod
補足:
package.jsonには react-hook-form も入っているが、実際のコードでは使っていない。フォームの値は jotai に置き、送信時に zod のsafeParseで確かめている。useMutationも使わず、API を直接呼んで try/catch している。
第12回 見た目と動き、配信
見た目
| 道具 | 役割 |
|---|---|
| Tailwind CSS v4 |
class="text-body-regular ..." のようにクラス名で見た目を書く。v4 は設定ファイル不要で、CSS の中で設定する |
| BoardUI | ボタン・入力欄などの部品集。ソースを自分のリポジトリにコピーして使う型(src/components/)。中身は react-aria-components なので、キーボード操作や読み上げに対応 |
src/app/theme.css |
色と文字の大きさを、暗い背景の日本語向けに上書き |
| IBM Plex Sans JP / Mono | 本文と、日付・メタ情報用の等幅 |
決めごと(frontend/CLAUDE.md):
- ダークテーマ固定。文字はすべてコントラスト 4.5:1 以上(
npm run check:contrastが Playwright で全ページを測る) - 飾りのアイコン・絵文字・グラデーションを付けない。何でも箱で囲まない
- 残すもの:背景に流れる CLI コマンド、送信中のターミナル風ダイアログ、Qiitan / Gopher、所属とスキルの色分け
動き(motion ライブラリ、src/shared/ui/motion/)
| 部品 | 動き |
|---|---|
| DecodeText | 見出しの文字が記号から 1 文字ずつ定まる |
| DecodeReveal | 中身の文字も記号から定まって出る |
| Reveal | 画面に入るとぼかしから浮かぶ |
| Rule | 区切り線が左から伸びる |
| ScrollProgress | 画面上端の線で読んだ位置を出す |
| ページ遷移 | 前のページは上へ消え、次はぼかしから浮かぶ(約 280ms) |
すべて prefers-reduced-motion(OS の「動きを減らす」設定)で止まる。
配信と計測
- リリース直後に古いタブが消えた JS を取りに行って失敗したら、1 回だけ自動で再読み込みする(
src/shared/lib/stale-chunk.ts) -
Datadog RUM(
src/app/providers/rum.ts):ブラウザでの体験を計測。traceparentを付けて、ブラウザ → Gateway → Go まで 1 本のトレースでつながる - ページビューは
navigator.sendBeacon("/api/metrics/pageview")→ analytics → Datadog のweb.pageviews
付録
用語集
| 用語 | 説明 |
|---|---|
| マイクロサービス | 役割ごとの小さなアプリに分ける作り方 |
| API | 画面からサーバーに頼みごとをする窓口 |
| RPC | 遠くの関数を呼ぶこと(Remote Procedure Call) |
| interface | 「こういう関数があるはず」という約束。中身は別に書く |
| DI | 部品を外から差し込むこと。テストで偽物に差し替えられる |
| JWT | 署名付きの身分証明の文字列 |
| CronJob | 決まった時刻に動く Job(ここでは 5 分ごとの同期) |
| HPA | 負荷に応じて Pod の数を自動で増減する |
| PDB | メンテナンス中も最低何個残すかの約束 |
| トレース | 1 つのリクエストがどこをどれだけの時間で通ったかの記録 |
| p95 | 遅い方から 5% を除いた中での最大の時間。「ほとんどの人はこれ以内」 |
ドキュメントと実装の食い違い(調べて分かったこと)
| 書いてあること | 実際 |
|---|---|
deploy/k8s/README.md:kind に「ローカルレジストリ」 |
作っていない。イメージはすべて GHCR から |
backend/CLAUDE.md:予約の件名・説明文を golden.json で守る |
golden.json があるのは inquiry と calendarsync だけ |
backend/CLAUDE.md:httpx に許可リスト |
許可リストは content と reservation がそれぞれ持っている |
backend/CLAUDE.md:ログは JSON |
JSON は stg・本番だけ。local・dev はテキスト |
frontend/CLAUDE.md:フォームは react-hook-form + zod |
react-hook-form は未使用(jotai + zod) |
| cloudflared のマニフェスト:「setup.sh が作る」 | setup.sh は無い。トンネルの鍵は seal.sh prod で作る |
deploy/gitops/argocd-values.yaml:applicationSet.enabled: false
|
今回修正。この項目はチャートに無く、コントローラーは動いていた |