6.5 インチ用に作り直したサムネイルを本ツリーへ移し終えて、あとはアップロードするだけ、というところで手が止まりました。
置き場所のディレクトリ名を変えたということは、いま App Store で配信されている版が組み立てる URL と、明日からサーバーに実体がある場所とが食い違う、ということです。手元のシミュレータでは最新のコードが動いていますので、この食い違いは開発中に一度も表へ出てきません。
個人開発で壁紙系のアプリを長く運用していると、この種の「サーバー側だけを整理する作業」が定期的に発生します。画像を1枚増やすたびに審査へ出すわけにはいきませんので、配信の実体はサーバー側へ寄せてあります。その結果、サーバーのディレクトリ構成を触る自由と、配信済みの版が焼き込んだ前提とが、正面からぶつかります。
移設そのものは10分で終わります。難しいのは、移設したあとで何を確認できたら「反映できた」と言い切ってよいのか、という部分でした。
配信済みの版が持ち続けるのは、画像ではなくパスの組み立て規則
リモート配信に切り替えたアプリがバイナリに持っているのは、画像そのものではありません。持っているのは、ID から URL を組み立てる規則です。
// 配信中の版(iOS 4.1.1)が持っている組み立て規則
const STORAGE_BASE = "https://storage.example.net" ;
const THUMB_DIR = "5_8inch/thumb_full" ; // ビルド時に決まる
export function thumbUrl ( app : string , id : number ) {
return `${ STORAGE_BASE }/ios/wallpaper_apps/${ app }/${ THUMB_DIR }/${ id }.jpg` ;
}
この THUMB_DIR は、ユーザーの端末に入っている版が更新されるまで一文字も変わりません。サーバー側で 6_5inch/thumb_full/ へ移した瞬間から、配信中の版は存在しない場所を叩き続けます。
見落としやすいのは、この規則がアプリのバージョンごとに違う という点です。私が運用している壁紙アプリでは、iOS と Android で世代が揃っていません。iOS 側は 4.1.1、Android 側は 1.9.0 が主力で、それぞれ別のディレクトリ規約を持って出荷されています。移設の影響範囲は「アプリが何本あるか」ではなく、「いま実際に使われているバージョンが何種類あるか」で決まります。
ストア側の管理画面でバージョン別のアクティブ端末数を見ると、リリースから半年が経った版がまだ二桁パーセント残っていることは珍しくありません。自動更新をオフにしている方、通信環境の都合で更新が進まない端末、機種変更後に古いバックアップから復元された端末。旧パスは、こちらが移設を終えた翌日に消えてくれるものではありません。
リモート設定を入れても、効くのは「入れた版から」です
最初に考えたのは、THUMB_DIR をリモート設定から受け取る形へ直すことでした。
type RemoteConfig = { thumbDir ?: string };
export async function loadThumbDir () : Promise < string > {
try {
const res = await fetch ( `${ API_BASE }/ios/wallpaper_apps/${ app }/config.json` , {
cache: "no-store" ,
});
const cfg = ( await res. json ()) as RemoteConfig ;
return cfg.thumbDir ?? "6_5inch/thumb_full" ; // 取得できなければ現行既定
} catch {
return "6_5inch/thumb_full" ; // 通信不能でも組み立ては続ける
}
}
設計としては素直です。ただ、これを入れても救えるのは「これを入れた版から先」だけでした。いま困っているのは、この分岐を持っていない版です。
ここが、私が最初に取り違えていた部分でした。リモート設定は将来の自由度を買う仕組みであって、過去に出荷した版へさかのぼって効くものではありません。過去に出した版を救えるのは、サーバー側の応答だけです。
同じ理由で、config.json のフォールバック既定値を固定文字列で書くのも避けています。既定値が実際の総数や置き場所とずれると、通信に失敗した端末だけが古い世界を見ることになります。配信中の総数を返す処理は、固定文字列をやめて同じ変数から組み立てる形へ直してあります。
旧パスと新パスの両方で 200 を返す
そこで、移設の前にサーバー側へ双方向のフォールバックを入れました。旧パスで来ても新パスで来ても、実体がある側を返します。
# storage 側 .htaccess — 旧 5_8inch と新 6_5inch のどちらで来ても実体を返す
RewriteEngine On
# (1) 旧パスで来た → 実体が無く、新パスにあるならそちらを返す
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{DOCUMENT_ROOT}/ios/wallpaper_apps/$1/6_5inch/$2/$3 -f
RewriteRule ^ios/wallpaper_apps/([^/]+)/5_8inch/(wallpaper|thumb_full)/([^/]+)$ \
/ios/wallpaper_apps/$ 1 /6_5inch/$ 2 /$ 3 [ L ]
# (2) 新パスで来た → 実体が無く、旧パスにあるならそちらを返す(移設途中の保険)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{DOCUMENT_ROOT}/ios/wallpaper_apps/$1/5_8inch/$2/$3 -f
RewriteRule ^ios/wallpaper_apps/([^/]+)/6_5inch/(wallpaper|thumb_full)/([^/]+)$ \
/ios/wallpaper_apps/$ 1 /5_8inch/$ 2 /$ 3 [ L ]
RewriteCond の中で $1 $2 $3 を使えるのは、直後の RewriteRule のパターンに対する後方参照が条件側でも解決されるためです。ここを %1 と書くと、直前の RewriteCond のキャプチャを指してしまい、意図と違う判定になります。私はここで一度つまずきました。
この2本を先に入れておくと、移設は「壊れる瞬間のない作業」に変わります。ファイルを動かしている最中でも、どちらのパスで来たリクエストも実体のある側へ吸われますので、移設の順序も、アプリ更新の時期も、気にしなくてよくなります。
-f ガードを外すと、404 が別の形で返ってきます
最初に書いた版では -f の条件を省いていました。条件がないと、実体がどちらにも無いリクエスト、つまり本来なら素直に 404 を返すべきリクエストが、もう一方のパスへ書き換えられます。書き換え先にも実体がありませんので、設定の書き方によっては相互に書き換え合って内部リダイレクトの上限に達し、500 が返ります。
ここが運用上やっかいなのは、アプリ側から見た症状が変わる 点です。404 なら「その ID はまだ無い」と解釈して次の画像へ進めますが、500 が返ると再試行のロジックが働き、同じリクエストが繰り返されます。存在しない ID を1件混ぜてしまっただけで、サーバーのアクセスログがその ID で埋まる、という状態になります。
-f は「実体があるときだけ書き換える」ためのガードです。書き換えの条件ではなく、書き換えないための条件 として置いておくと、意図が読み返しやすくなります。これから同じ仕組みを組む場合は、後から足すのではなく最初から -f を付けて書き始めることをお勧めします。
画像が先、カタログが後
もうひとつ、順序の取り決めがあります。新しい画像を追加するとき、私は必ず画像を先にアップロードし、総数とカテゴリの更新を後に回します。
順序 起きること
画像 → カタログ カタログを更新するまでの間、新しい画像は誰からも参照されません。表示上は何も起きません
カタログ → 画像 総数だけが増えた状態になり、一覧の末尾に存在しない ID のサムネイル枠が並びます。アップロードが数分かかる間ずっと、その状態が全ユーザーに見えます
前者は「まだ見えない」だけで、後者は「壊れて見える」です。作業の総時間は変わりませんので、順序を決めておくだけで後者を消せます。
同じ理由で、カテゴリの更新も画像より後に置いています。カテゴリの一覧に ID を足すのは、総数を増やすのと同じ効果を持ちます。
そして、この順序を守ったときの副作用が、確認を難しくします。画像だけ上がってカタログが古いままでも、アプリの見た目は正常なのです。アップロード漏れは、画面上では何も起きないまま静かに残ります 。
「反映できた」と言えるまでに確認する4か所
そのため、アップロード後は必ずサーバーの実応答を見に行きます。ローカルの状態は根拠になりません。確認するのは次の4か所です。
# 確認先 見るもの
1 iOS 版の config 応答 総数が新しい値になっているか
2 Android 版の config 応答 同上(配列上の位置が iOS と異なります)
3 カテゴリ一覧の応答 新しい ID が含まれているか
4 画像の実体(旧パスと新パスの両方) 200 が返り、寸法が想定どおりか
2番目に注釈を足したのは、config の応答が両プラットフォームで同じ形ではないためです。私の環境では、iOS 側は配列の 33 番目、Android 側は 0 番目に総数が入っています。歴史的な事情で位置が違うだけなのですが、この差を忘れて片方の位置だけを見ると、更新できていないほうを「更新済み」と読んでしまいます。確認のスクリプトには、この位置を定数として明示的に書いておくほうが安全でした。
#!/usr/bin/env bash
# verify_release.sh — アップロード後に4か所の実応答を確かめる
set -euo pipefail
API = "https://api.example.net"
IMG = "https://storage.example.net"
APP = "ukiyo-e"
EXPECT_TOTAL = " ${1 :? 新しい総数を渡してください } "
NEW_ID = " ${2 :? 追加した最新 ID を渡してください } "
CB = "v=$( date +%s)" # CDN を跨がずに実体を見るためのキャッシュバスター
IOS_TOTAL_INDEX = 33 # 配列上の位置。プラットフォームで異なる
AND_TOTAL_INDEX = 0
fail () { echo "NG: $* " >&2 ; exit 1 ; }
ios_total = $( curl -fsS "${ API }/ios/wallpaper_apps/${ APP }/4_1_1/index.php?config=1&${ CB }" \
| jq -r ".[${ IOS_TOTAL_INDEX }]" )
[ " $ios_total " = " $EXPECT_TOTAL " ] || fail "iOS config total=${ ios_total } expected=${ EXPECT_TOTAL }"
and_total = $( curl -fsS "${ API }/android/wallpaper_apps/${ APP }/1_9_0/index.php?config=1&${ CB }" \
| jq -r ".[${ AND_TOTAL_INDEX }]" )
[ " $and_total " = " $EXPECT_TOTAL " ] || fail "Android config total=${ and_total } expected=${ EXPECT_TOTAL }"
curl -fsS "${ API }/ios/wallpaper_apps/${ APP }/category_list.php?category=15&${ CB }" \
| grep -q "\b${ NEW_ID }\b" || fail "category_list does not contain id=${ NEW_ID }"
for p in "5_8inch/thumb_full" "6_5inch/thumb_full" ; do
code = $( curl -o /dev/null -s -w '%{http_code}' \
"${ IMG }/ios/wallpaper_apps/${ APP }/${ p }/${ NEW_ID }.jpg?${ CB }" )
[ " $code " = "200" ] || fail "${ p } returned ${ code }"
done
echo "OK: total=${ EXPECT_TOTAL } id=${ NEW_ID } 旧パス・新パスとも 200"
このスクリプトは、定期実行に回してからのほうが役に立っています。中途半端な反映は画面に出ませんので、こちらから定期的に問いかけない限り、何も起きないまま残り続けます。私は毎晩1回、現在の総数と最新 ID を渡して走らせ、非ゼロ終了ならアプリではなくストレージ側を見に行く、という運用にしています。
もうひとつ、3番目の確認について注意があります。応答を文字列としてそのまま検索する形は緩く、1291 は 11291 の中にも一致します。数値 ID なら単語境界を付ければ足りますが、カタログが JSON なら素直に解析して比較するほうが安全です。誤った理由で通る確認は、確認が無い状態より始末に困ります。未解決の問いが、根拠のない安心に化けてしまうためです。
4番目で旧パスも必ず叩いているのが、この記事の要点です。新パスだけを見て安心すると、双方向フォールバックが効いていない状態、つまり配信中の版だけが壊れている状態を、そのまま見逃します。
CDN 越しの確認は、しばしば嘘をつきます
ここでもう一段の落とし穴があります。移設の直前にアクセスがあったパスは、CDN のエッジに応答が残っています。移設後に同じ URL を叩くと、エッジのキャッシュがそのまま返ってくるため、実体を確かめたつもりで古い応答を見ていることになります。
逆のパターンもあります。移設中の一瞬に 404 を返してしまうと、その 404 がネガティブキャッシュとして保持されます。サーバー側はもう正しい状態なのに、特定の地域のユーザーだけが欠けた画像を見続ける、という形になります。
対策は単純で、確認のリクエストには毎回異なるクエリを足します。上のスクリプトの CB="v=$(date +%s)" がそれです。ブラウザで目視するときも ?v=1 を付け、タブのタイトルに出るファイル名と寸法まで見るようにしています。ヘッダーで判断したい場合は次の形が手早いところです。
curl -sI "${ IMG }/ios/wallpaper_apps/${ APP }/6_5inch/thumb_full/${ NEW_ID }.jpg?v=$( date +%s)" \
| grep -Ei '^(HTTP/|cf-cache-status|age|content-length):'
age が 0 でないなら、見ているのはエッジのキャッシュです。移設直後の判断材料には使えません。
端末側で拾い直す案を採らなかった理由
サーバーを触らずに、アプリ側で失敗したら旧パスへ切り替える、という案も検討しました。
// 端末側で拾い直す案(結果的に主役にはしませんでした)
const [ uri , setUri ] = useState (newUrl);
< Image
source = { { uri } }
onError = { () => {
if (uri !== legacyUrl) setUri (legacyUrl); // 1回だけ旧パスへ落とす
} }
/>
短いコードで済みますし、動きもします。それでも主役にしなかった理由は3つありました。
これも「入れた版から」しか効きません。いま困っている旧版には届きませんので、結局サーバー側の手当てが別途必要になります
リクエストが増えます。一覧をスクロールする画面では数十枚のサムネイルが同時に走りますので、失敗と再試行が全件で発生すると往復が単純に2倍になります。回線の細い環境ほど体験が落ちます
失敗を隠してしまいます。onError で静かに拾い直すと、移設ミスや欠番が表面化しません。私は、壊れているものは壊れたまま見えているほうが、運用としては安全だと考えています
端末側のフォールバックは、サーバー側の手当てが済んだうえで、通信の一過性の失敗に備える保険として置くのが収まりのよい場所でした。
旧パスをいつ畳めるのか
双方向フォールバックを入れると、旧パスは無期限に生き続けます。いつ撤去してよいのかは、こちらの都合ではなく、旧パスを叩いているユーザーが残っているかどうかで決まります。判断はアクセスログを数えるところから始めます。
# 直近のログから、旧パス・新パスそれぞれのリクエスト数を数える
awk '{print $7}' access.log \
| grep -oE '/(5_8inch|6_5inch)/(wallpaper|thumb_full)/' \
| sort | uniq -c | sort -rn
比率はアプリごとに大きく違いますので、他のアプリの数字は参考になりません。私の場合は、旧パスの比率が全体の 1% を下回るまでフォールバックを残す、という基準にしています。ここを日付で決めなかったのは、更新の進み方がアプリの性格に強く依存するためです。毎日開く種類のアプリと、思い出したときに開く種類のアプリとでは、旧版の残り方がまったく違いました。
撤去のときも、削除ではなく 410 Gone を返す形から始めています。404 だとアプリ側の再試行が働くことがありますが、410 なら「もう無い」と伝わります。
なお、配信の実体をアプリ審査から切り離す設計そのものについては、壁紙パックをアプリ審査なしで配る — リモートアセットのバージョニングと差分配信を6本で運用する設計 に、マニフェストの形と差分配信の実装を書いています。この記事は、その仕組みを運用し続けた先で出てきた問題を扱っています。
次の一歩
まず、ご自身のアプリでいま実際に使われているバージョンを、ストアの管理画面で数えてみてください。1種類しか出ていないと思っていたものが、3種類も4種類も生きていることがあります。その数がそのまま、サーバー側を触るときに守るべき前提の数になります。
サーバーの整理は、いつでもできる作業に見えます。私自身、置き場所を変えるだけの作業だと思っていました。実際には、過去に出荷した自分自身と約束を交わし直す作業でした。その約束を先に紙に書き出しておくと、移設は10分の作業に戻ります。
お読みいただきありがとうございました。