Dlaczego feature flags są kluczowe w CD?
W erze continuous deployment, gdzie zmiany trafiają do produkcji nawet kilka razy dziennie, potrzebujemy mechanizmów pozwalających na bezpieczne wdrażanie nowych funkcjonalności. Feature flags (znane również jako feature toggles) to jedna z najważniejszych praktyk DevOps, która oddziela deployment kodu od release funkcjonalności.
W 2020 roku, gdy remote work stał się standardem, a zespoły muszą jeszcze szybciej reagować na zmieniające się wymagania biznesowe, feature flags stają się niezbędnym narzędziem dla każdego zespołu developmentu.
Co się nauczysz:
- Czym są feature flags i jak wspierają continuous deployment
- Implementację feature flags w Spring Boot z różnymi strategiami
- Zarządzanie lifecycle feature flags i unikanie technicznego długu
- Integrację z narzędziami CI/CD jak Jenkins i GitLab CI
- Monitoring i metryki dla feature flags w produkcji
- Strategie rollout: canary deployment, blue-green z toggles
Czym są feature flags?
Feature flag to warunková instrukcja w kodzie, która decyduje czy dana funkcjonalność ma być aktywna. W przeciwieństwie do komentowania kodu czy usuwania funkcji, feature flags pozwalają na dynamiczne kontrolowanie dostępnych features bez redeploymentu aplikacji.
Typy feature flags w praktyce:
Typ toggle | Czas życia | Zarządzanie | Przykład użycia |
---|---|---|---|
Release toggles | Krótki (dni/tygodnie) | Zespół dev | Nowa funkcja przed pełnym rollout |
Experiment toggles | Średni (tygodnie/miesiące) | Product team | A/B testing nowego UI |
Ops toggles | Długi (miesiące/lata) | DevOps/SRE | Circuit breaker, graceful degradation |
Permission toggles | Permanentny | Security team | Dostęp do admin features |
Implementacja w Spring Boot
Zobaczmy praktyczną implementację feature flags w Spring Boot 2.3:
1. Konfiguracja toggles:
# application.yml features: new-payment-gateway: true advanced-search: false user-recommendations: enabled: false rollout-percentage: 10 maintenance-mode: false # Dla różnych środowisk --- spring: profiles: production features: new-payment-gateway: false # Bezpieczny start w prod user-recommendations: enabled: true rollout-percentage: 5 # Ostrożny rollout
2. Feature Toggle Service:
@Service @RefreshScope // Dla dynamicznej aktualizacji config public class FeatureToggleService { @Value("${features.new-payment-gateway:false}") private boolean newPaymentGatewayEnabled; @Value("${features.user-recommendations.enabled:false}") private boolean userRecommendationsEnabled; @Value("${features.user-recommendations.rollout-percentage:0}") private int recommendationsRolloutPercentage; private final RedisTemplateredisTemplate; private final MeterRegistry meterRegistry; public FeatureToggleService(RedisTemplate redisTemplate, MeterRegistry meterRegistry) { this.redisTemplate = redisTemplate; this.meterRegistry = meterRegistry; } public boolean isNewPaymentGatewayEnabled() { // Logowanie użycia toggle dla monitoringu meterRegistry.counter("feature.toggle.check", "feature", "new-payment-gateway", "enabled", String.valueOf(newPaymentGatewayEnabled)) .increment(); return newPaymentGatewayEnabled; } public boolean isUserRecommendationsEnabled(String userId) { if (!userRecommendationsEnabled) { return false; } // Gradual rollout na podstawie user ID int userHash = Math.abs(userId.hashCode() % 100); boolean enabled = userHash < recommendationsRolloutPercentage; meterRegistry.counter("feature.toggle.rollout", "feature", "user-recommendations", "enabled", String.valueOf(enabled)) .increment(); return enabled; } // Circuit breaker pattern z Redis public boolean isFeatureHealthy(String featureName) { String key = "feature:health:" + featureName; String errorCount = redisTemplate.opsForValue().get(key); if (errorCount != null) { int errors = Integer.parseInt(errorCount); return errors < 10; // Threshold dla wyłączenia } return true; } public void recordFeatureError(String featureName) { String key = "feature:health:" + featureName; redisTemplate.opsForValue().increment(key); redisTemplate.expire(key, Duration.ofMinutes(5)); } }
3. Użycie w warstwie biznesowej:
@Service public class PaymentService { private final FeatureToggleService toggleService; private final LegacyPaymentGateway legacyGateway; private final NewPaymentGateway newGateway; public PaymentResult processPayment(PaymentRequest request) { try { if (toggleService.isNewPaymentGatewayEnabled() && toggleService.isFeatureHealthy("new-payment-gateway")) { return processWithNewGateway(request); } else { return processWithLegacyGateway(request); } } catch (Exception e) { // Fallback + circuit breaker toggleService.recordFeatureError("new-payment-gateway"); log.warn("New payment gateway failed, falling back to legacy", e); return processWithLegacyGateway(request); } } private PaymentResult processWithNewGateway(PaymentRequest request) { log.info("Processing payment with new gateway for amount: {}", request.getAmount()); return newGateway.process(request); } private PaymentResult processWithLegacyGateway(PaymentRequest request) { return legacyGateway.process(request); } } @RestController public class RecommendationController { private final FeatureToggleService toggleService; private final RecommendationService recommendationService; @GetMapping("/api/users/{userId}/recommendations") public ResponseEntity> getRecommendations(@PathVariable String userId) { if (toggleService.isUserRecommendationsEnabled(userId)) { List
recommendations = recommendationService.getPersonalizedRecommendations(userId); return ResponseEntity.ok(recommendations); } else { // Fallback do podstawowych rekomendacji lub pustej listy return ResponseEntity.ok(recommendationService.getDefaultRecommendations()); } } }
Integracja z CI/CD pipeline
Feature flags pozwalają na oddzielenie deploymentu od release'u. Oto jak to wygląda w praktyce:
GitLab CI pipeline z feature flags:
# .gitlab-ci.yml stages: - test - build - deploy-staging - deploy-production - feature-release variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA test: stage: test script: - ./gradlew test - ./gradlew integrationTest build: stage: build script: - docker build -t $DOCKER_IMAGE . - docker push $DOCKER_IMAGE deploy-staging: stage: deploy-staging script: - kubectl set image deployment/app app=$DOCKER_IMAGE -n staging - kubectl rollout status deployment/app -n staging environment: name: staging deploy-production: stage: deploy-production script: - kubectl set image deployment/app app=$DOCKER_IMAGE -n production - kubectl rollout status deployment/app -n production environment: name: production when: manual # Osobny stage dla kontroli feature flags feature-release: stage: feature-release script: - | # Aktualizacja feature flags przez API curl -X PUT "$CONFIG_SERVER_URL/features/new-payment-gateway" \ -H "Authorization: Bearer $API_TOKEN" \ -d '{"enabled": true, "rollout": 10}' # Monitoring rollout - ./scripts/monitor-feature-rollout.sh new-payment-gateway when: manual only: - master
Zarządzanie lifecycle feature flags
Jednym z największych wyzwań z feature flags jest zarządzanie ich cyklem życia i unikanie technicznego długu:
Strategia usuwania toggles:
@Component public class FeatureFlagCleanupService { private final FeatureToggleService toggleService; private final ApplicationEventPublisher eventPublisher; @Scheduled(cron = "0 0 2 * * MON") // Każdy poniedziałek o 2:00 public void analyzeFeatureFlags() { Mapstats = collectFeatureFlagStats(); stats.entrySet().stream() .filter(this::shouldBeRemoved) .forEach(entry -> { log.warn("Feature flag {} is candidate for removal: {}", entry.getKey(), entry.getValue()); eventPublisher.publishEvent( new FeatureFlagCleanupEvent(entry.getKey(), entry.getValue()) ); }); } private boolean shouldBeRemoved(Map.Entry entry) { FeatureFlagStats stats = entry.getValue(); // Kryteria dla usunięcia toggle return (stats.isEnabledForAllUsers() && stats.getDaysEnabled() > 30) || (stats.hasNoUsageInDays(14)) || (stats.getType() == ToggleType.RELEASE && stats.getDaysEnabled() > 7); } } // Event listener dla notyfikacji zespołu @EventListener public void handleFeatureFlagCleanup(FeatureFlagCleanupEvent event) { // Integracja ze Slack/Teams slackService.sendMessage("#dev-team", String.format("🧹 Feature flag '%s' should be removed: %s", event.getFeatureName(), event.getReason())); // Utworzenie task w Jira jiraService.createCleanupTask(event.getFeatureName(), event.getStats()); }
Monitoring i metryki
Monitoring feature flags jest kluczowy dla sukcesu continuous deployment:
Kluczowe metryki do śledzenia:
@Component public class FeatureFlagMetrics { private final MeterRegistry meterRegistry; private final Timer.Sample sample; @EventListener public void handleFeatureToggleUsage(FeatureToggleUsageEvent event) { // Licznik użyć feature flag Counter.builder("feature.toggle.usage") .tag("feature", event.getFeatureName()) .tag("enabled", String.valueOf(event.isEnabled())) .tag("user.segment", event.getUserSegment()) .register(meterRegistry) .increment(); // Histogram czasu wykonania z włączonym/wyłączonym feature Timer.builder("feature.execution.time") .tag("feature", event.getFeatureName()) .tag("enabled", String.valueOf(event.isEnabled())) .register(meterRegistry) .record(event.getExecutionTime(), TimeUnit.MILLISECONDS); } // Gauge dla aktualnego stanu rollout @Gauge(name = "feature.rollout.percentage", description = "Current rollout percentage") public double getCurrentRolloutPercentage(@Tag("feature") String featureName) { return toggleService.getRolloutPercentage(featureName); } // Custom metric dla błędów związanych z feature public void recordFeatureError(String featureName, String errorType) { Counter.builder("feature.errors") .tag("feature", featureName) .tag("error.type", errorType) .register(meterRegistry) .increment(); } }
Strategie rollout w praktyce
1. Canary deployment z feature flags:
@Service public class CanaryRolloutService { public boolean shouldUseNewFeature(String userId, String featureName) { CanaryConfig config = getCanaryConfig(featureName); // Whitelist użytkowników (internal testing) if (config.getWhitelistedUsers().contains(userId)) { return true; } // Percentage rollout int userHash = Math.abs(userId.hashCode() % 100); if (userHash < config.getRolloutPercentage()) { return true; } // Geographic rollout String userRegion = getUserRegion(userId); if (config.getEnabledRegions().contains(userRegion)) { return true; } return false; } @Scheduled(fixedRate = 300000) // Co 5 minut public void adjustRolloutBasedOnMetrics() { for (String feature : getActiveCanaryFeatures()) { FeatureMetrics metrics = getFeatureMetrics(feature); if (metrics.getErrorRate() > 0.05) { // 5% error rate // Reduce rollout or disable reduceRollout(feature, metrics.getErrorRate()); } else if (metrics.getErrorRate() < 0.01 && metrics.getSuccessRate() > 0.95) { // Increase rollout increaseRollout(feature); } } } }
Best practices i pułapki
- Pozostawianie toggles na lata - tech debt rośnie wykładniczo
- Brak testowania wszystkich kombinacji flag - exponential explosion
- Używanie feature flags dla business logic zamiast config
- Brak monitoring i alertów dla toggles
- Używaj type-safe enum zamiast stringów dla nazw features
- Integruj z Kubernetes ConfigMaps dla dynamic config
- Implementuj A/B testing framework na bazie feature flags
- Dokumentuj każdy toggle w code + confluence
Utwórz test profile z predefiniowanymi kombinacjami toggles. Używaj @TestPropertySource w Spring Boot do kontrolowania flags w testach. Nie testuj wszystkich kombinacji - skup się na krytycznych ścieżkach biznesowych.
Minimal overhead - proste if statement nie ma znaczącego wpływu. Problemem mogą być częste zapytania do external config store. Używaj local cache z TTL lub push notifications dla aktualizacji flags.
Centralizowany config server (Spring Cloud Config) + distributed cache (Redis). Każdy service może mieć własne toggles, ale shared features wymagają koordinacji. Używaj correlation ID do śledzenia flag across services.
Release toggles: po pełnym rollout (7-14 dni). Experiment toggles: po zakończeniu A/B test. Ops toggles: mogą pozostać długo. Ustaw reminder w kalendarzu na review toggles co miesiąc.
Backwards compatible migrations first, potem code changes. Używaj separate migrations dla new features z toggles. Consider feature-specific database schemas lub column flags.
Tak! Deploy do green environment z disabled flags, potem switch traffic + enable flags postupnie. Daje to dodatkową warstwę bezpieczeństwa i możliwość instant rollback.
Hash user ID dla consistent behavior, używaj user attributes (region, subscription tier) dla targeting. Store user assignments w cache dla performance. Consider GDPR implications dla user data usage.
Przydatne zasoby:
- Spring Cloud Config Server 2.2 Documentation
- Martin Fowler - Feature Toggles (Feature Flags)
- Kubernetes ConfigMaps Documentation
- Micrometer Metrics Documentation
- GitLab CI/CD Documentation
🚀 Zadanie dla Ciebie
Zaimplementuj feature flag system w swojej aplikacji Spring Boot: 1) Stwórz FeatureToggleService z konfiguracją w YAML, 2) Dodaj gradual rollout na podstawie user ID, 3) Zintegruj z Micrometer dla metrics, 4) Napisz testy dla różnych kombinacji flags, 5) Stwórz Jenkins job do kontrolowania rollout przez API. Bonus: dodaj Slack notifications dla feature flag changes!
Jakie są Twoje doświadczenia z feature flags w continuous deployment? Czy używasz zewnętrznych narzędzi jak LaunchDarkly czy budujesz własne rozwiązania?