Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

How We Cut GPU Inference Cost by ~75%: Async, Autoscaled Diffusion on GKE

By Sandeep Kumar Nayak — AI Engineer at Gaudiy

A technical deep-dive on how a single oversized GPU turned into ~90% of a bill, and the four changes that brought it back down.

Framing note. This is a sanitized write-up of a real cost-optimization project. Service names, cluster names, and currency figures have been swapped for representative equivalents. Everything technical (the bottleneck analysis, the async design, the autoscaling metric, the pool right-sizing, and the cold-start fix) is described as it actually happened.

The names below are aliases, standing in for the real service and hardware: - image-gen-svc — the GPU-backed image-generation service this post is about. - BigGPU — a flagship, high-end data-center GPU with large VRAM (think A100-class). - SmallGPU — a mid-tier inference GPU with less VRAM, roughly one-third the price (think L4-class).


Introduction

Hi, I'm Sandeep, an AI engineer at Gaudiy. For a while, one GPU-backed image-generation feature was quietly one of the most expensive things our team ran. This post is how we cut its production GPU cost by about 75% without making the product any slower for users. I'll walk through the bottleneck analysis, the async autoscaling design (which is the interesting part), the node right-sizing, and the cold-start problem that nearly undid all of it.

TL;DR — one interactive image-generation feature had grown into ~90% of our cloud bill. It ran on a single always-on flagship GPU, and because one request nearly fills the GPU's memory, that GPU could only handle one request at a time. So it sat there billing 24/7 for a peak that rarely showed up. Four changes, stacked, cut production GPU cost by ~75%, all verified in production with no downtime:

  1. a test environment you can switch off, so it costs nothing when idle;
  2. a move to a GPU about a third of the price, made possible by a chunked-batch fix;
  3. autoscaling on a custom queue-depth metric we had to build ourselves, because the stock metric doesn't see async backlog;
  4. right-sizing the shared GPU node on top of that.

The one thing still open, cold-start latency under a sudden spike, is at the end.



1. Problem & background

The situation

One user-facing AI image-generation feature had become one of the most expensive line items we ran. It sat on an always-on flagship data-center GPU, and the monthly bill for that single feature was high enough (and its usage flat enough) that leadership started asking whether it was worth keeping at that cost.

That question set the bar. This wasn't about trimming a few percent. We needed a cut big enough to change the keep-or-kill conversation, and we couldn't make the product slower to get it. A 10% saving wouldn't have been worth the effort.

The problem, precisely

Three things defined it:

  • GPU compute was ~90%+ of the bill. Storage, egress, monitoring, and managed APIs barely registered next to it. Start to finish, this was a GPU cost problem.
  • The production GPU ran 24/7 and was oversized. One flagship GPU, sized for a peak that rarely arrived, never scaling down when idle. We paid peak rates around the clock.
  • A full copy of the stack ran in test, also 24/7, at about 5–10% utilization. Nobody runs tests at 3 a.m., but the meter didn't know that.

The constraints

The constraints are what ruled out the easy answers:

  • A hard latency cap. From the user's side the feature is synchronous: click generate, watch a spinner, get results. There's a 60-second ceiling on that wait, and going past it counts as a failure.
  • Data residency. The data had to stay in one country. That took the biggest theoretical lever, moving to a cheaper GPU in another region, off the table before we started.
  • Production reliability. No spot/preemptible GPUs on the interactive path, since a mid-request preemption shows up as a user-facing error, and no community-tier providers without real SLAs.

So everything below is what was left after those constraints removed the obvious options.

The workload

image-gen-svc produces four image variations per request, in roughly ten seconds on the BigGPU. Each request runs a short pipeline: a couple of detection and segmentation passes, then the expensive part, a diffusion UNet (the denoising loop, about 70% of the total time), a VAE decode, and an upscale.

The number that actually drove the cost, though, wasn't time. It was VRAM. A single request is memory-hungry enough to nearly fill the SmallGPU and take up most of the larger BigGPU too. At that size, two requests can't share a GPU; they simply don't both fit in memory. So each GPU handled exactly one request at a time, and the only way to add capacity was to add whole GPUs.

That one fact is the root of the cost. The service ran synchronously: one always-on BigGPU serving live traffic, plus a full copy idling in test. A flagship GPU locked to one-request-at-a-time throughput, billing 24 hours a day. That's where the money went.

Figure 1 — one request nearly fills GPU memory, so only one runs per GPU; capacity scales only by adding whole always-on GPUs.


2. Two problems, two fixes

It helped to split the GPU spend from §1 into two separate problems, because they have completely different fixes and it's easy to waste effort attacking the wrong one:

 Problem A — the test environment
   Two GPU services running 24/7 at ~5–10% utilization.
   Pure waste: nobody is testing at 3 a.m., but the meter runs.
   → Fix: make it switch off.  (Lever 1)

 Problem B — the production environment
   One always-on flagship BigGPU, sized for a peak that rarely arrives,
   never scaling down, and far more powerful than the workload needs.
   → Fix: cheaper GPU + autoscaling + right-sized nodes.  (Levers 2–4)

The rest of the post is the four levers, in the order we shipped them. Lever 1 was a config change that took an afternoon. Levers 2–4 were the real engineering.


3. Lever 1 — A test environment you can switch off

Test ran the same GPU stack as production, around the clock, for a workload that was idle almost all the time. The obvious fix, scale it to zero, hits a Kubernetes quirk: the Horizontal Pod Autoscaler (HPA) won't let you set minReplicas: 0. The floor is 1, and the HPA overrides any attempt to set the Deployment to replicas: 0, because keeping replicas up is its whole job.

So turning it "off" means removing the HPA and zeroing the Deployment together. We did that with a single GitOps toggle, one line in the overlay pointing at a Kustomize patch:

# test/kustomization.yaml
patches:
- path: deployment.yaml
- path: horizontal_pod_autoscaler.yaml
- path: service.yaml
- path: disable-gpu.yaml        # add this line = OFF ; remove it = ON

The disable-gpu.yaml patch does two things:

  1. deletes the HPA, so it can't override the replica count, and
  2. sets the Deployment to replicas: 0.
 ADD  disable-gpu.yaml  ──▶  GitOps sync (~3 min)  ──▶  pods terminate
                                                        no GPU demand
                                                        cluster autoscaler
                                                        removes the GPU node
                                                        (~10 min)  ──▶  cost = 0

 REMOVE disable-gpu.yaml ──▶  GitOps sync           ──▶  HPA + replicas restored
                                                        autoscaler adds a GPU node
                                                        pod boots (~7–15 min)
                                                        cost resumes

A few things make this cheap and safe:

  • The node pool is never created or deleted. The GKE cluster autoscaler adds and removes nodes based on whether any pod still needs a GPU. Turn the workload off, the node goes idle, and the autoscaler reclaims it. No infra permissions needed.
  • It's a pull request. Whoever needs the environment adds or removes one line and merges. No kubectl, no console, no on-call SRE, and it's auditable and revertible like any other commit.
  • Billing is per-second, so a one-hour test costs an hour, not a month. There's no minimum charge for spinning up briefly.

Roughly what that buys, depending on how the environment is used:

Usage pattern Savings vs always-on
Always on (24/7) — (baseline)
Business hours only (~12 h × weekdays) ~64%
~2 h/day, weekdays only ~94%
Off 100%

This shipped first, and it was a low-risk way to prove out the "scale GPUs to zero when idle" idea before we touched production.


4. The backbone: moving from BigGPU to SmallGPU

The big production lever was swapping the always-on flagship BigGPU for the much cheaper SmallGPU, at roughly a third of the price. You can't just change the machine type, though. The workload had to actually fit and perform on the smaller card, and the reason it didn't at first is the most reusable lesson here.

4.1 Compute-bound vs memory-bound

We ran the same pipeline on both GPUs and watched the hardware counters. The two cards are limited by different things:

 BigGPU (large VRAM, more compute)       SmallGPU (less VRAM, fewer compute)
 ─────────────────────────────────       ────────────────────────────────────
 Compute:  nearly all units busy          Compute:  ALL units busy
           during the denoising loop                during every active stage
 Memory :  most used, real headroom       Memory :  nearly full, ~no headroom
           left free

 Verdict:  COMPUTE-bound.                 Verdict:  BOTH compute-bound
           Spare VRAM is useless —                  AND memory-bound.
           the compute units are the wall.          No room to breathe.

Figure 2 — the BigGPU's spare memory can't buy throughput (compute is the wall); the SmallGPU is limited by both, hence the OOM.

This is what saved us from a couple of dead-end "optimizations":

  • Cross-user batching would gain basically nothing here. The instinct is that the BigGPU has spare memory, so you should batch more requests to use it. But throughput isn't limited by memory. It's limited by the compute units (the streaming multiprocessors), which are already saturated during the denoising pass. Adding more images just serializes more work onto units that are already busy. Spare VRAM doesn't turn into spare throughput.
  • Pipeline parallelism across the stages would also gain almost nothing. The UNet is ~70% of the wall-clock and saturates compute during that window, so overlapping the small pre/post stages around it doesn't buy much.

The changes that do help are the ones that cut compute per denoising step: attention optimizations, fewer steps, quantization. The lesson we took away was to measure which resource is actually saturated before optimizing, because the intuitive move is often aimed at the wrong one.

4.2 The VRAM wall and the chunked-batch fix

There was a hard blocker on the SmallGPU. Generating the full batch in one pass needs more memory than the SmallGPU has, so it OOMs immediately. The BigGPU had room to spare; the SmallGPU just couldn't run the production payload.

The fix was chunked batching: instead of generating the whole batch in one forward pass, split it into smaller chunks and clear the allocator's cache between them.

 BEFORE (OOM on SmallGPU):
   generate(full batch)  ──▶  peak memory > SmallGPU capacity  ──▶  OOM

 AFTER (fits on SmallGPU):
   chunk 1: generate(part of the batch)   →   empty_cache()
   chunk 2: generate(the rest)
   ────────────────────────────────────────────────────────
   peak memory per chunk now fits

Clearing the cached-but-unused GPU memory between chunks resets peak usage, so the second chunk starts clean. You pay a little wall-clock time (more than one pass) to run on a GPU that costs a third as much, and since the async design below hides that extra latency from users, it was an easy trade.

Once the memory wall was gone, the migration went all the way through: both flagship-GPU pools deleted, the workload live on SmallGPU, no downtime. The headline ~75% cut comes from this swap. But the swap was only safe because of the next three levers.


5. Lever 2 — Async processing + a custom queue-depth autoscaler

This is the part I'd reuse on any async GPU service.

Autoscaling is how you stop paying for a peak you rarely hit: scale up under load, drop to a floor when it's quiet. But it only works if the autoscaler can actually see the load, and for an async service the obvious metric doesn't. We had to build one that does.

5.1 Sync vs async request paths

The service supports both paths, picked per request:

 SYNC path (interactive, holds the connection)
 ─────────────────────────────────────────────
 client ──POST──▶ [inference server] ──▶ run full pipeline (~29 s)
                                          ◀── 200 OK with 4 image URLs
   client waits the full ~29 s.

 ASYNC path (what production uses)
 ─────────────────────────────────
 client ──POST──▶ [inference server]
                     ├─▶ 200 OK  { id, url:"" }   ◄── returned in ~50 ms
                     └─▶ submit real work to a background worker
                                │  (single worker thread, one job at a time)
                                ▼
                          run full pipeline (~29 s)
                                │
                                ▼
                          upload images + publish a
                          "job complete" message to a pub/sub topic
                                │
                                ▼
                    a subscriber updates the DB row; the frontend,
                    polling or subscribed, shows the images

Figure 3 — async returns instantly and queues the real work durably; important for cost, but it hides backlog from the stock autoscaler metric.

Async is good for the user (no 29-second held connection) and it's what makes autoscaling worthwhile, because requests can queue instead of each holding a live GPU. It also quietly breaks the autoscaler, which is what the next few sections are about.

5.2 Where the backlog actually hides

An async request can sit in three places:

                                        watched by autoscaler?   bounded?
   ─────────────────────────────────    ──────────────────────  ────────
 1 inference-server scheduler queue      yes                     soft cap
 2 background-worker internal queue      NO  ← the problem       unbounded
 3 pub/sub results topic (outbound)      no (results, not work)  n/a

Queue #2 is the one that bites. The instant the server returns the {id, url:""} placeholder, it treats the request as done, even though the GPU work hasn't started yet. Those jobs pile up in the worker's queue, and nothing on the outside is watching it.

5.3 Why the stock autoscaler was blind

The metric an inference server exposes out of the box is roughly "requests received but not yet dispatched." For a sync service that's a fine proxy for backlog. For our async service it's useless, because the server dispatches (and "completes") every request in about 50 ms. Here's what the autoscaler sees when 10 async requests hit one pod in two seconds:

 t (s)   server "pending"   real worker backlog   autoscaler thinks   reality
 ─────   ────────────────   ───────────────────   ─────────────────   ─────────────
 0.0            0                   0              "idle"              idle
 1.0            1                   5→9            "still low"         9 jobs behind
 2.0            0                  10              "IDLE"  ◀──         ~310 s of work owed
 30             0                   9              "idle"              worker on job #1
 300            0                   1              "idle"              job #10 times out

The autoscaler sees a brief blip, then a flatline at zero, and never scales up. In reality one pod is now ten jobs deep, about five minutes of work behind, and the last request in the burst eventually hits its timeout. Nothing on the dashboards shows it.

5.4 The custom queue-depth metric

The fix is to stop asking the inference server about backlog and instead publish the background worker's real queue length as a metric, then scale on that.

# expose a gauge that reports the ACTUAL background backlog
queue_depth = Gauge("async_queue_depth", "async tasks waiting in the worker")

def _update_queue_metric(self):
    queue_depth.set(self.worker._work_queue.qsize())
# called on every submit and every completion

Scrape that gauge, expose it as an external metric, and point the autoscaler at it:

metrics:
  - type: External
    external:
      metric: { name: async_queue_depth }
      target:
        type: AverageValue
        averageValue: "6"     # scale up when the summed backlog exceeds 6 × replicas

One thing worth being precise about: AverageValue sums the metric across pods rather than checking each pod against the target.

 desiredReplicas = ceil( SUM(queue_depth over ALL pods) / 6 )

   Pod A  Pod B   TOTAL   ceil/6   action
     5      5      10       2      stay at 2
     9      9      18       3      +1 → 3
    12     12      24       4      +2 → 4

Now the autoscaler reacts to real work owed instead of the server's version of events. In load tests it decided to scale about 17 seconds after a burst started, which is fast. (The slow part after that is cold start, covered in Lever 4.) In production it mostly sits at its floor of 2 replicas because real backlog is near zero, and it scaled correctly the one time load actually called for it.

Figure 4 — the stock "pending" metric flatlines while the async worker queue backs up. A custom queue-depth metric lets the autoscaler react to real load.

One thing that cost me real debugging time: this HPA is GitOps-managed with self-heal on. If you bump minReplicas by hand in the console to "test scaling," ArgoCD reverts it in about three seconds and nothing happens. The only real ways to test scaling here are a load test or a PR. Load-driven scaling is fine, since the HPA changing replica counts under load isn't treated as config drift.


6. Lever 3 — Right-sizing the shared GPU node pool

With the workload on the SmallGPU and autoscaling working on real backlog, one more thing stood out: the node itself was oversized.

The GPU node pool used a machine shape with far more vCPU and RAM than the workload needed. It only really used ~1 GPU and a small slice of the CPU and memory, so we were paying for a big machine to use a sliver of it.

   BEFORE                              AFTER
   ┌──────────────────────┐           ┌──────────────────────┐
   │ oversized machine     │  recreate │  right-sized machine  │
   │ 1× SmallGPU per node  │  ───────▶ │  1× SmallGPU per node │
   │ far more vCPU / RAM    │   pool    │  fits the workload    │
   │ than needed           │           │  exactly              │
   └──────────────────────┘           └──────────────────────┘
                                              │
                                              ▼
                                    ~37% lower cost PER NODE,
                                    right-sized to the workload,
                                    savings scaling with replica count

Figure 5 — a machine that fits the workload exactly, saving ~37% per node on top of the cheaper GPU.

The one wrinkle is that machine type is immutable in a GKE node pool, so "right-sizing" really means creating a new pool, draining onto it, and deleting the old one. With a parallel-pool pattern that's zero-downtime.

That ~37% per-node saving stacks on top of the BigGPU-to-SmallGPU swap. It's a discount on already-cheaper hardware.


7. Lever 4 — Cold start: baking the weights into the image

Autoscaling down to a low floor only pays off if scaling back up is fast. Ours wasn't, and the reason is a good reminder that saving money can introduce new failure modes.

Right after the production cutover, one new pod took about 41 minutes to become ready. Another pod, same image and same machine type, came up in about 2 minutes. The difference:

Several GB of model weights were downloaded from a public model hub on every cold start. Nothing was baked into the image and there was no cache volume, so cold-start time was really just download time: slow and unpredictable. And a startup probe would kill a download that ran too long, forcing the whole thing to start over.

 Attempt 1:  download weights ...  too slow ...  startup probe kills it at ~25 min
             (no cache persists — the partial download is thrown away)
 Attempt 2:  download weights again ...  this time fast ...  Ready
             ────────────────────────────────────────────────────────
             TOTAL ≈ 41 min   =   26 (wasted) + 15

That 2-vs-41-minute spread was pure network variance on the download. You can't size autoscaler windows around a cold start that ranges from 2 to 41 minutes, and worse, if the model hub had an outage during a scale-up or a node replacement, new pods could never reach ready at all. This wasn't just slow, it was an availability risk.

The fix was to bake the weights into the image at build time and stop the pod from fetching anything at boot:

 Docker build:  prefetch all model weights into an image layer
 Runtime env :  HUB_OFFLINE=1   (never touch the network at boot)

That turns startup into a local load with no network dependency:

 WARM node (image cached, pool keeps a warm floor)
   schedule → model load from local disk (~2.5 min)          ≈ 2.5 min to Ready

 COLD node (brand-new node needed)
   decision (~20 s) + node provision (~3 min)
   + first image pull (~3 min) + model load (~2.5 min)       ≈ 6 min to Ready

Figure 6 — baking weights into the image turned a 2-to-41-minute random tail into a deterministic 2.5–6 minutes. Spike absorption remains future work.

The two most recent cold pods loaded in 144 and 148 seconds, within four seconds of each other, which is what a local load looks like. The 41-minute tail is gone. The only cost the bake adds is a bigger image, so the very first pull onto a brand-new node is a bit slower, and it's cached after that.


8. Results

Stacking the four levers on the BigGPU-to-SmallGPU move:

 Lever                                    Effect
 ──────────────────────────────────────  ─────────────────────────────────────
 BigGPU → SmallGPU (chunked-batch fix)    ~75% lower production GPU cost  ◀ headline
 Async + queue-depth autoscaling          scale to a floor when idle;
                                          pay for load, not for peak
 Shared-pool right-sizing                 ~37% less PER NODE, stacked on top
 Test on/off toggle                       up to 100% off the test bill
 Cold-start bake                          41-min tail → deterministic 2.5–6 min

Figure 7 — four stacked levers take production GPU cost down ~75%, verified in production with zero downtime.

  • Production: moved onto SmallGPU with both flagship pools deleted, no downtime, ~75% lower GPU cost. Realized and verified in production, not a projection.
  • Autoscaling: healthy in production (floor 2, ceiling 10, scaling on real backlog), decides in ~15–30 s, cold start bounded at ~2.5–6 min.
  • Test: on-demand, effectively free when idle.

Every number here was checked against live production logs and metrics after the fact. The rule on this project was realized savings, not estimates.


9. What we looked at and didn't do

A few options we considered and turned down, with the reasons:

  • Spot / preemptible GPUs. A mid-request preemption on the interactive path is a user-facing error, so this was out for that path.
  • Cheaper GPUs in another region. Blocked by the data-residency requirement. This was the biggest theoretical saving and it simply wasn't available to us.
  • Cross-user dynamic batching. Ruled out by the compute-bound analysis. It looks like free throughput given the spare VRAM, but compute is the limit, so it would have been a lot of work for ~0% gain.
  • A dedicated node pool per service. Same per-replica cost as sharing, but more surface area and more ownership overhead, so we kept it shared.

10. What's still open

One problem is still open.

Cold start can't absorb a sharp spike. Even after the fix, it's about 2.5 minutes on a warm node and 6 on a new one. Autoscaling handles sustained load well, but it can't spin up a pod fast enough for a sudden 0-to-burst spike that lands inside that window, and during those minutes the extra requests just wait. A few ways to close the gap:

 - Raise the warm floor (more idle pods)         → costs money, defeats some savings
 - Over-provision a "pause pod" per node         → keeps a node pre-warmed for instant scheduling
 - Pre-pull the (now larger) image onto nodes    → shaves the ~3 min first-pull on new nodes
 - Move the queue OUT of the pod (event-driven autoscaling on a durable topic)
       → the autoscaler could then see backlog before any pod exists,
         and even scale to true zero

The durable-queue approach (event-driven autoscaling reading a pub/sub topic's depth) is where this is headed. It makes scaling predictive and would let the service scale to true zero. It's a bigger change on the caller side, though, so we didn't let it block the cutover.

We're hiring

Gaudiy is hiring engineers, product managers, and more. If any of this is the kind of work you like (cost-aware ML infrastructure, GPU autoscaling, building at that intersection), I'd be happy to talk. Casual chats are welcome, no commitment.

GKE から Cloud Run に戻した話

はじめに

こんにちは、SRE のあんどう(@Andoobomber)です。

突然ですが、2024 年に「Kubernetes 初学者が担当した GKE 移行プロセスの全貌」という記事を書きました。Cloud Run で動いていた弊社のプロダクトを GKE に移行した、というお話です。

あれから約 2 年。今度はそのをやりました。

つまり、GKE で動かしていたプロダクトを、もう一度 Cloud Run に戻しました。前回 GKE に持っていった本人が、今度は GKE を Cloud Run にしてるわけで、自分でも「何をやっているんだ」と思わなくもないのですが、これには理由があります。

私たち Gaudiy はスタートアップで、組織や事業のフェーズが進むごとに、インフラに求めるものも変わります。2024 年の GKE 移行も、今回の Cloud Run 回帰も、それぞれのフェーズで「いま最も合う構成」を選び続けてきた結果 です。後ろから見るとジグザグに見えるかもしれませんが、その時々で意図して選び直してきた話です。

この記事では、

  • なぜ Cloud Run に戻すことにしたのか
  • どうやって移行したのか
  • 何をやめて、何に置き換えたのか(Argo CD → GitHub Actions など)
  • 途中で詰まったポイントと、そこから得た学び

を、書いていきます。

Google Cloud でアーキテクチャ選定に迷っている方、「Cloud Run と GKE どっちにするか」を社内で議論している方、ベンチャー企業のリアルなインフラ判断が気になる方の、参考になれば嬉しいです。

TL;DR

経緯をひと息でまとめると、

Cloud Run(〜2023)→ GKE(2024〜)→ Cloud Run(2026〜)

という、ぐるっと一周してきた構成です。戻した理由は大きく 2 つあります。

  1. 事業フェーズの変化: Tech Base 中心の探索フェーズから、ビジネス中心のフェーズへシフトしたことに伴うコスト最適化。
  2. Cloud Run 側の機能拡充: GPU インスタンス対応や Direct VPC Egress の GA など、2024 年当時 GKE を選ぶ理由になっていた要素が、Cloud Run でも実現できるようになった。

主要コンポーネントの差分は次の通りです。

レイヤー Before(GKE) After(Cloud Run)
ランタイム GKE Standard Cloud Run
マニフェスト Kustomize Cloud Run Deploy YAML
CD Argo CD GitHub Actions
公開系ロードバランサ Gateway API(GKE Gateway Controller) Cloud Load Balancing + Serverless NEG
サービス間通信 Cluster 内 DNS / Service Direct VPC Egress + ID Token 認証
定期実行 CronJob Cloud Scheduler → Cloud Run
シークレット External Secrets Operator Secret Manager(Cloud Run ネイティブ統合)
監視 Datadog(OpenTelemetry Collector 経由) Datadog(変更なし)

Before/After アーキテクチャ図

ざっくり言えば、「Kubernetes のエコシステムを使っていたのを、Google Cloud のマネージドサービスに換えた」という構図になります。


なぜ GKE から Cloud Run に戻すのか

そもそもなぜ GKE に行ったのか

詳しい経緯は前回の記事に書いていますが、ざっくり 3 行でいうと、

  • 当時の Cloud Run には GPU インスタンスがなく、AI ワークロード・画像生成(Stable Diffusion)を動かすには GKE が必須だった
  • Datadog Agent / OpenTelemetry Collector のような常駐型コンポーネントを置きたかった
  • クラウドネイティブやAIなどの技術スタックを試験して、エンジニア組織として最新技術を持って組織を引っ張る方針だった。

この選択をしたことにより、SRE / クラウドネイティブ領域の知見は組織としてかなり積み上がりました。GKE 移行を経て OpenTelemetry Collector の導入や AI Workload の使用が可能となり、エンジニアのスキルアップに大きく繋がりました。

では、何が変わったのか

主に 事業フェーズの変化 と、Cloud Run 側の機能拡充 の 2 つです。

1. 事業フェーズの変化

プロダクトのフェーズが、新しい技術を試しながら作る「探索フェーズ」から、すでに見えているビジネスをきちんと回す「スケールフェーズ」に進みました。

  • 探索フェーズ: 自由度・拡張性・最新技術の取り込みやすさ
  • スケールフェーズ: 運用負荷の低さ・コスト効率・安定性

これに合わせて、インフラをもう一度見直しました。

2. Cloud Run 側の機能拡充

私たちが GKE に移った 2024 年当時、Cloud Run には足りないピースがいくつかありました。それが、この 2 年で次々に埋まりました。特に決定打になったのは次の 2 つです。

  • GPU インスタンス対応: Cloud Run でも GPU が使えるようになり、AI ワークロードのために GKE を残す理由がなくなりました。
  • Direct VPC Egress の GA: かつての Serverless VPC Connector を介した通信ではなく、Cloud Run のインスタンスから VPC 経由で Memorystore Redis などのリソースへ直接アクセスできるようになりました。Cloud Run 同士の Internal 通信自体は Connector 時代から可能でしたが、Connector インスタンスを経由する分のコストとレイテンシが乗っていた ところを、Direct VPC Egress ではゼロにできます。複数プロダクト・Google Cloud Projects を抱える我々にとって、コスト面とレイテンシ面の両方で移行を後押しする要因でした。

加えて、min-instances や CPU always-allocated、Cloud Run jobs、Cloud Run worker pools、Pub/Sub Push との統合など、「常駐型に近い使い方」が現実的になっており、今まで GKE 上の Deployment / CronJob としていたワークロードも Cloud Run に寄せられるようになりました。

まとめると、事業フェーズが進み、Cloud Run も進化したため、アーキテクチャを刷新した。 ということです。

ここから先は実際にどう戻したかの話に入っていきます。


アーキテクチャ Before / After

TL;DR の差分表で全体像は示しましたが、ここでは図を交えてもう少し詳しく見ていきます。図は 「処理パス」「サービス間通信」「CD」「非同期処理」 の 4 つの観点で並べました。

1. クライアント → LB → ランタイムの処理パス

処理パス Before/After 図

Before(GKE)

Client
  → Cloud Load Balancing (Global HTTP(S) LB)
  → Network Endpoint Group (GKE Gateway controller 経由)
  → GKE Gateway API
  → アプリケーション Pod

After(Cloud Run)

Client
  → Cloud Load Balancing (Global HTTP(S) LB)
  → Serverless NEG
  → アプリケーション (Cloud Run service)

GKE Gateway Controller が Cloud Load Balancing 上のリソース(NEG / Backend Service / URL Map …)を裏で組み立ててくれていた構成から、Serverless NEG を使った構成に変わりました。Cloud Load Balancing 自体は変わっていません。

2. サービス間通信(VPC / Direct VPC Egress / PSA)

ネットワーク Before/After 図

Before

  • GKE 内のサービス間通信は Kubernetes Service / ClusterDNS で完結。
  • Memorystore Redis は VPC ピアリング(DIRECT_PEERING モード) で GKE Pod から接続。
  • 認証は基本的に内部 IP ベース or mTLS。

After

  • Cloud Run の各サービスは Direct VPC Egress で VPC に直接接続。
  • Memorystore Redis は VPC ピアリング(PRIVATE_SERVICE_ACCESS モード、新規インスタンス) で接続。
  • サービス間通信は Direct VPC Egress で VPC を経由し、呼び出し側のクライアントで OIDC ID Token を生成 → roles/run.invoker を持つ呼び出し先 Cloud Run service を叩く構成(ingress: internal で受ける)。

3. デプロイパイプライン(CD)

CD パイプライン Before/After 図

Before(GKE / GitOps)

GitHub PR merge
  → CI (build / test / image push to Artifact Registry)
  → Kustomize manifest を Git へ commit
  → Argo CD が同期
  → GKE クラスタに反映

After(Cloud Run / GitHub Actions)

GitHub PR merge
  → GitHub Actions ワークフロー
    - build / test / image push
    - Cloud Run Deploy YAML を render
    - `gcloud run services replace` で適用
  → Cloud Run revision として反映

GitOps の良さ(Git を Source of Truth にできる、差分レビューがしやすい)は手放しましたが、 「PR 1 本ですべて完結する」「Argo CD と Kustomize の二重学習コストが消える」 という2点はかなりのメリットだと思います。

4. Pub/Sub / Cloud Tasks / Scheduler の処理経路

私たちのアプリケーションは内部的に gRPC で実装しているのですが、Pub/Sub Push / Cloud Tasks / Cloud Scheduler のような Google Cloud のマネージドコンポーネントは、ターゲットを HTTP でしか叩けません。そこで、HTTP → gRPC のプロトコル変換を担う層として、ESPv2(Extensible Service Proxy V2 / Cloud Endpoints)を挟んでいます。

非同期経路 Before/After 図

Before

  • Pub/Sub: Pub/Sub Push が中心。Google Cloud 側 → Internal LB → ESPv2(独立 Pod / Deployment)→ アプリ Pod(gRPC) という経路で届く構成。
  • Cloud Tasks / Cloud Scheduler: 同じく Internal LB → ESPv2 → アプリ Pod の経路。
  • 定期実行: Kubernetes CronJob。CronJob は ESPv2 を介さず 直接アプリ Pod に gRPC で到達(Cluster 内なので HTTP→gRPC 変換が不要)。

After

  • Pub/Sub: Pub/Sub Push サブスクリプション → ESPv2(Cloud Run service)→ アプリ(Cloud Run service, gRPC)。OIDC 付き。
  • Cloud Tasks 経由の即時実行: Cloud Tasks → ESPv2 → アプリ。
  • 定期実行: Cloud Scheduler → ESPv2 → アプリ。

ESPv2 とアプリの役割(HTTP→gRPC 変換)は変わらず、置き場が Pod / Deployment から Cloud Run service に変わり、Internal LB を挟む必要が無くなりました(Cloud Run の Serverless NEG が直接 ESPv2 service にルーティングしてくれるため)。CronJob は Cloud Scheduler に置き換え。とはいえ Cloud Run 上で ESPv2 を動かすにはそれなりの作法があり(HTTP ヘッダ周りの設定や audience の取り扱いなど)、移行に苦戦した箇所でもあります。

このレイヤーは、地味ながら一番「Cloud Run らしさ」を活かせた箇所でもあります。GKE 上で常時起動していた Deployment / CronJob 群を、リクエスト単位の従量課金に寄せられたからです。


移行プロセス

dev → test/prod → GKE 解体の 3 フェーズ で動きました。

Phase 1: dev 環境の Cloud Run 化

dev に Cloud Run を新規構築し、GKE と並走させながら必要な作法を一通り洗い出しました。

  1. Terraform で Cloud Run 用ネットワークを敷く: サブネット、Cloud Router、Cloud NAT、PSA レンジ、必要な Firewall ルール。Memorystore Redis に届かせるための PSA は PRIVATE_SERVICE_ACCESS で構成。
  2. GitHub Actions ワークフローを用意する: image build → Cloud Run Deploy YAML render → gcloud run services replace までを 1 本のワークフローで回す。
  3. Cloud Run Deploy YAML を書く: Kustomize で生成していた manifest の Cloud Run 版。サービスアカウント、環境変数、VPC アクセス(Direct VPC Egress)、Concurrency、min/max instances などを揃える。
  4. サービス間通信を ID Token 認証に切り替える: Cluster 内 DNS で叩いていた呼び出しを、roles/run.invoker を持つ SA から OIDC ID Token を付けて叩く方式へ。Go であれば idtoken.NewClient を使う、といった呼び出し側の改修もここで合わせて入れる。
  5. ESPv2 を Cloud Run service として切り出す: GKE 時代は独立した Pod / Deployment として動いていた ESPv2 を、Cloud Run service として再定義する。Cloud Run 用の Endpoints 設定(audience が GKE 用と別になる、HTTP ヘッダ周りの設定など)を整える。GKE 時代に必要だった Internal LB は、Cloud Run の Serverless NEG が代わりに繋いでくれるので不要になる。
  6. dev で並走させる: GKE 側を生かしたまま Cloud Run を立てて、後段の動作確認を回す。

dev の構築が作業量やハマりポイントが多く一番大変で、次の test/prod フェーズではIaCのメリットを活かして、楽に構築できました。

Phase 2: test / prod の Cloud Run 化と LB 切替

dev で固めた構成を まず test 環境に展開 し、サービスオーナーとの動作確認・ステークホルダー調整を挟んだうえで、prod に切り替えました。

  1. test 環境に Cloud Run 構成を展開

    • dev で確立した Terraform / GitHub Actions ワークフロー / Cloud Run Deploy YAML を test 用に複製・調整。
    • Cloud Run を立てつつ、LB は当面 GKE 側に向けたままにし、いつでも比較できる状態を維持。
  2. test 環境で各サービスのエンジニアと一緒に動作確認

    • 移行は私たちインフラチームだけで完結する話ではないので、各サービスのエンジニアと並走しながら、Cloud Run 側の挙動を実機で確認していきました。
    • 機能・性能・ログ・メトリクスの差分を、Synthetics / E2E / 個別の手動チェックを組み合わせて洗い出し、見つかった違和感はその場で Cloud Run 構成側にフィードバック。
    • 「サービスごとのオーナーが Cloud Run 側で OK を出している」という状態を、prod 切替の前提条件に置きました。
  3. prod 環境でも「社員専用の確認用 LB」経由で事前に動作確認

    • 私たちのサービスには 社内専用アプリ(社内テナント) があり、ここから 本番環境のまま動作確認 ができます。これが今回の移行で本当に助けられました。
    • ただし、本番ユーザーのトラフィックが流れている 本番 LB はあくまで GKE 側に向けたままにしておきたい。そこで、Cloud Run 側を本番環境で叩くための 社員しかアクセスできない確認用 LB を別途用意 しました。経路としては
      • 社員アカウント → 確認用 LB(社員限定アクセス制御 / 本番ユーザーは到達不可)→ 本番 Cloud Run
      • 一般ユーザー → 本番 LB(GKE 向けのまま)→ 本番 GKE
    • という形で、本番ユーザーが流れる経路と確認経路が 完全に分離 されています。確認用 LB はアクセス制御で社員アカウントに限定しており、エンドユーザーが誤ってこの経路を踏むことはありません。
    • この経路で、社内専用アプリから本番 Cloud Run 側を直接叩いて、実 prod のデータ・依存サービスと組み合わせた挙動を確認しました。test では拾いきれない、本番固有の構成差分もここで潰せています。
    • Create / Update / Delete 系の操作は 他テナントに影響が出ないよう細心の注意を払って、Read 系と限定的な書き込みに絞って実施しました。
    • 「test だけでなく prod 環境でも事前に Cloud Run 側の動作確認ができた」 のが、このフェーズで一番効いたポイントです。
  4. prod 切り替えのスケジューリング調整

    • prod のカットオーバーは、技術的に問題なくても「いつやるか」がそのまま事業リスクに直結します。リリースフリーズ期間や、他チームのキャンペーン・運用イベントを避けるため、プロダクトマネージャー / 開発リード / カスタマーサポート といったステークホルダーと、切替日時を事前にすり合わせました。
    • 合わせて、当日の オンコール体制 / ロールバック条件 / 責任者 も明文化して合意。「何かあったらどう判断して、誰が踏むか」を当日の判断材料にしないことで、本番作業の負荷をかなり下げられました。
  5. LB を Serverless NEG に切替

    • Terraform 側に use_gke_backends = true/false という 1 行のフラグを用意し、
      • true: LB の backend は GKE NEG(スタンドアロン)
      • false: LB の backend は Serverless NEG(Cloud Run)
    • を切り替えられるようにしました。万一の不具合時に、Terraform 側で 1 行戻すだけで GKE 構成に戻せる 安全策を入れたうえでカットオーバー。前段の test / prod 両方での事前確認が効いていたため、カナリアリリースは挟まずに一発で Cloud Run 側へ置換しています。「ぱっと戻せる保険」と「事前にちゃんと触れた事実」の両方が揃っていたからこそ取れた判断 で、これは精神衛生上もとても助かりました。

Phase 3: GKE の解体

LB が Cloud Run に切り替わり、過去のトラフィックが完全に Cloud Run 側に乗っていることを確認したうえで、GKE 側の構成を畳みました。

  • アプリケーションサービスの Manifest と Argo CD ApplicationSet を削除
  • GKE 用の Pub/Sub / Scheduler モジュール(Terraform)を削除
  • GKE クラスタは Platform 系(cert-manager、external-dns、Datadog 周辺)と AI 系の一部のみ残す

合計で数百ファイル単位の YAML が消えることになり、Before / After のスケール感としても象徴的な作業でした。

期間感

ざっくりした期間感はこんな具合です。

フェーズ 期間目安
Phase 1(dev 移行) 約3週間
Phase 2(test / prod 移行 + LB 切替) 約2週間
Phase 3(GKE 解体) 2日

動作確認 / 品質保証

カットオーバーで一番怖いのは「気付かないうちに何かが壊れている」ことです。今回はそれを防ぐために、E2E テストDatadog Synthetics を 2 段構えで使いました。

E2E テスト(Playwright)

  • フロントエンドからの主要ユーザーシナリオ(ログイン、コミュニティ閲覧、投稿、購入処理等)を一通り通す。
  • カットオーバー前 / 後にそれぞれ実行し、差分が出ないかを比較。

E2E は以前からずっと運用してきており、シナリオが十分に積み上がって充実していたので、移行のために改めて E2E を整備する必要がなく、既存のテスト資産をそのまま今回の検証に活用できたのが大きかったです。

Datadog Synthetics

  • Browser Test でフロントエンドの主要画面の死活と性能を定期監視。
  • カットオーバー時のリアルタイムなダッシュボードとしても使う。

特に LB 切替の瞬間は、Synthetics の成功率グラフを横に並べながら作業しました。「数値が正常に推移していること」を視覚的に確認しながら進められる のは、本番カットオーバーの心の余裕としてかなり大きかったです。

異常を検知した場合は、Phase 2 で説明した Terraform 1 行 (use_gke_backends) のロールバック を即実行できる体制で臨みました。実際にロールバックを発動するような事態はありませんでしたが、「踏める保険を持っている」ことが移行の判断と速度に効いていたと思います。


詰まったポイントと学び

移行を進めるなかで「思わずハマったポイント」をピックアップしてご紹介します。

サービス間認証は roles/run.invoker + ID Token に切り替え

GKE にいた頃は、サービス間通信は Cluster 内部の DNS で名前解決して、そのまま叩けば動いていました。社内ネットワーク内の話なので、認証もシンプル。

Cloud Run になると、これが 「Direct VPC Egress で VPC を経由しつつ、roles/run.invoker を持つ SA から OIDC ID Token を付けて呼び出す」 に変わります。経路としては、

呼び出し元 Cloud Run service
  → Direct VPC Egress(VPC の serverless サブネットに egress)
  → VPC ネットワーク経由で呼び出し先 Cloud Run service の URL を解決
  → 呼び出し先 Cloud Run service(ingress: internal で受ける)
  • 呼び出される側: ingress を internal にして、VPC 経由のトラフィックのみ受け入れる。roles/run.invoker を呼び出し元の SA に対して付与
  • 呼び出す側: gRPC / HTTP クライアントで Google ID Token を取得し、Authorization: Bearer ... を付ける(audience は呼び出し先サービスの URL)。Direct VPC Egress 経由なので外部インターネットには出ない。
  • 参考: Cloud Run から Cloud Run へのプライベートアクセス(G-gen Tech Blog)

実際にやったことは、大きく次の 2 つです。

1. Terraform で google_cloud_run_service_iam_member を呼び出し関係ごとにすべて用意

「誰が誰を呼ぶか」の組み合わせに対応する形で、roles/run.invoker 付与を Terraform リソースとして明示的に書きました(example)。

resource "google_cloud_run_service_iam_member" "invoker" {
  service  = google_cloud_run_v2_service.target.name
  location = google_cloud_run_v2_service.target.location
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.caller.email}"
}

権限付与の状態を Terraform 上で一元管理できるので、レビュー時に「このサービスから呼べる相手」が明示的に追えるのも副次的なメリットでした。

2. gRPC クライアント側で ID Token を付与する共通ヘルパーを用意

呼び出し側のコードでは、ランタイム環境を環境変数で判定して、Cloud Run(Services / Jobs)のときだけ ID Token を付与する grpc.DialOption を返すヘルパーを共通化しました(example)。

// Cloud Run Services (K_SERVICE) または Cloud Run Jobs (CLOUD_RUN_JOB) では
// ID Token を付与し、それ以外(GKE / ローカル)では insecure で接続する。
func credentialOption(address string) grpc.DialOption {
    if os.Getenv("K_SERVICE") != "" || os.Getenv("CLOUD_RUN_JOB") != "" {
        creds, err := newIDTokenCredentials(context.Background(), address)
        if err != nil {
            // ID Token 取得失敗時は insecure にフォールバック
            return grpc.WithTransportCredentials(insecure.NewCredentials())
        }
        return creds
    }
    return grpc.WithTransportCredentials(insecure.NewCredentials())
}

ポイントは、Cloud Run Services だけでなく Cloud Run Jobs(CLOUD_RUN_JOB 環境変数)も同じヘルパーで扱える ようにしている点です。Job からも同じやり方でサービス間呼び出しができるので、バッチ処理や定期実行を Cloud Run Jobs に寄せた際も、認証まわりに特別な分岐を入れずに済みました。

各サービスからはこのヘルパーを呼ぶだけで済むので、「サービスごとに ID Token の付け方を考えなくていい」 状態を作れました。Cloud Run でも GKE でもローカルでも同じインターフェースで動かせるので、移行期の並走も楽になります。

Memorystore Redis は Shared VPC では PRIVATE_SERVICE_ACCESS モード必須(DIRECT_PEERING は選べない)

GKE 時代は Memorystore Redis を DIRECT_PEERING モード で作成していました。ノードプールが同じ VPC にあれば直接ルーティングできるモードです。

Cloud Run 移行で、このまま既存 Redis に接続しようとしたところ繋がらない。Memorystore for Redis は Shared VPC ネットワークで作成する場合 PRIVATE_SERVICE_ACCESS モードのみサポートされ、DIRECT_PEERING は選択できません

docs.cloud.google.com

私たちの構成は Shared VPCなので、GKE 時代に DIRECT_PEERING で作っていたインスタンスは、この Shared VPC 構成では到達経路が成立しませんでした。

しかも、connect_modeインスタンス作成後に変更できません。そのため、

  1. 新しい Redis インスタンスを PRIVATE_SERVICE_ACCESS モード で作成
  2. データを乗せ替え(キャッシュ用途なので意識的な移行は最小限)
  3. DIRECT_PEERING インスタンスを削除

という手順で Redis ごと立て直しました。

revision の "暗黙の再利用" で deploy が進まない

gcloud run services replace は、設定が前回と完全に一致していると 新しい revision を作らずに既存 revision をそのまま使い回す 挙動をします。普段は嬉しいのですが、たとえば「直前の revision が起動失敗で Ready=False のまま残っている」ような状況で、同じ image + 同じ YAML を再投入すると、

  • 新しい revision が作られず
  • 失敗した古い revision が「現行」のまま

という、"何度デプロイしても直らない" 現象になります。CI のログだけ見ていると Successfully deployed のように見えるのに、実際のサービスは壊れている、という状態です。

回避策はシンプルで、Manifest の "何かを変えて" 新 revision を強制的に切らせることです。具体的には、

  • image tag を必ず一意にする(コミット SHA を使う)
  • どうしても同じ image の場合は、Annotations やラベルにビルド時刻などを入れて差分を作る

のいずれかをデプロイパイプライン側で担保しておくと、この罠を踏まずに済みます。


何が変わったか(運用面)

技術スタックの差分はすでに TL;DR とアーキ図で見ていただきましたが、それを 「日々の運用がどう変わったか」 という視点で改めて整理します。

マニフェストの軽量化(Kustomize → Cloud Run YAML)

GKE 時代は、サービスごとに base/overlays/{dev,test,prod}/ を持ち、Kustomize で環境差分を patch していく構成でした。柔軟である一方、

  • patch の入れ子で「結局どの値が効いているのか」が追いづらい
  • 新しいサービスを足すときに、テンプレ+4 環境分の overlay を毎回作る必要がある
  • Kustomize 自体の挙動を理解しないとレビューが通せない

といった、地味な認知コストがありました。

Cloud Run 化したことで、サービスごとの manifest は 環境ごとに 1 ファイルcloudrun_deploy.yaml)に集約されました。差分は GitHub Actions 側のレンダリングで埋め込み、適用は gcloud run services replace 1 コマンド。Kubernetes 特有のリソース(Deployment / Service / HPA / PodDisruptionBudget …)を 1 サービスごとに揃える必要もなくなり、新サービス追加の心理的ハードルがぐっと下がりました。

CD のシンプル化(Argo CD → GitHub Actions)

GKE 時代は GitOps パターンに乗っており、image を build / push したあと、別リポジトリの manifest を更新する PR を出して、Argo CD が同期するのを待つ、という流れでした。「Git が Source of Truth」の世界として一貫性は高いものの、

  • リポジトリをまたいだ PR レビューになる
  • Argo CD の sync が詰まったときにデバッグ先が増える
  • ロールバックが「manifest を書き戻す PR を出す」になる

というトレードオフがありました。

GitHub Actions に寄せた今は、1 つの PR をマージすると、その PR の CI が直接 Cloud Run へ反映してくれる シンプルさになりました。ロールバックも gcloud run services update-traffic --to-revisions=... で対応できます。

Datadog の設定はそのまま

GKE 時代に整えた Datadog のダッシュボード・Monitor・Synthetics は、ランタイムが Cloud Run になっても そのまま使えました。理由はシンプルで、アプリ側の計装は OpenTelemetry SDK でやっていて、Cloud Run でも同じ Collector / Datadog 連携が成立するからです。

「観測の仕組みを抽象化しておくと、ランタイム差し替えのコストが下がる」というのは OpenTelemetry Collector 導入の記事 でも書いた話ですが、今回の移行で改めて実感しました。


コスト削減効果

具体的な金額は伏せますが、結論からいうと インフラコストを大幅に削減することができました。GKE クラスタ自体の固定コスト、Daemonset / sidecar として常駐していた周辺コンポーネントのコスト、Pub/Sub サブスクライバとして常時起動させていた Deployment のコストなど、「使っていないときも止まらないもの」を Cloud Run の従量課金モデルに寄せられたのが大きかったです。

事業フェーズに合った正しい削減ができ、結果としてプロダクトの体力も維持できる、というのが今回の移行の大きな成果のひとつでした。


今後

すべてのワークロードを Cloud Run に統一したわけではなく、GKE には AI 領域 が残っています。具体的には、

  • AI 系: 推論で GPU(NVIDIA A100)を使うワークロード
  • Platform 系: その AI 系を動かすための Platform。cert-manager、external-dns、内部用の常駐コントローラなど、クラスタ全体の前提を支えるもの

AI 系を Cloud Run に寄せていない理由は、運用ポリシーというよりも 現時点(2026/05)での Cloud Run GPU の制約 によるものです。

  • Cloud Run の GPU は asia-northeast1(Tokyo)でまだ提供されていない
    • 現時点(2026/05)で Cloud Run GPU が提供されているのは asia-southeast1(Singapore)、europe-west1 / europe-west4、us-east4 などの一部リージョンに限られており、私たちの本番リージョンである asia-northeast1(Tokyo)はまだ対応していません
  • 使っている A100 が Cloud Run GPU のラインナップに無い
    • Cloud Run でサポートされている GPU は NVIDIA L4NVIDIA RTX PRO 6000 Blackwell の 2 種類で、私たちが本番推論で使っているNVIDIA A100 は Cloud Run のラインナップには現状含まれていません。

つまり、AI 系を Cloud Run に寄せようとすると 「Tokyo を諦めて Singapore にする」かつ「A100 を諦めて L4 などに変える」 という大きな構成変更が同時に必要になります。これは性能・コスト・レイテンシ(クロスリージョン化の影響)・法務など評価すべき軸が多く、現在検討中です。GKE 上であれば、Tokyo リージョンの A100 ノードプールでそのまま動かせるので、無理に急がず、Cloud Run 側に寄せるメリットが上回るかをじっくり見極めている段階です。

それまでは 「全員 Cloud Run」ではなく、Cloud Run と GKE を適材適所で使い分ける という現実解で運用していきます。今回の移行で得た「ぱっと戻せる設計の効きどころ」を頭に置きながら、また数年後に何かのきっかけで構成を見直すことになっても身軽に動けるように環境を整えておきたいと思っています。


まとめ

ここまで読んでいただき、ありがとうございました。

今回の件で学びとしては、インフラ選定は事業フェーズと要件に合わせて見直していく。でした。 GKE → Cloud Run も、その逆も、どちらが正解という話ではなく、「いまの自分たちにとっての最適解」を選び続ける作業です。そして、その選び直しを軽くするには、ぱっと戻せる設計と、ランタイムに依存しない観測の仕組み(OpenTelemetry / Datadog)を、最初に仕込んでおくことが効きます。

Special Thanks

今回の移行は、自分ひとりで進めたものではありません。

  • 一緒に PR をレビューしてくれた SRE / インフラチーム
  • 各サービスのオーナーとして、Cloud Run 化の影響範囲を一緒に確認してくれた開発チーム
  • 移行までのスケジュールを調整してくださったプロダクトマネージャー

のみなさん、ありがとうございました。

一緒に働きませんか

Gaudiy ではエンジニアを募集しています。Cloud Run, GKE など、この領域に知見のある方や、Gaudiyでのプロダクト開発に興味のある方がいたらぜひお話ししましょう!

gaudiy.com

special.gaudiy.com

Gaudiy流AI駆動開発: AI-DLCを3ヶ月実践してみて

この記事は#GauDev Advent Calendar 2025の15日目です。

はじめに

こんにちは、Gaudiyでエンジニアをしている@mrskiroです。

私が所属するチームでは、新規事業の複数立ち上げに向けてより高速に開発を行える、AIネイティブな開発フローを模索していました。AI駆動開発というキーワードは盛り上がり始めていましたが、コード生成の話が中心で開発プロセス全体をどう回すかのベストプラクティスはまだ見当たりませんでした。

そんな中で出会ったのがAWSの提唱する「AI-DLC(AI-Driven Development Lifecycle)」です。この記事では、AI-DLCを実践したこの3ヶ月間の取り組みについて紹介します。

AI-DLCとは

AI-DLC(AI-Driven Development Lifecycle)は、AWSが提唱した開発ライフサイクルの考え方です。

aws.amazon.com

AI-DLCでは、「人間が主導し、AIがアシストする」という従来の形を逆転させています。AIが開発を主導し、人間はAIの成果物をレビューして意思決定に責任を持つという考え方です。

また、AIを中心にプロダクト開発を進めると、AIのアウトプット速度に対して人間のレビュー・意思決定がボトルネックになりやすいため、チーム内での同期コミュニケーションが重視されています。

AI-DLCはInception、Construction、Operationの3つのフェーズで構成されています。

出典: AI 駆動開発ライフサイクル:ソフトウェアエンジニアリングの再構築

フェーズ AIの役割 人間の役割
Inception 人間との対話を通じて仕様書・ユーザーストーリーを生成し、構想を具体化 ビジネスの目的や作りたい機能の概要をインプット
Construction 設計・コード・テストなどを自律的に生成 AIが生成した成果物を専門家としてレビュー・検証
Operation パフォーマンス監視、エラー検知、ログ分析を実施。問題発見時は原因分析と修正案を提示 AIからの報告や提案を評価し、本番適用の可否を最終判断

具体化するまで

AI-DLCはあくまで考え方や方法論なので、実際にどうやるかは自分たちで決める必要がありました。そのため、まずはホワイトペーパーの末尾にあるプロンプト例をClaude Codeのスラッシュコマンドとして実装し、Inceptionを試したのちに、ドキュメントの構造や保存方法を検討していきました。

0→1で作るときはプロトタイプでチーム内認識を合わせるフェーズがほしかったので、α ConstructionやInception Refiningといった独自のフェーズも追加することにしました。一方で、AI-DLCには「Bolt」というイテレーションの単位が定義されていますが、聞き慣れない用語を増やすよりシンプルにフェーズ単位で進める方が浸透しやすいと判断し、導入しませんでした。

フェーズを検討していた時期は、チームメンバーが整理してくれたFigJamを見ながら「次は何なんだっけ」「ここに戻ってもいいんだっけ」といった議論を都度していました。Unitの依存関係やデザインを反映するタイミングなど、実際に回しながら考えることも多かったです。

figjam

ツール面での苦労もあり、スラッシュコマンドを改善するたびにClaude Codeで動作確認が必要で、レートリミットに達したりAIの出力に揺れがあったりして難航することもありました。

現在運用中のフロー

Inceptionの進め方

Inceptionでは、PdM・エンジニア・デザイナーなどプロジェクトメンバーが参加し、ビジネス目標を元にIntentとユーザーストーリーを作ります。

スラッシュコマンドを実行すると、AIが「このプロダクトで解決したい課題は?」「ターゲットユーザーは?」といった質問を投げかけてきます。対話を通じて要件を具体化し、Intent(プロダクトの目的、スコープ、成功指標などをまとめたPRDに近いドキュメント)とユーザーストーリーを生成します。

実際の進め方としては、PdMが要件の言語化に集中し、エンジニアは技術的な観点から補足を入れる形で対話しながら進めています。1セッションは1時間程度で終わることが多いです。

雰囲気を伝えるため、実際のスラッシュコマンドを抜粋して紹介します。

# AI-DLC Inception Phase

現在のプロジェクトのインセプションフェーズを実行します。
AI が主導して、Intent 発見から User Stories 作成、Units 分解まで一連の流れで進めます。

## このセッションの概要

AI-DLC 思想に基づき、以下を一連の流れで実施します:

1. **ビジネス価値の明確化** - 段階的な対話で真の Intent を発見
2. **Intent Statement 生成** - 価値、ユーザー、メトリクス、MVP を統合
3. **User Stories 作成** - Intent を User Stories に分解
4. **Units 分解** - User Stories を独立開発可能な Units に設計
5. **計画承認プロセス** - 各ステップでチェックボックス付き計画をレビュー・承認

## Phase 1: Intent Discovery

### Intent番号の確認

1. `aidlc-docs/requirements/` ディレクトリ内の最新Intent番号(3桁: 001, 002...)を確認
2. 最新Intent番号+1を `NEXT_INTENT_NUM` とする
3. 既存Intentがあれば Brown-Field、なければ Green-Field として判定

### Intent Discovery プロセス

私(AI)がファシリテーターとなり、対話を通じてあなたの Intent を明確にしていきます。

状況に応じて適切な質問を行いながら、以下の要素を明確化していきます:

- 解決すべき問題または追求する機会
- 価値を受けるターゲットユーザー
- 成功の測定方法
- MVP(数日〜1 週間で実現可能なもの)

Green-Field(新規開発)でも Brown-Field(既存改善)でも、まずはあなたが取り組みたいことについて自由にお話しください。私が適切な質問で詳細を引き出していきます。

### Intent Statement 生成

対話を通じて収集した情報を基に、Intent Statement を生成します。以下の形式でまとめます:

# Intent-XXX: [簡潔で明確なビジネス目標]

## Business Value
[実現するビジネス価値]

## Target Users
[価値を受けるターゲットユーザー]

## Success Metrics
[成功を測定する指標]

## MVP Scope
[最初に実現する最小限の機能 - 数日〜1 週間で実現可能]

(以下、Phase 2: User Stories 作成、Phase 3: Units 分解と続く)

α Constructionの進め方

α Constructionでは、PdM・エンジニア・デザイナーなどプロジェクトメンバーが参加し、Inceptionの内容を元にプロトタイプを作成します。

プロトタイプ用の設計を生成し、Next.jsやExpo等でプロトタイプを自動生成します。ここで作るのは「動くもの」であって、「完成品」ではありません。実際に触ってみて「これで合ってる?」を確認するのが目的です。

Inception Refiningの進め方

プロトタイプを触ると、だいたい何かしら課題が見つかります。「この画面遷移、分かりにくいな」「この機能、実は要らないかも」といった気づきです。

そういった課題をインプットすると、AIが対話を通じてIntentとユーザーストーリーを更新します。α ConstructionとInception Refiningは2〜3回繰り返すことが多いです。

β Constructionの進め方

β Constructionでは、クローズドベータ向けのプロダクトを構築します。ここからは本番を見据えた実装になります。

設計フェーズでは、まず全体設計としてDDD戦略的設計とUnit分割を行います。その後、Unitごとにドメイン設計、DB設計、API設計、UIフロー設計を進めます。設計ドキュメントは実装時に参照することが多いため、時間をかけてレビューします。

最初はチーム全員で進めていましたが、慣れてきた後半からはエンジニアのみで行うようになりました。InceptionやRefiningと違って、技術的な判断が中心になるためです。

実装フェーズでは、設計ドキュメントを参照しながらClaude CodeのSubAgentなどを用いて各メンバーが実装を進めます。いわゆる仕様駆動開発(SDD)と同様のアプローチで、Inceptionで作成したIntentやユーザーストーリーが仕様として機能するので、ある程度精度よく実装を進められます。デザインがある場合は、MCPでFigmaデータを参照しながら実装します。こちらについては@minamiが記事を書いているので、ぜひご覧ください。

zenn.dev

なお、フローチャートに記載したConstruction以降のフェーズは、AI主体で実行していく部分をまだ模索中です。

プラグイン化

AI-DLCを実践していく中でやり方が固まってきましたが、プロジェクト固有のスラッシュコマンドとして実装していたため、他のプロジェクトでも使えるようにClaude Codeのプラグインとして切り出しました。

Claude Codeは他のCLIツールと比べてチームで設定を共有する機能が豊富で、プラグイン化に手間がかからなかったのはありがたいポイントでした。「aidlc-kit」という名前で、各フェーズに対応した以下のスラッシュコマンドを用意しています。

コマンド 説明
setup プロジェクトのセットアップ
inception-new 新規Intent作成
inception-fix Intent修正
alpha-design プロトタイプ用設計
prototype プロトタイプ生成
design-overview 全体設計
design-unit Unit個別設計
task 実装タスク生成

将来的にはPluginはOSSにするかもしれません

実践してみて

プロジェクトメンバーが同期でAIと一緒に要件を整理していくので、機能の実装優先度や仕様の背景について認識が揃いやすくなっています。

実際に、エンジニア3名で開発を始め、途中から1名が加わり計4名で約2ヶ月と少し、Web・モバイル・バックエンドを含むプロダクトをデリバリー直前の状態まで開発しました(リリース時期はビジネス上の理由で調整中)。

α Constructionでプロトタイプ作成とユーザーテストを何度も繰り返したおかげで、β Construction以降はほとんど手戻りなく進められました。また、AI-DLCのフロー構築自体も並行して検証していたのもあり、フローが固まった今後はもっと短縮できる余地があると考えています。

課題

AIのアウトプットが早く多いため、人間のレビューがボトルネックになりやすいです。β Constructionの設計ドキュメントのレビューだけで1〜2時間かかることもあり、レビューしやすいフォーマットの検討を進めています。

現状はClaude Codeのスラッシュコマンドやプラグインとして実装していますが、今後AIモデルや開発ツールが変わる可能性もあります。MCPやシェルスクリプトを活用し、ツールに依存しない形への切り出しも検討しています。

他チームへの展開の難しさも課題の一つです。AI-DLCの用語や、カスタマイズしたフローの学習コストが一定存在します。途中のフェーズからでも使える形や、通常の開発でも便利なコマンドを一緒に配布するなど、小さく始められる状態を目指しています。

おわりに

AI-DLCを3ヶ月実践してきました。ホワイトペーパーの概念を自分たちなりに解釈し、Claude Codeで運用できるようにしました。まだまだ課題は多いですが、従来よりスピード感を持って開発を進められています。

AI-DLCの実践に関心がある方や、すでに取り組まれている方にとって、一つの事例として参考になれば嬉しいです。

なお、私たちが独自に実践を始めた後、AWSからAI-DLCのワークフローがオープンソースとして公開されました。これから始める方はこちらも参考になると思います。

github.com

GaudiyではAI駆動開発に興味のあるエンジニアを募集しています。カジュアル面談も歓迎ですので、気になった方はぜひお声がけください。

special.gaudiy.com

次回の#GauDev Advent Calendar 2025はameさんの記事です。お楽しみに!


参考

AI活用で開発加速!GCP×AWS マルチクラウドリバースETLの技術選定と実装

はじめに

この記事はGaudiy Engineers Advent Calendar 2025の6日目の記事です。

はじめまして。Gaudiy でデータエンジニアをしている miyataku です。

私たちのプロジェクトでは、「BigQuery で分析したデータをアプリケーション側で使いたい」というニーズがありました。
課題となったのは、データ分析基盤が GCP (BigQuery)、アプリケーションが AWS で動いているというマルチクラウド構成 です。プラットフォームをどちらかに統一する案も出ましたが、コストやリスクを試算すると現実的ではありませんでした。

そこで、マルチクラウド環境を維持したまま、BigQuery のデータを AWS 側で活用する仕組み を構築する方針を選択しました。

技術選定から開発まで進めていった結果、いい感じに要件を満たす構成がまとまって、わずか3 週間で完了まで持っていくことができました
これほど短期間で仕上げられた大きな理由は、技術選定から開発まで Claude Code を積極的に活用した点にあります。

本記事では、そのときの意思決定や構成、AIをどう活用したかなどの裏側をまとめて紹介します。

🔍 この記事のハイライト

  • 🔄 データ連携基盤構築:BigQuery → S3 → アプリケーションのデータ連携を短期間で実現
  • ⏱️ 開発工数削減:Cloud Run Jobs の採用で 6〜9 日短縮。Go / Docker の既存スキルのみで対応可能
  • 🔐 セキュアな認証:Cloud Run OIDC × AWS STS でマルチクラウド認証を実現(WIF構築不要)
  • 💰 高いコスト効率:数百万ユーザー規模のデータを月額コスト数ドルで処理
  • 🤖 AI 活用の工夫:技術選定や実装パターン検討で効率化を実現
  • 📚 ドキュメント戦略:spec / research の分離で新メンバーのキャッチアップを大幅に高速化

1. 背景と課題

実現したかったことはシンプルで、BigQuery で分析・集計したユーザーの記録や行動履歴を、API 経由でアプリケーションに提供することです。
これができれば、よりパーソナライズされた体験を提供できます。

ただ、実際にやろうとすると、2 つの壁にぶつかりました。

マルチクラウド認証をどうするか

データは GCP(BigQuery)にあって、アプリケーションインフラは AWS(ECS/S3)にある。
つまり GCP 側から AWS の S3 に安全にアクセスする必要があります。

アクセスキーみたいな長期秘密情報は管理リスクが高いので避けたいですし、かといって認証基盤を新たに構築・運用するコストもできるだけ抑えたい。

数百万ユーザー分のデータを高速に処理するには

1 日 1 回、数百万ユーザー分のデータを全件更新する必要があります。
Cloud Run のメモリ・CPU 制限の中で、並列処理をうまく使って高速化しつつ、コストも最適化しないといけません。

マルチクラウドのままいくか、統合するか

最初はチーム内でも「マルチクラウドって運用複雑じゃない?どっちかに統合した方がいいのでは?」という意見がありました。

AWS 統合案(BigQuery → Redshift に移行)や GCP 統合案(ECS → Cloud Run に移行)も一応検討しましたが、どちらも既存システムの大規模な移行が必要で、移行期間やビジネスリスクを考えると現実的ではありませんでした。

となると、マルチクラウドのままでいくことになるのですが、「アクセスキーの管理コストが心配…」という懸念がありました。
でも実際に検証してみたら Cloud Run のデフォルト OIDC だけで AWS 認証が完結して、Workload Identity Federation(WIF)の構築も不要で、意外とシンプルに構築できました。

この検証結果を受けて、マルチクラウド構成を維持する方針を確定。
データ連携基盤の構築を進めて、短期間で完了まで持っていくことができました。

2. 技術選定とアーキテクチャ

今回構築したのは、BigQuery(データウェアハウス)から S3(データストア)を経由してアプリケーションにデータを提供する、リバースETL(Reverse ETL) の仕組みです。
要するに、BigQuery で分析・集計したデータを、アプリケーション側で使えるようにする流れになります。

全体構成

先ほど挙げた 2 つの課題(マルチクラウド認証と大量データ処理)を解決するために、こちらの構成を選定しました。

 

 


データストアは S3 + ElastiCache に決定

データストア選定では、2つの観点を重視しました。
1つ目は、アプリケーション側が ECS に決まっているため、ECS から参照しやすい形であること。
2つ目は、BigQuery から取得したデータを転送する集計ジョブからの一括更新のしやすさです。

データストアの候補としては S3、DynamoDB、Aurora の 3 つを検討しました。最終的に選んだのは S3 + ElastiCache for Redis の組み合わせです。

データ特性を整理してみると、読み取り専用・日次更新・検索不要というパターンなので、RDB の利点(トランザクション、JOIN)も DynamoDB の利点(低レイテンシ書き込み)もあまり活かせないことが分かりました。
むしろ「安価に大量データを長期保管する仕組み」が最適です。

当初は「数百万ユーザーのデータ扱うなら RDB(Aurora)の方が安全ではないか?」という意見もありましたが、よくデータ特性を整理すると以下のことがわかりました。

  • トランザクション不要:1 日 1 回の全件置換だし、ACID 特性いらない
  • JOIN 不要:もう BigQuery で集計済みのデータを転送するだけ
  • スキーマ変更の柔軟性:S3 なら JSON 形式でスキーマフリー。RDB だと ALTER TABLE が必要
  • コスト:Aurora は月額数十ドル〜、S3 は月額数ドル程度

こういった理由で RDB は選択肢から外れました。
さらに、「RDB だと高速だが、S3 だと遅い」という懸念も、ElastiCache をキャッシュとして使うことで解決できました。

観点 S3 の優位性
ストレージコスト DynamoDB と比較して大幅に安価。数百万ユーザー規模でも月額コストを抑えられる
アクセスパターン 日次全件更新・読取専用に最適
バージョン管理 S3 標準機能で過去データ保持が容易(RDB は履歴テーブル設計が必要)
レイテンシ S3 単体では数十〜百ms程度だが、ElastiCache(サブミリ秒)で十分カバー可能と判断

ジョブ実行環境は Cloud Run Jobs で

ジョブ実行環境はデータソースとして BigQuery がメインになるだろうとは想定していましたが、将来的に他のデータソースも扱う可能性があったため、特定のデータソースに依存しない柔軟な仕組みを選びたいと考えました。

ジョブ実行環境の候補は Cloud Run Jobs と Vertex AI Pipelines の 2 つ。最終的に選んだのは Cloud Run Jobs です。

観点 Cloud Run Jobs Vertex AI Pipelines 判定
開発工数 短期間で対応可能 パイプライン定義・DSL学習が必要 ✅ 大幅に削減
コスト 低コスト 相対的に高コスト ✅ 圧倒的に低コスト
アーキテクチャ コンテナ 1 つで完結(修正も容易) DSL 学習が必要 ✅ シンプル

チームが既に持っている Go / Docker のスキルを中心に開発できて、パイプライン定義や DSL の学習が不要だったのが決め手でした。
結果として、開発工数を大幅に削減できました。

3. 実現のポイント

BigQuery からデータをストリーミングで取得

数百万ユーザーのデータを一気にメモリに読み込んだら、OOM(Out Of Memory)で落ちるリスクがあります。
なので、Query.Read() が返す RowIterator を使って、ストリーミングで 1 件ずつ処理する方式にしました。

// BigQuery クエリを実行
query := client.Query("SELECT ...")
it, err := query.Read(ctx)
if err != nil {
    return fmt.Errorf("failed to read query: %w", err)
}

// RowIterator でレコードを1件ずつ処理(全件メモリに載せない)
for {
    var row MyStruct
    err := it.Next(&row)
    if err == iterator.Done {
        break
    }
    if err != nil {
        return fmt.Errorf("iterator error: %w", err)
    }
    if err := processRow(ctx, row); err != nil {
        return err
    }
}

ポイント:

  • RowIteratorは内部でページングを行うため、数百万レコードでも全データをメモリに保持する必要がない
  • 常に処理中の1レコード分のみがメモリに載るため、Cloud Runのメモリ制限内で安定した処理が可能

マルチクラウド認証は意外とシンプルだった

結論から言うと、Cloud Run のデフォルト OIDC トークン(Issuer: accounts.google.com)を使うだけで完結しました。
最初は「Workload Identity Federation(WIF)の構築が必須では?」と思ってましたが、AWS は accounts.google.com を WebIdentity Provider として直接扱えるため、WIF Pool の作成は不要でした。

AWS 側の IAM Role(Trust Policy)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "accounts.google.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        // Cloud Run の ID Token を検証する条件を設定
        // 詳細は AWS STS と Google Cloud OIDC のドキュメントを参照
      }
    }
  ]
}

ポイントは "Federated": "accounts.google.com" の設定で、これだけで Cloud Run のデフォルト OIDC を使えます。
Condition 部分は環境によって異なるため、AWS STS と Google Cloud OIDC のドキュメントを参照してください。

認証の有効期限について: AWS STS が発行する一時クレデンシャルは 1 時間で期限切れになります。
今回はクレデンシャルをキャッシュし、期限切れ 10 分前に自動更新する仕組みを実装しました。

並列アップロードで高速化

BigQuery からのストリーミング取得と S3 への並列アップロードを組み合わせて、errgroup.WithContext で制御しました。
並列数を 10〜100 で調整して、1 日 1 回の全件更新という要件を十分満たせる性能を実現できました。

ただ、高並列で S3 にアクセスする場合、Go のデフォルト HTTP クライアント設定がボトルネックになるので、接続プールを拡張しました。

httpClient := &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 200,
        IdleConnTimeout:     90 * time.Second,
    },
}

ポイント:

  • MaxIdleConnsPerHost:デフォルトは2ですが、並列数に合わせて200に拡張
  • これにより、接続の再利用(Keep-Alive)が効き、高速かつ安定した処理が可能になります

4. AI をどう活用したか

今回のマルチクラウド構成のデータ連携基盤では、Claude Code を活用して、技術選定から開発まで短期間で完了できました。

技術選定の壁打ち相手として

技術選定を AI に相談するとき、一番大事なのは前提となる要件を明確に伝えることです。
データ特性、アクセスパターン、パフォーマンス要件を構造化して提示すると、AI が適切な比較軸で回答を返してくれます。

# 技術選定:データストアの選定

## 集計対象の情報
- 対象ユーザー数: 数百万
- 更新頻度: 1日1回(全件更新)
- アクセスパターン: 読み取り専用

## 比較観点
1. 月額コスト
2. 開発工数
3. 性能

## 比較対象
- S3 + ElastiCache
- DynamoDB
- Aurora

この形式で依頼すると、AI が各候補を一貫した観点で比較してくれて、意思決定の質が上がりました。
マルチクラウド認証の構築など、初めて扱う技術要素についても、ベストプラクティスを含むサンプルコード付きで回答が返ってくるので、方針を素早く固められました。

AI 活用で重要なのは、入力の厳格さと出力への深掘りの両立です。
生成されたコードやアーキテクチャ案に対して「このエラーケースは?」「パフォーマンスは?」「この情報は本当に正しい?」と繰り返し問いかけ、公式ドキュメントで裏取りしながら品質を高めていきました。
AI はハルシネーション(誤った情報の生成)もあるため、出力を鵜呑みにせず、批判的に検証することが不可欠です。

意思決定に "唯一の正解" はありません。だからこそ、意思決定理由を明確にし、曖昧さを残さないことが重要です。
AI は選択肢の整理や情報収集を効率化してくれる存在として活用しました。

コード生成の活用

AI によるコード生成は、errgroup による並列処理や HTTP クライアントの接続プール設定など、Go のベストプラクティスに沿った実装パターンを素早く提案してくれるため、調査時間の短縮に役立ちました。
ただし、初期段階では失敗もありました。

設計方針を明確にせず「BigQuery から S3 にデータを転送する処理を書いて」とだけ依頼したら、レビューで大量の修正が発生。
エラーハンドリング、ログ出力、インターフェース設計が曖昧なまま生成されたので、開発時間よりもレビュー対応時間の方が長くなってしまいました。

対策として、開発前にエラーハンドリングポリシー、ログ出力ポリシー、インターフェース設計などを CLAUDE.md に記載するようにしたところ、レビューコメントが大幅に削減されました。
プロジェクト固有のルールを事前に伝えることで、最初から本番投入レベルのコードが生成されるようになりました。

ドキュメント管理の工夫

AI を活用して技術選定を行うと、どうしても膨大な検討ログが生成されます。
これをそのままチームに共有すると、必要な結論が埋もれ、読むのに時間がかかり、レビューが難しくなります。

解決策:spec / research 分離

  • docs/spec/(開発仕様書:結論): A4 1〜2 枚に収まる簡潔な仕様書。What(何を作るか)、Why(なぜ必要か)、How(どう作るか)のみを記載。検討過程は含めない。
  • docs/research/(根拠の記録): 詳細な検討記録。候補技術の比較、コスト試算、実測ベンチマーク、AI との議論ログ、不採用案の理由を記載。

効果:

  • 新メンバーは spec(A4 1-2 枚)を読めば全体を把握でき、research を最初から読む必要がない
  • 意思決定の理由が research に残るので透明性が保たれる
  • PR では spec だけ確認すればよく、詳細は必要に応じて research を参照
  • 方針転換の判断がしやすい:research に「なぜその選択をしたか」が残っているので、状況が変わったときに「前提条件が崩れた」と気づきやすく、適切なタイミングで方針を見直せる

この分離により、「新メンバーは spec を読むだけで開発開始」「マネージャーは結論だけ見れば十分」「詳細が必要な時のみ research を参照」という効率的なワークフローが実現しました。
さらに、意思決定の根拠が明確に残るので、半年後・1年後に「なぜこの構成にしたんだっけ?」と振り返るときにも役立ちます。

5. やってみて分かったこと

マルチクラウドは思ったよりシンプルだった

Cloud Run のデフォルト OIDC と AWS STS を組み合わせることで、追加インフラなしでクラウド間の安全な認証を実現できました。
Workload Identity Federation(WIF)が不要だったのは大きな発見で、構築も思ったよりずっとシンプルでした。

ストリーミング取得 × 並列処理で高速化

BigQuery のストリーミング取得(RowIterator)でメモリ使用量を抑えつつ、S3 へのアップロードは worker pool と errgroup を組み合わせて並列化。
さらに HTTP コネクションプールを適切に調整したことで、安定して高いスループットが出せるようになりました。

ドキュメント管理とAI活用はセットで考える

spec / research の分離で、チーム全体での情報共有の質が大幅に向上しました。
新メンバーが短時間で追いつけるし、認識のズレもなくなるし、仕様変更点も分かりやすくなりました。
AI を使うと検討ログが膨大になりがちですが、この分離戦略で整理することで、必要な情報に素早くアクセスできるようになります

6. おわりに

マルチクラウド構成って複雑そう…と最初は不安でしたが、実際に検証してみると Cloud Run の OIDC + AWS STS だけでクラウド間認証が完結して、思ったよりシンプルに構築できました。

データストアは S3 + ElastiCache、ジョブは Cloud Run Jobs という構成で、短期間で完了できました。
Go と Docker の既存スキルだけで対応できたのも大きかったです。

Claude Code を技術選定の壁打ちに活用したことで開発スピードが上がりました。
要件を構造化して AI に相談することで選択肢の整理が効率化され、意思決定の材料を素早く集められました。
ただし設計方針や最終判断は人間が行う必要があります。
最初から完璧な指示を出すのではなく、AI の出力に対して疑問を投げかけ、改善を重ねていく対話プロセスが、品質の高い成果物につながります。

マルチクラウドや AI 活用の開発プロセスで悩んでいる方の参考になれば幸いです。

#GauDev Advent Calendar 2025、明日の担当はYuseiWhiteさんです!

少人数の新規チームでAI駆動開発を1ヶ月半実践してみた

こんにちは。Gaudiyでバックエンドエンジニアをしているryio1010です。

突然ですが、皆さんの開発チームは何人構成でしょうか?

私の所属する新たに新設されたチームは、PdM、デザイナーが0.5人(他PJと兼務)、エンジニアがフロントエンドとバックエンド各1人です。

Gaudiyの他の開発チームはPdMとデザイナーそれぞれ1人、エンジニアが4-5人ほどのチームが多いので、従来よりも少ないチーム構成となっています。

詳細は後述しますが、そんな構成のチームの中で私たちに与えられたミッションは、「AIをフル活用して、他チームと同等のパフォーマンスを出す」というものでした。

従来のやり方にとらわれない自由な開発が許されている環境で、ここ1ヶ月半ほどAI駆動開発に本格的に取り組んできました。

この記事ではこのミッションのもと「AI駆動開発」に取り組んでみて得た学びや、実践と試行錯誤の過程をお届けします。

1. なぜAI駆動開発を始めたのか

チームが発足した当初、Dev代表からこんなミッションをもらいました。

デリバリーする機能の開発が最優先だが、それを担保した上でAIをフル活用して従来のやり方にとらわれない自由な開発の仕方を模索してほしい。 AIをチームでどう活用できるかを考えてほしい。

エンジニア一人当たり4万円/月まで会社負担でAIツールを自由に利用できる福利厚生も整備されてきており、これまでももちろんエンジニアは個人としてAIツールを開発に取り入れていましたが、改めてチームとしてAIを利用した上での開発フローの確立をしたいという意図がありました。

フロントエンド・バックエンドエンジニア各1名という構成で、AIツールの活用を前提としたチーム作りは、ある程度チームとして戦略立ててAIを使っていかなければ個人で使用していた以上の成果を出すことは難しいだろうと考えていました。

そこでまず、チームとして使うAIツールの選定から始めることにしました。

2. チームとして実践したこと

実践内容 詳細
AIツールの統一 • ナレッジの共有や蓄積のしやすさを重視
• その他のツールは各自の責任で自由に利用可能
MCPサーバーの活用 filesystem MCPで別リポジトリを参照
• 社内Go共通実装リポジトリのMCPを活用
• その他MCPの活用
AI Daily Sync 毎朝5〜10分のカジュアルな情報交換
• フロントエンド・バックエンドの異なるアプローチを共有

チームとして取り組むという観点から話し合った結果、ナレッジの共有や蓄積のしやすさ考えてメインで使うツールはなるべく統一したほうがよいだろう、という結論になりました。

CursorやChatGPTなどいくつかのツールを試していたとき、ちょうどClaude Codeがサブスクプランで使えるようになり、世間的に盛り上がりを見せていたタイミングでした。

そこで私たちのチームは Claude (Code/Desktop) をメインとし、設計の壁打ち相手やClaude以外の視点を入れる目的として Gemini を併用するスタイルに落ち着きました。それ以外のAIツールについてはセキュリティーなどは考慮しつつも各自の責任で好きなものを開発に利用することとしました。

また、MCPサーバーも積極的に活用しました。filesystem MCPで別リポジトリを参照できるようにしたり、別チームが開発を進めていた社内のGo共通実装リポジトリの MCPなども活用し、開発を進めていきました。

そしてツール以上に効果的だったのが、毎日実践した「AI Daily Sync」です。これは朝会の前にほんの5〜10分、AIについてカジュアルに情報交換する時間です。

「昨日Gemini CLIを試したら、結構良くて…」 「CLAUDE.mdの管理、どうしてる?」

フロントエンドとバックエンドの開発の取り組みについては後述しますが、両者では使うツールも違えば、AIへのアプローチも細かい部分では異なっていました。だからこそ短い時間でのsync会が、次の取り組みへのヒントになることも多くありました。実際にCLAUDE.mdの管理方法やdocsディレクトリの構成の再検討などを実践する機会となりました。

何より「それいいね!他のrepoでも試してみよう」と、互いの知見を取り入れることでAI活用の知見をチームとして高められた点が特に良かったと感じています。

また毎日話す機会を作ることで、各人が自然とAIについて調査する時間を意識的にとるようにもなっていました。この取り組みは今でも続けています。

Syncの内容をSlackでシェアしてる様子

3. ドキュメント駆動開発を軸にする

AI駆動開発を進めていく中で、どのAIツールを使う上でも共通していることがあると感じていました。

AIのパフォーマンスは、インプットされるコンテキストの質で決まる」

AIに質の高い仕事やアウトプットをしてもらうには、私たちが質の高い情報、すなわち「ドキュメント」をコンテキストとして整備して与えてあげることが不可欠だと感じています。ドキュメントがない場合とのアウトプットの質にかなり差があることはこれまでのAIを活用した開発でも感じていた部分でした。

実践を通して私たちは以下の理由からAIと協働する時にはドキュメント駆動開発を導入した方が良いと考えるようになりました。

3-1. 共通認識としてのドキュメント

開発において最もコストがかかるのは、後から発覚する仕様の認識齟齬による手戻りだと感じています。ドキュメントを起点に開発を進めることで、そのドキュメントをもとに仕様の確認ができるため認識齟齬を減らすことができています。

何を作るべきか、なぜ作るのかが明文化されるため、開発者間はもちろんAIとの間でも認識がズレるリスクを大幅に低減できました。また実装の前にドキュメントのレビューを行うことで、各人が共通認識を持った上で適切なコンテキストのもとでAI開発を行うための土台になっています。

実際のドキュメントの一例

├── docs
│   ├── 01-requirements ★要件定義書など
│   ├── 02-architecture ★ドメイン設計・データ設計など
│   ├── 03-api-reference ★各RPCの仕様書
│   ├── 04-development ★開発ガイドラインなど
│   ├── 05-decisions ★ADR

3-2. 「とりあえず実装」からの脱却

ドキュメントを作成する過程は、要件や設計の曖昧な点を強制的に洗い出すプロセスでもありました。

「とりあえず実装しながら考えよう」という進め方では見落としがちなエッジケースや考慮すべき点を事前に特定できるため、結果的により具体的なプロンプトをAIに与えることができるようになり、AIが生成するコードの質を向上させることができたと感じています。

3-3. チームの生産性向上

AIのために整備を進めたドキュメントはAIだけのものではなく、むしろチームにとっての資産になります。

新しいメンバーが参加した際、コードを隅々まで読まなくてもドキュメントを読めばプロジェクトの全体像や過去の意思決定(なぜこの技術を採用したのか等)をキャッチアップできます。

そして何より「AIに正確に指示を出すため」という明確な目的が、これまで後回しにされがちだったドキュメント作成のモチベーションになっていると感じています。

結果として、ドキュメント駆動開発は「AIのため」ではなく、「チーム全体の開発プロセスを最適化する手法」であるという結論に至り、この開発手法を導入しています。

4. バックエンド開発 with AI

ここからはバックエンド/フロントエンドそれぞれでの取り組みを簡単に紹介します。

バックエンド開発はAIとの協業を成功させるために、まず「AIが迷わず、最高のアウトプットを出せる土台をどう作るか」という点から考え始めました。

4-1. AI駆動開発の3つの指針

AI駆動開発を始める前に、AIと開発するための3つの指針を考えました。

ドキュメント駆動

「3. ドキュメント駆動開発を軸にする」でもご説明した考え方です。AIのパフォーマンスはインプットされるコンテキストの質に大きく左右されます。人間とAIが同じドキュメントを「共通のコンテキスト」として参照することで、双方の認識のズレを防ぎ、精度の高い実装を目指しました。

テストファースト

AIは高速なコード生成は可能ですが、人間が見ればすぐに気づくような単純なミスや、考慮漏れなどのハレーションを起こすことが少なくありません。AIの生成物を盲信せずに必ずテストで品質を担保することを目指しました。

DDDとレイヤードアーキテクチャ

ドメイン駆動設計(DDD)の考え方を取り入れ、責務を明確に分離するレイヤードアーキテクチャを採用しました。これにより、各レイヤーのインターフェースを定義すれば、AIが実装を並列で進められるようになることを目指しました。

4-2. Claude Slash Commandsを利用した標準化

上記の指針を、日々の開発作業に落とし込むためにClaude Slash Commandsをベースとした開発フローを実践しました。AIへの指示が個人のプロンプトスキルに依存するのを防ぎ、「誰がやっても同じ品質」の開発の「型」を作りました。

コマンド 目的 実現される効果
implrpc RPCエンドポイントの実装 ドキュメント存在確認を強制(仕様書がないと実装不可)
• テスト駆動開発(TDD)の徹底
• analyze → 人間実装 → implementの段階的実装
commit-ready コミット前の品質チェック • モック生成・フォーマット・Lint・テストを順次実行
• エラーが完全に解決するまで継続的にAIが修正
• コミットされるコードの品質を一定以上に保証
create-pr PR作成の自動化 • PRテンプレートに従った一貫性のあるPR作成
• 修正内容に適したPRタイトル・説明の自動生成
• セルフレビューコメントの自動追加

implrpcコマンド

RPCのエンドポイントを実装するためのコマンドです。実行時にまず対応するRPC仕様書の存在を確認し、「ドキュメントをもとに必ず開発する」というルールをコマンドで強制する仕組みです。またドメインレイヤーや各レイヤーを繋ぐInterfaceは人間が主導して実装することでAIによるハレーションを減らすようにしています。

# Implement RPC

**このコマンドの目的**: テスト駆動開発)手法に従って新しいRPCエンドポイントを実装します。

## Overview

Implement a new RPC endpoint following strict TDD (Test-Driven Development) methodology.

⚠️ **IMPORTANT NOTICE** ⚠️

Always carefully consider before executing commands from this file. Before implementation, verify:
- The RPC specification is clearly defined
- You understand the impact on the existing codebase
- You understand the Test-Driven Development process

## Arguments

- `rpc_name` (required): The name of the RPC method to implement
- `phase` (optional): The execution phase - "analyze" (default) or "implement"

## Prerequisites (MANDATORY)

**→ For detailed implementation standards, refer to the following sections in `coding-standards.md`:**
- RPC Implementation Standards
- Documentation Standards
- Test-Driven Development (TDD)

### Mandatory Pre-implementation Checklist:

1. **Verify RPC Specification**
2. **Study Project Documentation**
3. **Validate Proto Definition**
4. **Reference Existing Implementations**
5. **Check Auto-generated Files**

## Execution Modes

This command operates in two phases:

### Phase: "analyze" (Default)
When executed without phase argument or with `phase=analyze`:
- Analyzes the proto definition
- Provides guidance and templates for human implementation
- Shows examples of interfaces and tests to write

### Phase: "implement"
When executed with `phase=implement`:
- Reads human-written code from Phase 1
- Executes three parallel implementation tasks
- Generates complete implementation based on interfaces

## RPC Implementation Specification

Based on the provided `rpc_name` argument, the command will:

### RPC Details
- **RPC Name**: `{rpc_name}`
- **Functionality**: {Analyze proto definition to determine functionality}
- **Proto Messages**: {Extract Request/Response types from proto definition}

## Implementation Process

### When phase="analyze" (First Execution)

The command will:

1. **Review Documentation** (MANDATORY)
2. **Analyze Proto Definition**
3. **Generate E2E Test Implementation**
5. **Provide Implementation Guidance**
6. **Output Templates and Implementation**
   **E2E Test Implementation** (`test/e2e/{snake_case_rpc_name}_test.go`):

### Human Implementation Phase (Between Commands)

**After reviewing the analysis and AI-generated E2E tests, humans should implement:**

1. **Layer Interfaces**
2. **Domain Layer** (if new domain concepts are needed)

### When phase="implement" (Second Execution)

**After human implementation is complete, the command will:**

1. **First, review all documentation** (CRITICAL)
   - **MUST** re-read docs to ensure implementation follows project standards
   - **MUST** verify patterns match architecture documentation
   - **MUST** check RPC specification in `docs/03-api-reference/{snake_case_rpc_name}.md`
   - **MUST** follow patterns from `docs/architecture/` and `docs/technical/`
   - **MUST** ensure all implementations align with documentation

2. **Then execute parallel tasks following strict TDD methodology:**

#### Parallel Task 1: Interface Layer Implementation (RPC Handler) - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

#### Parallel Task 2: UseCase Layer Implementation - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

#### Parallel Task 3: Infrastructure Layer Implementation - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

commit-readyコマンド

コミット前に必ず実行する、品質チェック用のコマンドです。mock生成、フォーマット、Lint、テストを順番に実行し、問題があればAIが可能な範囲で自動修正まで行います。これにより、コミットされるコードの品質を一定以上に保ちます。

# Commit Ready

**このコマンドの目的**: コードをコミットする前に、フォーマット、リント、テストのチェックを実行してコードベースを準備します。

## Overview

Prepare the codebase for commit by running format, lint, and test checks. This command runs the essential pre-commit checks to ensure code quality and correctness before committing changes.

## CRITICAL REQUIREMENTS

**IMPORTANT**: This command MUST execute ALL of the following commands in order:
1. `make gomock`
2. `make fmt`
3. `make lint`
4. `make test`

**MANDATORY**: The command MUST continue fixing all lint and test errors until they are completely resolved. Do not stop at the first error - continue iterating and fixing issues until all checks pass successfully.

## What this command does

1. **Generate mocks** (`make gomock`) - MUST be run first to regenerate all mock files ensuring they're up to date with current interfaces
2. **Format code** (`make fmt`) - Formats all Go code using goimportz and gofumpt
3. **Lint code** (`make lint`) - Runs golangci-lint to check for code quality issues
4. **Check test coverage** (`make coverage`) - Generates test coverage report and ensures adequate coverage
5. **Verify test implementation** - Checks that all layers have proper tests:
6. **Run tests** (`make test`) - Executes all tests with race detection

## Execution Order and Error Handling

The commands are executed in this specific order:
1. `make gomock` - ALWAYS runs first to ensure mocks are up to date
2. `make fmt` - Formats the code (including newly generated mocks)
3. `make lint` - Checks code quality
   - If lint errors are found, fix them and re-run `make lint`
   - Continue fixing and re-running until all lint errors are resolved
4. `make coverage` - Checks test coverage
5. `make test` - Runs all tests
   - If test failures occur, fix them and re-run `make test`
   - Continue fixing and re-running until all tests pass

**IMPORTANT**: Do NOT stop at the first error. Keep fixing issues and re-running the failed command until it passes, then continue with the next command.

## Prerequisites

- All source code files should be saved
- Docker should be running (for Spanner emulator during tests)
- No ongoing file modifications

## Output

The command will:
- Show formatting results
- Display any lint issues that need to be fixed
- Display test coverage report with percentages per package
- Identify any missing tests or low coverage areas
- Run the full test suite and report results
- Indicate if the code is ready for commit

## Exit behavior

- If any step fails, the command will stop and show the error
- Only when all checks pass is the code considered commit-ready
- Fix any reported issues before attempting to commit

## Common issues and solutions

### Mock generation issues
- **Outdated mocks**: If interfaces have changed but mocks haven't been regenerated, tests will fail
- **Missing mocks**: New interfaces need mock generation before tests can be written
- **Solution**: Always run `make gomock` before committing to ensure all mocks are up to date

### Lint errors
- **godot**: Comments should end with a period
- **gofmt**: File formatting issues (automatically fixed by `make fmt`)
- **goimports**: Import organization issues (automatically fixed by `make fmt`)

### Test failures
- Check test output for specific failure details
- Ensure all mocks are properly configured
- Verify database schema is up to date

### Coverage issues
- **Missing E2E tests**: Check that all RPC endpoints have corresponding tests in `test/e2e/`
- **Low use case coverage**: Ensure all use case methods have unit tests with both success and error scenarios
- **Missing converter tests**: Add tests for all conversion and validation functions
- **Repository coverage**: Verify integration tests cover all repository methods

### Format issues
- Usually auto-fixed by `make fmt`
- Ensure consistent indentation and import grouping

create-prコマンド

開発が完了した際にcommitからPRの作成までを行うコマンドです。PRのテンプレートを参照し、修正内容にあったPRを作成します。

# Create PR

**このコマンドの目的**: CLAUDE.mdのルールに従ってプルリクエストを作成し、レビューコメントを追加します。

## Overview

This Claude Code project command creates a PR following the rules in CLAUDE.md and adds a review comment.

## Functionality

1. Ensures commits follow Semantic Git commits convention using `npx git-cz`
2. Checks current branch changes using git commands
3. Creates PR following CLAUDE.md PR creation rules:
4. After PR creation, adds self-review comment:

## Implementation

This command is executed internally by Claude Code, not as a bash script. When invoked via `/create-pr`, Claude will:

1. Use `npx git-cz` for creating semantic commits if there are uncommitted changes
2. Run `git status`, `git diff`, and `git log` to analyze changes
3. Use `gh pr create` with proper template structure and semantic title
4. Add review comment using `gh pr comment`

## Commit Convention

All commits must follow Semantic Git commits format using `npx git-cz`:

- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code
- **refactor**: A code change that neither fixes a bug nor adds a feature
- **test**: Adding missing tests or correcting existing tests
- **chore**: Changes to the build process or auxiliary tools

PR titles should match the primary commit type and scope.

## Prerequisites

- Changes must be pushed to remote branch beforehand
- Must be executed from a branch other than main
- Ensure commits exist before execution
- GitHub CLI (`gh`) must be configured and authenticated

これらのコマンドを整備したことで、「AIにどう指示すればいいか迷う時間」がほぼゼロになりある程度実装の型を作ることができました。

4-3. コンテキスト管理と役割分担

またAIの能力を最大限に引き出すため、コンテキスト管理や役割分担も工夫しました。

ドキュメントとコンテキストの分離・整備

AIに与える情報は質だけでなく量も重要です。要件定義からDB設計・ADRなど、必要だと思ったドキュメントはまずAIにたたき台を作らせ、人間がレビューする方針で網羅的に整備しました。 またコンテキストファイルは役割を分け、AI向けのCLAUDE.mdはAIの理解度・精度が高い英語で、人間が主に参照するドキュメントは可読性を重視して日本語で管理しています。

AIと人間の役割分担

アプリケーションの核となるドメインロジックや、レイヤー間の繋がりを定義するインターフェース部分は人間が主体となって実装するようにしています。重要な部分の実装をAIに全て任せてしまうとどれだけ適切にコンテキストを与えることができていたとしても、手戻りが大きくなるリスクがあるためです。

5. フロントエンド開発 with AI

(この章はFEのAI駆動開発を推進したエンジニアのhan sanに寄稿いただきました。)

フロントエンド開発は、バックエンドとはまた違ったアプローチでの開発を実施しました。

5-1. 目的ごとのツール群とMCPサーバーの活用

フロントエンドではより細かく用途によるAIツールの使い分け、MCPの活用を行いました。

  • メインツール: Claude (Code/Desktop)
  • 自動化・サブツール: GitHub Copilot Agent
  • 壁打ち・相談役: Gemini Pro

特に開発効率を大きく向上させたのが、MCPサーバーの活用です。

  • context7: OSSのドキュメントなどを参照させ、Mantine UIのようなライブラリの正しい使い方をAIに学習させる。
  • playwright: AI自身に画面を操作・確認させ、E2EテストやUIの動作確認を行わせる。
  • figma: デザインデータからUIプロパティを直接取得させ、デザインと実装の乖離を防ぐ。

5-2. 2つの開発パターン

日々の開発では、タスクの複雑さに応じてAIへの指示の出し方を2つのパターンで使い分けていました。

パターン1: シンプルなタスクは「直接指示」

「テキストのvariantを変えてください」や「実装に合わせてStorybookのケースを増やしてください」などは直接指示でも十分期待通りの結果を出せます。

パターン2: 複雑なタスクは「Opus 4で計画、Sonnet 4で実行」

一機能の実装計画を立てる場合はまず思考能力の高いClaude Opus 4に「フロントエンドのエキスパート」として詳細な実装計画を立て、その計画をClaude Sonnet 4に渡してコーディングを行います。 Opus 4 はセッションあたりのトークン上限が Sonnet 4 より小さいため、コスト効率の観点から「計画=Opus 4」「実装=Sonnet 4」と使い分けています。計画の精度が高ければ、実装品質は Sonnet 4 で十分に担保できます。

5-3. AIと協働するための工夫

並列作業の実現

Git worktreeとClaude CodeなどのAI CLIツールを組み合わせることで、複数の、ターミナルセッションで作業を同時に進められることができます。これによってエンジニア一人で複数のタスクを分担処理でき、作業の効率化を図ることができました。

エンジニアがUIデザインまで担う自由度

UIライブラリで構築された管理画面の実装を行う際、Context7 MCPを活用することで、AIがUIライブラリの仕様を把握でき、一貫性のあるモダンなUIを迅速に作成することができました。

6. 見えてきた課題と今後取り組みたいこと

AI駆動開発を本格的に実践したからこそ、現実的な課題も見えてきました。ここでは、私たちが直面した主な課題と、今後どのような取り組みをしていきたいと考えているかについてお伝えします。

6-1. AIレビューの限界

私たちは当初、コードレビューもAIが行うことで開発がうまく回るのではないかと考えていました。しかし、1ヶ月半試行錯誤した上での正直な感想は、「どれだけpromptを改善しても、レビュアーとしてはまだ物足りない」というものでした。

以下は試したAIコードレビューの一例です。

AIツール 手法 結果
Github Copilot PRのレビュアーに入れることによる自動レビュー typoの修正や部分ごとの実装の修正提案はあるが、PRコード全体でのレビューは難しい。
Devin slackでのコミュニケーションによるレビュー PR全体でのレビューは可能だが、設計思想やドメインを理解した上でのレビューは難しい。
Claude Code Action カスタムプロンプトによるGHAを使用した自動レビュー プロンプトのチューニングも行ったが、Devinとほぼ同じ結果であった。PR全体でのレビューは可能だが、設計思想やドメインを理解した上でのレビューは難しい。

AIはコーディング規約違反や単純なロジックミスといったレビューであれば問題なく対応できます。しかし、私たちがレビューで本当に求めているのはそこだけではありません。

  • この設計は、半年後の機能拡張に耐えられるか?
  • ビジネスのドメインルールを正しくコードに反映できているか?
  • パフォーマンス上の懸念や、よりシンプルな代替案はないか?

こういった背景知識や将来のプロダクト展開までを考慮した「設計の妥当性」に関するレビューは、やはり経験を積んだ人間のエンジニアが行う必要があると感じました。

この経験から、あくまで我々がトライした条件下の中ではありますが、人間によるコードレビューは品質を担保する上で今後も不可欠であると感じています。

6-2. コンテキストを十分に与えられない分野でのコード生成

AIは一般的なWeb開発の知識は豊富ですが、特定のデータベースでの最適化などはそのままでは難しいのではないかと感じました。

特にSpannerのような比較的新しいDBを扱う上で例えば、

  • interleaveで親子関係を設計したテーブルに対する、効率的なJOINクエリ
  • クエリの実行計画を考慮した、パフォーマンスの最適化
  • Spanner特有のトランザクション管理やインデックスのベストプラクティス

といった内容はAIが生成するコードだけでは不十分なケースが多くありました。

6-3. 「AIの実装しやすさ」と「人間のレビューしやすさ」のジレンマ

アーキテクチャの選定においても、課題を感じる部分がありました。例えば今回バックエンドで採用したレイヤードアーキテクチャは、責務が明確に分離されているため、AIに対して「このレイヤーを実装して」と指示を出しやすく、並列でのコード生成も可能という点で「AIフレンドリー」と感じたため採用しました。

しかしその一方でコード量が増加し、人間のレビュー負荷が高まるというデメリットも生じました。例えば一つの簡単な機能追加のために、各レイヤーの複数のファイルにまたがって変更が必要になり、レビュー時に全体像を把握するのが難しくなっていました。

AIの生産性を最大化するアーキテクチャと、人間が保守・レビューしやすいアーキテクチャをどう両立させるかは今後より実践を重ねていきたいと思っています。

6-4. 今後取り組みたいこと

今回AI駆動開発を実践してみて、これからチームとして取り組んでいきたいことがいくつか見えてきました。今後はチームとして特に下記2つを意識してAI駆動開発に取り組んでいきたいと考えています。

1つ目は、開発の専門領域を超えた動きをしていくことです。 フロントエンドとバックエンドそれぞれの領域でAI駆動開発の実践によってAIを利用した開発の型が定まり、AIを活用することで専門でない分野のキャッチアップも容易になってきています。

そのため「フロントエンドだから」、「バックエンドだから」と役割を限定せず、互いの領域をどんどん越境して助け合えるようにしていきたいと考えています。少数チームであってもAIを活用することでお互いの領域の手助けをし合えるチームにしていきたいです。

2つ目は、開発に閉じない動きをしていくことです。 今後はこれまで以上にエンジニアも企画や要件定義の段階からどんどん首を突っ込んでいきたいです。

AIと開発することでこれまでよりも確実に開発スピードは上がってくると思っています。そんな状況の中で、「どう作るか」だけでなく、「なぜ作るのか」、「何を作るのか」から一緒に考えていけるようにしていきたいです。

7. おわりに

それぞれの開発フェーズでAIと人間の役割をまとめてみました。

開発フェーズ AIの役割 人間の役割
設計・計画 アイデアの壁打ち、たたき台作成 要件定義、アーキテクチャの最終決定
実装 定型コードの生成、並列実装、翻訳 複雑なビジネスロジックの実装、設計判断
テスト 単体テストコードの生成、E2Eテストの実行 テストケースの設計、仕様に基づいた検証
レビュー Lint、コーディング規約の自動チェック 設計の妥当性評価、拡張性・保守性のレビュー
ドキュメント テンプレートからの生成、議事録の要約 仕様の明確化、全体像の記述

今回は私たちなりのAI駆動開発という取り組みを紹介させていただきました。この記事が同じようにAIと共に新しい開発の型を模索している皆さんの何かしらのヒントになれば幸いです。

Gaudiyでは私たちと新しいことに積極的に向き合って新しい型を作っていく仲間を積極的に募集しています!この記事を読んで、Gaudiyの開発スタイルに少しでも興味を持ってくださった方、ぜひ一度カジュアルにお話ししませんか?

ご応募、お待ちしています!

site.gaudiy.com