2026年9月13日追記。 この記事の公開後、CodemagicがCodePush系のオープンソースでセルフホストできるOTAサーバー、Patchをリリースしました。サーバーを自分で作りたくなくてここにたどり着いた方には、いまや現実的な選択肢です。私自身、その後この記事の構成からPatchへアプリを移行してその顛末を書きましたし、おわりにの下に短い補足も足してあります。開示: その移行と執筆にはCodemagicから報酬を受けていて、ここで触れることも先方からの依頼です。本文のコードは、その後短い例に絞りました。
私が開発している見積もりアプリでは、バグ修正をOTA(Over-the-Air)で配信しています。JavaScriptの変更が、App Storeの審査を通さずにユーザーの端末へ届くわけです。ExpoのホストサービスであるEAS Updateを使えば同じことができるのですが、私はDjango REST FrameworkとTigris S3で自前のサーバーを作りました。以来、iOS・Androidのアップデートはすべてここから配信しています。
この記事では、モデル設計からマニフェストエンドポイント、パブリッシュのパイプライン、そして実際に運用して初めて分かったハマりどころまで、一通り紹介します。
そもそもなぜ自前でホスティングするのか#
私の場合、一番の理由はコストでした。EAS Updateの料金は使用量に応じて増えていくので、ユーザーが増えて更新頻度も上がるとそれなりの金額になります。一方、TigrisのようなS3互換ストレージならほとんどタダみたいなものです。ほかの理由が効いてくる場合もあるでしょう。アプリケーションのアセットをすべて自社インフラ内に置くことが求められる業界もありますし、サーバーを自分で持てばアップデートの流れを丸ごと制御できるので、特定のユーザー層への配信やバンドルのA/Bテストも自由にできます。Expoの稼働状況や次の料金改定に振り回されずに済むのも、地味に大きいところです。
全体の構成#
構成要素は4つです。アップデートのマニフェストを返しメタデータを保存するDjangoバックエンド、バンドルとアセットの実体を置くTigris、エクスポート・アップロード・登録をまとめて行うパブリッシュ用スクリプト、そしてExpoのサーバーではなく自分のサーバーを向くように設定したモバイルアプリ本体です。
┌─────────────────┐
│ Mobile App │
│ (expo-updates) │
└────────┬────────┘
│ 1. Request manifest
│ (with headers: platform, runtime-version)
↓
┌─────────────────┐
│ Django Server │
│ /api/expo- │◄─── 2. Query DB for latest update
│ updates/ │
│ manifest/ │
└────────┬────────┘
│ 3. Generate presigned URLs
│
↓
┌─────────────────┐
│ Tigris S3 │
│ (Asset Files) │◄─── 4. App downloads bundles directly
└─────────────────┘実装#
2つのモデル#
すべての土台になるのがExpoUpdateとExpoUpdateAssetです。前者は各アップデートのメタデータを持ちます。
class ExpoUpdate(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
runtime_version = models.CharField(max_length=50, db_index=True)
platform = models.CharField(
max_length=10,
choices=[("ios", "iOS"), ("android", "Android")],
db_index=True
)
is_active = models.BooleanField(default=True, db_index=True)
manifest_data = models.JSONField()
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
]設計上のポイントがいくつかあります。runtime_versionはapp.jsonのruntimeVersionに対応するフィールドで、クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしないため、ここが要になります。バンドルの中身が違うので、iOSとAndroidは別レコードです。is_activeはロールバックの仕組みで、問題のあるアップデートを無効化すればクライアントはひとつ前のものに戻ります。そしてmanifest_dataにはExpo Updates v1プロトコルのマニフェストを丸ごとJSONで保存しておくので、配信時は取り出すだけで済みます。
ExpoUpdateAssetは個々のファイルを管理します。
class ExpoUpdateAsset(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE, related_name="assets")
hash = models.CharField(max_length=255, db_index=True)
key = models.CharField(max_length=255)
content_type = models.CharField(max_length=100)
file_extension = models.CharField(max_length=10)
file_path = models.CharField(max_length=500)
file_size = models.IntegerField(default=0)アセットはSHA-256ハッシュで参照するので、不変でキャッシュも効きます。同じアセットを複数のアップデートで共有することも可能です。
マニフェストエンドポイント#
アプリとサーバーが実際にやり取りするのが/api/expo-updates/manifest/です。Expo Updates v1プロトコルを実装しています。
@action(detail=False, methods=["get"], url_path="manifest")
def manifest(self, request):
# Extract required headers
protocol_version = request.META.get("HTTP_EXPO_PROTOCOL_VERSION")
platform = request.META.get("HTTP_EXPO_PLATFORM")
runtime_version = request.META.get("HTTP_EXPO_RUNTIME_VERSION")
# Validate protocol version
if protocol_version != "1":
return Response(
{"error": f"Unsupported protocol version: {protocol_version}"},
status=400
)
# Find latest active update for this runtime + platform
update = ExpoUpdate.objects.filter(
runtime_version=runtime_version,
platform=platform,
is_active=True,
).order_by("-created_at").first()
# No update available - client uses embedded bundle
if not update:
response = Response(status=204)
response["expo-protocol-version"] = "1"
return response
# Generate presigned URLs for all assets
manifest_data = build_manifest(update) # fills in presigned asset URLs
return Response(manifest_data, status=200)ポイントは3つあります。アップデートがないときは204を返し、アプリは組み込みのバンドルをそのまま使い続けます。マニフェスト内のアセットURLは署名付きURL(presigned URL)なので、Djangoを経由せずTigrisのCDNから直接ダウンロードされます。そしてレスポンスのexpo-protocol-versionヘッダーは省略できません。クライアントがきちんと確認しています。
アップデートのパブリッシュ#
パブリッシュはDjangoの管理コマンドと、それを包むシェルスクリプトで行います。管理コマンドはexpo exportの出力を読み取り、各アセットのSHA-256ハッシュを計算し、バンドルとアセットをTigrisへ並列アップロードし、データベースにレコードを作り、必要ならプロダクション同期用のインポートJSONもバケットに置きます。要点だけに絞るとこうなります。
from concurrent.futures import ThreadPoolExecutor
from django.db import transaction
def publish(platform, runtime_version, manifest, files):
# Upload the bundle and assets in parallel (keys are content hashes)
with ThreadPoolExecutor(max_workers=10) as executor:
list(executor.map(upload_asset, files))
# Swap the active update in one transaction
with transaction.atomic():
ExpoUpdate.objects.filter(
runtime_version=runtime_version,
platform=platform,
is_active=True,
).update(is_active=False)
return ExpoUpdate.objects.create(
runtime_version=runtime_version,
platform=platform,
manifest_data=manifest,
)普段実際に叩くのは、ラッパースクリプトのほうです。
# Publish to local environment
./publish.sh ios
# Publish to production
./publish.sh ios --production
# Dry run to validate
./publish.sh --dry-runエクスポート前にプロダクションの環境変数を読み込み、プラットフォームは片方でも両方でも指定でき、次に説明するプロダクション同期まで面倒を見てくれます。
プロダクションへの同期#
プロダクションの認証情報を手元のマシンに置きたくなかったので、本番へのデプロイは2段階にしました。まずローカルでパブリッシュし(アセットはTigrisへ、メタデータのJSONスナップショットも一緒に)、そのあとS3のパスを添えてプロダクションのAPIを呼び、サーバー側でメタデータを取り込んでもらいます。
@action(detail=False, methods=["post"], url_path="import-update")
def import_update(self, request):
# Authenticate via Bearer token
token = request.META.get("HTTP_AUTHORIZATION", "").removeprefix("Bearer ")
if not hmac.compare_digest(token, settings.IMPORT_TOKEN):
return Response({"error": "Invalid token"}, status=401)
# Download the import JSON from the bucket
s3_key = request.data.get("s3_key")
obj = s3_client.get_object(Bucket=bucket_name, Key=s3_key)
data = json.loads(obj["Body"].read())
# Import into the production database
with transaction.atomic():
update, _ = ExpoUpdate.objects.update_or_create(id=data["id"], defaults={...})
for asset_data in data["assets"]:
ExpoUpdateAsset.objects.update_or_create(...)
# Clean up the import JSON
s3_client.delete_object(Bucket=bucket_name, Key=s3_key)
return Response({"update_id": str(update.id)})アプリをサーバーに向ける#
app.jsonでアップデートURLとランタイムバージョンを設定します。
{
"expo": {
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://api.example.com/api/expo-updates/manifest/"
}
}
}runtimeVersionはアプリとサーバーで厳密に揃えてください。ネイティブコードを変えたときやExpo SDKを上げたときは、ランタイムバージョンを上げて、そのバージョン向けにアップデートを出し直します。
Tigrisでのストレージ#
TigrisはS3互換のオブジェクトストレージで、本家AWS S3よりかなり安く、グローバルなエッジキャッシュも込みです。数メガバイト級のバンドルをユーザーがダウンロードすることを考えると、これが効いてきます。Django側の設定は次の通りです。
# settings.py
BUCKET_NAME = os.getenv("BUCKET_NAME")
AWS_ENDPOINT_URL_S3 = os.getenv("AWS_ENDPOINT_URL_S3")
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_REGION = os.getenv("AWS_REGION", "auto")クライアントの生成はごく普通のboto3です。
import boto3
def create_s3_client(endpoint_url, region, access_key, secret_key):
return boto3.client(
"s3",
endpoint_url=endpoint_url,
region_name=region,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
)ダウンロードには署名付きURLを使うため、アプリはCDNから直接取得します。
presigned_url = s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket_name, "Key": asset.file_path},
ExpiresIn=3600, # 1 hour
)セキュリティ#
マニフェストエンドポイントは、あえて認証なしにしています。ログイン前のアプリにもアップデートは必要だからです。一方、インポートエンドポイントはプロダクションのデータベースに書き込めるので話が別で、共有シークレットで守り、タイミング攻撃を避けるために定数時間で比較します。
IMPORT_TOKEN = os.getenv("IMPORT_TOKEN")
# Constant-time comparison prevents timing attacks
if not hmac.compare_digest(token, IMPORT_TOKEN):
return Response({"error": "Invalid token"}, status=401)アセットの整合性は、この設計なら勝手についてきます。すべてSHA-256ハッシュで検証されるので、改ざんされたアセットは単に失敗するだけです。署名付きURLも1時間で失効するため、バンドルが延々と直リンクされ続ける心配はありません。
速度まわり#
(runtime_version, platform, is_active, -created_at)の複合インデックスのおかげで、アップデートがいくら溜まってもマニフェストのクエリは速いままです。
class Meta:
indexes = [
models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
]パブリッシュ用スクリプトはアセットを並列でアップロードします。
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(upload_asset, asset): asset for asset in assets}
for future in as_completed(futures):
# Track progressアセット50個ほどの典型的なアップデートで、パブリッシュ時間は約2分から15秒になりました。配信側でやることは何もありません。Tigrisが勝手にユーザーの近くのエッジへキャッシュしてくれます。
日々の運用#
開発中#
# 1. Make code changes in the app
git commit -am "Fix bug"
# 2. Publish OTA update to local environment
./publish.sh ios
# 3. Test on device
# App automatically downloads and applies updateプロダクションへのリリース#
# 1. Publish to production
./publish.sh ios --production
# 2. Monitor
# Check Django admin for update records
# Verify assets in Tigris dashboardロールバック#
# Mark problematic update as inactive in Django admin
# or via management shell:
python manage.py shell
>>> from updates.models import ExpoUpdate
>>> bad_update = ExpoUpdate.objects.get(id="uuid-here")
>>> bad_update.is_active = False
>>> bad_update.save()
# Clients will now receive the previous active updateかかっている費用#
このアプリのアクティブユーザーは500人ほどです。Tigrisでは、過去のアップデート約200MB分のストレージが月$0.02ほど、アップデートのダウンロードによる月約50GBのエグレスが$1.00ほど。Django側はもともとAPIを動かしているサーバーに相乗りしているので、追加コストはゼロです。全部で月1ドルといったところ。同じ規模でEAS Updateを使うと年間$300〜500ほどになるはずなので、元はほぼ即座に取れました。
モニタリングとデバッグ#
凝ったことはしていません。ViewSetがマニフェストのリクエストをすべてログに残します。
logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")実質的なダッシュボードはDjango adminです。モデルを登録しておけば、確認も絞り込みも一通りできます。
@admin.register(ExpoUpdate)
class ExpoUpdateAdmin(admin.ModelAdmin):
list_display = ["platform", "runtime_version", "is_active", "created_at"]
list_filter = ["platform", "is_active", "runtime_version"]
search_fields = ["description"]クライアント側では、expo-updatesが何を見ているかを教えてくれます。
import * as Updates from 'expo-updates';
Updates.checkForUpdateAsync().then(update => {
console.log('Update available:', update.isAvailable);
console.log('Manifest:', update.manifest);
});ハマりどころ#
ランタイムバージョンの不一致#
一番よくある失敗が、一番静かに起きます。クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしません。インストール済みのアプリがランタイム1.0.0なのに1.0.1向けにパブリッシュすると、何も届かず、エラーも出ません。ランタイムバージョンはビルドと同期させ、上げるのはネイティブコードが変わったときだけにしてください。
createdAtタイムスタンプ#
expo-updatesクライアントはマニフェストのcreatedAtを組み込みバンドルのcommitTimeと比較し、createdAtのほうが新しいアップデートだけを適用します。ViewSetではデータベースのタイムスタンプで上書きしています。
manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")ローカル開発では、OTAをパブリッシュしたあとにバイナリをビルドした場合、タイムスタンプが新しくなるようにOTAを出し直してください。
マニフェストのアセットkey#
マニフェスト内の各アセットのkeyフィールドはexpo-updatesがキャッシュに使うもので、ランダムなUUIDや適当な文字列ではなく、決定論的なハッシュ(ファイル名のMD5など)でなければなりません。ここを間違えると、クライアントがアセットを正しくキャッシュ・取得できなくなり、最初の1回だけ成功したあとアップデートが黙って壊れます。この問題を報告してくれた読者のRaphael Mutschlerさんに感謝します。原因を突き止めるまで、彼の環境ではアップデートがちょうど1回だけ動いていたそうです。
expoClientのアプリ設定#
LinkingやConstantsなど、実行時にアプリ設定を読むものを使っている場合は、マニフェストのextra.expoClientフィールドにアプリ設定を含める必要があります。これがないと、最初は普通に起動するのに、アプリを閉じたあと再起動できなかったりクラッシュしたりします。expo-updatesが組み込みマニフェストをOTAのものに置き換えるため、expoClientが欠けているとこれらのAPIが頼りにしている設定へアクセスできなくなるのです。これもRaphael Mutschlerさんが見つけてくれました。
アセットの掃除#
古いアップデートはTigrisに溜まっていきます。今のところ、掃除は手作業です。
# Delete updates older than 30 days
from datetime import timedelta
from django.utils import timezone
cutoff = timezone.now() - timedelta(days=30)
old_updates = ExpoUpdate.objects.filter(created_at__lt=cutoff, is_active=False)
for update in old_updates:
# Delete assets from S3
for asset in update.assets.all():
s3_client.delete_object(Bucket=bucket_name, Key=asset.file_path)
# Delete DB records
update.delete()本来はスケジュールタスクに載せるべき処理ですが、まだ手を付けられていません。
この先やりたいこと#
(2026年9月: いまではPatchがこの3つも、それ以上もやってくれます。移行の記事を参照してください。)
段階的ロールアウト#
rollout_percentageフィールドを足せば、まず一部のユーザーだけに配信できます。
rollout_percentage = models.IntegerField(default=100)
# In the manifest view:
if update.rollout_percentage < 100:
# Hash user ID and check if they're in rollout group
user_hash = int(hashlib.sha256(user_id.encode()).hexdigest(), 16)
if (user_hash % 100) >= update.rollout_percentage:
return Response(status=204) # No updateステージング用アップデートの分離#
environmentフィールドを足せば、ステージングビルドとプロダクションで別のアップデートを配れます。
environment = models.CharField(max_length=20, default="production")
# Client sends environment in custom header
environment = request.META.get("HTTP_X_UPDATE_ENVIRONMENT", "production")
update = ExpoUpdate.objects.filter(environment=environment, ...).first()ダウンロードの計測#
小さなモデルをひとつ足せば、「このアップデート、実際に届いたのか?」という問いにきちんと答えられるようになります。
class ExpoUpdateDownload(models.Model):
update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE)
user_id = models.CharField(max_length=255, null=True)
platform = models.CharField(max_length=10)
downloaded_at = models.DateTimeField(auto_now_add=True)おわりに#
システム全体で、Djangoのモデルが2つとビューがいくつか、パブリッシュ用スクリプトがひとつ、インフラ費用が月1ドル。始める前に想像していたよりずっと小さく収まり、以来両プラットフォームで淡々と動き続けています。
ここに載せたコードはそのプロダクションアプリのコードを例として簡略化したものなので、作るものに合わせて手を入れてください。読者のRaphael Mutschlerさんがスタンドアロンの実装をexpo-ota-serverとして公開してくれているので、すぐ動くものから始めたい方はそちらもどうぞ。
サーバーを自分で書きたくないなら#
この記事を書いたあと、Djangoなしで同じ領域をカバーするものが2つ登場しました。1つ目は、上で触れたRaphaelさんのexpo-ota-server。2つ目はCodemagic Patchです。オープンソースで、Docker Composeのインストールで自分のマシン上で動き、Expo UpdatesプロトコルではなくCodePushを話すので、expo-updatesをそのSDKに、eas updateをcmpatch release-reactに置き換えることになります。S3互換ストレージを使えるので、この記事に出てくるようなTigrisのバケットもそのまま使えます。
「この先やりたいこと」に挙げて結局作らなかったものが、最初から揃っています。ステージングとプロダクションのデプロイメント、パーセンテージ指定のロールアウト、リリースごとのダウンロード数とインストール数、必須アップデート、バイナリパッチ、コード署名、そして新しいバンドルがアプリの準備完了を報告する前にクラッシュしたときの端末側でのロールバック。引き換えになるのは、SDKをアプリに入れる必要があるため誰かにPatchのアップデートが届く前に新しいネイティブバイナリが要ることと、管理コマンドの代わりに運用するサーバーが要ることです。
私は自分のアプリの1つを、コミット済みのネイティブプロジェクトと本物のリリースパイプラインごと、この記事の構成からPatchへ移行しました。その記事では、うまくいったこと、いかなかったこと、実際どれくらい大変かを書いています。この作業はCodemagicのスポンサードで、先方のレビューは事実確認のみで、編集的なものではありません。
仕様の全文はExpo Updates Protocol Specificationを参照してください。
