Canary deployments в Swift: как внедрять фичи без страха

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему Canary — это не про птичек?

Представьте: вы выпускаете новую фичу в приложении. Всё протестировано, CI/CD зелёный, код-ревью пройдено. Через час — шквал негативных отзывов, баги, падения. Знакомо?

В мире iOS-разработки есть элегантное решение — Canary deployments (канареечные развёртывания). Это техника, при которой новую версию фичи получает лишь небольшой процент пользователей (обычно 1–5%). Если всё хорошо — раскатываем на всех. Если нет — откатываемся с минимальным уроном.

В этой статье мы разберём, как реализовать Canary deployments на Swift с помощью фича-флагов и Firebase Remote Config. Никакой магии — только рабочий код и реальные практики.

Что такое Canary deployment и зачем он нужен в Swift?

Термин пришёл из горнодобывающей промышленности: шахтёры брали с собой канареек, чтобы те своим состоянием сигнализировали об утечке газа. В IT — то же самое: небольшая группа пользователей служит «канарейкой», показывая, работает ли новая версия.

Почему это важно для Swift-разработчиков?

  • Безопасность: Вы не рискуете всеми пользователями сразу
  • A/B тестирование: Можно сравнить метрики старой и новой фичи
  • Гибкость: Откат занимает секунды, а не дни
  • Контроль: Можно точечно включить фичу для тестировщиков или бета-юзеров

В Swift мы используем фича-флаги (feature flags) — это булевы переменные, которые включают/выключают функционал без нового билда. Комбинируем их с удалённой конфигурацией (Firebase, LaunchDarkly) — и получаем полноценный Canary deployment.

Архитектура Canary deployment на Swift

Давайте построим простую, но надёжную систему. Нам понадобятся:

  1. Feature Flag Provider — сервис, который выдаёт статус фичи
  2. Remote Config — удалённое хранилище флагов (используем Firebase)
  3. Логика распределения — кто попадает в канареечную группу
  4. Мониторинг — собираем метрики и краш-репорты

Шаг 1: Настройка Firebase Remote Config

Первым делом добавляем Firebase в проект. Если вы ещё не сделали этого — следуйте официальной документации. Затем создаём Remote Config параметры:

  • new_checkout_enabled — включает новый чек-аут
  • new_checkout_percentage — процент пользователей, которые его увидят (0.0–1.0)
  • new_checkout_deadline — дата полного включения (опционально)

В консоли Firebase задаём значения по умолчанию: false, 0.0, nil.

Шаг 2: Feature Flag Provider на Swift

Создаём протокол и реализацию. Это позволит легко переключаться между Firebase, локальными флагами или моками для тестов.

import Foundation
import FirebaseRemoteConfig

// MARK: - Protocol
protocol FeatureFlagProvider {
    func isFeatureEnabled(_ feature: FeatureFlag) -> Bool
    func percentage(for feature: FeatureFlag) -> Double
    func fetchRemoteConfig(completion: @escaping (Bool) -> Void)
}

// MARK: - Feature Flags
enum FeatureFlag: String, CaseIterable {
    case newCheckout = "new_checkout_enabled"
    case newCheckoutPercentage = "new_checkout_percentage"
    case newCheckoutDeadline = "new_checkout_deadline"
    
    var defaultValue: Any {
        switch self {
        case .newCheckout: return false
        case .newCheckoutPercentage: return 0.0
        case .newCheckoutDeadline: return "" // ISO8601 string
        }
    }
}

// MARK: - Firebase Implementation
final class FirebaseFeatureFlagProvider: FeatureFlagProvider {
    private let remoteConfig = RemoteConfig.remoteConfig()
    private var isFetched = false
    
    init() {
        let settings = RemoteConfigSettings()
        settings.minimumFetchInterval = 3600 // 1 час в проде
        remoteConfig.configSettings = settings
        remoteConfig.setDefaults(fromPlist: "RemoteConfigDefaults")
    }
    
    func fetchRemoteConfig(completion: @escaping (Bool) -> Void) {
        remoteConfig.fetch { [weak self] status, error in
            guard let self = self else { return }
            if status == .success {
                self.remoteConfig.activate { changed, error in
                    self.isFetched = true
                    completion(true)
                }
            } else {
                print("[Canary] Remote Config fetch failed: \(error?.localizedDescription ?? "")")
                completion(false)
            }
        }
    }
    
    func isFeatureEnabled(_ feature: FeatureFlag) -> Bool {
        return remoteConfig.configValue(forKey: feature.rawValue).boolValue
    }
    
    func percentage(for feature: FeatureFlag) -> Double {
        return remoteConfig.configValue(forKey: feature.rawValue).numberValue?.doubleValue ?? 0.0
    }
}

Шаг 3: Логика Canary — кто в группе?

Теперь самое интересное: как решить, попал ли пользователь в канареечную группу? Используем детерминированный хеш — это гарантирует, что один и тот же пользователь всегда будет в одной группе (пока не изменим процент).

import CommonCrypto

// MARK: - User Bucketing
final class CanaryDistributor {
    private let provider: FeatureFlagProvider
    private let userId: String
    
    init(provider: FeatureFlagProvider, userId: String) {
        self.provider = provider
        self.userId = userId
    }
    
    /// Проверяет, входит ли пользователь в канареечную группу для фичи
    func isInCanaryGroup(for feature: FeatureFlag) -> Bool {
        // Если фича полностью выключена — false
        guard provider.isFeatureEnabled(feature) else { return false }
        
        let percentage = provider.percentage(for: feature)
        // Если процент 1.0 — фича включена для всех
        if percentage >= 1.0 { return true }
        if percentage <= 0.0 { return false }
        
        // Детерминированное распределение на основе userId + feature
        let hash = hashUser(userId, for: feature)
        let normalized = Double(hash % 1000) / 1000.0
        return normalized < percentage
    }
    
    // MARK: - Private Helpers
    private func hashUser(_ userId: String, for feature: FeatureFlag) -> Int {
        let input = "\(userId)-\(feature.rawValue)"
        guard let data = input.data(using: .utf8) else { return 0 }
        
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        data.withUnsafeBytes { buffer in
            _ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash)
        }
        // Берём первые 4 байта как Int
        let value = hash.prefix(4).reduce(0) { ($0 << 8) | Int($1) }
        return abs(value)
    }
}

Почему хеш, а не random? Потому что random даёт разный результат при каждом вызове — пользователь будет прыгать между группами. Хеш на основе userId + feature гарантирует стаби

Блоги

Рекомендаций по книгам