前回、次のお題を「tech-digest」(技術ニュースRSS収集→LLM要約・分類→BigQueryトレンド分析)に決めた。今回はそこから実際に手を動かして、環境構築からRSS収集・LLM分類・データ保存までの最小パイプラインが動くところまで進めた記録。
結論から言うと、ほぼ全ステップで何かしらハマった。でもハマりポイントそのものが「個人開発で学びたかったこと」に直結していたので、振り返りとしてまとめておく。
GCPプロジェクトの確認
ローカルでリポジトリを作り、GCPプロジェクト(tech-digest-prod)を作成・課金リンクまでは前回の記事の最後で完了していた。まずはその状態を確認。
gcloud projects list --filter="projectId:tech-digest*"
gcloud billing projects describe tech-digest-prod
プロジェクトも課金リンクも問題なし。ここからTerraformで骨格を組んでいく。
Terraformで骨格を組む
入れたいリソースは3つ。
- Cloud Run(Hono、
min_instance_count=0) - Firestore(サーバーレスDB、記事メタデータの重複チェック用)
- BigQuery(分析用データセット)
Firestoreは最初から Terraform管理にする
todo-apiではCloud SQLの運用で何度か事故を起こした反省があったので、今回は最初からインフラ変更をTerraform経由に統一したいと考えた。ただFirestoreは手動で先に作ってしまっていたので、terraform importで後から管理下に入れる形になった。
resource "google_firestore_database" "default" {
project = var.project_id
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
terraform import google_firestore_database.default "projects/tech-digest-prod/databases/(default)"
import後のterraform planで差分ゼロになることを確認してから次に進む、という手順。
タイプミスとの戦い
terraform initの時点で早速引っかかった。
Error: Unsupported block type
on cloud_run.tf line 17:
17: resources "google_cloud_run_service_ima_member" "public_access" {
resources → resource、google_cloud_run_service_ima_member → google_cloud_run_service_iam_member。単純なタイプミスだったが、この手のミスはterraform planより前の構文チェックで止まるので、エラーメッセージ自体が読みやすいのが救い。
直した後もmin_instance_const(正しくはmin_instance_count)でもう一度同じパターンのミスを踏んだ。Terraformの属性名はエディタの補完に頼らず手入力すると、これくらいの頻度でタイポが出る。
Cloud Runのdeletion_protection
terraform applyでBigQueryは一発で通ったが、Cloud Runだけ「イメージが見つからない」エラーで失敗した(後述)。そこでイメージパスを修正して再apply したところ、別のエラーが出た。
Error: cannot destroy service without setting deletion_protection=false and running `terraform apply`
Cloud Run v2のリソースはデフォルトで削除保護がかかる。個人開発では不要なので明示的にdeletion_protection = falseを設定したが、設定を追加した直後の apply でもまだ同じエラーが出た。これは、GCP側に残っている実体のdeletion_protectionがtrueのままで、「destroyしてから再作成」という手順の中で古い保護設定に引っかかっていたため。
今回は一度中途半端な状態になっていたCloud Runサービスを諦めて、きれいに作り直す方を選んだ。
terraform state rm google_cloud_run_v2_service.tech_digest_api
gcloud run services delete tech-digest-api --region=asia-northeast1 --project=tech-digest-prod --quiet
terraform apply
Terraformのstateと実リソースを両方リセットしてから、deletion_protection = false込みの設定で新規作成する形にしたら解決した。
イメージがないとCloud Runが作れない、というニワトリと卵
Cloud Runサービスを作るには、参照するコンテナイメージが先に存在していないといけない。つまり「Terraformでインフラを作る」より前に「イメージをビルドしてpushする」工程が必要になる。これは最初のapply失敗(Image '...' not found)で気づいた。
gcloud artifacts repositories create tech-digest --repository-format=docker --location=asia-northeast1 --project=tech-digest-prod
gcloud auth configure-docker asia-northeast1-docker.pkg.dev
GCR(Container Registry)はもう非推奨なので、Artifact Registryでリポジトリを作成。ここからDockerビルドに入る。
Dockerビルドで連続ヒット
ネイティブバイナリが見つからない
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build 2>/dev/null || npx tsc
最初に書いたシンプルなDockerfileでは、npm run build(=tsc)の実行時にエラーになった。
Error: Unable to resolve @typescript/typescript-linux-x64. Either your platform is unsupported, or you are missing the package on disk.
npm ci --omit=devでdevDependenciesを除外していたのが原因。ビルドに必要なパッケージ(typescript含む)は実行時には要らないが、ビルド時には要る。この「ビルド時だけ必要なもの」と「実行時だけ必要なもの」を一つのステージに混ぜていたのが構造的な間違いだった。
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx tsc
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
ENV PORT=8080
EXPOSE 8080
CMD ["node", "dist/index.js"]
ビルドステージは依存関係フルで入れてコンパイルし、実行ステージはdistだけをコピーして軽量化する。
CommonJSとESMの衝突
次に出たのはこれ。
error TS1295: ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'.
package.jsonに"type": "module"が無かったため、.tsファイルがCommonJSとして扱われ、ESMのimport/export構文と噛み合っていなかった。追加して解決、と思ったら次のビルドでまた同じエラーが再発。確認すると"type": "commonjs"に書き変わっていた。エディタの保存タイミングか何かで意図せず上書きされていたらしい。地味にハマったポイントで、直したはずの設定が戻っていないかの再確認は今後も必要だと感じた。
outDirが設定されていない
tscは通るようになったが、今度はCOPY --from=build /app/dist ./distで失敗。
failed to calculate checksum of ref ...: "/app/dist": not found
tsconfig.jsonを確認するとrootDir/outDirがコメントアウトされたままだった。
// "rootDir": "./src",
// "outDir": "./dist",
テンプレートそのままで使い始めると、こういう「存在するが無効化されている」設定に気づきにくい。コメントを外してdistに出力されるようにして解決。
RSS取得の実装
ここからようやくアプリケーションロジック。RSSソースはZennとQiitaにした。
import Parser from 'rss-parser'
const parser = new Parser()
export const RSS_SOURCES = [
{ name: 'zenn', url: 'https://zenn.dev/feed' },
{ name: 'qiita', url: 'https://qiita.com/popular-items/feed' },
] as const
export async function fetchAllFeeds() {
const results = []
for (const source of RSS_SOURCES) {
const feed = await parser.parseURL(source.url)
for (const item of feed.items) {
if (!item.title || !item.link) continue
results.push({ source: source.name, title: item.title, url: item.link, publishedAt: item.pubDate })
}
}
return results
}
/ingestエンドポイントから呼び出して、タイトル・URL一覧が取れることを確認。ここは特にハマらず一発で通った。
Gemini Flashでの要約・分類
VertexAI SDKが非推奨だった
最初に@google-cloud/vertexaiで実装したところ、起動時にこんな警告が出た。
The VertexAI class and all its dependencies are deprecated as of June 24, 2025 and will be removed on June 24, 2026.
Please use the Google Gen AI SDK (@google/genai) to access Gemini features.
せっかく新規プロジェクトなので、ここで非推奨SDKのまま進めるのはもったいない。@google/genaiに乗り換えた。
import { GoogleGenAI } from '@google/genai'
const ai = new GoogleGenAI({
vertexai: true,
project: process.env.GCP_PROJECT_ID ?? 'tech-digest-prod',
location: 'global',
})
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
})
APIが有効化されていない
Agent Platform API has not been used in project tech-digest-prod before or it is disabled.
gcloud services enable aiplatform.googleapis.com --project=tech-digest-prod
有効化して数分待てば解決するパターン。todo-apiでも似た経験をしていたので、今回は慌てずに対処できた。
リージョンでモデルが見つからない
API有効化後、今度はモデル自体が404になった。
Publisher model `.../locations/asia-northeast1/publishers/google/models/gemini-2.0-flash-001` was not found or your project does not have access to it.
Gemini系モデルはリージョンごとに利用可能なものが異なる。Vertex AIクライアントのlocationをasia-northeast1からglobalに変更したところ通った。Cloud Run自体のデプロイ先リージョンとは別の話として扱う必要がある。
ここまでで、RSS取得→要約・カテゴリ分類まで一通り動いた。
{
"source": "zenn",
"title": "UnityでなぜC# 10以降を使うライブラリが動くのか",
"summary": "Unity環境において、C# 10以降の新しい言語機能を用いたライブラリが問題なく動作する技術的な背景やその仕組みを解説しています。",
"category": "プログラミング"
}
Firestore/BigQueryへの保存、そして見慣れないプロジェクトID
保存ロジックを実装し、重複チェック(Firestoreに既存URLがあればスキップ)とBigQueryへのinsertを追加。ローカルで動かしたところ、見覚えのないエラーが出た。
Error: 7 PERMISSION_DENIED: Cloud Firestore API has not been used in project my-hono-app-v2 before or it is disabled.
tech-digest-prodではなくmy-hono-app-v2という別プロジェクト(たぶん以前の個人開発で使っていたもの)に対してアクセスしようとしていた。原因は単純で、FirestoreとBigQueryのクライアントを初期化する際にprojectIdを明示していなかったため、ローカル環境のデフォルトプロジェクト設定を見てしまっていた。
const PROJECT_ID = process.env.GCP_PROJECT_ID ?? 'tech-digest-prod'
const firestore = new Firestore({ projectId: PROJECT_ID })
const bigquery = new BigQuery({ projectId: PROJECT_ID })
明示的に渡すように直して解決。複数のGCPプロジェクトを個人で使い回していると、この「どのプロジェクトに向いているか」の暗黙の前提が一番事故りやすいポイントだと再認識した。
.gitignoreを整えてコミット
最後に.gitignoreを整備。
# Node
node_modules/
dist/
*.log
# Env
.env
.env.*
!.env.example
# Terraform
terraform/.terraform/
terraform/*.tfstate
terraform/*.tfstate.*
terraform/*.tfplan
terraform/tfplan
# OS
.DS_Store
Thumbs.db
.terraform.lock.hclはプロバイダのバージョン固定のためにコミットする方針にした。ここまでの変更をまとめてコミット・push。
まとめ
- Terraformでインフラを組む前に「参照するコンテナイメージが存在する」という前提があり、初回は手動でイメージをビルド・pushする工程が必要になる
-
npm ci --omit=devはビルド時に必要なパッケージまで削ってしまうことがあるので、マルチステージビルドで「ビルド専用ステージ」と「実行専用ステージ」を分けるのが安全 -
package.jsonの"type"フィールドは意図せず書き変わることがあるので、ビルドが再度落ちた時はまず差分を見直す - Vertex AIのGeminiモデルはリージョンごとに使えるものが違う。ハマったら
globalを試す価値がある - GCPクライアントSDKは
projectIdを明示しないと、ローカルのデフォルトプロジェクトに向いてしまう。複数プロジェクトを使い回しているなら必須の設定 - 今回のつまずきはどれも「個人開発で経験値を積みたかった領域」そのものだったので、遠回りも含めて収穫が大きかった
次はCI/CD(GitHub Actions)の構築と、todo-apiで作ったplan/apply整合性の型をどう移植するかについて書く予定。

