個人開発でApp Storeリリースフローを学ぶ(三部作)― 本番だけ静かに壊れた、CI/CDの2つの事故
前回の記事で、iOS(UIKit)+ FastAPI + Kubernetesで作ったMinecraftサーバー監視アプリ「MineWatch」が3回のリジェクトを経て審査を通過した経緯を書きました。MineWatchの公式サイトはこちらです。
審査を通過したあとも、CI/CD周りで2つの本番障害を起こしています。どちらもCIは緑のまま通り、事前に気づける仕組みはありませんでした。1つ目は、Firebase Authでログインできなくなったことで気づきました。2つ目は、ArgoCDの異常通知がきっかけでした。この記事は、その2つの障害がなぜ起きたか、どう気づいたか、他のプロジェクトでも再現しうる技術的な落とし穴も含めてまとめたものです。
TL;DR
-
事故1: Release用の秘密ファイルが実体を欠いたまま、
makeのフォールバック設計によってエラーなくApp Storeへ提出されてしまい、本番でFirebaseが未初期化のままログインが全滅した。 - 事故2: 「今回は機能追加が無いから本番イメージの再ビルドは不要」という最初の判断を、後から積んだCVE修正コミットにまで引きずってしまい、修正がgitには存在するのに本番には届いていない状態が数日続いた。
- 対応中に、Kaniko/Dockerのキャッシュキーの仕様と、docker composeのproject名解決の仕様、それぞれに起因する技術的な落とし穴を2つ踏んだ。
- 2つの事故の共通点は「本来は止まるべき場所で、システムが黙って先に進んでしまう」という形をしていたこと。
事故1: Firebaseがプレースホルダのまま本番へ出た
MineWatchのiOS側は、Firebaseの設定ファイル(GoogleService-Info.plist)をDebug/Release用に分けて持っています。どちらも秘密ファイルなので.gitignore対象です。
swift-init: $(GSI_DEBUG) $(GSI_RELEASE) ## Xcode プロジェクトを生成(settings.env + project.yml から)
このMakeターゲットには、開発体験を優先した設計判断がひとつ入っていました。本物のファイルが無いときはエラーにせず、黙ってプレースホルダ(.sample)で代用してビルドを通すというものです。新しいマシンでcloneした直後や、開発中に一時的にファイルを退避したときでも、いちいち止まらずビルドが通る。開発中はこれで何の問題もありません。
問題は、この同じフォールバックがApp Storeへの提出物を作る最後のコマンドでも同じように働いたことでした。Release用のFirebase設定を本物に差し替え忘れたままmake archiveを実行しても、ビルドは正常終了します。プレースホルダのapiKeyではFirebase SDKが実質初期化されないため、そのまま提出されたビルドはサインイン・App Checkが本番で全滅する状態でした。
発覚したのは、本番のアプリでFirebase Authによるログインができなくなったことからです。自分の端末で「検証に失敗しました」と表示されてログインできず、知人に試してもらったところ、独立した別の端末でも同じ症状でした。ビルドから提出までの間、CIも手元のコマンドも全部正常終了していて、この時点まで何も止まりませんでした。
恒久対策:配布直前だけ「存在確認」を「妥当性確認」に変える
開発中のフォールバックはそのままに、archiveの直前だけプレースホルダ検出のゲートを追加しました。なお、Makefileのレシピ行(コマンド行)は実際にはタブ文字での字下げが必須です。以下では表示の都合でスペースにしています。
.PHONY: check-firebase-release
check-firebase-release: ## Release用Firebase設定がプレースホルダのままでないか確認
@if [ ! -f $(GSI_RELEASE) ]; then \
echo "error: $(GSI_RELEASE) が無い。make swift-init を先に実行すること。"; \
exit 1; \
fi
@if diff -q $(GSI_RELEASE) $(GSI_SAMPLE) >/dev/null 2>&1; then \
echo "error: $(GSI_RELEASE) がプレースホルダのまま(GoogleService-Info.sample.plistと同一内容)。"; \
echo " 本物のFirebase設定ファイルを配置してから再実行すること。"; \
exit 1; \
fi
.PHONY: archive
archive: $(XCODEPROJ) check-firebase-release ## TestFlight 用に Archive する(Release + Apple Distribution)
...
ポイントは、ファイルが存在するかどうかだけでなく、diffでサンプルファイルと中身が一致していないかまで見ていることです。「ファイルはある」だけでは、プレースホルダをコピーしただけの状態を見逃します。
「無ければ代用して進める」設計そのものは間違っていません。開発中の利便性と、配布直前の安全性は、要求されるタイミングがそもそも違う。同じ入力チェックを、フェーズによって「警告して続行」と「即座に停止」で使い分ける必要があるという、当たり前だけど見落としやすい教訓でした。
事故2: CVE修正が本番に届いていなかった
こちらは少し込み入っています。時系列で説明します。
- Firebase未初期化の真因を修正したリリース(バージョンA)を提出。この時点でのバックエンド・コンテナイメージには変更が無かった(原因はiOS側のファイル配置だけだったため)ので、「今回はAPIイメージの再ビルドは不要」と判断した。
- 提出後、別件でtrivy image scanが検出したOSパッケージのCVEを修正する作業を並行して進めた。これは
api/web/adminすべてのDockerfileに手を入れる変更で、最初の判断を下したあとに追加された変更でした。 - CVE修正をリリースブランチにマージし、本番へ反映。ここで、最初の「イメージ再ビルド不要」という判断をそのまま引きずり、本番のイメージタグ更新を忘れました。
- 数日後、ArgoCDが
health: Degradedを通知。調べると、本番のコンテナイメージは修正前のタグのままで、CVE修正はgit上には存在するのに本番には一切反映されていませんでした。
MineWatchの本番イメージタグは、Kubernetesのoverlay(kustomization.yaml)に手で書く運用にしています。
images:
# 本番のタグはここが唯一の情報源(イミュータブルタグ <ブランチ>-<SHA12>。
# ビルドパイプラインが push する)。デプロイ = release / hotfix ブランチで
# ここを実タグに書き換え → main マージ。パイプラインは本番に set image しない。
- name: registry.example.com/minewatch/api
newTag: release-1.2.1-<SHA12>
「gitのここを直せば本番に反映される」という運用は、裏を返せば「直し忘れても何も起きない」運用でもあります。CIは通り、他のPRはマージでき、誰も気づけません。
落とし穴1: ARG は宣言するだけでは効かない
CVE修正自体(OSパッケージのapt-get upgrade / apk upgrade)は、ビルドごとに一意な値をARGで渡してキャッシュを無効化する、よくあるやり方で実装しました。
ARG CACHEBUST=dev
RUN apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
これがまったく効きませんでした。ローカルで初回ビルドしたときは確かにCVEが解消されていたのですが、2回目以降のビルドでは古いパッケージのままキャッシュが再利用され続けます。
原因は、Kaniko(そしてDocker)のレイヤキャッシュのキーが「変数展開後のRUN文字列そのもの」であることでした。ARGを宣言しても、RUNの中で実際にその変数を参照していなければ、RUN文字列自体は毎回同じ(apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*)ままです。キャッシュキーが変わらないので、CACHEBUSTの値をいくら変えても素通りします。
修正はシンプルで、RUNの中で実際に変数を展開させるだけです。
ARG CACHEBUST=dev
RUN echo "cachebust: ${CACHEBUST}" && apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
echo一行を挟むだけで、RUN文字列に${CACHEBUST}の値が展開されて含まれるようになり、値を変えるたびにキャッシュキーが変わってレイヤが作り直されます。地味ですが、キャッシュを無効化する目的でARGを使うときは必ず確認すべきポイントです。
落とし穴2: ローカル検証が「何も検証していない」状態になっていた
CACHEBUSTを直したあと、ローカルでdocker compose build → trivy image scanを回して「CVEが0件になった」ことを確認し、これで解決したと判断しました。ところが本番リリースをまたいでも同じCVEがまだ報告される、という再現に悩まされました。
原因は、docker composeがprojectの名前を明示しないとカレントディレクトリ名を使うという仕様でした。MineWatchはリリース作業のたびにgit worktreeで別ディレクトリを切って作業する運用をしていたため、ビルドされるイメージ名が<ディレクトリ名>-apiのように、作業のたびに変わっていたのです。
一方でtrivyスキャン対象は固定名(minewatch-api)で指定していたため、CIやローカルの状況によっては過去に別の作業でビルドされた無関係な古いイメージをスキャンして「CVE 0件」と報告していました。ローカルでは緑、でも実際には何も検証できていない状態です。
# projectを固定する。無指定だとdocker composeがカレントディレクトリ名を
# projectとして使い、イメージ名が`<dir>-<service>`になる(例: `minewatch-admin`
# ではなく`fix-trivy-admin-admin`)。worktree運用(ディレクトリ名がブランチ
# ごとに変わる)だとMakefileのTRIVY_IMAGES(固定名`minewatch-admin`等)と
# ビルド後の実イメージ名がずれ、trivy-scanが古い別イメージを誤ってスキャン
# し続ける事故になる。
name: minewatch
services:
db:
...
docker-compose.ymlのトップレベルにname:を固定するだけで解決しますが、気づくまでは「ローカルでは直っているはずなのに、なぜ本番だけ直らないのか」という、原因の切り分けが一番厄介なパターンでした。
2つの事故を貫く共通パターン
書き終えてから振り返ると、事故1と事故2はまったく別の技術領域(iOSビルド設定とKubernetesのデプロイ運用)で起きていますが、形はよく似ています。
- 開発中に正しいフォールバックが、配布・本番反映の最終防衛線でも同じように働いてしまう(事故1)
- 一度下した「影響なし」の判断を、その後に追加された変更にまで適用してしまう(事故2)
どちらも、CIやテストが検出できる種類の失敗ではありません。CIが検証しているのは「書いたコードが仕様どおり動くか」であって、「本番に届けるべきものが実際に届いているか」は別の軸だからです。
再発防止として、次の2点をチェックリスト・仕組みの両面で入れています。
- 配布物を作る最後のコマンドの直前だけ、秘密ファイルの存在確認をプレースホルダとの内容一致確認まで踏み込んで行う(
check-firebase-release) - 本番イメージタグの更新漏れは、監視(ArgoCDの health-degraded 通知)で検知する前提を置きつつ、リリースブランチにコミットが追加されるたびに「このコミットは本番イメージの再ビルドを要るか」を都度チェックリストで確認する運用に変えた
おわりに
「アプリを作る」「アプリを審査に通す」ときに続いて、今回は「アプリを動かし続ける」ところでも学びがありました。個人開発だと、この手のインフラ・CI/CDの落とし穴を実際に踏んで初めて仕様の細部を理解する、ということが多い気がします。Kanikoのキャッシュキーの仕様も、docker composeのproject名解決の仕様も、公式ドキュメントには書いてあるのですが、実際に事故を起こすまで自分ごととして読めていませんでした。
MineWatchは現在も個人で運用を続けています。次に何か大きな事故を踏んだら、また記事にする予定です。
JQITのエンジニアの95%以上は未経験からの採用です。
よければコーポレートサイトにも遊びに来てください。
未経験から学べます!一緒に挑戦していきましょう![]()
noteやXもやってます↓