Mid💻Практика9 min

Release Engineering

CI/CD, blue-green deployments, canary releases, feature flags. PHP Deployer и GitHub Actions

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-проекта

PHPGo
<?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

PHPGo
<?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 flags (feature toggles) позволяют включать/отключать функциональность без деплоя.

Типы Feature Flags

Тип Срок жизни Пример
Release Дни-недели Новая фича за флагом
Experiment Недели-месяцы A/B тест
Ops Постоянно Kill switch, degraded mode
Permission Постоянно Premium features

PHP: система Feature Flags

PHPGo
<?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
}
### Использование Feature Flags в контроллере
PHPGo
<?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)
}
## PHP Deployer

Deployer — инструмент для автоматизации деплоя PHP-приложений.

PHPGo
<?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
		},
	}
}
## Rollback стратегии
Стратегия Скорость Сложность
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 Системный подход к каждому релизу