Введение: Почему 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
Давайте построим простую, но надёжную систему. Нам понадобятся:
- Feature Flag Provider — сервис, который выдаёт статус фичи
- Remote Config — удалённое хранилище флагов (используем Firebase)
- Логика распределения — кто попадает в канареечную группу
- Мониторинг — собираем метрики и краш-репорты
Шаг 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 гарантирует стаби