メインコンテンツへスキップ
  1. 投稿/

DjangoとTigrisで自己ホスト型Expo OTAアップデートサーバーを構築する

· loading · loading ·
ジャレッド リンスキー
著者
ジャレッド リンスキー
韓国に住むキウイ
目次

OTA(Over-the-Air)アップデートを使えば、App Storeの審査を待たずにJavaScriptの変更、バグ修正、新機能をユーザーにプッシュできます。Expoのホスト型サービス(EAS Update)はよくできていますが、自己ホスティングには相応の理由があります:大規模なコスト管理、データ主権の要件、あるいは単にインフラを自分で所有したい場合です。

この記事では、Django REST FrameworkとTigris S3を使って自己ホスト型Expoアップデートサーバーを構築した方法を紹介します。私のCurtain EstimatorアプリでiOSとAndroidの両方にOTAアップデートを配信しているアーキテクチャです。

なぜExpoアップデートを自己ホスティングするのか?
#

コスト管理
#

EAS Updateの料金は使用量に応じて増加します。大規模なユーザーベースに頻繁にアップデートをプッシュする場合、Tigrisのような安価なS3互換ストレージでの自己ホスティングは確実にコストを節約できます。

データ主権
#

一部の業界では、アプリケーションアセットの保存場所を完全に制御する必要があります。自己ホスティングにより、すべてのアップデートバンドルが自社のインフラに留まることが保証されます。

カスタムビジネスロジック
#

特定のユーザーセグメントにアップデートを展開する必要がありますか?異なるバンドルでA/Bテストをしたいですか?カスタムサーバーなら、アップデートフロー全体を制御できます。

ベンダーロックインなし
#

アップデートインフラがExpoのサービス可用性や価格変更に依存しません。

アーキテクチャ概要
#

システムは4つの主要コンポーネントで構成されています:

  1. Djangoバックエンド:アップデートマニフェストを提供し、メタデータを保存
  2. Tigris S3ストレージ:実際のバンドルとアセットファイルをホスティング
  3. パブリッシングパイプライン:エクスポート、アップロード、アップデート登録を行うスクリプト
  4. モバイルアプリ:サーバーからアップデートを確認するよう構成

動作の仕組みは以下の通りです:

┌─────────────────┐
│   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
└─────────────────┘

実装の詳細
#

Djangoモデル
#

基盤となるのは2つのDjangoモデルです:ExpoUpdateExpoUpdateAsset

ExpoUpdateは各アップデートのメタデータを保存します:

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_versionapp.jsonruntimeVersionと一致します。これは非常に重要です—クライアントは自身のランタイムバージョンに一致するアップデートのみをダウンロードします。
  • platform: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 = self._generate_manifest_with_presigned_urls(update)

    return Response(manifest_data, status=200)

重要な詳細:

  1. 204 No Content:アップデートが見つからない場合、204を返します。アプリは組み込みバンドルを使用します。
  2. Presigned URL:アプリがTigris CDNから直接ダウンロードできる時間制限付きURLを生成します—Djangoを経由するプロキシよりはるかに高速です。
  3. ヘッダー:レスポンスにexpo-protocol-versionヘッダーが必須です。

アップデートのパブリッシング
#

パブリッシングワークフローはDjango管理コマンドとシェルスクリプトラッパーで自動化されています。

管理コマンドpublish_expo_update.py)は以下を処理します:

  1. expo exportの出力を読み取る
  2. すべてのアセットのSHA-256ハッシュを計算
  3. バンドルとアセットをTigris S3に並列アップロード
  4. アップデートとアセットのデータベースレコードを作成
  5. オプションでプロダクション同期用のインポートJSONをアップロード

コアフローは以下の通りです:

def _publish_platform(self, platform, runtime_version, export_dir, ...):
    # 1. Find the bundle file
    bundle_files = list(bundle_dir.glob("entry-*.hbc"))
    bundle_file = bundle_files[0]

    # 2. Calculate hash
    with open(bundle_file, "rb") as f:
        bundle_content = f.read()
    bundle_hash = self._calculate_hash(bundle_content)

    # 3. Collect all assets and their hashes
    for asset_file in assets_dir.rglob("*"):
        # Calculate hash, determine content type...
        assets_metadata.append({...})

    # 4. Upload to S3 in parallel
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = {executor.submit(upload_asset, a): a for a in assets_metadata}

    # 5. Create database records
    with transaction.atomic():
        # Deactivate previous updates
        ExpoUpdate.objects.filter(
            runtime_version=runtime_version,
            platform=platform,
            is_active=True
        ).update(is_active=False)

        # Create new update
        update = ExpoUpdate.objects.create(...)

シェルスクリプトpublish-ota-update.sh)はユーザーフレンドリーなインターフェースを提供します:

# Publish to local environment
./scripts/publish-ota-update.sh ios

# Publish to production
./scripts/publish-ota-update.sh ios --production

# Dry run to validate
./scripts/publish-ota-update.sh --dry-run

主な機能:

  • プロダクション環境変数でアプリを自動エクスポート
  • 速度向上のためアセットを並列アップロード
  • プラットフォーム別またはマルチプラットフォームのアップデートをサポート
  • APIエンドポイント経由でプロダクションに同期

APIを介したプロダクション同期
#

プロダクションデプロイメントでは、システムは巧みな2段階プロセスを使用します:

  1. ローカルパブリッシュ:Tigrisにアセットをアップロードし、JSONスナップショットを作成
  2. リモートインポート:S3パスとともにプロダクションAPIを呼び出してメタデータをインポート

このアプローチにより、ローカルマシンにプロダクションの認証情報を置く必要がなくなります:

@action(detail=False, methods=["post"], url_path="import-update")
def import_update(self, request):
    # Authenticate via Bearer token
    secret = settings.OTA_IMPORT_SECRET
    token = request.META.get("HTTP_AUTHORIZATION", "")[7:]  # Strip "Bearer "
    if not hmac.compare_digest(token, secret):
        return Response({"error": "Invalid token"}, status=401)

    # Download import JSON from Tigris
    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 to production database
    with transaction.atomic():
        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)

モバイルアプリの設定
#

app.jsonでアップデートURLとランタイムバージョンを設定します:

{
  "expo": {
    "runtimeVersion": "1.0.0",
    "updates": {
      "url": "https://your-server.com/api/expo-updates/manifest/"
    }
  }
}

重要runtimeVersionはアプリとサーバー間で一致する必要があります。ネイティブコードを変更したりExpo SDKをアップグレードしたりする場合は、ランタイムバージョンをインクリメントして新しいアップデートをパブリッシュしてください。

Tigrisを使ったストレージ
#

Tigrisは、AWS S3よりも大幅に安価な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")

S3クライアントの作成:

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,
    )

ファイルアップロードはCDN直接配信のためにpresigned URLを使用します:

presigned_url = s3_client.generate_presigned_url(
    "get_object",
    Params={"Bucket": bucket_name, "Key": asset.file_path},
    ExpiresIn=3600,  # 1 hour
)

セキュリティに関する考慮事項
#

認証
#

マニフェストエンドポイントは設計上認証なしでアクセス可能です—モバイルアプリはユーザーがログインする前にアップデートが必要です。ただし、インポートエンドポイントには共有シークレットが必要です:

OTA_IMPORT_SECRET = os.getenv("OTA_IMPORT_SECRET")

# Constant-time comparison prevents timing attacks
if not hmac.compare_digest(token, secret):
    return Response({"error": "Invalid token"}, status=401)

アセットの整合性
#

すべてのアセットはSHA-256ハッシュで検証されます。アセットが改ざんされるとハッシュが一致せず、アップデートは失敗します。

Presigned URL
#

URLは1時間後に期限切れとなり、バンドルへの長期的な不正アクセスを防止します。

パフォーマンス最適化
#

データベースインデックス
#

(runtime_version, platform, is_active, -created_at)の複合インデックスにより、数千のアップデートがあってもマニフェストクエリが高速であることが保証されます:

class Meta:
    indexes = [
        models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
    ]

並列アップロード
#

パブリッシングスクリプトはThreadPoolExecutorを使用してアセットを同時にアップロードします:

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秒に短縮されます。

CDN配信
#

Tigrisにはグローバルエッジキャッシングが含まれています。アセットはユーザーの近くのエッジロケーションに自動的に配信され、ダウンロード時間が短縮されます。

運用ワークフロー
#

日常の開発
#

# 1. Make code changes in mobile app
cd mobile-app && git commit -am "Fix bug"

# 2. Publish OTA update to local environment
yarn publish-update:ios

# 3. Test on device
# App automatically downloads and applies update

プロダクションデプロイメント
#

# 1. Publish to production
yarn publish-update:prod:ios

# 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 jobs.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人のアクティブユーザーがいる私のCurtain Estimatorアプリの場合:

Tigrisストレージ:

  • ストレージ:〜200 MB(過去のアップデート)= $0.02/月
  • エグレス:〜50 GB/月(アップデートのダウンロード)= $1.00/月
  • 合計:〜$1/月

Djangoホスティング(Fly.io):

  • 既存のアプリホスティングに含まれる
  • 追加コスト:$0

OTAインフラ総コスト:〜$1/月

同様の使用量でのEAS Updateの料金は年間$300-500です。自己ホスティングのアプローチは即座にコストを回収できます。

モニタリングとデバッグ
#

ロギング
#

ViewSetはすべてのマニフェストリクエストをログに記録します:

logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")

Django Admin
#

簡単な確認のために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"]

クライアント側のデバッグ
#

アプリでアップデートログを有効にします:

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でcreatedAtをデータベースのタイムスタンプでオーバーライドします:

manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

ローカル開発で、OTAをパブリッシュした後にバイナリをビルドした場合は、より新しいタイムスタンプを確保するためにOTAを再パブリッシュしてください。

アセットのクリーンアップ
#

古いアップデートが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()

スケジュールタスクへの追加を検討してください。

今後の改善
#

ロールアウト制御
#

アップデートを段階的にリリースするために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とS3互換ストレージを使った自己ホスト型Expo OTAアップデートは、思ったよりシンプルです。全体で必要なのは:

  • 約150行のDjangoモデルとビュー
  • 約200行のパブリッシングスクリプト
  • 約$1/月のインフラコスト

ここで紹介したコード例は、私のプロダクションCurtain Estimatorアプリのものです。構築するものに合わせて活用してください。


仕様の詳細はExpo Updates Protocol Specificationをご覧ください。