Jump to content
View in the app

A better way to browse. Learn more.

T.M.I IThub

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

IThub

Administrators

Blog Entries posted by IThub

  1. Пользователи не любят ждать. Если кнопка "Отправить" не реагирует три секунды — они уже в Twitter пишут что ваш сайт сломан. Один из самых мощных инструментов для улучшения perceived performance — асинхронная обработка через очереди.
    Что уходит в очередь
    В HTTP-запросе должно происходить только то, что нужно пользователю для немедленного ответа.
    Что НЕ нужно пользователю немедленно:
    Отправка email
    Генерация PDF
    Пересчёт статистики
    Синхронизация с внешними системами
    Изменение размеров изображений
    Push-уведомления
    Webhook-уведомления партнёров
    Всё это — в очередь. HTTP отвечает за 50ms. Фоновый воркер делает остальное.
    Базовая архитектура очередей на Redis + CI4
    <?php namespace App\Libraries\Queue; class Queue { private \Redis $redis; private string $prefix = 'queue:'; public function push(string $queueName, BaseJob $job, int $delay = 0): string { $payload = [ 'id' => $id = uniqid('job_', true), 'class' => get_class($job), 'data' => serialize($job), 'attempt' => 0, 'created_at' => time(), ]; if ($delay > 0) { $this->redis->zAdd( $this->prefix . 'delayed:' . $queueName, time() + $delay, json_encode($payload) ); } else { $this->redis->lPush($this->prefix . $queueName, json_encode($payload)); } return $id; } public function pop(string $queueName, int $timeout = 5): ?array { $this->promoteDelayedJobs($queueName); $result = $this->redis->brPop($this->prefix . $queueName, $timeout); return $result ? json_decode($result[1], true) : null; } private function promoteDelayedJobs(string $queueName): void { $delayedKey = $this->prefix . 'delayed:' . $queueName; $jobs = $this->redis->zRangeByScore($delayedKey, '-inf', time()); foreach ($jobs as $job) { $this->redis->multi(); $this->redis->zRem($delayedKey, $job); $this->redis->lPush($this->prefix . $queueName, $job); $this->redis->exec(); } } } Worker — CI4 CLI команда
    <?php namespace App\Commands; use App\Libraries\Queue\Queue; class QueueWorker extends BaseCommand { protected $name = 'queue:work'; protected $description = 'Process jobs from the queue'; private bool $shouldStop = false; public function run(array $params): void { $queueName = $params[0] ?? 'default'; $maxJobs = (int) ($this->getOption('max-jobs') ?? 0); $processed = 0; if (function_exists('pcntl_signal')) { pcntl_signal(SIGTERM, fn() => $this->shouldStop = true); pcntl_signal(SIGINT, fn() => $this->shouldStop = true); } $queue = new Queue(); while (!$this->shouldStop) { if (function_exists('pcntl_signal_dispatch')) { pcntl_signal_dispatch(); } $payload = $queue->pop($queueName); if (!$payload) continue; $this->processJob($queue, $queueName, $payload); $processed++; if ($maxJobs > 0 && $processed >= $maxJobs) break; } } private function processJob(Queue $queue, string $queueName, array $payload): void { try { $job = unserialize($payload['data']); $job->setAttempt($payload['attempt'] + 1); set_time_limit($job->getTimeout()); $job->handle(); } catch (\Throwable $e) { $payload['attempt']++; if ($payload['attempt'] < $job->getMaxAttempts()) { $delay = pow(2, $payload['attempt']) * 10; $queue->push($queueName, $job, $delay); } else { $job->failed($e); $queue->bury($queueName, $payload); } } } } Реальный пример: отправка email
    <?php namespace App\Jobs; use App\Libraries\Queue\BaseJob; class SendWelcomeEmailJob extends BaseJob { protected int $maxAttempts = 5; protected int $timeout = 30; public function __construct( private readonly int $userId, private readonly string $email, private readonly string $name ) {} public function handle(): void { service('email')->send( to: $this->email, subject: 'Добро пожаловать!', view: 'emails/welcome', data: ['name' => $this->name] ); model('EmailLogModel')->insert([ 'user_id' => $this->userId, 'type' => 'welcome', 'sent_at' => date('Y-m-d H:i:s'), 'status' => 'sent', ]); } } Использование в Controller:
    // Вместо: $emailService->send(...); // ждём 500ms // Делаем: $queue = new Queue(); $queue->push('emails', new SendWelcomeEmailJob($user->id, $user->email, $user->name)); // HTTP response за 15ms вместо 515ms return $this->response->setJSON(['status' => 'registered', 'message' => 'Check your email']); Результаты
    Endpoint
    До
    После
    POST /register
    580ms
    45ms
    POST /upload-photo
    2300ms
    120ms
    POST /generate-report
    8000ms
    80ms (async)
    POST /checkout
    1200ms
    340ms
    Bounce rate на страницах с "тяжёлыми" действиями упал на 34%. Конверсия регистрации выросла на 12%. Просто потому что форма теперь отвечает мгновенно.
    Пользователи не знают о ваших очередях. Они просто чувствуют что сайт быстрый. Это и есть цель. ⚡
    Максим — продуктовый DevOps с горящими глазами и умеренно сгоревшими нервами. Пишу про реальный highload, реальные ошибки и реальные решения. Больше таких историй — на ithub.uno, там собираются те, кто делает, а не только говорит.
  2. "Просто добавь серверов" — говорят менеджеры. "Это не так просто" — говорим мы. Сегодня расскажу почему не так просто, и как всё-таки сделать так, чтобы было просто.
    Проблема №1: Состояние сессий
    Запускаете второй сервер, и пользователи жалуются: "я только что вошёл, а меня снова просит логин". Потому что сессия хранится в файловой системе первого сервера.
    Правильное решение: Redis-сессии:
    // app/Config/App.php public string $sessionDriver = 'CodeIgniter\Session\Handlers\RedisHandler'; public string $sessionSavePath = 'tcp://redis-cluster:6379?auth=password&database=1'; public int $sessionExpiration = 7200; public bool $sessionMatchIP = false; // Важно! Иначе CDN сломает сессии С Sentinel для HA:
    public string $sessionSavePath = 'tcp://redis-sentinel:26379?auth=password&database=1&sentinel_master=mymaster'; Проблема №2: Загрузка файлов
    Пользователь загружает аватар на сервер #1. Следующий запрос идёт на сервер #2 — он ничего не знает об этом файле.
    Решение: S3-совместимое хранилище:
    <?php namespace App\Services; use Aws\S3\S3Client; class FileStorageService { private S3Client $s3; public function __construct() { $config = config('FileStorage'); $this->s3 = new S3Client([ 'version' => 'latest', 'region' => $config->region, 'endpoint' => $config->endpoint, 'credentials' => ['key' => $config->key, 'secret' => $config->secret], 'use_path_style_endpoint' => $config->usePathStyle, ]); } public function upload(string $localPath, string $remotePath, string $acl = 'private'): string { $this->s3->putObject([ 'Bucket' => $this->bucket, 'Key' => $remotePath, 'SourceFile' => $localPath, 'ACL' => $acl, 'ContentType' => mime_content_type($localPath), ]); return $this->getUrl($remotePath); } public function getSignedUrl(string $remotePath, int $expiry = 3600): string { $cmd = $this->s3->getCommand('GetObject', ['Bucket' => $this->bucket, 'Key' => $remotePath]); return (string) $this->s3->createPresignedRequest($cmd, "+{$expiry} seconds")->getUri(); } } Проблема №3: Cron jobs
    10 серверов, у каждого crontab — пользователи получают email 10 раз. Решение: distributed locking:
    <?php namespace App\Libraries; class DistributedLock { private string $lockValue; public function __construct( private readonly string $key, private readonly int $ttl = 300 ) { $this->lockValue = gethostname() . '_' . getmypid() . '_' . uniqid(); } public function acquire(): bool { // SET key value NX EX ttl — атомарная операция $result = cache()->getRedis()->set( $this->key, $this->lockValue, ['NX', 'EX' => $this->ttl] ); return $result === true; } public function release(): bool { // Lua для атомарного освобождения только СВОЕГО лока $script = <<<LUA if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end LUA; return (bool) cache()->getRedis()->eval($script, [$this->key, $this->lockValue], 1); } } Базовый класс для singleton cron-команд:
    abstract class SingletonCommand extends BaseCommand { protected int $lockTtl = 300; final public function run(array $params): void { $lock = new DistributedLock('cron_lock_' . static::class, $this->lockTtl); if (!$lock->acquire()) { $this->write("Another instance is already running. Skipping."); return; } try { $this->execute($params); } finally { $lock->release(); } } abstract protected function execute(array $params): void; } Чеклист горизонтального масштабирования
    Перед тем как добавить второй сервер:
    ✅ Сессии в Redis/Memcached, не в файловой системе
    ✅ Файлы в S3/MinIO, не на диск
    ✅ Кэш в Redis, не в файловой системе
    ✅ Логи в centralized storage (ELK/Loki)
    ✅ Cron с distributed locking
    ✅ Конфиги из environment variables
    ✅ Нет hardcoded hostname в коде
    ✅ Нет состояния в оперативной памяти между запросами
    ✅ Graceful shutdown (SIGTERM handler)
    ✅ Health checks настроены
    Если всё это есть — добавление сервера занимает минуты. Если нет — недели головной боли.
    На ithub.uno таких архитектурных обсуждений всегда много — там есть практические нюансы, которых нет в документации.
    Масштабируйтесь грамотно. 📊🖥️
  3. CI/CD: от "фтп в продакшн" до zero-downtime deploy за 4 минуты

    Это история эволюции. Начнём с того момента, когда деплой выглядел так: "Ваня, выгрузи файлики на сервер через FileZilla". И закончим тем, что у нас сейчас — полностью автоматический pipeline с тестами, security checks, zero-downtime deploy и автоматическим rollback.
    Эра FTP (тёмные времена)
    Просто знайте: когда я пришёл в эту компанию, деплой выглядел следующим образом:
    Разработчик локально делал изменения
    Открывал FileZilla
    Перетаскивал папку app/ на сервер
    Молился
    Это было в 2019 году. Не в 2009, а в 2019. Такое ещё встречается.
    Шаг 1: Git + простой CI
    # .gitlab-ci.yml v1.0 — наивная версия stages: - test - deploy test: stage: test script: - composer install - php vendor/bin/phpunit deploy: stage: deploy script: - ssh deploy@production "cd /var/www/app && git pull && composer install" only: - main git pull на продакшне — это тоже не очень хорошая идея, но это следующий шаг.
    Шаг 2: Деплой через rsync + atomic switch
    #!/bin/bash set -euo pipefail DEPLOY_PATH="/var/www" RELEASES_PATH="$DEPLOY_PATH/releases" CURRENT_LINK="$DEPLOY_PATH/current" RELEASE_ID=$(date +%Y%m%d_%H%M%S) RELEASE_PATH="$RELEASES_PATH/$RELEASE_ID" mkdir -p "$RELEASE_PATH" rsync -az --exclude='.git' --exclude='vendor' --exclude='writable' \ ./ "$RELEASE_PATH/" ln -sf "$SHARED_PATH/writable" "$RELEASE_PATH/writable" ln -sf "$SHARED_PATH/.env" "$RELEASE_PATH/.env" cd "$RELEASE_PATH" composer install --no-dev --optimize-autoloader --no-interaction php spark migrate --no-interaction # Атомарное переключение симлинка — zero downtime! ln -sfn "$RELEASE_PATH" "$CURRENT_LINK" nginx -s reload ls -dt "$RELEASES_PATH"/* | tail -n +6 | xargs rm -rf echo "✅
    Deploy $RELEASE_ID complete"
    Rollback:
    ls -dt /var/www/releases/* | head -2 | tail -1 | xargs -I{} ln -sfn {} /var/www/current nginx -s reload Шаг 3: Docker + Kubernetes
    Dockerfile для PHP 8.3 + CI4:
    FROM composer:2.7 AS vendor-builder WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --optimize --classmap-authoritative FROM php:8.3-fpm-alpine AS production RUN apk add --no-cache libzip-dev icu-dev \ && docker-php-ext-install pdo_mysql zip intl opcache \ && pecl install redis igbinary \ && docker-php-ext-enable redis igbinary COPY docker/php/php.ini /usr/local/etc/php/php.ini COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf WORKDIR /var/www/html COPY --from=vendor-builder /app/vendor ./vendor COPY --from=vendor-builder /app/app ./app COPY --from=vendor-builder /app/system ./system COPY --from=vendor-builder /app/public ./public COPY --from=vendor-builder /app/spark ./spark HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ CMD php-fpm -t || exit 1 EXPOSE 9000 USER www-data CMD ["php-fpm"] GitLab CI/CD — финальная версия:
    stages: - validate - test - security - build - deploy-staging - integration-test - deploy-production - verify variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA lint: stage: validate script: - find app/ -name "*.php" -exec php -l {} \; - composer validate --strict unit-tests: stage: test services: - mysql:8.0 - redis:7.0-alpine script: - php spark migrate --env=testing - vendor/bin/phpunit --coverage-min=70 sast: stage: security script: - vendor/bin/psalm --no-cache - composer audit --no-dev build-image: stage: build script: - docker build --target production -t $DOCKER_IMAGE . - docker push $DOCKER_IMAGE deploy-production: stage: deploy-production when: manual # Требует ручного подтверждения! script: - kubectl set image deployment/php-app php-fpm=$DOCKER_IMAGE -n production - kubectl rollout status deployment/php-app -n production --timeout=10m post-deploy-verify: stage: verify script: - sleep 60 - php spark synthetic:run --suite=smoke --env=production - | ERROR_RATE=$(curl -s "http://prometheus/api/v1/query?query=rate(http_errors_total[5m])" | jq '.data.result[0].value[1]') if (( $(echo "$ERROR_RATE > 0.05" | bc -l) )); then echo "Error rate too high! Auto-rollback!" kubectl rollout undo deployment/php-app -n production exit 1 fi Итого: что получили
    Метрика
    FTP era
    Git + rsync
    Docker + k8s
    Время деплоя
    5-40 мин
    3-8 мин
    2-4 мин
    Downtime при деплое
    30s-5min
    0
    0
    Rollback время
    10-30 мин
    2 мин
    45 секунд
    Деплоев в день
    1-2
    5-10
    20-30
    Инцидентов из-за деплоя
    2-3/мес
    0-1/мес
    ~0
    Больше деплоев в день — это не потому что мы хаотичнее. Маленькие частые деплои безопаснее больших редких. Каждый PR идёт в прод в тот же день. Это называется continuous delivery. И это меняет жизнь. 🚀
  4. Есть два типа DevOps. Первые узнают об авариях от пользователей. Вторые — за 5 минут до того как проблема станет аварией. Я прошёл путь от первого ко второму. Это было долго, больно и неочевидно. Расскажу как.
    Уровень 0: Никакого мониторинга
    Это страшное место. Узнаёшь что что-то не так, когда:
    Пишет пользователь в поддержку
    Звонит CEO в воскресенье
    Видишь в Twitter "ваш сайт сломан"
    Это лечится быстро — после первого воскресного звонка от CEO инстинкт самосохранения быстро мотивирует к действию.
    Уровень 1: Uptime monitoring
    Самое простое: проверяем что сайт отвечает:
    class HealthCheck extends BaseCommand { protected $name = 'health:check'; public function run(array $params): void { $endpoints = config('HealthCheck')->endpoints; foreach ($endpoints as $endpoint) { $start = microtime(true); try { $response = \Config\Services::curlrequest()->get($endpoint['url'], [ 'timeout' => 10, 'http_errors' => false, ]); $duration = (microtime(true) - $start) * 1000; $status = $response->getStatusCode(); if ($status !== 200 || $duration > $endpoint['threshold_ms']) { $this->alertTeam($endpoint, $status, $duration); } } catch (\Exception $e) { $this->alertTeam($endpoint, 0, 0, $e->getMessage()); } } } } Лучше чем ничего. Но это как термометр в одной комнате большого дома.
    Уровень 2: Application метрики
    Prometheus + Grafana. В CI4 интегрируем через After-фильтр:
    <?php namespace App\Filters; class MetricsFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null): void { $GLOBALS['_request_start_time'] = microtime(true); } public function after( RequestInterface $request, ResponseInterface $response, $arguments = null ): ResponseInterface { $duration = microtime(true) - ($GLOBALS['_request_start_time'] ?? microtime(true)); $status = $response->getStatusCode(); $route = service('router')->controllerName(); $method = $request->getMethod(); $metrics = service('metricsCollector'); $metrics->histogram( 'http_request_duration_seconds', $duration, ['method' => $method, 'route' => $route, 'status' => $status] ); $metrics->counter( 'http_requests_total', 1, ['method' => $method, 'route' => $route, 'status' => (string)intdiv($status, 100) . 'xx'] ); if ($status >= 500) { $metrics->counter('http_errors_total', 1, ['route' => $route]); } return $response; } } Уровень 3: Предиктивные алерты
    Настоящий прорыг — когда алерты стали срабатывать ДО того как всё упало.
    Пример: за 8-12 минут до OOM Redis всегда происходило:
    Memory usage рос быстрее обычного (+15% за 5 минут)
    Cache hit rate начинал падать
    Eviction rate был 0
    Prometheus alerting rule:
    groups: - name: redis_predictive rules: - alert: RedisMemoryGrowthAnomaly expr: | ( redis_memory_used_bytes / redis_memory_max_bytes > 0.75 ) and ( rate(redis_memory_used_bytes[5m]) > 0 ) and ( redis_stat_keyspace_hits / (redis_stat_keyspace_hits + redis_stat_keyspace_misses) < 0.85 ) for: 3m labels: severity: warning annotations: summary: "Redis memory growing fast, potential OOM in ~10min" - alert: MySQLConnectionPoolDanger expr: | mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.8 for: 2m labels: severity: critical annotations: summary: "MySQL connection pool at {{ $value | humanizePercentage }}" Уровень 4: Synthetic monitoring
    Проверяем что реальный пользовательский сценарий работает end-to-end. CLI-команда CI4 каждые 2 минуты симулирует key user journey:
    namespace App\Commands\Synthetic; class CheckoutFlow extends BaseCommand { protected $name = 'synthetic:checkout'; public function run(array $params): void { $client = service('curlrequest'); $baseUrl = env('SYNTHETIC_BASE_URL'); $metrics = service('metricsCollector'); $start = microtime(true); $step = 'init'; try { $step = 'login'; $loginResp = $client->post($baseUrl . '/api/auth/login', [ 'json' => ['email' => env('SYNTHETIC_USER_EMAIL'), 'password' => env('SYNTHETIC_USER_PASSWORD')], 'timeout' => 5, ]); assert($loginResp->getStatusCode() === 200); $token = json_decode($loginResp->getBody(), true)['token']; $step = 'product_list'; $productsResp = $client->get($baseUrl . '/api/products', [ 'headers' => ['Authorization' => "Bearer $token"], 'timeout' => 3, ]); assert($productsResp->getStatusCode() === 200); // ... другие шаги $metrics->counter('synthetic_checkout_flow_success_total', 1); $this->write("✅ Checkout flow OK
    "); } catch (\Throwable $e) {
    $metrics->counter('synthetic_checkout_flow_failure_total', 1, ['step' => $step]);
    service('alertManager')->fire(level: 'critical', title: "Synthetic checkout failed at: {$step}", body: $e->getMessage()); } } }
    Текущий стек мониторинга
    Prometheus — сбор метрик
    Grafana — визуализация и алертинг
    Loki — агрегация логов
    Alertmanager — маршрутизация (Telegram, PagerDuty, Slack)
    Synthetic monitoring — CI4 CLI команды в cron
    OpenTelemetry + Jaeger — distributed tracing
    MTTR за год:
    Период
    MTTR
    До нормального мониторинга
    47 минут
    После
    8 минут
    Это не просто красивая цифра. Это живые деньги. Мониторинг — это бизнес-инвестиция с прямым ROI. 📈
  5. Когда PHP 8.0 вышел с JIT-компилятором, интернет взорвался. "PHP теперь БЫСТРЕЕ Python!" "JIT изменит всё!" "Переписывайте на PHP!". Заголовки были прекрасны. Реальность — немного иначе.
    Я потратил месяц на бенчмарки JIT в нашем реальном highload-проекте на PHP 8.3 + CodeIgniter 4.6. Расскажу что нашёл. Без маркетинга, только цифры и контекст.
    Немного теории (обещаю, коротко)
    JIT (Just-In-Time compilation) — это компиляция горячего кода в нативные машинные инструкции во время выполнения. В отличие от OPcache, который кэширует opcode (байткод PHP), JIT генерирует настоящий машинный код.
    PHP реализует два режима JIT:
    Tracing JIT (jit=tracing) — анализирует трассы выполнения, оптимизирует горячие пути. Лучше для численных вычислений.
    Function JIT (jit=function) — компилирует целые функции. Более предсказуемый, но менее агрессивный.
    Конфигурация в php.ini:
    [opcache] opcache.enable=1 opcache.jit=tracing opcache.jit_buffer_size=256M opcache.jit_hot_loop=64 opcache.jit_hot_func=127 opcache.jit_hot_return=8 opcache.jit_hot_side_exit=8 Бенчмарк 1: Мандельброт (классика)
    function mandelbrot(float $x0, float $y0): int { $x = 0.0; $y = 0.0; $iteration = 0; $maxIteration = 1000; while ($x * $x + $y * $y <= 4.0 && $iteration < $maxIteration) { $xTemp = $x * $x - $y * $y + $x0; $y = 2.0 * $x * $y + $y0; $x = $xTemp; $iteration++; } return $iteration; } Результаты:
    Конфигурация
    Время
    PHP 8.3, OPcache off
    8.43s
    PHP 8.3, OPcache on, JIT off
    4.21s
    PHP 8.3, OPcache on, JIT tracing
    0.84s
    PHP 8.3, OPcache on, JIT function
    1.12s
    JIT Tracing ускорил Мандельброт в 5 раз. Потрясающий результат! Но кто в вашем web-приложении считает Мандельброт?
    Бенчмарк 2: Реальный CI4 request
    Результаты на типичном API endpoint (среднее по 10,000 запросов):
    Конфигурация
    Среднее время
    P99
    OPcache on, JIT off
    42.3ms
    89ms
    OPcache on, JIT tracing
    41.8ms
    87ms
    OPcache on, JIT function
    42.1ms
    88ms
    Разница: 1.2%. Это в пределах погрешности измерений.
    Почему JIT не помогает веб-приложениям
    Типичный PHP web-request проводит время примерно так:
    ~45% — ожидание БД (I/O bound)
    ~25% — сетевые операции (Redis, внешние API)
    ~15% — OPcache-оптимизированный PHP-код
    ~10% — сериализация/десериализация (JSON, etc.)
    ~5% — собственно бизнес-логика с вычислениями
    JIT помогает именно с CPU-bound вычислениями. Но в web-запросе CPU-bound части — это ~20% от общего времени. Максимальное теоретическое ускорение: 16%. На практике — 1-3%.
    Правильная конфигурация для highload CI4
    [opcache] opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=256 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; В prod — ВСЕГДА 0 opcache.save_comments=1 ; CI4 использует атрибуты opcache.preload=/var/www/app/preload.php opcache.preload_user=www-data opcache.jit=tracing opcache.jit_buffer_size=128M opcache.jit_hot_loop=64 opcache.jit_hot_func=127 И обязательно — preload для CI4:
    <?php // preload.php $preloadClasses = [ \App\Models\UserModel::class, \App\Models\ProductModel::class, \App\Libraries\Cache\ResilientCacheService::class, \App\Services\AuthService::class, // ... топ-50 самых используемых классов ]; foreach ($preloadClasses as $class) { if (class_exists($class)) { opcache_compile_file((new \ReflectionClass($class))->getFileName()); } } С preload время ответа на первые запросы после деплоя сократилось с 200ms до 45ms. Это важнее JIT.
    Итог: стоит ли включать JIT?
    Включайте, если:
    У вас PHP 8.1+ (в 8.0 JIT был сыроват)
    Есть CPU-bound операции (обработка данных, вычисления)
    Достаточно памяти для JIT buffer
    Не ждите чудес, если:
    Приложение I/O-bound (БД, Redis, внешние API)
    Это стандартное CRUD web-приложение
    Надеетесь что JIT заменит оптимизацию запросов
    Прежде чем думать о JIT, убедитесь что правильно настроен OPcache, нет N+1 запросов, есть кэширование и нормальные индексы в БД. Это даст 10-100× ускорение. JIT — ещё 1-5% сверху. Но 5% при 10 миллионах запросов в сутки — тоже деньги. Поэтому включайте. Просто знайте зачем. 📊
  6. Это не та история, которой гордятся. Это история, которую рассказывают тихо, за пивом, другим DevOps'ам — чтобы они не совершили те же ошибки. Но знаете что? Я решил рассказать её громко. Потому что честность важнее репутации, а реальные истории учат лучше, чем придуманные кейсы.
    Black Friday. Наш e-commerce на CI4. Трафик × 15 от обычного. Что могло пойти не так?
    Спойлер: всё.
    Подготовка (которой, как оказалось, было недостаточно)
    За три недели до BF мы провели "подготовку". По тем временам нам казалось, что мы сделали всё правильно:
    ✅ Провели нагрузочный тест на 5× нормального трафика
    ✅ Настроили автомасштабирование k8s
    ✅ Прогрели CDN-кэш для статики
    ✅ Оптимизировали топ-20 медленных запросов
    ✅ Увеличили connection pool MySQL
    ✅ Настроили алерты
    Что мы не сделали (и о чём потом пожалели):
    ❌ Не протестировали 15× трафика (только 5×)
    ❌ Не протестировали scenario с высоким числом одновременных checkout операций
    ❌ Не проверили поведение при частичном отказе зависимостей
    ❌ Не подготовили runbook для дежурной команды
    Хронология событий
    00:00 — Midnight madness старт
    Трафик начал расти в 23:45. К полуночи — ×4 от нормы. Всё отлично, k8s масштабируется, метрики в норме, team в Teams чатится позитивно.
    00:23 — Первый звонок

    🔴 ALERT: Checkout error rate > 5%
    5% checkout'ов не проходят. Это много. Начинаем копать.
    В логах:
    Deadlock found when trying to get lock; try restarting transaction MySQL deadlock. Два процесса пытались обновить одну и ту же запись в таблице inventory (остатки товаров) одновременно. При ×4 трафике вероятность коллизии выросла критически.
    Быстрое решение: добавили SELECT ... FOR UPDATE с retry-логикой:
    public function decrementStock(int $productId, int $quantity): bool { $maxRetries = 3; $retryDelay = 100; // ms for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { $this->db->transStart(); try { $product = $this->db ->table('inventory') ->where('product_id', $productId) ->lockForUpdate() ->get() ->getRowArray(); if ($product['stock'] < $quantity) { $this->db->transRollback(); return false; } $this->db->table('inventory') ->where('product_id', $productId) ->update(['stock' => $product['stock'] - $quantity]); $this->db->transCommit(); return true; } catch (\Exception $e) { $this->db->transRollback(); if ($attempt < $maxRetries && str_contains($e->getMessage(), 'Deadlock')) { usleep($retryDelay * 1000 * $attempt); continue; } throw $e; } } return false; } Деплой. Прошло. Продолжаем.
    01:47 — Главное событие
    🔴 CRITICAL: Database primary unreachable
    🔴 CRITICAL: All checkout endpoints down
    🔴 CRITICAL: Payment service timeout
    MySQL primary упал. Не лёг — упал. Полностью. Причина выяснилась позже: диск заполнился из-за бинарных логов (binary log retention не был настроен для ситуации с × 15 write операциями).
    df -h /var/lib/mysql # Filesystem: 100% used (512GB / 512GB) 512 гигабайт. Всё. Диск кончился. MySQL не может писать — MySQL падает. Элегантно, ничего не скажешь.
    Автоматический failover сработал — реплика стала primary. Это заняло 23 секунды. 23 секунды — 100% ошибок на checkout. При трафике BF это $147,000 потерянной выручки. За двадцать три секунды.
    Затем выяснилось: наша реплика не была настроена на роль primary. У неё не было некоторых критических stored procedures. Checkout стал работать, но с ошибками 15%.
    Следующие 40 минут команда в поту накатывала stored procedures на новый primary, чистила binlogs на упавшем сервере (он был ещё нужен), настраивала новую репликацию.
    02:47 — Всё относительно стабильно
    Error rate 2.3%. Для BF — терпимо, но не хорошо.
    04:15 — Redis OOM
    OOM command not allowed when used memory > 'maxmemory' Redis закончил память. Eviction policy была noeviction — вместо того чтобы выкидывать старые ключи, Redis начал отклонять все write-операции. Приложение посыпалось.
    Быстрый фикс:
    redis-cli CONFIG SET maxmemory-policy allkeys-lru LRU eviction включён. Redis начал вытеснять старые ключи. Кэш-хиты упали с 94% до 61%, нагрузка на MySQL снова выросла, но хотя бы приложение работало.
    Постмортем: что пошло не так
    Причина 1: Мы не тестировали реальный сценарий
    Нагрузочный тест в ×5 — это не BF при ×15. Мы протестировали "всё нормально", а не "всё горит". Нужно было делать chaos testing: убивать primary во время нагрузки, заполнять диск, устраивать OOM Redis.
    Причина 2: MySQL binlog retention
    -- Должно быть настроено! SET GLOBAL binlog_expire_logs_seconds = 86400; -- 24 часа Это одна строчка. ОДНА. И она бы предотвратила заполнение диска.
    Причина 3: Replica не была готова к роли primary
    Наша "репликация" была настроена для read scaling, а не для failover. Stored procedures, triggers, специфичные настройки — ничего из этого не дублировалось. Это фундаментальная ошибка в архитектуре HA.
    Причина 4: Redis maxmemory-policy noeviction
    Кто-то (я) настроил noeviction "чтобы данные не терялись". Логика благородная. Результат катастрофический. В production eviction лучше, чем полный отказ сервиса.
    Что изменилось после
    1. GameDay — обязательная практика
    Раз в квартал мы проводим "день катастрофы": специально ломаем production-like окружение и смотрим как команда реагирует. Сценарии: упал primary MySQL, заполнился диск, OOM Redis, одна нода k8s недоступна.
    2. Runbook для каждого critical alert
    Каждый алерт в PagerDuty теперь ссылается на confluence-страницу с пошаговым runbook. Дежурный не должен думать — он должен читать и выполнять.
    3. Pre-BF чеклист
    # Automated pre-event checks ./scripts/pre-event-check.sh # Checks: # ✓ MySQL disk usage < 50% # ✓ Binlog retention configured # ✓ Redis maxmemory-policy = allkeys-lru # ✓ Replica can promote to primary (stored procs present) # ✓ HPA max replicas sufficient # ✓ CDN cache warm # ✓ All alerts configured and tested # ✓ On-call rotation confirmed # ✓ Rollback plan documented Финансовые итоги
    Общие потери от инцидентов BF: ~$230,000 (прямые потери выручки + компенсации + репутационный ущерб).
    Следующий год, после всех изменений: BF прошёл без единого critical инцидента. Error rate — 0.08%. Выручка выросла на 340% по сравнению с прошлым BF.
    Цена нормальной подготовки: 3 недели работы команды + $15,000 на gameday инфраструктуру.
    Разница: 230,000 vs 15,000. Выбор очевиден.
    Делитесь похожими историями — на ithub.uno такие postmortem'ы читают и обсуждают живее всего. Потому что в них — настоящий опыт. 💀➡️🧠
  7. Есть проблемы, о которых говорят на каждой конференции, пишут в каждом учебнике и которые всё равно продолжают жить в каждом втором продакшн-проекте. N+1 — именно такая. Это как тараканы: знаешь о них, ведёшь с ними борьбу, думаешь что победил — а потом открываешь новый модуль и привет.
    Сегодня расскажу про реальный кейс из нашего highload-проекта на PHP 8.2 + CodeIgniter 4. И покажу, как мы с этим боролись системно, а не точечными заплатками.
    Что такое N+1 на практике
    Теория все знают. Загружаешь список из N объектов, потом для каждого делаешь ещё один запрос. Итого 1 + N запросов вместо 1-2. При N=100 это 101 запрос вместо 2. Ничего страшного, да? Нет.
    Вот реальный пример из нашего кода. Страница со списком заказов:
    // OrderModel.php — выглядит невинно public function getOrdersList(int $page = 1): array { $orders = $this->paginate(50); foreach ($orders as &$order) { // Запрос #1 ... #50: грузим пользователя $order['user'] = model('UserModel')->find($order['user_id']); // Запрос #51 ... #100: грузим товары заказа $order['items'] = model('OrderItemModel') ->where('order_id', $order['id']) ->findAll(); // Запрос #101 ... #150: грузим статус доставки $order['delivery'] = model('DeliveryModel') ->where('order_id', $order['id']) ->first(); } return $orders; } 50 заказов на странице. Итого: 1 (список) + 50 (пользователи) + 50 (товары) + 50 (доставка) = 151 запрос. На страницу. Которую открывают 500 раз в минуту. Итого 75,500 запросов в минуту только на эту одну страницу.
    MySQL рыдал. Тихо, но рыдал.
    Обнаружение: логирование запросов в CI4
    Первым шагом была инструментация. CI4 позволяет логировать все запросы через Toolbar, но в highload нам нужно что-то более production-ready.
    Мы написали EventSubscriber, который считает запросы на request:
    <?php namespace App\Subscribers; use CodeIgniter\Events\Events; class QueryCounterSubscriber { private static int $queryCount = 0; private static array $slowQueries = []; public static function register(): void { Events::on('DBQuery', [self::class, 'onQuery']); } public static function onQuery(\CodeIgniter\Database\Query $query): void { self::$queryCount++; $duration = $query->getDuration(6); if ($duration > 0.1) { // 100ms threshold self::$slowQueries[] = [ 'sql' => $query->getQuery(), 'duration' => $duration, 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10), ]; } if (self::$queryCount > 50) { log_message('warning', sprintf( 'N+1 suspicion: %d queries for %s %s', self::$queryCount, service('request')->getMethod(), service('request')->getUri()->getPath() )); } } public static function getReport(): array { return [ 'total' => self::$queryCount, 'slow_queries' => self::$slowQueries, ]; } } Через неделю мы нашли 23 endpoint'а с N+1. Некоторые делали до 800 запросов за один HTTP request. Один из них — статистическая страница для администратора — делал 1,247 запросов. Нет, это не опечатка.
    Решение 1: Eager Loading через Query Builder
    В CI4 нет встроенного ORM с eager loading как в Laravel. Но это не повод делать N+1. Пишем вручную — это даже лучше, потому что контролируешь каждый запрос:
    <?php namespace App\Models; class OrderModel extends Model { public function getOrdersWithRelations(int $page = 1): array { // Запрос 1: список заказов $orders = $this->paginate(50); if (empty($orders)) { return []; } $orderIds = array_column($orders, 'id'); $userIds = array_unique(array_column($orders, 'user_id')); // Запрос 2: все пользователи одним запросом $users = model('UserModel') ->whereIn('id', $userIds) ->findAll(); $usersMap = array_column($users, null, 'id'); // Запрос 3: все товары заказов одним запросом $items = model('OrderItemModel') ->whereIn('order_id', $orderIds) ->findAll(); $itemsMap = []; foreach ($items as $item) { $itemsMap[$item['order_id']][] = $item; } // Запрос 4: вся доставка одним запросом $deliveries = model('DeliveryModel') ->whereIn('order_id', $orderIds) ->findAll(); $deliveriesMap = array_column($deliveries, null, 'order_id'); // Собираем результат в памяти foreach ($orders as &$order) { $order['user'] = $usersMap[$order['user_id']] ?? null; $order['items'] = $itemsMap[$order['id']] ?? []; $order['delivery'] = $deliveriesMap[$order['id']] ?? null; } return $orders; } } 151 запрос → 4 запроса. Время ответа страницы: с 3.2 секунды до 87 миллисекунд. Разница в 37 раз. Буквально изменением подхода к написанию одного метода.
    Решение 2: Автоматическое обнаружение в CI4 Filter
    Чтобы N+1 не возвращались незаметно, добавили Filter для development/staging:
    <?php namespace App\Filters; use CodeIgniter\Filters\FilterInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; class QueryAnalyzerFilter implements FilterInterface { private const WARNING_THRESHOLD = 30; private const ERROR_THRESHOLD = 100; public function before(RequestInterface $request, $arguments = null): void { QueryCounterSubscriber::reset(); } public function after( RequestInterface $request, ResponseInterface $response, $arguments = null ): ResponseInterface { $report = QueryCounterSubscriber::getReport(); $count = $report['total']; if ($count >= self::WARNING_THRESHOLD) { $level = $count >= self::ERROR_THRESHOLD ? 'error' : 'warning'; log_message($level, sprintf( '[QueryAnalyzer] %s %s: %d queries', $request->getMethod(), $request->getUri()->getPath(), $count )); if (ENVIRONMENT === 'development') { $response->setHeader('X-Query-Count', (string) $count); $response->setHeader('X-Query-Warning', $count >= self::ERROR_THRESHOLD ? 'N+1_DETECTED' : 'HIGH_QUERIES'); } } return $response; } } Теперь любой новый endpoint с N+1 автоматически логируется с уровнем error. Это попадает в наш ELK стек, алертинг срабатывает, приходит уведомление. Разработчик узнаёт о проблеме ещё на code review этапе, а не когда MySQL упал в прод.
    Цифры до и после
    Endpoint
    Запросов ДО
    Запросов ПОСЛЕ
    Время ответа ДО
    Время ПОСЛЕ
    Список заказов
    151
    4
    3200ms
    87ms
    Профиль пользователя
    89
    3
    1800ms
    45ms
    Каталог товаров
    347
    5
    8900ms
    210ms
    Статистика
    1247
    12
    31000ms
    890ms
    Суммарная нагрузка на MySQL упала на 78%. Не шучу. Просто убрали N+1 — и почти вдвое освободили ресурсы БД.
    Главный вывод
    N+1 — это не ошибка джуниоров. Это системная проблема, которая возникает когда нет инструментов для её обнаружения и нет культуры её предотвращения. Добавьте автоматическое логирование числа запросов. Сделайте Code Review check на паттерны N+1. И помните: каждый .find() внутри цикла — это потенциальная бомба.
    Удачи вашим базам данных. 🗄️
  8. Прежде чем начать, хочу сказать одну важную вещь: я люблю Kubernetes. Искренне. Как любят сложного человека — за глубину, за то, что никогда не знаешь чего ожидать, за то, что каждый день чему-то учишься. И одновременно хочется иногда взять его и... ну, вы понимаете.
    Восемнадцать месяцев в продакшне с k8s. Сорок семь инцидентов в PagerDuty. Из них тридцать один — "это мы сами виноваты". Остальные шестнадцать — "это k8s виноват, но мы неправильно его настроили". Итого: сорок семь раз мы были виноваты сами. Добро пожаловать в правду.
    Начало: эйфория
    Мы переехали на k8s с bare-metal + ansible + systemd. По тем временам это был большой шаг. Наконец-то: декларативная конфигурация, автомасштабирование, rolling updates без даунтайма, самолечение. Красота!
    Первая неделя прошла в написании манифестов. Я писал их как поэт — вдохновенно, не жалея строк. YAML цвёл. Deployments, Services, ConfigMaps, Secrets, Ingress — всё было прекрасно.
    apiVersion: apps/v1 kind: Deployment metadata: name: php-app spec: replicas: 3 template: spec: containers: - name: php-fpm image: myapp:latest # ← вот здесь уже первая мина resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" image: myapp:latest — это первая из ошибок, которая позже стоила нам часа даунтайма. Тег latest — это зло. Это не версия, это "бог знает что". При следующем деплое k8s может подтянуть другой образ на разные ноды, и у вас будет кластер, где половина подов — старая версия, а половина — новая, и они несовместимы. Всегда используйте immutable теги: git sha, semver, timestamp. Всегда.
    Инцидент #7: OOMKiller приходит ночью
    Наше PHP приложение — CI4 с тяжёлой бизнес-логикой. PHP-FPM воркеры потребляют память по-разному в зависимости от endpoint'а. Лёгкий API — 40MB. Тяжёлый отчёт — 380MB. Мы выставили limits.memory: 512Mi и думали, что этого хватит.
    Ночью запустился cron-джоб, который генерировал ежемесячные отчёты. Каждый воркер под отчёт — ~380MB. PHP-FPM с 8 воркерами = 3GB. Лимит пода — 512MB.
    OOMKilled Exit Code: 137 Под убит. k8s перезапускает. Новый под опять запускает отчёт. Опять OOMKilled. Restart loop. Кейс называется CrashLoopBackOff, и выглядит он в логах примерно так:
    pod/php-app-7d9f8c-xk2p9 0/1 CrashLoopBackOff 14 47m Четырнадцать рестартов за 47 минут. k8s упорно пытался поднять под, PHP упорно пытался сожрать память, OOMKiller упорно его убивал. Это было похоже на зомби-апокалипсис в миниатюре.
    Решение: разделение воркеров
    Мы разделили PHP-FPM на два пула:
    ; /etc/php-fpm.d/www.conf — общий пул [www] pm = dynamic pm.max_children = 20 pm.max_requests = 500 ; /etc/php-fpm.d/heavy.conf — пул для тяжёлых операций [heavy] pm = ondemand pm.max_children = 4 pm.max_requests = 50 pm.process_idle_timeout = 10s И создали отдельный Deployment для heavy-операций с увеличенными лимитами:
    # heavy-deployment.yaml resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "2Gi" cpu: "2000m" Nginx роутит по prefix:
    location /api/reports/ { fastcgi_pass heavy-php-fpm:9001; } location / { fastcgi_pass www-php-fpm:9000; } Элегантно? Не очень. Работает? Да.
    Инцидент #19: Liveness probe убивает прод
    Это был шедевр. Мы настроили liveness probe — k8s регулярно проверяет, жив ли под, и если нет — убивает и перезапускает:
    livenessProbe: httpGet: path: /health port: 80 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 Endpoint /health возвращал 200 и JSON со статусами всех зависимостей: Redis, MySQL, очередь. Казалось бы — отлично.
    И вот в один прекрасный день MySQL реплика начала лагать (проблема с дисками). Запросы к реплике стали занимать 15-20 секунд. Наш /health endpoint проверял реплику, таймаут probe — 5 секунд. Итог:
    Liveness probe failed: Get "http://10.244.2.5/health": context deadline exceeded k8s решил, что под нездоров. Убил его. Поднял новый. Новый тоже упёрся в лагающую реплику. Тоже убит. В какой-то момент k8s убивал поды быстрее, чем они успевали принять трафик.
    Мы устроили собственноручный DDoS на наш прод с помощью liveness probe. Это надо уметь.
    Решение:
    Разделили liveness и readiness:
    # Liveness: только "жив ли процесс" livenessProbe: httpGet: path: /health/live # Проверяет только PHP-FPM ping port: 80 initialDelaySeconds: 30 periodSeconds: 30 timeoutSeconds: 2 failureThreshold: 5 # Readiness: "готов ли принимать трафик" readinessProbe: httpGet: path: /health/ready # Проверяет все зависимости port: 80 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 // CI4 Controller class HealthController extends BaseController { public function live(): ResponseInterface { // Только базовая проверка процесса return $this->response->setJSON(['status' => 'ok']); } public function ready(): ResponseInterface { $checks = [ 'database' => $this->checkDatabase(), 'redis' => $this->checkRedis(), 'queue' => $this->checkQueue(), ]; $healthy = !in_array(false, $checks, true); return $this->response ->setStatusCode($healthy ? 200 : 503) ->setJSON([ 'status' => $healthy ? 'ready' : 'degraded', 'checks' => $checks, ]); } } Теперь при проблемах с репликой поды переставали получать трафик (readiness failed), но не убивались (liveness — ok). Через 20 минут реплика восстановилась, readiness снова позеленела, трафик вернулся. Без единой 500-й ошибки.
    Инцидент #31: HPA и смерть от масштабирования
    Horizontal Pod Autoscaler — прекрасная вещь. Трафик растёт — поды добавляются. Трафик падает — поды убираются. Автоматически. Без участия человека.
    Кроме случаев, когда всё идёт не так.
    Мы настроили HPA по CPU:
    apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: php-app-hpa spec: minReplicas: 3 maxReplicas: 30 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 Ждём нагрузочного теста. Трафик растёт. HPA видит CPU 85% → добавляет поды. Новые поды стартуют, прогревают OPcache, пока прогреваются — CPU у них 90% → HPA добавляет ещё поды. Те тоже прогреваются → CPU опять высокий → ещё поды.
    Через 3 минуты у нас было 28 подов из максимальных 30. Они все одновременно прогревали OPcache, коннектились к MySQL, коннектились к Redis. MySQL задыхался от 28×20 = 560 новых соединений. Redis cluster не успевал. Прод лёг под весом собственного масштабирования.
    Решение:
    Добавить cooldown и stabilization:
    behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 # Не более 2 подов за раз periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300 Использовать custom metrics (RPS), а не CPU:
    metrics: - type: Pods pods: metric: name: http_requests_per_second target: type: AverageValue averageValue: "300" Добавить PHP-FPM warming в startup probe:
    startupProbe: httpGet: path: /warmup port: 80 failureThreshold: 30 periodSeconds: 5 Endpoint /warmup прогревал OPcache и пул соединений. Пока startupProbe не вернула 200 — под не получал трафик и не учитывался в метриках HPA.
    Просветление
    После 18 месяцев и 47 инцидентов я пришёл к простому выводу: Kubernetes не упрощает жизнь. Он переносит сложность из "как запустить" в "как правильно настроить". И это совершенно другой уровень сложности — более тонкий, более коварный, и требующий понимания системы на глубоком уровне.
    Ключевые правила, которые я вколотил в стену рядом с рабочим столом:
    Никогда image:latest — только иммутабельные теги
    Liveness ≠ Readiness — это разные вещи с разной ценой ошибки
    Resource limits — это не потолок, это граница жизни и смерти пода
    HPA с CPU — ловушка для PHP. Используйте RPS или custom metrics
    Один под не значит один процесс — PHP-FPM это N воркеров
    Chaos engineering — убивайте поды намеренно, пока это не сделает production
    И последнее: k8s — это не серебряная пуля. Это мощный инструмент с огромным количеством движущихся частей. Уважайте его, изучайте, читайте исходники при необходимости. И обязательно найдите коммьюнити — на ithub.uno, на форумах, на конференциях. Потому что некоторые грабли лучше подбирать чужим лбом.
    До следующего CrashLoopBackOff. 🤕
  9. Есть вещи, которые меняют тебя как специалиста. Первый деплой в прод. Первый incident report, который ты пишешь в 3 ночи. И первый раз, когда ты видишь в логах Redis: CLUSTERDOWN Hash slot not served. Вот это последнее — особенное. После такого начинаешь иначе смотреть на жизнь, на архитектуру и на документацию, которую ты "почти дочитал".
    Сегодня расскажу про Redis Cluster в highload-продакшне. Без прикрас, без маркетинговых буклетов. Только боль, инсайты и несколько команд, которые спасли мне карьеру.
    Контекст: зачем вообще Redis Cluster
    К тому моменту мы уже года полтора успешно жили на одном Redis-инстансе с репликой. Всё было хорошо: 50GB данных, ~80,000 ops/sec в пике, latency стабильно под 1ms. Идиллия.
    Потом случился бизнес. Нас купили, влили денег, пользователей стало в пять раз больше. Нагрузка выросла до 380,000 ops/sec. Один Redis задыхался. CPU на инстансе — 94% (Redis однопоточный в плане основного event loop, напоминаю). Latency поползла вверх — 8ms, 15ms, 40ms...
    Решение очевидное: Redis Cluster. Шардирование данных по hash slots (всего 16384 слота) на несколько нод. Я читал документацию. Я смотрел туториалы. Я думал, что готов.
    Я не был готов.
    Первая попытка: наивная
    Поднял кластер из 3 мастеров + 3 реплик. Конфигурация нод:
    redis1 (master) — слоты 0-5460 redis2 (master) — слоты 5461-10922 redis3 (master) — слоты 10923-16383 redis4 (replica) — реплицирует redis1 redis5 (replica) — реплицирует redis2 redis6 (replica) — реплицирует redis3 Всё прекрасно работало на стейджинге. В прод переехали ночью. Первые два часа — тишина и красивые графики.
    Потом началось.
    Наш код активно использовал MGET, MSET и пайплайны. И вот тут — сюрприз из документации, которую я "почти дочитал": в Redis Cluster мульти-ключевые операции работают только если все ключи находятся в одном hash slot.
    CROSSSLOT Keys in request don't hash to the same slot Это сообщение я запомнил навсегда. Потому что половина нашего кода сыпала им как из ведра.
    Погружение в hash tags
    Redis Cluster использует CRC16 от имени ключа для определения слота. Но если в ключе есть фигурные скобки {}, то для вычисления слота используется только содержимое скобок — это называется hash tag.
    # Эти ключи попадут в РАЗНЫЕ слоты: user:1:profile → slot 7638 user:1:settings → slot 2892 user:1:cart → slot 6899 # А эти — в ОДИН, потому что hash tag {user:1}: {user:1}:profile → slot 7638 {user:1}:settings → slot 7638 {user:1}:cart → slot 7638 Казалось бы, просто добавить фигурные скобки и всё. Но у нас было 200+ мест в коде с генерацией ключей. И это был PHP-монолит, переписанный на CI4 модули. Кайф.
    Мы написали специальный класс-обёртку:
    <?php namespace App\Libraries\Cache; class ClusterAwareCacheKey { public static function userScoped(int $userId, string $suffix): string { return sprintf('{user:%d}:%s', $userId, $suffix); } public static function sessionScoped(string $sessionId, string $suffix): string { return sprintf('{session:%s}:%s', $sessionId, $suffix); } public static function globalKey(string $key): string { // Глобальные ключи не группируем — пусть шардируются равномерно return $key; } } И CI4 Cache Driver, который умеет работать с Cluster:
    <?php namespace App\Libraries\Cache; use CodeIgniter\Cache\Handlers\RedisHandler; class RedisClusterHandler extends RedisHandler { protected \RedisCluster $redis; public function initialize(): void { $config = config('Cache'); $this->redis = new \RedisCluster( null, $config->redisClusterNodes, $config->redisTimeout, $config->redisReadTimeout, true, // persistent $config->redisAuth ); $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY); } public function getMultiple(array $keys, mixed $default = null): array { // Группируем ключи по слотам для batch-операций $slotGroups = $this->groupBySlot($keys); $result = []; foreach ($slotGroups as $slotKeys) { $values = $this->redis->mget($slotKeys); foreach ($slotKeys as $i => $key) { $result[$key] = $values[$i] !== false ? $this->unserialize($values[$i]) : $default; } } return $result; } private function groupBySlot(array $keys): array { $groups = []; foreach ($keys as $key) { $slot = $this->calculateSlot($key); $groups[$slot][] = $key; } return array_values($groups); } private function calculateSlot(string $key): int { // Извлекаем hash tag если есть if (preg_match('/\{([^}]+)\}/', $key, $matches)) { $key = $matches[1]; } return crc32($key) & 0x3FFF; // 16384 слота } } Второй уровень ада: failover
    Мы починили CROSSSLOT. Живём. Счастливы. Три недели тишины.
    Потом у нас умер сервер. Не виртуалка, не под — физический сервер. Просто взял и умер в 14:22 в среду. На нём жили redis2 (мастер) и redis5 (реплика redis1, то есть другого мастера).
    Вот тут начался настоящий thriller.
    Redis Cluster должен автоматически делать failover: реплика замечает, что мастер недоступен, объявляет выборы, становится новым мастером. Это работает. Но есть нюанс, который я знал теоретически и совершенно недооценил практически.
    Время failover — от 15 до 30 секунд.
    Пятнадцать секунд, в течение которых слоты 5461-10922 не обслуживаются. При 380,000 ops/sec. Можете себе представить, как выглядел error rate в эти 15 секунд?
    Error rate: 0.1% → 34% → 67% → 89% → 67% → 12% → 0.3% Наш PHP-код в CI4 при ошибке Redis просто падал с exception. Никакого graceful degradation. Никакого fallback на БД. Просто 500-е ответы.
    Вот это был момент просветления.
    Решение: Circuit Breaker для Redis
    Мы реализовали паттерн Circuit Breaker специально для Redis. В CI4 это элегантно делается через Service Container:
    <?php namespace App\Libraries\Cache; class ResilientCacheService { private const FAILURE_THRESHOLD = 5; private const RECOVERY_TIMEOUT = 30; private const HALF_OPEN_MAX_CALLS = 3; private string $state = 'closed'; // closed | open | half-open private int $failureCount = 0; private int $lastFailureTime = 0; private int $halfOpenCalls = 0; public function __construct( private readonly RedisClusterHandler $redis, private readonly \CodeIgniter\Cache\CacheInterface $fallback ) {} public function get(string $key, mixed $default = null): mixed { if ($this->isOpen()) { return $this->fallback->get($key, $default); } try { $value = $this->redis->get($key); $this->onSuccess(); return $value ?? $default; } catch (\Throwable $e) { $this->onFailure($e); return $this->fallback->get($key, $default); } } public function set(string $key, mixed $value, int $ttl = 0): bool { if ($this->isOpen()) { return $this->fallback->set($key, $value, $ttl); } try { $result = $this->redis->save($key, $value, $ttl); $this->onSuccess(); return $result; } catch (\Throwable $e) { $this->onFailure($e); return $this->fallback->set($key, $value, $ttl); } } private function isOpen(): bool { if ($this->state === 'open') { if (time() - $this->lastFailureTime > self::RECOVERY_TIMEOUT) { $this->state = 'half-open'; $this->halfOpenCalls = 0; return false; } return true; } return false; } private function onSuccess(): void { if ($this->state === 'half-open') { $this->halfOpenCalls++; if ($this->halfOpenCalls >= self::HALF_OPEN_MAX_CALLS) { $this->state = 'closed'; $this->failureCount = 0; } } elseif ($this->state === 'closed') { $this->failureCount = max(0, $this->failureCount - 1); } } private function onFailure(\Throwable $e): void { $this->lastFailureTime = time(); $this->failureCount++; if ($this->state === 'half-open' || $this->failureCount >= self::FAILURE_THRESHOLD) { $this->state = 'open'; log_message('critical', 'Redis Circuit Breaker OPEN: ' . $e->getMessage()); } } } Регистрируем в Services:
    // app/Config/Services.php public static function resilientCache(bool $getShared = true): ResilientCacheService { if ($getShared) { return static::getSharedInstance('resilientCache'); } return new ResilientCacheService( new RedisClusterHandler(), new FileHandler() // filesystem как fallback ); } Теперь при падении Redis кластера приложение продолжало работать — медленнее, с filesystem кэшем, но без 500-х ошибок. Error rate во время следующего (учинённого намеренно!) failover теста: 0.8%. Против 89% до.
    Третий круг: Memory fragmentation
    Это тихий убийца. Redis работает, данные записываются и читаются, но потихоньку mem_fragmentation_ratio ползёт вверх.
    redis-cli --cluster call all-nodes INFO memory | grep mem_fragmentation_ratio Через полгода работы я увидел значение 2.47. Норма — от 1.0 до 1.5. Значение 2.47 означает, что Redis использует в 2.47 раза больше памяти, чем реально нужно для данных. На наших 80GB инстансах это ~50GB впустую.
    Причина — интенсивные операции записи/удаления ключей с разным TTL, что приводит к фрагментации heap у jemalloc.
    Решение — Active defragmentation:
    redis-cli CONFIG SET activedefrag yes redis-cli CONFIG SET active-defrag-ignore-bytes 100mb redis-cli CONFIG SET active-defrag-threshold-lower 10 redis-cli CONFIG SET active-defrag-threshold-upper 100 redis-cli CONFIG SET active-defrag-cycle-min 25 redis-cli CONFIG SET active-defrag-cycle-max 75 Через сутки mem_fragmentation_ratio опустился до 1.18. Мы вернули ~45GB памяти. Без перезапуска. В прод.
    Итог и выводы
    Redis Cluster — мощнейший инструмент, но он требует понимания на уровне "читал исходники, а не только README". Мои главные уроки:
    Hash tags планируйте заранее, не когда уже CROSSSLOT в логах
    Failover длится 15-30 секунд — ваш код должен это переживать
    Circuit Breaker — обязательный паттерн, не опциональный
    Мониторьте mem_fragmentation_ratio — без этого потеряете память
    Multi-key операции — только в одном слоте — это не баг, это дизайн
    Latency в cluster выше, чем в standalone — заложите это в SLA
    Если вы тоже занимаетесь highload и Redis — заходите на ithub.uno, там есть живые обсуждения именно таких кейсов. Без теории ради теории, только production experience.
    Удачи с кластерами. И пусть ваши слоты всегда будут served. 🔴✅
  10. Пятница, 23:47. Прод лежит. Я в ванной. Классика.

    Привет, коллеги по несчастью. Меня зовут Максим, я продуктовый DevOps с десятью годами шрамов на психике и подгоревшим нервным окончанием там, где у нормальных людей находится чувство покоя. Сегодня я расскажу вам историю, которую в каждой IT-компании мира знают наизусть, но всё равно каждый раз проживают как первый раз. Историю о том, как прод падает именно тогда, когда тебе это меньше всего нужно.
    Итак. Декабрь. Пятница. Мы только что задеплоили «маленький hotfix» — ну там, буквально пять строчек, ничего серьёзного. Я уже мысленно дома, уже открываю холодильник, уже слышу шипение открываемой банки. И тут — дзынь. Алерт в Telegram. Потом ещё один. Потом ещё пять. Потом просто поток, как будто кто-то открыл кран с тревогами.

    🔴 CRITICAL: Response time > 30s
    🔴 CRITICAL: Error rate 78%
    🔴 CRITICAL: Database connections exhausted
    🔴 CRITICAL: Redis timeout
    🔴 CRITICAL: Payment service unreachable
    Пять алертов за 40 секунд. Это рекорд, кстати. Я горжусь.
    Анатомия катастрофы
    Теперь давайте по-серьёзному, потому что случай был действительно интересный с технической точки зрения, и на ithub.uno такие постморtem-разборы ценятся.
    Итак, что мы имели на тот момент:
    Стек: PHP 8.2, CodeIgniter 4.5, MySQL 8.0 (кластер primary + 2 replica), Redis 7.0 Cluster, Nginx, всё это добро в k8s на трёх нодах
    Трафик: ~3500 RPS в пике, средний — около 800 RPS
    Hotfix: изменили одну строчку в модели, которая отвечала за выборку пользовательских настроек
    Что могло пойти не так? Всё. Абсолютно всё.
    Первое, что я сделал — зашёл на Grafana. Там картина маслом: RPS упал с 800 до 120, латентность взлетела с 80ms до 28 секунд (ДВАДЦАТИ ВОСЬМИ, Карл!), количество активных connections к MySQL упёрлось в потолок — 500/500, CPU на всех подах — 95%+.
    Классический симптом. Я такое уже видел. Это называется «connection pool exhaustion» в сочетании с «slow query лавиной». Но причина была нетривиальной.
    Копаем
    Первым делом — slow query log на MySQL:
    SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 0.1; Через 30 секунд смотрю лог и вижу:
    SELECT u.*, us.*, p.*, pr.*, c.* FROM users u LEFT JOIN user_settings us ON us.user_id = u.id LEFT JOIN profiles p ON p.user_id = u.id LEFT JOIN preferences pr ON pr.user_id = u.id LEFT JOIN cart c ON c.user_id = u.id AND c.status = 'active' WHERE u.id = 12345 Запрос сам по себе несложный. Выполняется за 0.003 секунды. Но... EXPLAIN показывает Using temporary; Using filesort на таблице cart. И вот тут начинается детективная история.
    Дело в том, что за час до деплоя наш аналитик (земля ему пухом) запустил миграцию, которая добавила в таблицу cart новое поле meta_json TEXT. При этом индекс idx_cart_user_status не пересоздавался. Он просто... перестал эффективно работать после изменения статистики таблицы. MySQL решил, что full scan выгоднее. При этом таблица cart содержала 47 миллионов строк.
    А наш «маленький hotfix»? Он убрал кэширование этого запроса. Буквально одну строку:
    // Было: return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId)); // Стало (hotfix убрал кэш "для дебага"): return $this->buildUserData($userId); И вот оно. Идеальный шторм. Медленный запрос × отсутствие кэша × высокий трафик = прод в нокауте.
    Решение в боевых условиях
    Времени на красивые решения нет. Алгоритм действий:
    Шаг 1: Feature flag
    У нас есть система feature flags на Redis. Мгновенно включаю maintenance mode для новых пользователей, пропуская залогиненных:
    // CI4 Filter class MaintenanceFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { if (cache()->get('maintenance_mode') && !auth()->check()) { return redirect()->to('/maintenance'); } } } Трафик упал на 40%. Дышим.
    Шаг 2: Откат
    kubectl rollout undo deployment/app --to-revision=15 Это не решение проблемы с индексом, но снимает острую боль — кэш вернулся, запросы снова летают.
    Шаг 3: Экстренное создание индекса
    Пока прод восстанавливается, параллельно:
    -- На реплике сначала тестируем CREATE INDEX idx_cart_user_status_new ON cart(user_id, status) INCLUDE (id, created_at, meta_json) ALGORITHM=INPLACE, LOCK=NONE; ALGORITHM=INPLACE, LOCK=NONE — это не просто красивые слова. На таблице в 47 миллионов строк это разница между «индекс создаётся 4 минуты без локов» и «БД заблокирована на 25 минут, прощайте».
    Через 6 минут индекс на реплике. Тестируем — запрос летает за 1.2ms. Прогоняем на primary. Ещё 4 минуты. Готово.
    Шаг 4: Хотфикс хотфикса
    return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId)); Да, просто вернули строку обратно. Иногда лучшее решение — отмотать назад.
    Постмортем и уроки
    Через два дня я провёл постмортем. Вот ключевые выводы, которые я теперь вколачиваю в голову каждому новому разработчику:
    1. Никогда не убирайте кэш "для дебага" в прод Это как снять шлем "чтобы лучше видеть". Дебажьте на стейджинге. Там специально и создано это место.
    2. Любая миграция схемы БД требует аудита индексов Мы внедрили правило: к каждому PR с миграцией прилагается EXPLAIN до и после на production-like данных (у нас есть анонимизированный дамп).
    3. Алерты должны быть actionable Пять одновременных алертов — это не пять проблем. Это одна проблема с пятью симптомами. Мы перенастроили alertmanager с группировкой и подавлением дублей.
    4. Connection pool — ваш первый друг и первый враг В CI4 мы теперь явно конфигурируем пул:
    // app/Config/Database.php public array $default = [ 'DBDriver' => 'MySQLi', 'hostname' => env('DB_HOST'), 'pconnect' => false, // persistent connections OFF в highload! 'DBDebug' => false, // ... ]; Persistent connections в highload — это бомба замедленного действия. Отключайте.
    5. Chaos engineering — не роскошь, а необходимость После этого инцидента мы раз в квартал намеренно убиваем случайный под в прод-кластере. Да, в проде. Нет, это не безумие — это единственный способ убедиться, что система действительно resilient.
    Финал
    В 01:23 прод поднялся полностью. Показатели вернулись к норме. Я наконец открыл ту банку. Она была тёплой.
    Но знаете что? Этот инцидент стоил нам примерно $4,000 потерянной выручки и несколько седых волос. Зато мы получили бесценный опыт и полностью переработали процесс деплоя. Теперь у нас есть автоматическая проверка slow queries перед каждым деплоем, обязательный review индексов при миграциях и — самое главное — правило: никаких деплоев в пятницу после 18:00.
    Это правило нарушают только те, кто ещё не прожил свою первую пятничную аварию. После первой — никогда.
  11. Есть команды, которые надо вводить с холодной головой и полным осознанием последствий. rm -rf / — очевидный пример. Но среди разработчиков и DevOps есть своя версия этой русской рулетки — git push --force.
    Я работал тогда в продуктовой компании — делали SaaS-платформу для управления проектами. Небольшая команда, человек двенадцать, хороший продукт, живые клиенты, нормальный процесс разработки. Мы использовали GitHub, feature-ветки, pull requests, code review — всё как у взрослых.
    Репозиторий был защищён: ветка main заблокирована от прямых пушей, обязательный PR с одобрением от двух ревьюеров. Всё серьёзно. Но была одна маленькая деталь, которую мы упустили: ветка develop — наша основная рабочая ветка, куда мерджились все фичи перед выходом в main — была защищена только от обычного push. Не от push --force.
    Это упущение жило полтора года. До того утра.
    Антон — старший разработчик, умный, опытный, пять лет в команде — в среду утром пришёл на работу в слегка раздражённом состоянии. Накануне вечером работал из дома, что-то переделывал в своей feature-ветке, несколько раз rebase'ил на свежий develop, история коммитов запуталась. Он хотел сделать красиво — squash коммиты, причесать историю, залить обратно.
    Он сделал git rebase -i HEAD~15. Причесал. Сделал git push origin feature/my-branch. Получил ошибку — ветка расходилась с remote из-за rebase. Это нормально. Написал git push --force origin feature/my-branch.
    И тут автодополнение в его терминале сыграло злую шутку.
    Он не заметил. Нажал Enter. Терминал отработал молниеносно.
    git push --force origin develop
    Ветка develop теперь содержала только его пятнадцать причёсанных коммитов. Полгода работы двенадцати разработчиков — восемьсот с лишним коммитов, десятки смёрджённых feature-веток — перестали существовать в remote.
    Первым заметил Кирилл — он в это время делал git pull origin develop и увидел странное сообщение о том, что его локальная ветка «впереди» remote на 847 коммитов. Написал в слак: «ребят, что-то странное с develop».
    Я в это время пил кофе. Зашёл в GitHub. Посмотрел на историю develop. Увидел пятнадцать коммитов за подписью Антона.
    Поставил кружку. Написал в общий чат: «стоп, никто не делайте git pull и не трогайте develop».
    Антон в этот момент только что дошёл до своего рабочего места — потому что через тридцать секунд в чате появилось его сообщение: «ребята, кажется, я что-то сломал».
    Восстановление заняло примерно двадцать минут и было почти триумфальным.
    Git — распределённая система. Это означает, что у каждого разработчика в локальном репозитории была актуальная копия develop на момент последнего git fetch. У Кирилла — который как раз делал git pull в момент инцидента — локальная ветка содержала все 847 коммитов.
    # На машине Кирилла: git log origin/develop..develop --oneline | wc -l # 847 # Пришлось временно снять все защиты с ветки в GitHub # и залить обратно правильную историю git push --force origin develop Через двадцать минут develop снова содержал все 847 коммитов. Все локальные репозитории сделали git fetch. Ничего не потерялось.
    Антон написал мне в личку: «Максим, я понимаю что случилось. Хочу объяснить». Мы созвонились. Он рассказал про автодополнение, про то, как перепутал ветки. Голос у него был как у человека, который ещё не понял, уволен он или нет.
    Я сказал ему три вещи. Первое: никто ничего не потерял, всё восстановлено, все живы. Второе: это системная ошибка, не личная — защита ветки была настроена неправильно, и это моя ответственность как DevOps. Третье: мы немедленно это исправим.
    В тот же день мы включили защиту от force push на develop. Добавили в onboarding раздел «Никогда не используй git push --force без явного указания feature-ветки». Везде заменили --force на --force-with-lease — он проверяет что remote не изменился с момента вашего последнего fetch, и отказывает если кто-то уже запушил.
    Антон остался в команде. Ещё через полгода стал тимлидом.
    Самый важный вывод из этой истории не технический. Технический прост: защищайте все ветки от force push, используйте --force-with-lease, настройте алиасы.
    Важный вывод другой: реакция команды на инцидент определяет культуру команды. Можно было устроить показательную порку. Вместо этого мы исправили систему — и получили разработчика, который с тех пор параноидально аккуратен с git и научил этому ещё троих новых.
    Ошибки надо исправлять в системе. Не в людях.
    А git push --force без явного указания ветки — это как ходить с заряженным пистолетом без предохранителя. Рано или поздно что-то нажмётся не то.
  12. Эта история не о катастрофе — она о тех моментах, когда система работает именно так, как ты настроил, но совсем не так, как ты хотел.
    Мы запускали новый высоконагруженный сервис — рекомендательный движок. Перед запуском нужно было провести нагрузочный тест: убедиться что сервис держит планируемые 500 rps.
    Я накануне настроил nginx с rate limiting: 100 rps с одного IP, burst 200. Это защита от DDoS. Всё правильно, всё продуманно.
    На следующий день Вася из QA запустил нагрузочный тест с помощью k6 с офисного сервера. Офисный сервер имеет один внешний IP-адрес.
    Тест начался. Вася смотрел на метрики k6 — ошибки сыпались сразу: 503 Service Unavailable. Он написал мне: «Максим, сервис не выдерживает нагрузку, уже при 150 rps всё падает».
    Я зашёл в логи nginx. Увидел километры строк:
    [error] limiting requests, excess: 102.840 by zone "api_limit" Наш офисный сервер, с которого шёл тест, получил rate limit — и все 500 запросов в секунду, начиная со 101-го получали 503. Сервис при этом работал абсолютно нормально — его никто по-настоящему не нагружал.
    Я минуту просто смотрел в экран. Потом засмеялся — впервые за долгое время по-настоящему засмеялся в рабочее время.
    Мы добавили IP офисного нагрузочного сервера в whitelist. Провели нормальный тест. Сервис держал 800 rps без проблем. Все были довольны.
    Но главный урок этой истории не технический. Главный урок — это то, что перед тем как диагностировать проблему, надо убедиться что смотришь в нужное место. Вася видел ошибки и думал что сервис не справляется. Я видел ошибки и знал что это rate limit. Разница — в том, что смотрели на разные части системы.
    Коммуникация между QA и DevOps о том, с каких IP будет идти нагрузочный тест — теперь обязательный пункт в чеклисте перед любым нагрузочным тестированием. На ithub.uno есть хорошая статья про то, как правильно организовать этот процесс — рекомендую.
  13. Есть комментарии в коде, которые являются либо руководством к действию, либо тихим криком о помощи. Комментарии с «TODO» — это обещания, которые редко выполняются.
    Денис, бэкенд-разработчик с которым я пересекался на одном проекте, рассказал мне эту историю как предупреждение. История произошла на его предыдущем месте работы.
    Был endpoint /api/debug/users — он возвращал список всех пользователей с email, именами и датами регистрации. Без всякой авторизации. Создан во время разработки, чтобы фронтенд-разработчик мог быстро проверять данные.
    В коде стоял комментарий:
    # TODO: УБРАТЬ ПЕРЕД ДЕПЛОЕМ В ПРОД!!! # Только для разработки, не для продакшна @app.route('/api/debug/users') def debug_users(): users = User.query.all() return jsonify([u.to_dict() for u in users]) Три восклицательных знака. Заглавные буквы. Всё честно.
    Разработчик сделал деплой. TODO не убрал — дедлайн, «потом». Потом не наступило.
    Endpoint жил в проде тихо и незаметно два года. Через два года при проведении security audit penetration tester обнаружил его за тридцать секунд работы с Burp Suite. База данных на тот момент содержала информацию о 340,000 пользователей. Всё это время данные были доступны любому, кто знал URL.
    Конца истории Денис не знает — его к тому времени уже не было в той компании. Знает только что был большой скандал и несколько уволенных.
    В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны TODO.*прод, TODO.*prod, REMOVE BEFORE, DEBUG ONLY. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.
  14. Самый коварный вид отказа систем — когда всё выглядит как работает, но не работает. Бэкап-система в этом смысле особенно опасна: вы никогда не проверяете её по-настоящему, пока не нужно восстановиться.
    Мой коллега Паша — педантичный и аккуратный инженер — настраивал резервное копирование для CRM-системы. Написал скрипт на bash: каждую ночь mysqldump, gzip, upload на S3. Всё логируется. При успехе в Slack приходит уведомление «Backup completed: 2.3GB».
    Скрипт работал восемь месяцев. Каждую ночь в Slack приходило: «Backup completed: 2.3GB». Красиво. Надёжно. Профессионально.
    Потом упал продакшн-сервер. Физически — сгорел диск. Паша пошёл восстанавливаться из бэкапа.
    Достал последний архив с S3. Распаковал. Открыл. Внутри был SQL-дамп базы данных information_schema. Не crm_production. А information_schema — системная база MySQL с метаданными: список таблиц, колонок, индексов. Никаких реальных данных.
    Паша открыл скрипт. Нашёл строку:
    mysqldump -u backup -p$PASS $DATABASE | gzip > backup.sql.gz Переменная $DATABASE. Посмотрел где она определяется:
    DATABASE=${DB_NAME:-information_schema} Переменная DB_NAME должна была передаваться через environment. Но при настройке cron-job он забыл добавить эту переменную. Cron запускал скрипт без переменной — скрипт использовал дефолтное значение information_schema — дамп создавался, весил 2.3GB (много метаданных за восемь месяцев), улетал на S3. Полное видимое благополучие.
    Восемь месяцев данных CRM были потеряны безвозвратно.
    После этого у нас появилось правило: бэкап-скрипт обязан проверять содержимое архива после создания. Не просто «файл существует» — а «внутри есть CREATE TABLE для нужных таблиц, INSERT строк больше нуля». И раз в месяц — тестовое восстановление в отдельную базу с проверкой количества записей.
    Если вы сейчас читаете это и у вас есть бэкап-система, которую вы ни разу не проверяли восстановлением — сделайте это сегодня. Не завтра. Сегодня.
  15. Это история о том, почему production дашборды должны быть красного цвета, а staging — зелёного. И почему вкладки браузера надо называть.
    Пятница. Последний рабочий день перед длинными майскими. Я чистил старый staging кластер Kubernetes — там скопился мусор за несколько месяцев. Namespace за namespace, удаляю deployment'ы, PVC, сервисы. Всё идёт хорошо. В соседней вкладке открыт продакшн дашборд — краем глаза поглядываю на метрики.
    Не помню точно как это случилось — кажется, я переключился между вкладками не обратив внимания. Увидел список namespace в дашборде. Увидел namespace с именем legacy-services — его я и собирался удалить в staging. Нажал Delete. Подтвердил (да, там был confirm dialog — и я его подтвердил, потому что только что двадцать раз делал то же самое).
    Через тридцать секунд в слаке началось:
    #alerts: CRITICAL: payment-service down #alerts: CRITICAL: auth-service down #alerts: CRITICAL: user-service down
    Я перевёл взгляд на адресную строку. Там был URL продакшн кластера.
    legacy-services в продакшне — это было название, которое выбрал кто-то три года назад. В нём жило восемь критических сервисов.
    Дальше начался самый быстрый деплой в моей карьере. ArgoCD хранил все манифесты, кластер был жив — я удалил только namespace с содержимым, но не сам кластер. Запустил sync для всех приложений через ArgoCD.
    Kubernetes начал поднимать поды. Stateless сервисы встали за две-три минуты. Payment-service — за пять, потому что у него была initContainer-миграция. Auth-service — за четыре.
    Общее время даунтайма: семь минут двадцать секунд.
    Что изменилось: все дашборды теперь имеют цветовую маркировку — продакшн красным, staging жёлтым, dev зелёным. Это реализовано через плагин kubie — при переключении в prod-контекст терминал меняет цвет prompt на красный. И главное — удаление namespace теперь требует ввода имени namespace вручную. Никаких кнопок «Delete» без явного подтверждения текстом.
  16. Есть баги, которые тихо портят данные и обнаруживаются через месяцы. А есть такие, которые существуют годами, всех раздражают, но никто не может их воспроизвести — потому что они зависят от часового пояса.
    Та система занималась планированием задач. Пользователь создаёт задачу на «завтра в 10:00» — система её выполняет в нужное время. Казалось бы, простейшая логика.
    Жалобы начались в марте. Клиенты из Екатеринбурга писали: «задачи выполняются не вовремя, иногда с опозданием на два часа». Я смотрел на логи — по нашим данным задачи выполнялись точно в срок. Поддержка закрывала тикеты: «не воспроизводится». Клиенты злились.
    Я взялся за это в начале апреля. Провёл два дня, читая код планировщика. Нашёл несколько мест где работа с временем выглядела подозрительно, отрефакторил — баг остался. Третий день — ревью всех datetime операций в системе. Ничего.
    На четвёртый день Игорь из поддержки сказал фразу, которая сразу всё объяснила:
    — Слушай, я заметил, что жалуются только пользователи из Екатеринбурга, Омска и вот этих городов...
    Он показал список. Я посмотрел на карту. Это были города в часовых поясах UTC+5 и восточнее.
    Зашёл на продакшн-сервер. Ввёл date. Увидел: Thu Apr 4 14:23:11 UTC 2024. Сервер работал в UTC. Это нормально. Проблема была в другом: когда пользователь из Екатеринбурга (UTC+5) создавал задачу на «завтра 10:00» через браузер, фронтенд отправлял строку 2024-04-05 10:00:00 — без timezone offset. Backend принимал её как UTC и сохранял в базу. Потом выполнял задачу в 10:00 UTC — что для пользователя в UTC+5 было 15:00 по местному времени.
    Пять часов разницы. Это проявилось только когда начали приходить пользователи из регионов — потому что первые клиенты и команда разработки были из Москвы (UTC+3), и у них разница была три часа, а не пять.
    Фикс: фронтенд теперь всегда отправляет datetime с timezone offset. Backend парсит только aware datetime объекты. На Хабре есть статья «Никогда не используйте naive datetime» — я её знал. Просто не я писал тот фрагмент. Теперь у нас есть линтер-правило, которое не даёт мержить код с naive datetime объектами.
  17. Автоматизация — это прекрасно. Автоматизация без ограничений — это финансовая катастрофа. Я усвоил этот урок очень конкретным способом: через счёт от AWS на $23,000 за четыре ночных часа.
    Мы настраивали горизонтальный автоскейлинг для API сервиса. Логика была простая: если CPU выше 70% — добавляем инстансы. Работало замечательно в рабочие часы. Что мы не предусмотрели — верхний лимит. Мы установили minReplicas: 2 и забыли про maxReplicas. В Kubernetes HPA это означает «масштабируй сколько нужно».
    В 2:47 ночи наш сервис получил DDoS-атаку. Не особо сложную — просто поток запросов, каждый из которых немного нагружал CPU. Автоскейлер увидел рост CPU и начал добавлять поды. Поды поднимались, нагрузка на каждый снижалась — но общий поток атаки оставался постоянным. Автоскейлер видел всё ещё высокую нагрузку и добавлял ещё. И ещё.
    В 4:15 у нас работало 847 инстансов одного сервиса. Нода-группа в AWS автоматически масштабировала EC2 — тоже без ограничений. Именно в этот момент сработал billing alert и разбудил меня.
    Я зашёл в AWS console полусонный. Увидел цифру. Проснулся мгновенно.
    Мы остановили атаку через WAF (пришлось поднять и настроить с нуля, потому что «руки не доходили» раньше — за десять минут). Потом убили лишние инстансы. Написали в AWS поддержку — они вернули около $18,000 как «goodwill credit», потому что это был явно аномальный spike. $5,000 мы всё же заплатили.
    Что изменили: maxReplicas в каждом HPA, budget alerts с автоматическим отключением при превышении, WAF с базовыми rate-limit правилами — теперь это первое, что поднимается для нового сервиса. И чеклист «Перед включением автоскейлинга», который мы опубликовали в нашей вики и в комьюнити на ithub.uno.
  18. Я долго не понимал, почему Kubernetes такой педантичный. Зачем все эти liveness probes, resource limits, PodDisruptionBudget — когда можно просто запустить контейнер и пусть работает? Потом был один день, который изменил моё отношение радикально.
    Мы деплоили крупное обновление — новая версия API с переработанной системой авторизации. Дата релиза была согласована с бизнесом, пресс-релиз готов, маркетинг ждёт. Всё тщательно проверено на стейджинге. Я жму deploy.
    Kubernetes начинает rolling update. Первые поды поднимаются — и тут Kubernetes останавливает деплой. Просто стоп. Ни один новый под не создаётся, старые не удаляются.
    Открываю kubectl describe pod — там написано: Readiness probe failed. Злюсь. Открываю логи пода. Вижу ошибку подключения к базе данных. Думаю: ну и что, это временная ошибка при старте, он бы сам восстановился. Хочу вручную форсировать деплой.
    Но что-то заставляет меня сначала проверить само соединение с базой. Открываю dashboard PostgreSQL — и вижу, что на новой версии приложения миграция схемы прошла неправильно. Один из индексов создался с ошибкой, из-за чего конкретный запрос в /api/v2/auth/check — тот самый, который проверяет readiness probe — возвращал 500.
    Если бы Kubernetes не остановил деплой, то старые поды с рабочей авторизацией были бы убиты, а новые — со сломанной — встали бы вместо них. Все пользователи получили бы 500 при попытке войти. Прямо в день анонса.
    Kubernetes оказался умнее меня. Его педантичность — которая меня так раздражала — спасла релиз.
    Мы откатили миграцию, исправили скрипт, прогнали ещё раз на стейджинге, задержали деплой на два часа. Бизнес поворчал — потом сказал спасибо, когда я объяснил альтернативу.
    С того дня я стал большим фанатом readiness probes. Не просто /healthz с ответом 200 — а настоящая проверка: соединение с базой, доступность зависимостей, корректность конфигурации.
  19. Это история о том, как можно делать всё правильно — и всё равно облажаться. Потому что правильные действия, направленные не туда — это хуже бездействия.
    Два года назад мы запускали новый микросервис — агрегатор данных для аналитики. Я настроил мониторинг: Prometheus, Grafana, alertmanager, всё по классике. Дашборд выглядел прекрасно. Зелёный. Живой. Метрики бежали в реальном времени.
    Через неделю аналитики начали жаловаться: данные в отчётах иногда выглядят странно, какие-то пропуски. Я смотрел на дашборд — сервис работает, ошибок нет, очередь обрабатывается.
    Ещё через неделю жалобы участились. Я снова смотрел на мониторинг. Снова — всё хорошо. Начал думать, что проблема в данных источника.
    На четырнадцатый день Саша из аналитики подошла ко мне с конкретным примером: вот событие, которое должно было попасть в базу вчера в 14:32 — его нет. Вот ещё пять таких событий за последние две недели.
    Я зашёл непосредственно на сервер, посмотрел логи — и увидел сотни ошибок коннекта к базе данных. Каждую минуту. Все последние две недели.
    Но мониторинг показывал зелёный!
    Через десять минут я нашёл причину. При настройке мониторинга я указал IP-адрес сервера вручную. Потом — за день до запуска — инфраструктурная команда переехала на новые машины и IP поменялся. Я обновил конфиг сервиса, но забыл обновить конфиг Prometheus. Prometheus две недели радостно скрейпил метрики другого сервера, которому достался старый IP.
    Все эти две недели я смотрел на графики совершенно нормально работающего чужого сервера. Пока наш тихо терял данные.
    Пропущенные события восстановить не удалось. После этого я перешёл на service discovery в Prometheus — никаких статических IP. Только DNS-имена или автоматическое обнаружение. И добавил тест: alertmanager должен прислать тестовый алерт при старте — чтобы убедиться, что нотификации реально доходят.
  20. Есть категория менеджеров, которые искренне верят, что перезагрузка решает все проблемы. Эту веру они несут через годы опыта работы с Windows на домашнем компьютере. Столкновение этой веры с реальностью продакшн-сервера — зрелище одновременно трагическое и поучительное.
    Эту историю рассказал мне Антон — DevOps в среднем онлайн-ретейлере — в баре, после второго бокала, с видом человека, который прошёл терапию, но ещё не полностью.
    Была пятница, около шести вечера. Их основной сервер начал подтормаживать — latency выросла раза в три. Антон уже нашёл проблему: утечка соединений в пуле базы данных, накопившаяся за неделю. Требовалось примерно двадцать минут на аккуратный фикс без рестарта.
    Тут появился продакт-менеджер Геннадий Витальевич.
    — Антон, у нас тормозит. Долго ещё? У меня через час встреча с клиентом.
    — Геннадий Витальевич, я нашёл проблему, фикс займёт минут двадцать, перезагрузка не нужна—
    — Просто перезагрузите сервер. Всегда помогает.
    — Нет, правда, в данном случае лучше не надо, потому что после перезагрузки будет несколько минут простоя—
    — Антон. Перезагрузите. Сервер.
    Антон перезагрузил сервер.
    Что он не знал — и что Геннадий Витальевич знать не мог — так это то, что накануне ночью обновился GRUB через автоматические обновления Ubuntu. Обновление прошло нормально, но файл конфигурации GRUB получил неверный UUID для корневого раздела. Система прекрасно работала — до первой перезагрузки.
    Сервер ушёл на перезагрузку. И не вернулся.
    Антон пять минут смотрел на консоль Hetzner с ошибкой Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0). Потом ещё пять минут просто сидел, не двигаясь.
    Дальнейшее — двухчасовая спасательная операция через rescue mode, ручное восстановление GRUB, три созвона с поддержкой хостинга и один звонок от директора с вопросом «что происходит». Геннадий Витальевич на встречу с клиентом опоздал на час пятнадцать.
    После этого в их компании появилось официальное правило: любая перезагрузка продакшн-сервера должна быть согласована с дежурным инженером, который проверяет чеклист из восьми пунктов. Геннадий Витальевич правило подписал. Говорят, без энтузиазма.
  21. Эту историю я долго не хотел рассказывать. Не потому что она страшная — потому что она embarrassing. Но потом я прочитал пост на ithub.uno о том, что культура безопасности строится на открытости, а не на замалчивании. И решился.
    Было это в 2019-м. Я настраивал GitLab CI для небольшого проекта — сервис рассылки уведомлений. В процессе отладки пайплайна мне нужен был быстрый тест. Я создал файл .env.test с заглушками: DB_PASSWORD=test123, API_KEY=dummy_key_for_testing, SMTP_PASSWORD=test123. Потом написал .gitlab-ci.yml, который деплоил приложение, и указал в нём cp .env.test .env — чтобы для CI это работало.
    Commit. Push. Пайплайн прошёл. Всё работает.
    Я написал нормальный продакшн конфиг, залил через GitLab Secrets как положено — и на этом моя работа с тем проектом закончилась. Меня перевели на другой.
    Проект жил своей жизнью. Рассылки уходили. Никто не проверял, откуда именно берутся credentials.
    Через три года я вернулся на тот проект для аудита. Открыл конфиг на сервере. Увидел DB_PASSWORD=test123. Мне понадобилось секунд тридцать, чтобы понять что произошло.
    Оказывается, команда cp .env.test .env в пайплайне выполнялась после загрузки GitLab Secrets и перезаписывала их. Три года подряд, при каждом деплое, на сервер улетал файл с паролем test123.
    Самое парадоксальное: база данных была доступна только изнутри приватной сети, поэтому этот пароль де-факто ни на что не влиял. Но это — счастливое стечение обстоятельств, а не правильная архитектура.
    Я провёл полный security audit. Нашёл ещё две похожих проблемы — других инженеров, таких же «временных» решений, ставших постоянными. Написал документ «Как мы храним секреты» и внедрил pre-commit hook, который ищет в коде паттерны типа password=test, key=dummy, secret=123.
    Но test123 в том конфиге я помню с фотографической точностью.
    Три восклицательных знака. Заглавные буквы. Всё честно.
    Разработчик сделал деплой. TODO не убрал — дедлайн, «потом». Потом не наступило.
    Endpoint жил в проде тихо и незаметно два года. Через два года при проведении security audit penetration tester обнаружил его за тридцать секунд работы с Burp Suite. База данных на тот момент содержала информацию о 340,000 пользователей. Всё это время данные были доступны любому, кто знал URL.
    Конца истории Денис не знает — его к тому времени уже не было в той компании. Знает только что был большой скандал и несколько уволенных.
    В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны TODO.*прод, TODO.*prod, REMOVE BEFORE, DEBUG ONLY. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.
  22. В этой индустрии есть негласный закон: если что-то может сломаться в самый неподходящий момент — оно сломается именно тогда. Новогодняя ночь — идеальный момент для проверки этого закона.
    Я работал в финтех-компании. Мы делали платёжный шлюз. Нагрузка в новый год — одна из пиковых: все переводят деньги, покупают подарки в последний момент, пьют шампанское и одновременно пытаются провести транзакцию.
    31 декабря, примерно в 22:00 я сидел у родителей. Оливье, телевизор, ощущение что ты наконец человек, а не придаток к ноутбуку. Дежурство официально было у Димы. Я был «вторым уровнем».
    В 23:58 мне позвонил Дима. Голос у него был такой, что я сразу встал из-за стола и вышел в коридор.
    — Макс, у нас лежит. Всё. Payment gateway не отвечает. Метрики нормальные, сервисы запущены, но транзакции не проходят.
    Я открыл ноутбук прямо в коридоре, на тумбочке с телефонным аппаратом эпохи СССР. Зашёл в Grafana — всё зелёное. CPU нормальный, память нормальная, сетевой трафик... стоп. Входящий — есть. Исходящий — ноль. Абсолютный ноль. Сервисы запущены, слушают порты, принимают соединения — но ничего не отправляют в ответ.
    В это время в телевизоре начали бить куранты. Моя мама заглянула в коридор с бокалом шампанского. Я сделал жест «одну минуту» — что в нашей профессии означает «от тридцати минут до нескольких часов».
    Нашёл проблему через двадцать две минуты нового года. Оказалось, в 23:55 сработал cron-job, который запускался раз в год 31 декабря для «очистки годовых логов». Скрипт удалял log-файлы старше 365 дней — разумная идея. Но через glob-паттерн он также захватывал конфигурационный файл SSL-сертификатов. Сертификаты физически никуда не делись, но конфиг, который указывал на них — исчез. Nginx перечитал конфигурацию и тихо перестал устанавливать TLS-соединения с upstream банковским API, требовавшим mutual TLS.
    Фикс занял четыре минуты: восстановить конфиг из git, перезапустить nginx, убедиться что транзакции пошли.
    Я вернулся к столу в 00:31. Шампанское было тёплым. Оливье съели без меня.
    А тот cron-job мы заменили нормальным logrotate с явными паттернами. И добавили тест: после каждого cron-задания запускается smoke-test платёжной цепочки. Каждую ночь. Включая 31 декабря.ии. Знает только что был большой скандал и несколько уволенных.
    В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны TODO.*прод, TODO.*prod, REMOVE BEFORE, DEBUG ONLY. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.
  23. Это был мой второй месяц на новом месте. Я ещё не до конца понимал архитектуру системы, но уже вполне уверенно держался — учился быстро, читал документацию запоем, задавал правильные вопросы. Тогда я считал, что всё идёт хорошо.
    Было обычное утро вторника. Технический директор Андрей зашёл ко мне с просьбой, которая казалась абсолютно невинной:
    — Макс, у нас заканчивается место на prod-db-01. Там где-то есть временные файлы от старых бэкапов, почисти, пожалуйста.
    — Хорошо, посмотрю.
    Я зашёл на сервер. Открыл df -h. Действительно — диск забит под 94%. Начал смотреть, где место. du -sh /* — ничего подозрительного. Тогда запустил find / -name "*.tmp" -type f — и вот оно, целая папка /var/backup/temp/ с файлами с расширением .tmp общим весом около 180 гигабайт.
    Временные файлы. Именно то, о чём говорил Андрей.
    Я выполнил rm -rf /var/backup/temp/. Получил ошибку прав. Повторил с sudo. Команда отработала молниеносно — что меня немного удивило: для 180 гигабайт это подозрительно быстро. Но я не придал этому значения.
    Через двадцать минут ко мне подошёл бэкенд-разработчик Слава с характерным выражением лица человека, которому только что сообщили о смерти любимого питомца:
    — Макс, у нас приложение упало. API не отвечает вообще. База говорит «connection refused».
    Я зашёл на сервер. Попробовал сделать простейший SELECT — и тут у меня похолодело внутри:
    ERROR: could not open file "base/16384/1259": No such file or directory Через три минуты до меня дошло: папка /var/backup/temp/ — это была не папка с временными бэкапами. Это был симлинк. Симлинк на /var/lib/postgresql/14/main/base/. Кто-то, видимо в процессе миграции полгода назад, создал символическую ссылку с историческим названием, и она там тихо жила. А я, такой молодец, удалил через неё всю директорию с данными PostgreSQL. Всю. До последнего файла.
    База данных была жива. Процесс работал. Но данных больше не существовало физически.
    Следующие два часа я провёл в состоянии, которое сложно описать словами. Это не паника — паника это когда хаотично двигаешься. Я наоборот — сидел совершенно неподвижно и методично восстанавливал базу из последнего бэкапа. Бэкап был. Слава богу, бэкап был. Последний — в 03:00 ночи. Мы потеряли восемь часов транзакций.
    Потом было долгое молчаливое совещание с Андреем. Он не кричал. Он вообще почти не говорил. Это было хуже крика.
    Я написал подробный post-mortem. Ввёл правило: перед любым rm на проде обязательно проверять ls -la и file — убедиться, что это не симлинк. Добавил в онбординг пункт о том, как работают симлинки в Linux.
    Восемь часов данных так и не восстановили. Клиенты получили извинения. Я остался работать — Андрей оказался человеком, который верит в то, что ошибки надо исправлять, а не наказывать за них. Но ту папку с симлинком я помню до сих пор.
  24. Карьера в IT: роли, задачи и возможности

    Мир IT огромен и многогранен: здесь нужны специалисты самых разных направлений, от продуктовой аналитики до DevOps и дизайна. Разберём ключевые роли, чтобы понять, кто за что отвечает и какие задачи выполняет.
    🔹 Продукт и аналитика
    1. Product Manager (PM)
    Роль: отвечает за продукт целиком — от идеи до метрик.
    Задачи:
    формировать стратегию продукта и ставить цели;
    проводить исследования, интервью с пользователями и анализ конкурентов;
    описывать функциональность (PRD, User Stories);
    расставлять приоритеты: что делаем сейчас, что откладываем;
    работать с командами разработки, дизайна и маркетинга;
    анализировать метрики, управлять ростом и оптимизировать продукт.
    Дополнительно: PM часто взаимодействует с бизнес-аналитиками и UX-командой, чтобы продукт был не только функциональным, но и востребованным.
    2. Business Analyst (BA)
    Роль: связывает бизнес-задачи с технической реализацией.
    Задачи:
    формализует требования бизнеса;
    анализирует процессы и выявляет узкие места;
    помогает команде разработки понять цели и приоритеты;
    участвует в тестировании и оценке фичей.
    Варианты: иногда BA объединяет функции PM или Data Analyst в небольших командах.
    🔹 Разработка
    3. Frontend Developer
    Роль: создаёт интерфейс — всё, что видит пользователь.
    Задачи:
    реализовывать дизайн в коде;
    создавать интерактивные элементы;
    подключать API и работать с данными;
    оптимизировать скорость загрузки страниц;
    поддерживать кроссбраузерность и адаптивность.
    Дополнительно: участвует в уточнении требований и тестировании UI.
    4. Backend Developer
    Роль: отвечает за серверную часть — «мозги» приложения.
    Задачи:
    разрабатывать API и бизнес-логику;
    работать с базами данных;
    создавать системы авторизации, уведомлений, платежей;
    обеспечивать безопасность данных;
    оптимизировать производительность серверов.
    5. Mobile Developer
    Роль: работает над мобильными приложениями.
    Задачи:
    создавать приложение под iOS и Android;
    реализовывать дизайн и бизнес-логику;
    оптимизировать работу под разные устройства;
    интегрировать API, пуш-уведомления, авторизацию и оплату;
    выпускать обновления и фиксить баги.
    6. Full-stack Developer
    Роль: закрывает фронтенд и бэкенд.
    Задачи:
    вести разработку «от интерфейса до базы данных»;
    быстро создавать прототипы;
    поддерживать продукт в малых командах;
    координировать взаимодействие между частями.
    7. QA Engineer (тестировщик)
    Роль: отвечает за качество продукта.
    Задачи:
    писать тест-кейсы и чек-листы;
    искать баги и оформлять отчёты;
    проверять фичи перед релизом;
    автоматизировать тесты (QA-автоматизатор);
    контролировать стабильность продукта после обновлений.
    🔹 Архитектура и инфраструктура
    8. System Architect
    Роль: проектирует структуру продукта.
    Задачи:
    выбирать архитектурные паттерны;
    проектировать взаимодействие сервисов;
    обеспечивать масштабируемость и надёжность;
    консультировать разработчиков по сложным задачам;
    решать, какие технологии использовать.
    9. DevOps / SRE
    Роль: отвечает за стабильность, инфраструктуру и автоматизацию.
    Задачи:
    настраивать и поддерживать серверы, контейнеры, облака;
    автоматизировать CI/CD;
    мониторить систему и устранять сбои;
    обеспечивать безопасность сервисов;
    оптимизировать стоимость инфраструктуры.
    10. Data Engineer
    Роль: работает с потоками данных.
    Задачи:
    строить ETL-пайплайны;
    настраивать хранилища, базы данных, Data Lakes;
    обеспечивать качество и доступность данных;
    оптимизировать процессы передачи данных.
    🔹 Аналитика и ML
    11. Data Analyst
    Роль: отвечает за анализ данных и выводы для бизнеса.
    Задачи:
    собирать данные из разных источников;
    строить дашборды и отчёты;
    искать аномалии, паттерны, точки роста;
    проводить A/B-тесты;
    отвечать на вопросы бизнеса цифрами.
    12. Data Scientist
    Роль: создаёт модели машинного обучения.
    Задачи:
    разрабатывать модели (рекомендации, прогнозы, классификация);
    готовить датасеты;
    обучать и тестировать модели;
    внедрять модели в продукт;
    анализировать качество моделей и корректировать алгоритмы.
    🔹 Дизайн и пользовательский опыт
    13. UX/UI Designer
    Роль: отвечает за интерфейсы и визуальную часть продукта.
    Задачи:
    исследовать пользователей и сценарии;
    строить CJM и проектировать логику экранов;
    создавать прототипы и макеты;
    работать с дизайн-системами;
    передавать макеты разработчикам.
    14. UX Researcher
    Роль: связывает продукт с реальными потребностями пользователей.
    Задачи:
    проводить интервью и исследования;
    устраивать юзабилити-тесты;
    формировать гипотезы и проверять их;
    давать рекомендации по улучшению продукта;
    подтверждать продуктовые решения данными.
    🔹 Контент и коммуникации
    15. Technical Writer
    Роль: создаёт документацию для пользователей и разработчиков.
    Задачи:
    писать инструкции, гайды, мануалы;
    описывать API и технические процессы;
    обновлять документацию после релизов;
    работать с разработчиками для уточнений.
    16. Community Manager
    Роль: выстраивает отношения с пользователями и сообществом.
    Задачи:
    отвечать на вопросы в чатах и соцсетях;
    собирать обратную связь;
    формировать лояльность пользователей;
    помогать пользователям решать проблемы;
    эскалировать важные запросы команде.
    🔹 Управление процессами
    17. Project Manager (PM / PMO)
    Роль: следит за тем, чтобы задачи выполнялись вовремя, а команда работала слаженно.
    Задачи:
    ставить задачи и планировать спринты;
    контролировать сроки и ресурсы;
    поддерживать коммуникацию между командами;
    снимать блокеры у специалистов;
    управлять рисками и таймлайнами.
  25. Представьте: вы сидите за монитором, на экране уже есть кнопки, текст, картинки. Всё вроде работает. И тут менеджер кидает новую фичу: «Сделай модалку с формой для отзывов». Казалось бы — мелочь. Но как это сделать так, чтобы не сломать весь фронтенд? Давайте разберём, что реально происходит, когда фронтенд-разработчик «ковыряет» код на React.
    💻 Фронтенд – это то, что видит пользователь
    Фронтенд — это интерфейс, всё, что юзер видит и трогает: кнопки, меню, формы, анимации.
    Фронтенд-разработчик не просто «рисует сайт». Он создаёт UX, разбивает интерфейс на компоненты, следит за рендерингом, а ещё иногда сражается с багами, которые появляются из ниоткуда.
    ⚛️ React – библиотека для сборки UI
    React — это как конструктор LEGO, только в мире фронтенда. Каждый компонент — это независимая деталь: кнопка, форма, карточка товара.
    Используя React, мы можем комбинировать эти кубики, создавать реактивные интерфейсы и управлять их состояниями (state).
    🧩 Фича – это новая функция, не меньше
    Когда фронтендер говорит «прикрутить фичу», это не просто «добавить элемент». Это продумать:
    структуру компонентов,
    state management,
    логику валидации,
    а иногда и асинхронные запросы к серверу через API.
    В нашем примере фича — это модалка с формой отзывов.
    🪟 Модальное окно – всплывающее окно поверх сайта
    Модалка — это UI overlay, который блокирует взаимодействие с остальной страницей до закрытия.
    Каждый фронтендер знает, что модалки могут стать хаотичными: неправильное состояние, баги с z-index, проблемы с focus trap — и вот уже страница ведёт себя как капризный робот.
    Компоненты, state и хуки
    Компонент — это атом интерфейса. Вместо монолитного кода у нас маленькие, тестируемые и переиспользуемые куски.
    State — внутренняя память компонента. Открыт модал или закрыт? Какие данные ввёл пользователь? Это всё хранится в state.
    useState — встроенный React Hook, который позволяет создавать и менять state. Ошибки с ним приводят к странному перерендеру или багам, которые тяжело отловить.
    🧾 Разметка и стили
    HTML = структура страницы
    CSS/SCSS = как эта структура выглядит
    JS/TS = как она реагирует на действия юзера
    «Накидать разметку» — это не просто вставить теги. Нужно учитывать accessibility, semantic HTML, а ещё то, как всё будет работать с React и его виртуальным DOM.
    ✅ Валидация формы
    Валидация проверяет, корректно ли пользователь заполняет поля.
    Для фронтендера это значит:
    подключить либы типа Yup или Zod,
    написать кастомные валидаторы,
    позаботиться о UX ошибок: показать подсказки, подсветить поля, не дать юзеру сломать приложение.
    ⚙️ Логика и дебаг
    Логика — это последовательность действий кода: что происходит при клике, при вводе, при отправке формы.
    Дебаг — это целая философия. Консоль браузера, React DevTools, breakpoints, network tab, иногда даже прямой просмотр state в Redux.
    Типичная ошибка фронтендера:
    TypeError: Cannot read property 'value' of nullОзначает: «Ты пытаешься достучаться до элемента, которого нет». Скорее всего, забыл добавить id или неправильно прокинул ref.
    🧪 Тестирование
    После исправления багов наступает этап QA на фронтенде:
    кликаем все кнопки,
    вводим разные данные,
    проверяем в разных браузерах,
    убеждаемся, что модалка не ломает остальной UI.
    Только после этого можно быть уверенным: фича рабочая.
    🌐 Итог
    Даже маленькая задача вроде модалки включает десятки понятий: компоненты, state, рендеринг, хуки, валидацию, дебаг, логику, тесты.
    И чем больше ковыряешь фронтенд, тем больше начинаешь ценить эти термины.
    Главное — не бояться лезть в код и разбираться, почему что-то ломается. Потому что именно так становится настоящим фронтендером.

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.