Release Engineering
Что такое Release Engineering
Release engineering — дисциплина, которая обеспечивает надёжную, повторяемую и безопасную доставку кода в production. Цель: сделать релизы скучными и рутинными, а не страшными событиями.
Принципы Release Engineering
| Принцип | Описание |
|---|---|
| Reproducibility | Каждый билд воспроизводим из одних и тех же inputs |
| Automation | Минимум ручных шагов в процессе релиза |
| Hermetic builds | Билд не зависит от окружения машины |
| Incremental | Маленькие, частые релизы вместо больших |
| Rollback-ready | Любой релиз можно откатить за минуты |
CI/CD Pipeline
Continuous Integration
CI — практика частой интеграции кода (несколько раз в день) с автоматическими проверками.
Push → Build → Lint → Test (unit) → Test (integration) → Artifact → Ready
Continuous Delivery vs Continuous Deployment
| Аспект | Continuous Delivery | Continuous Deployment |
|---|---|---|
| Деплой | Ручное подтверждение | Автоматически |
| Когда | Код всегда готов к деплою | Каждый commit в production |
| Для кого | Большинство компаний | Зрелые DevOps-организации |
| Риск | Контролируемый | Требует отличного тестирования |
GitHub Actions для PHP-проекта
<?php
// .github/workflows/ci.yml structure (shown as PHP config concept)
// Actual YAML workflow is in the repository root
/**
* CI Pipeline stages for a Symfony application.
* This class represents the pipeline logic, not the YAML config.
*/
declare(strict_types=1);
namespace App\Deploy;
final readonly class CiPipeline
{
/**
* Define pipeline stages and their checks.
*
* @return array<string, array{command: string, failFast: bool}>
*/
public function getStages(): array
{
return [
'install' => [
'command' => 'composer install --no-interaction --prefer-dist',
'failFast' => true,
],
'lint' => [
'command' => 'vendor/bin/php-cs-fixer fix --dry-run --diff',
'failFast' => true,
],
'static-analysis' => [
'command' => 'vendor/bin/phpstan analyse -l 9',
'failFast' => true,
],
'unit-tests' => [
'command' => 'vendor/bin/phpunit --testsuite=unit',
'failFast' => true,
],
'integration-tests' => [
'command' => 'vendor/bin/phpunit --testsuite=integration',
'failFast' => true,
],
'security-check' => [
'command' => 'composer audit',
'failFast' => false,
],
];
}
}
package deploy
// CIPipeline defines CI pipeline stages and their checks.
type CIPipeline struct{}
// Stage describes a CI pipeline stage.
type Stage struct {
Command string `json:"command"`
FailFast bool `json:"fail_fast"`
}
// GetStages returns the ordered CI pipeline stages.
func (p *CIPipeline) GetStages() map[string]Stage {
return map[string]Stage{
"install": {Command: "go mod download", FailFast: true},
"lint": {Command: "golangci-lint run ./...", FailFast: true},
"static-analysis": {Command: "go vet ./...", FailFast: true},
"unit-tests": {Command: "go test -short ./...", FailFast: true},
"integration-tests": {Command: "go test -run Integration ./...", FailFast: true},
"security-check": {Command: "govulncheck ./...", FailFast: false},
}
}
Сравнение стратегий
| Стратегия | Downtime | Rollback | Риск | Сложность |
|---|---|---|---|---|
| Big Bang | Да | Сложный | Высокий | Низкая |
| Rolling | Минимальный | Средний | Средний | Средняя |
| Blue-Green | Нет | Мгновенный | Низкий | Высокая |
| Canary | Нет | Быстрый | Низкий | Высокая |
| Shadow | Нет | Не нужен | Минимальный | Очень высокая |
Blue-Green Deployment
Два идентичных окружения: Blue (текущий production) и Green (новая версия).
Load Balancer
/ \
[Blue - v1.0] [Green - v1.1]
(active) (staging)
Шаги:
1. Задеплоить v1.1 на Green
2. Протестировать Green
3. Переключить LB на Green
4. Green становится active
5. Blue = rollback target
Canary Release
Постепенное направление трафика на новую версию.
Время: 0 мин 5 мин 15 мин 30 мин 60 мин
v1.0: 100% → 95% → 90% → 50% → 0%
v1.1: 0% → 5% → 10% → 50% → 100%
На каждом шаге: проверка метрик → если OK → увеличить %
→ если NOT OK → rollback
PHP: реализация Canary Router
<?php
declare(strict_types=1);
namespace App\Deploy;
final readonly class CanaryRouter
{
/**
* @param int $canaryPercentage Percentage of traffic to route to canary (0-100)
* @param array<string> $allowedUsers Users always routed to canary (beta testers)
*/
public function __construct(
private int $canaryPercentage = 0,
private array $allowedUsers = [],
) {}
/**
* Determine if a request should be routed to canary version.
*/
public function shouldRouteToCanary(string $userId, string $requestId): RoutingDecision
{
// Always route allowed users to canary
if (in_array($userId, $this->allowedUsers, true)) {
return new RoutingDecision(
target: DeployTarget::Canary,
reason: 'User in canary allowlist',
);
}
// Use consistent hashing for sticky sessions
$hash = crc32($userId);
$bucket = abs($hash) % 100;
if ($bucket < $this->canaryPercentage) {
return new RoutingDecision(
target: DeployTarget::Canary,
reason: sprintf('Bucket %d < %d%% canary threshold', $bucket, $this->canaryPercentage),
);
}
return new RoutingDecision(
target: DeployTarget::Stable,
reason: sprintf('Bucket %d >= %d%% canary threshold', $bucket, $this->canaryPercentage),
);
}
}
enum DeployTarget: string
{
case Stable = 'stable';
case Canary = 'canary';
}
final readonly class RoutingDecision
{
public function __construct(
public DeployTarget $target,
public string $reason,
) {}
}
package deploy
import "hash/crc32"
// DeployTarget identifies the deployment target.
type DeployTarget string
const (
TargetStable DeployTarget = "stable"
TargetCanary DeployTarget = "canary"
)
// RoutingDecision records a canary routing decision.
type RoutingDecision struct {
Target DeployTarget `json:"target"`
Reason string `json:"reason"`
}
// CanaryRouter routes traffic between stable and canary deployments.
type CanaryRouter struct {
CanaryPercentage int
AllowedUsers map[string]bool
}
// ShouldRouteToCanary determines if a request should go to the canary.
func (r *CanaryRouter) ShouldRouteToCanary(userID, requestID string) RoutingDecision {
if r.AllowedUsers[userID] {
return RoutingDecision{
Target: TargetCanary,
Reason: "User in canary allowlist",
}
}
hash := crc32.ChecksumIEEE([]byte(userID))
bucket := int(hash) % 100
if bucket < 0 {
bucket = -bucket
}
if bucket < r.CanaryPercentage {
return RoutingDecision{
Target: TargetCanary,
Reason: fmt.Sprintf("Bucket %d < %d%% canary threshold", bucket, r.CanaryPercentage),
}
}
return RoutingDecision{
Target: TargetStable,
Reason: fmt.Sprintf("Bucket %d >= %d%% canary threshold", bucket, r.CanaryPercentage),
}
}
Feature flags (feature toggles) позволяют включать/отключать функциональность без деплоя.
Типы Feature Flags
| Тип | Срок жизни | Пример |
|---|---|---|
| Release | Дни-недели | Новая фича за флагом |
| Experiment | Недели-месяцы | A/B тест |
| Ops | Постоянно | Kill switch, degraded mode |
| Permission | Постоянно | Premium features |
PHP: система Feature Flags
<?php
declare(strict_types=1);
namespace App\Feature;
enum FlagStatus: string
{
case Enabled = 'enabled';
case Disabled = 'disabled';
case Percentage = 'percentage';
case UserList = 'user_list';
}
final readonly class FeatureFlag
{
public function __construct(
public string $name,
public FlagStatus $status,
public int $percentage = 0,
public array $allowedUsers = [],
public ?\DateTimeImmutable $expiresAt = null,
) {}
}
final class FeatureFlagManager
{
/** @var array<string, FeatureFlag> */
private array $flags = [];
public function __construct(
private readonly \Redis $redis,
private readonly string $prefix = 'feature_flag:',
) {
$this->loadFlags();
}
/**
* Check if a feature is enabled for a given context.
*/
public function isEnabled(string $flagName, ?string $userId = null): bool
{
$flag = $this->flags[$flagName] ?? null;
if ($flag === null) {
return false;
}
// Check expiration
if ($flag->expiresAt !== null && $flag->expiresAt < new \DateTimeImmutable()) {
return false;
}
return match ($flag->status) {
FlagStatus::Enabled => true,
FlagStatus::Disabled => false,
FlagStatus::Percentage => $this->checkPercentage($flagName, $userId, $flag->percentage),
FlagStatus::UserList => $userId !== null && in_array($userId, $flag->allowedUsers, true),
};
}
/**
* Toggle a flag on/off.
*/
public function toggle(string $flagName, FlagStatus $status): void
{
$flag = $this->flags[$flagName] ?? null;
if ($flag === null) {
return;
}
$updated = new FeatureFlag(
name: $flag->name,
status: $status,
percentage: $flag->percentage,
allowedUsers: $flag->allowedUsers,
expiresAt: $flag->expiresAt,
);
$this->flags[$flagName] = $updated;
$this->redis->hSet($this->prefix . 'flags', $flagName, serialize($updated));
}
/**
* Get all flags for debugging.
*
* @return array<string, array{status: string, percentage: int}>
*/
public function getAllFlags(): array
{
$result = [];
foreach ($this->flags as $name => $flag) {
$result[$name] = [
'status' => $flag->status->value,
'percentage' => $flag->percentage,
];
}
return $result;
}
private function checkPercentage(string $flagName, ?string $userId, int $percentage): bool
{
// Consistent: same user always gets the same result
$seed = $userId ?? uniqid('anon_', true);
$hash = crc32($flagName . ':' . $seed);
$bucket = abs($hash) % 100;
return $bucket < $percentage;
}
private function loadFlags(): void
{
$data = $this->redis->hGetAll($this->prefix . 'flags');
foreach ($data as $name => $serialized) {
$this->flags[$name] = unserialize($serialized);
}
}
}
package feature
import (
"context"
"hash/crc32"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// FlagStatus represents the state of a feature flag.
type FlagStatus string
const (
FlagEnabled FlagStatus = "enabled"
FlagDisabled FlagStatus = "disabled"
FlagPercentage FlagStatus = "percentage"
FlagUserList FlagStatus = "user_list"
)
// Flag defines a feature flag configuration.
type Flag struct {
Name string `json:"name"`
Status FlagStatus `json:"status"`
Percentage int `json:"percentage"`
AllowedUsers []string `json:"allowed_users"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Manager manages feature flags with Redis persistence.
type Manager struct {
mu sync.RWMutex
flags map[string]Flag
rdb *redis.Client
}
// NewManager creates a feature flag manager.
func NewManager(rdb *redis.Client) *Manager {
return &Manager{
flags: make(map[string]Flag),
rdb: rdb,
}
}
// IsEnabled checks if a feature is enabled for a given user.
func (m *Manager) IsEnabled(flagName string, userID string) bool {
m.mu.RLock()
flag, ok := m.flags[flagName]
m.mu.RUnlock()
if !ok {
return false
}
if flag.ExpiresAt != nil && flag.ExpiresAt.Before(time.Now()) {
return false
}
switch flag.Status {
case FlagEnabled:
return true
case FlagDisabled:
return false
case FlagPercentage:
return checkPercentage(flagName, userID, flag.Percentage)
case FlagUserList:
for _, u := range flag.AllowedUsers {
if u == userID {
return true
}
}
return false
default:
return false
}
}
func checkPercentage(flagName, userID string, percentage int) bool {
hash := crc32.ChecksumIEEE([]byte(flagName + ":" + userID))
bucket := int(hash) % 100
if bucket < 0 {
bucket = -bucket
}
return bucket < percentage
}
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Feature\FeatureFlagManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class OrderController
{
public function __construct(
private readonly FeatureFlagManager $features,
private readonly OrderService $orderService,
private readonly NewOrderService $newOrderService,
) {}
#[Route('/api/orders', methods: ['POST'])]
public function create(CreateOrderRequest $request): JsonResponse
{
$userId = $request->getUserId();
// Gradual rollout of new order processing
if ($this->features->isEnabled('new_order_processing', $userId)) {
$order = $this->newOrderService->create($request->toDto());
} else {
$order = $this->orderService->create($request->toDto());
}
return new JsonResponse($order, 201);
}
}
package api
import (
"encoding/json"
"net/http"
"myapp/feature"
)
// OrderHandler handles order API requests with feature flag support.
type OrderHandler struct {
features *feature.Manager
orderSvc OrderService
newSvc NewOrderService
}
// Create handles POST /api/orders with gradual rollout.
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(string)
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var order *Order
var err error
// Gradual rollout of new order processing
if h.features.IsEnabled("new_order_processing", userID) {
order, err = h.newSvc.Create(req)
} else {
order, err = h.orderSvc.Create(req)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
Deployer — инструмент для автоматизации деплоя PHP-приложений.
<?php
// deploy.php — Deployer configuration concept
// Demonstrates the deployment recipe structure
declare(strict_types=1);
namespace App\Deploy;
/**
* Deployment configuration for Symfony application.
* This represents the Deployer recipe logic.
*/
final readonly class DeploymentConfig
{
/** @return array<string, mixed> */
public function getConfig(): array
{
return [
'repository' => '[email protected]:company/app.git',
'branch' => 'main',
'shared_dirs' => ['var/log', 'var/sessions'],
'shared_files' => ['.env.local'],
'writable_dirs' => ['var'],
'keep_releases' => 5,
];
}
/** @return array<string, array<string>> */
public function getTasks(): array
{
return [
'deploy:prepare' => [
'deploy:info',
'deploy:setup',
'deploy:lock',
'deploy:release',
'deploy:update_code',
'deploy:shared',
],
'deploy:build' => [
'composer install --no-dev --optimize-autoloader',
'bin/console cache:clear --env=prod',
'bin/console cache:warmup --env=prod',
'bin/console assets:install',
],
'deploy:publish' => [
'deploy:symlink', // Atomic switch (blue-green via symlink)
'deploy:unlock',
'deploy:cleanup',
],
'rollback' => [
'deploy:rollback', // Revert symlink to previous release
],
];
}
}
package deploy
// DeploymentConfig describes Deployer-style deployment configuration.
type DeploymentConfig struct {
Repository string `json:"repository"`
Branch string `json:"branch"`
SharedDirs []string `json:"shared_dirs"`
SharedFiles []string `json:"shared_files"`
WritableDirs []string `json:"writable_dirs"`
KeepReleases int `json:"keep_releases"`
}
// DefaultConfig returns a typical deployment configuration.
func DefaultConfig() DeploymentConfig {
return DeploymentConfig{
Repository: "[email protected]:company/app.git",
Branch: "main",
SharedDirs: []string{"var/log", "var/sessions"},
SharedFiles: []string{".env.local"},
WritableDirs: []string{"var"},
KeepReleases: 5,
}
}
// Tasks returns deployment stages with their commands.
func (c DeploymentConfig) Tasks() map[string][]string {
return map[string][]string{
"deploy:prepare": {
"deploy:info", "deploy:setup", "deploy:lock",
"deploy:release", "deploy:update_code", "deploy:shared",
},
"deploy:build": {
"go build -o app ./cmd/server",
"./app migrate",
},
"deploy:publish": {
"deploy:symlink", // Atomic switch
"deploy:unlock",
"deploy:cleanup",
},
"rollback": {
"deploy:rollback", // Revert symlink to previous release
},
}
}
| Стратегия | Скорость | Сложность |
|---|---|---|
| Symlink switch | Секунды | Deployer style |
| Container swap | Секунды | Blue-green, Docker |
| Git revert + deploy | Минуты | Полный pipeline |
| Database rollback | Минуты-часы | Обратная миграция |
| Feature flag off | Мгновенно | Без деплоя |
Правило: Всегда имейте план rollback до деплоя. Если rollback невозможен (например, необратимая миграция БД) — используйте canary и feature flags.
Checklist релиза
| Шаг | Проверка |
|---|---|
| Pre-deploy | CI зелёный, тесты проходят |
| Database | Миграции обратимы или совместимы |
| Config | Переменные окружения настроены |
| Feature flags | Новые фичи за флагами |
| Monitoring | Dashboards и алерты готовы |
| Rollback | План отката задокументирован |
| Communication | Команда уведомлена о деплое |
| Post-deploy | Smoke tests, метрики в норме |
Итоги
| Концепция | Суть |
|---|---|
| CI/CD | Автоматизация от commit до production |
| Blue-Green | Два окружения, мгновенное переключение |
| Canary | Постепенное увеличение трафика на новую версию |
| Feature Flags | Включение/отключение фич без деплоя |
| Rollback | Всегда иметь план, всегда тестировать |
| Release Checklist | Системный подход к каждому релизу |