<?xml version="1.0"?>
<rss version="2.0"><channel><title>&#x41F;&#x440;&#x43E; &#x410;&#x419;&#x422;&#x418; (About IT)</title><link>https://ithub.uno/blogs/blog/2-%D0%BF%D1%80%D0%BE-%D0%B0%D0%B9%D1%82%D0%B8-about-it/</link><description/><language>en</language><item><title>&#x410;&#x440;&#x445;&#x438;&#x442;&#x435;&#x43A;&#x442;&#x443;&#x440;&#x430; &#x43E;&#x447;&#x435;&#x440;&#x435;&#x434;&#x435;&#x439;: &#x43A;&#x430;&#x43A; &#x43C;&#x44B; &#x443;&#x431;&#x440;&#x430;&#x43B;&#x438; "&#x442;&#x43E;&#x440;&#x43C;&#x43E;&#x437;&#x430;" &#x438;&#x437; HTTP-&#x437;&#x430;&#x43F;&#x440;&#x43E;&#x441;&#x43E;&#x432; &#x438; &#x441;&#x43F;&#x430;&#x441;&#x43B;&#x438; &#x43D;&#x435;&#x440;&#x432;&#x44B; &#x43F;&#x43E;&#x43B;&#x44C;&#x437;&#x43E;&#x432;&#x430;&#x442;&#x435;&#x43B;&#x435;&#x439;</title><link>https://ithub.uno/blogs/entry/92-arhitektura-ocheredej-kak-my-ubrali-tormoza-iz-http-zaprosov-i-spasli-nervy-polzovatelej/</link><description><![CDATA[<p>Пользователи не любят ждать. Если кнопка "Отправить" не реагирует три секунды — они уже в Twitter пишут что ваш сайт сломан. Один из самых мощных инструментов для улучшения perceived performance — асинхронная обработка через очереди.</p><hr><h3>Что уходит в очередь</h3><p>В HTTP-запросе должно происходить только то, что нужно пользователю для немедленного ответа.</p><p>Что НЕ нужно пользователю немедленно:</p><ul><li><p>Отправка email</p></li><li><p>Генерация PDF</p></li><li><p>Пересчёт статистики</p></li><li><p>Синхронизация с внешними системами</p></li><li><p>Изменение размеров изображений</p></li><li><p>Push-уведомления</p></li><li><p>Webhook-уведомления партнёров</p></li></ul><p>Всё это — в очередь. HTTP отвечает за 50ms. Фоновый воркер делает остальное.</p><hr><h3>Базовая архитектура очередей на Redis + CI4</h3><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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'         =&gt; $id = uniqid('job_', true),
            'class'      =&gt; get_class($job),
            'data'       =&gt; serialize($job),
            'attempt'    =&gt; 0,
            'created_at' =&gt; time(),
        ];

        if ($delay &gt; 0) {
            $this-&gt;redis-&gt;zAdd(
                $this-&gt;prefix . 'delayed:' . $queueName,
                time() + $delay,
                json_encode($payload)
            );
        } else {
            $this-&gt;redis-&gt;lPush($this-&gt;prefix . $queueName, json_encode($payload));
        }

        return $id;
    }

    public function pop(string $queueName, int $timeout = 5): ?array
    {
        $this-&gt;promoteDelayedJobs($queueName);

        $result = $this-&gt;redis-&gt;brPop($this-&gt;prefix . $queueName, $timeout);

        return $result ? json_decode($result[1], true) : null;
    }

    private function promoteDelayedJobs(string $queueName): void
    {
        $delayedKey = $this-&gt;prefix . 'delayed:' . $queueName;
        $jobs       = $this-&gt;redis-&gt;zRangeByScore($delayedKey, '-inf', time());

        foreach ($jobs as $job) {
            $this-&gt;redis-&gt;multi();
            $this-&gt;redis-&gt;zRem($delayedKey, $job);
            $this-&gt;redis-&gt;lPush($this-&gt;prefix . $queueName, $job);
            $this-&gt;redis-&gt;exec();
        }
    }
}
</code></pre><hr><h3>Worker — CI4 CLI команда</h3><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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-&gt;getOption('max-jobs') ?? 0);
        $processed = 0;

        if (function_exists('pcntl_signal')) {
            pcntl_signal(SIGTERM, fn() =&gt; $this-&gt;shouldStop = true);
            pcntl_signal(SIGINT,  fn() =&gt; $this-&gt;shouldStop = true);
        }

        $queue = new Queue();

        while (!$this-&gt;shouldStop) {
            if (function_exists('pcntl_signal_dispatch')) {
                pcntl_signal_dispatch();
            }

            $payload = $queue-&gt;pop($queueName);

            if (!$payload) continue;

            $this-&gt;processJob($queue, $queueName, $payload);
            $processed++;

            if ($maxJobs &gt; 0 &amp;&amp; $processed &gt;= $maxJobs) break;
        }
    }

    private function processJob(Queue $queue, string $queueName, array $payload): void
    {
        try {
            $job = unserialize($payload['data']);
            $job-&gt;setAttempt($payload['attempt'] + 1);
            set_time_limit($job-&gt;getTimeout());
            $job-&gt;handle();

        } catch (\Throwable $e) {
            $payload['attempt']++;

            if ($payload['attempt'] &lt; $job-&gt;getMaxAttempts()) {
                $delay = pow(2, $payload['attempt']) * 10;
                $queue-&gt;push($queueName, $job, $delay);
            } else {
                $job-&gt;failed($e);
                $queue-&gt;bury($queueName, $payload);
            }
        }
    }
}
</code></pre><hr><h3>Реальный пример: отправка email</h3><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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')-&gt;send(
            to:      $this-&gt;email,
            subject: 'Добро пожаловать!',
            view:    'emails/welcome',
            data:    ['name' =&gt; $this-&gt;name]
        );

        model('EmailLogModel')-&gt;insert([
            'user_id' =&gt; $this-&gt;userId,
            'type'    =&gt; 'welcome',
            'sent_at' =&gt; date('Y-m-d H:i:s'),
            'status'  =&gt; 'sent',
        ]);
    }
}
</code></pre><p>Использование в Controller:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// Вместо: $emailService-&gt;send(...); // ждём 500ms

// Делаем:
$queue = new Queue();
$queue-&gt;push('emails', new SendWelcomeEmailJob($user-&gt;id, $user-&gt;email, $user-&gt;name));

// HTTP response за 15ms вместо 515ms
return $this-&gt;response-&gt;setJSON(['status' =&gt; 'registered', 'message' =&gt; 'Check your email']);
</code></pre><hr><h3>Результаты</h3><div class="tmiRichText__table-wrapper"><table style="width: 719px;"><colgroup><col style="width:378px;"><col style="width:173px;"><col style="width:168px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Endpoint</p></th><th colspan="1" rowspan="1"><p>До</p></th><th colspan="1" rowspan="1"><p>После</p></th></tr><tr><td colspan="1" rowspan="1"><p>POST /register</p></td><td colspan="1" rowspan="1"><p>580ms</p></td><td colspan="1" rowspan="1"><p>45ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>POST /upload-photo</p></td><td colspan="1" rowspan="1"><p>2300ms</p></td><td colspan="1" rowspan="1"><p>120ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>POST /generate-report</p></td><td colspan="1" rowspan="1"><p>8000ms</p></td><td colspan="1" rowspan="1"><p>80ms (async)</p></td></tr><tr><td colspan="1" rowspan="1"><p>POST /checkout</p></td><td colspan="1" rowspan="1"><p>1200ms</p></td><td colspan="1" rowspan="1"><p>340ms</p></td></tr></tbody></table></div><p>Bounce rate на страницах с "тяжёлыми" действиями упал на 34%. Конверсия регистрации выросла на 12%. Просто потому что форма теперь отвечает мгновенно.</p><p>Пользователи не знают о ваших очередях. Они просто чувствуют что сайт быстрый. Это и есть цель. <span class="tmiEmoji" title="">⚡</span></p><hr><p><em>Максим — продуктовый DevOps с горящими глазами и умеренно сгоревшими нервами. Пишу про реальный highload, реальные ошибки и реальные решения. Больше таких историй — на </em><a rel="external nofollow" href="https://ithub.uno"><em>ithub.uno</em></a><em>, там собираются те, кто делает, а не только говорит.</em></p>]]></description><guid isPermaLink="false">92</guid><pubDate>Sat, 21 Mar 2026 16:01:10 +0000</pubDate></item><item><title>&#x413;&#x43E;&#x440;&#x438;&#x437;&#x43E;&#x43D;&#x442;&#x430;&#x43B;&#x44C;&#x43D;&#x43E;&#x435; &#x43C;&#x430;&#x441;&#x448;&#x442;&#x430;&#x431;&#x438;&#x440;&#x43E;&#x432;&#x430;&#x43D;&#x438;&#x435; PHP: &#x432;&#x441;&#x451; &#x447;&#x442;&#x43E; &#x432;&#x44B; &#x445;&#x43E;&#x442;&#x435;&#x43B;&#x438; &#x437;&#x43D;&#x430;&#x442;&#x44C;, &#x43D;&#x43E; &#x431;&#x43E;&#x44F;&#x43B;&#x438;&#x441;&#x44C; &#x441;&#x43F;&#x440;&#x43E;&#x441;&#x438;&#x442;&#x44C;</title><link>https://ithub.uno/blogs/entry/89-gorizontalnoe-masshtabirovanie-php-vsyo-chto-vy-hoteli-znat-no-boyalis-sprosit/</link><description><![CDATA[<h2><span data-tmi-font-size="80">"Просто добавь серверов" — говорят менеджеры. "Это не так просто" — говорим мы. Сегодня расскажу почему не так просто, и как всё-таки сделать так, чтобы было просто.</span></h2><hr><h3>Проблема №1: Состояние сессий</h3><p>Запускаете второй сервер, и пользователи жалуются: "я только что вошёл, а меня снова просит логин". Потому что сессия хранится в файловой системе первого сервера.</p><p>Правильное решение: Redis-сессии:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// app/Config/App.php
public string $sessionDriver = 'CodeIgniter\Session\Handlers\RedisHandler';
public string $sessionSavePath = 'tcp://redis-cluster:6379?auth=password&amp;database=1';
public int $sessionExpiration = 7200;
public bool $sessionMatchIP = false; // Важно! Иначе CDN сломает сессии
</code></pre><p>С Sentinel для HA:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>public string $sessionSavePath = 'tcp://redis-sentinel:26379?auth=password&amp;database=1&amp;sentinel_master=mymaster';
</code></pre><hr><h3>Проблема №2: Загрузка файлов</h3><p>Пользователь загружает аватар на сервер #1. Следующий запрос идёт на сервер #2 — он ничего не знает об этом файле.</p><p>Решение: S3-совместимое хранилище:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?php

namespace App\Services;

use Aws\S3\S3Client;

class FileStorageService
{
    private S3Client $s3;

    public function __construct()
    {
        $config   = config('FileStorage');
        $this-&gt;s3 = new S3Client([
            'version'     =&gt; 'latest',
            'region'      =&gt; $config-&gt;region,
            'endpoint'    =&gt; $config-&gt;endpoint,
            'credentials' =&gt; ['key' =&gt; $config-&gt;key, 'secret' =&gt; $config-&gt;secret],
            'use_path_style_endpoint' =&gt; $config-&gt;usePathStyle,
        ]);
    }

    public function upload(string $localPath, string $remotePath, string $acl = 'private'): string
    {
        $this-&gt;s3-&gt;putObject([
            'Bucket'      =&gt; $this-&gt;bucket,
            'Key'         =&gt; $remotePath,
            'SourceFile'  =&gt; $localPath,
            'ACL'         =&gt; $acl,
            'ContentType' =&gt; mime_content_type($localPath),
        ]);

        return $this-&gt;getUrl($remotePath);
    }

    public function getSignedUrl(string $remotePath, int $expiry = 3600): string
    {
        $cmd = $this-&gt;s3-&gt;getCommand('GetObject', ['Bucket' =&gt; $this-&gt;bucket, 'Key' =&gt; $remotePath]);
        return (string) $this-&gt;s3-&gt;createPresignedRequest($cmd, "+{$expiry} seconds")-&gt;getUri();
    }
}
</code></pre><hr><h3>Проблема №3: Cron jobs</h3><p>10 серверов, у каждого crontab — пользователи получают email 10 раз. Решение: distributed locking:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?php

namespace App\Libraries;

class DistributedLock
{
    private string $lockValue;

    public function __construct(
        private readonly string $key,
        private readonly int $ttl = 300
    ) {
        $this-&gt;lockValue = gethostname() . '_' . getmypid() . '_' . uniqid();
    }

    public function acquire(): bool
    {
        // SET key value NX EX ttl — атомарная операция
        $result = cache()-&gt;getRedis()-&gt;set(
            $this-&gt;key,
            $this-&gt;lockValue,
            ['NX', 'EX' =&gt; $this-&gt;ttl]
        );
        return $result === true;
    }

    public function release(): bool
    {
        // Lua для атомарного освобождения только СВОЕГО лока
        $script = &lt;&lt;&lt;LUA
            if redis.call("get", KEYS[1]) == ARGV[1] then
                return redis.call("del", KEYS[1])
            else
                return 0
            end
        LUA;

        return (bool) cache()-&gt;getRedis()-&gt;eval($script, [$this-&gt;key, $this-&gt;lockValue], 1);
    }
}
</code></pre><p>Базовый класс для singleton cron-команд:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>abstract class SingletonCommand extends BaseCommand
{
    protected int $lockTtl = 300;

    final public function run(array $params): void
    {
        $lock = new DistributedLock('cron_lock_' . static::class, $this-&gt;lockTtl);

        if (!$lock-&gt;acquire()) {
            $this-&gt;write("Another instance is already running. Skipping.");
            return;
        }

        try {
            $this-&gt;execute($params);
        } finally {
            $lock-&gt;release();
        }
    }

    abstract protected function execute(array $params): void;
}
</code></pre><hr><h3>Чеклист горизонтального масштабирования</h3><p>Перед тем как добавить второй сервер:</p><p><code><span class="tmiEmoji" title="">✅</span> Сессии в Redis/Memcached, не в файловой системе </code></p><p><code><span class="tmiEmoji" title="">✅</span> Файлы в S3/MinIO, не на диск </code></p><p><code><span class="tmiEmoji" title="">✅</span> Кэш в Redis, не в файловой системе </code></p><p><code><span class="tmiEmoji" title="">✅</span> Логи в centralized storage (ELK/Loki) </code></p><p><code><span class="tmiEmoji" title="">✅</span> Cron с distributed locking </code></p><p><code><span class="tmiEmoji" title="">✅</span> Конфиги из environment variables </code></p><p><code><span class="tmiEmoji" title="">✅</span> Нет hardcoded hostname в коде </code></p><p><code><span class="tmiEmoji" title="">✅</span> Нет состояния в оперативной памяти между запросами </code></p><p><code><span class="tmiEmoji" title="">✅</span> Graceful shutdown (SIGTERM handler) </code></p><p><code><span class="tmiEmoji" title="">✅</span> Health checks настроены </code></p><p>Если всё это есть — добавление сервера занимает минуты. Если нет — недели головной боли.</p><p>На ithub.uno таких архитектурных обсуждений всегда много — там есть практические нюансы, которых нет в документации.</p><p>Масштабируйтесь грамотно. <span class="tmiEmoji" title="">📊</span><span class="tmiEmoji" title="">🖥️</span></p><hr>]]></description><guid isPermaLink="false">89</guid><pubDate>Sat, 21 Mar 2026 15:57:38 +0000</pubDate></item><item><title>CI/CD: &#x43E;&#x442; "&#x444;&#x442;&#x43F; &#x432; &#x43F;&#x440;&#x43E;&#x434;&#x430;&#x43A;&#x448;&#x43D;" &#x434;&#x43E; zero-downtime deploy &#x437;&#x430; 4 &#x43C;&#x438;&#x43D;&#x443;&#x442;&#x44B;</title><link>https://ithub.uno/blogs/entry/86-cicd-ot-ftp-v-prodakshn-do-zero-downtime-deploy-za-4-minuty/</link><description><![CDATA[<p></p><p>Это история эволюции. Начнём с того момента, когда деплой выглядел так: "Ваня, выгрузи файлики на сервер через FileZilla". И закончим тем, что у нас сейчас — полностью автоматический pipeline с тестами, security checks, zero-downtime deploy и автоматическим rollback.</p><hr><h3>Эра FTP (тёмные времена)</h3><p>Просто знайте: когда я пришёл в эту компанию, деплой выглядел следующим образом:</p><ol><li><p>Разработчик локально делал изменения</p></li><li><p>Открывал FileZilla</p></li><li><p>Перетаскивал папку <code>app/</code> на сервер</p></li><li><p>Молился</p></li></ol><p>Это было в 2019 году. Не в 2009, а в 2019. Такое ещё встречается.</p><hr><h3>Шаг 1: Git + простой CI</h3><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code># .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 &amp;&amp; git pull &amp;&amp; composer install"
  only:
    - main
</code></pre><p><code>git pull</code> на продакшне — это тоже не очень хорошая идея, но это следующий шаг.</p><hr><h3>Шаг 2: Деплой через rsync + atomic switch</h3><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>#!/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 "</code></pre><p><code><span class="tmiEmoji" title="">✅</span></code></p><p><code> Deploy $RELEASE_ID complete" </code></p><p>Rollback:</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>ls -dt /var/www/releases/* | head -2 | tail -1 | xargs -I{} ln -sfn {} /var/www/current
nginx -s reload
</code></pre><hr><h3>Шаг 3: Docker + Kubernetes</h3><p>Dockerfile для PHP 8.3 + CI4:</p><pre spellcheck="" class="tmiCode language-dockerfile" data-language="Dockerfile"><code>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 \
    &amp;&amp; docker-php-ext-install pdo_mysql zip intl opcache \
    &amp;&amp; pecl install redis igbinary \
    &amp;&amp; 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"]
</code></pre><p>GitLab CI/CD — финальная версия:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>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 &gt; 0.05" | bc -l) )); then
        echo "Error rate too high! Auto-rollback!"
        kubectl rollout undo deployment/php-app -n production
        exit 1
      fi
</code></pre><hr><h3>Итого: что получили</h3><div class="tmiRichText__table-wrapper"><table style="width: 665px;"><colgroup><col style="width:221px;"><col style="width:113px;"><col style="width:157px;"><col style="width:174px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Метрика</p></th><th colspan="1" rowspan="1"><p>FTP era</p></th><th colspan="1" rowspan="1"><p>Git + rsync</p></th><th colspan="1" rowspan="1"><p>Docker + k8s</p></th></tr><tr><td colspan="1" rowspan="1"><p>Время деплоя</p></td><td colspan="1" rowspan="1"><p>5-40 мин</p></td><td colspan="1" rowspan="1"><p>3-8 мин</p></td><td colspan="1" rowspan="1"><p><strong>2-4 мин</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p>Downtime при деплое</p></td><td colspan="1" rowspan="1"><p>30s-5min</p></td><td colspan="1" rowspan="1"><p>0</p></td><td colspan="1" rowspan="1"><p><strong>0</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p>Rollback время</p></td><td colspan="1" rowspan="1"><p>10-30 мин</p></td><td colspan="1" rowspan="1"><p>2 мин</p></td><td colspan="1" rowspan="1"><p><strong>45 секунд</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p>Деплоев в день</p></td><td colspan="1" rowspan="1"><p>1-2</p></td><td colspan="1" rowspan="1"><p>5-10</p></td><td colspan="1" rowspan="1"><p><strong>20-30</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p>Инцидентов из-за деплоя</p></td><td colspan="1" rowspan="1"><p>2-3/мес</p></td><td colspan="1" rowspan="1"><p>0-1/мес</p></td><td colspan="1" rowspan="1"><p><strong>~0</strong></p></td></tr></tbody></table></div><p>Больше деплоев в день — это не потому что мы хаотичнее. Маленькие частые деплои безопаснее больших редких. Каждый PR идёт в прод в тот же день. Это называется continuous delivery. И это меняет жизнь. <span class="tmiEmoji" title="">🚀</span></p><hr>]]></description><guid isPermaLink="false">86</guid><pubDate>Sat, 21 Mar 2026 15:54:24 +0000</pubDate></item><item><title>&#x41C;&#x43E;&#x43D;&#x438;&#x442;&#x43E;&#x440;&#x438;&#x43D;&#x433;: &#x43E;&#x442; "&#x43F;&#x440;&#x43E;&#x434; &#x443;&#x43F;&#x430;&#x43B;, &#x443;&#x437;&#x43D;&#x430;&#x43B;&#x438; &#x43E;&#x442; &#x43F;&#x43E;&#x43B;&#x44C;&#x437;&#x43E;&#x432;&#x430;&#x442;&#x435;&#x43B;&#x435;&#x439;" &#x434;&#x43E; "&#x43F;&#x440;&#x43E;&#x434; &#x434;&#x443;&#x43C;&#x430;&#x435;&#x442; &#x447;&#x442;&#x43E; &#x443;&#x43F;&#x430;&#x441;&#x442;&#x44C; &#x438; &#x443;&#x436;&#x435; &#x43F;&#x43E;&#x43B;&#x443;&#x447;&#x438;&#x43B; &#x430;&#x43B;&#x435;&#x440;&#x442;"</title><link>https://ithub.uno/blogs/entry/83-monitoring-ot-prod-upal-uznali-ot-polzovatelej-do-prod-dumaet-chto-upast-i-uzhe-poluchil-alert/</link><description><![CDATA[<p>Есть два типа DevOps. Первые узнают об авариях от пользователей. Вторые — за 5 минут до того как проблема станет аварией. Я прошёл путь от первого ко второму. Это было долго, больно и неочевидно. Расскажу как.</p><hr><h3>Уровень 0: Никакого мониторинга</h3><p>Это страшное место. Узнаёшь что что-то не так, когда:</p><ul><li><p>Пишет пользователь в поддержку</p></li><li><p>Звонит CEO в воскресенье</p></li><li><p>Видишь в Twitter "ваш сайт сломан"</p></li></ul><p>Это лечится быстро — после первого воскресного звонка от CEO инстинкт самосохранения быстро мотивирует к действию.</p><hr><h3>Уровень 1: Uptime monitoring</h3><p>Самое простое: проверяем что сайт отвечает:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>class HealthCheck extends BaseCommand
{
    protected $name = 'health:check';

    public function run(array $params): void
    {
        $endpoints = config('HealthCheck')-&gt;endpoints;
        
        foreach ($endpoints as $endpoint) {
            $start = microtime(true);
            
            try {
                $response = \Config\Services::curlrequest()-&gt;get($endpoint['url'], [
                    'timeout' =&gt; 10,
                    'http_errors' =&gt; false,
                ]);
                
                $duration = (microtime(true) - $start) * 1000;
                $status = $response-&gt;getStatusCode();
                
                if ($status !== 200 || $duration &gt; $endpoint['threshold_ms']) {
                    $this-&gt;alertTeam($endpoint, $status, $duration);
                }
                
            } catch (\Exception $e) {
                $this-&gt;alertTeam($endpoint, 0, 0, $e-&gt;getMessage());
            }
        }
    }
}
</code></pre><p>Лучше чем ничего. Но это как термометр в одной комнате большого дома.</p><hr><h3>Уровень 2: Application метрики</h3><p>Prometheus + Grafana. В CI4 интегрируем через After-фильтр:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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-&gt;getStatusCode();
        $route    = service('router')-&gt;controllerName();
        $method   = $request-&gt;getMethod();

        $metrics = service('metricsCollector');

        $metrics-&gt;histogram(
            'http_request_duration_seconds',
            $duration,
            ['method' =&gt; $method, 'route' =&gt; $route, 'status' =&gt; $status]
        );

        $metrics-&gt;counter(
            'http_requests_total',
            1,
            ['method' =&gt; $method, 'route' =&gt; $route, 'status' =&gt; (string)intdiv($status, 100) . 'xx']
        );

        if ($status &gt;= 500) {
            $metrics-&gt;counter('http_errors_total', 1, ['route' =&gt; $route]);
        }

        return $response;
    }
}
</code></pre><hr><h3>Уровень 3: Предиктивные алерты</h3><p>Настоящий прорыг — когда алерты стали срабатывать ДО того как всё упало.</p><p>Пример: за 8-12 минут до OOM Redis всегда происходило:</p><ol><li><p>Memory usage рос быстрее обычного (+15% за 5 минут)</p></li><li><p>Cache hit rate начинал падать</p></li><li><p>Eviction rate был 0</p></li></ol><p>Prometheus alerting rule:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>groups:
  - name: redis_predictive
    rules:
      - alert: RedisMemoryGrowthAnomaly
        expr: |
          (
            redis_memory_used_bytes / redis_memory_max_bytes &gt; 0.75
          ) and (
            rate(redis_memory_used_bytes[5m]) &gt; 0
          ) and (
            redis_stat_keyspace_hits /
            (redis_stat_keyspace_hits + redis_stat_keyspace_misses) &lt; 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 &gt; 0.8
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "MySQL connection pool at {{ $value | humanizePercentage }}"
</code></pre><hr><h3>Уровень 4: Synthetic monitoring</h3><p>Проверяем что реальный пользовательский сценарий работает end-to-end. CLI-команда CI4 каждые 2 минуты симулирует key user journey:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>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-&gt;post($baseUrl . '/api/auth/login', [
                'json' =&gt; ['email' =&gt; env('SYNTHETIC_USER_EMAIL'), 'password' =&gt; env('SYNTHETIC_USER_PASSWORD')],
                'timeout' =&gt; 5,
            ]);
            assert($loginResp-&gt;getStatusCode() === 200);
            $token = json_decode($loginResp-&gt;getBody(), true)['token'];

            $step = 'product_list';
            $productsResp = $client-&gt;get($baseUrl . '/api/products', [
                'headers' =&gt; ['Authorization' =&gt; "Bearer $token"],
                'timeout' =&gt; 3,
            ]);
            assert($productsResp-&gt;getStatusCode() === 200);

            // ... другие шаги

            $metrics-&gt;counter('synthetic_checkout_flow_success_total', 1);
            $this-&gt;write("</code></pre><p><code><span class="tmiEmoji" title="">✅</span> Checkout flow OK</code></p><p><code>");          } catch (\Throwable $e) {             </code></p><p><code>$metrics-&gt;counter('synthetic_checkout_flow_failure_total', 1, ['step' =&gt; $step]);              </code></p><p><code>service('alertManager')-&gt;fire(level: 'critical', title: "Synthetic checkout failed at: {$step}", body:  $e-&gt;getMessage());  }     } } </code></p><hr><h3>Текущий стек мониторинга</h3><ul><li><p><strong>Prometheus</strong> — сбор метрик</p></li><li><p><strong>Grafana</strong> — визуализация и алертинг</p></li><li><p><strong>Loki</strong> — агрегация логов</p></li><li><p><strong>Alertmanager</strong> — маршрутизация (Telegram, PagerDuty, Slack)</p></li><li><p><strong>Synthetic monitoring</strong> — CI4 CLI команды в cron</p></li><li><p><strong>OpenTelemetry + Jaeger</strong> — distributed tracing</p></li></ul><p>MTTR за год:</p><div class="tmiRichText__table-wrapper"><table style="width: 409px;"><colgroup><col style="width:175px;"><col style="width:234px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Период</p></th><th colspan="1" rowspan="1"><p>MTTR</p></th></tr><tr><td colspan="1" rowspan="1"><p>До нормального мониторинга</p></td><td colspan="1" rowspan="1"><p>47 минут</p></td></tr><tr><td colspan="1" rowspan="1"><p>После</p></td><td colspan="1" rowspan="1"><p><strong>8 минут</strong></p></td></tr></tbody></table></div><p>Это не просто красивая цифра. Это живые деньги. Мониторинг — это бизнес-инвестиция с прямым ROI. <span class="tmiEmoji" title="">📈</span></p><hr>]]></description><guid isPermaLink="false">83</guid><pubDate>Sat, 21 Mar 2026 15:52:43 +0000</pubDate></item><item><title>PHP 8.3 JIT: &#x44F; &#x43F;&#x440;&#x43E;&#x432;&#x451;&#x43B; &#x43C;&#x435;&#x441;&#x44F;&#x446; &#x431;&#x435;&#x43D;&#x447;&#x43C;&#x430;&#x440;&#x43A;&#x43E;&#x432; &#x438; &#x432;&#x43E;&#x442; &#x447;&#x442;&#x43E; &#x440;&#x435;&#x430;&#x43B;&#x44C;&#x43D;&#x43E; &#x43F;&#x440;&#x43E;&#x438;&#x441;&#x445;&#x43E;&#x434;&#x438;&#x442;</title><link>https://ithub.uno/blogs/entry/80-php-83-jit-ya-provyol-mesyac-benchmarkov-i-vot-chto-realno-proishodit/</link><description><![CDATA[<p>Когда PHP 8.0 вышел с JIT-компилятором, интернет взорвался. "PHP теперь БЫСТРЕЕ Python!" "JIT изменит всё!" "Переписывайте на PHP!". Заголовки были прекрасны. Реальность — немного иначе.</p><p>Я потратил месяц на бенчмарки JIT в нашем реальном highload-проекте на PHP 8.3 + CodeIgniter 4.6. Расскажу что нашёл. Без маркетинга, только цифры и контекст.</p><hr><h3>Немного теории (обещаю, коротко)</h3><p>JIT (Just-In-Time compilation) — это компиляция горячего кода в нативные машинные инструкции во время выполнения. В отличие от OPcache, который кэширует opcode (байткод PHP), JIT генерирует настоящий машинный код.</p><p>PHP реализует два режима JIT:</p><ul><li><p><strong>Tracing JIT</strong> (<code>jit=tracing</code>) — анализирует трассы выполнения, оптимизирует горячие пути. Лучше для численных вычислений.</p></li><li><p><strong>Function JIT</strong> (<code>jit=function</code>) — компилирует целые функции. Более предсказуемый, но менее агрессивный.</p></li></ul><p>Конфигурация в <code>php.ini</code>:</p><pre spellcheck="" class="tmiCode language-ini" data-language="ini"><code>[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
</code></pre><hr><h3>Бенчмарк 1: Мандельброт (классика)</h3><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>function mandelbrot(float $x0, float $y0): int
{
    $x = 0.0;
    $y = 0.0;
    $iteration = 0;
    $maxIteration = 1000;

    while ($x * $x + $y * $y &lt;= 4.0 &amp;&amp; $iteration &lt; $maxIteration) {
        $xTemp = $x * $x - $y * $y + $x0;
        $y = 2.0 * $x * $y + $y0;
        $x = $xTemp;
        $iteration++;
    }

    return $iteration;
}
</code></pre><p>Результаты:</p><div class="tmiRichText__table-wrapper"><table style="width: 497px;"><colgroup><col style="width:321px;"><col style="width:176px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Конфигурация</p></th><th colspan="1" rowspan="1"><p>Время</p></th></tr><tr><td colspan="1" rowspan="1"><p>PHP 8.3, OPcache off</p></td><td colspan="1" rowspan="1"><p>8.43s</p></td></tr><tr><td colspan="1" rowspan="1"><p>PHP 8.3, OPcache on, JIT off</p></td><td colspan="1" rowspan="1"><p>4.21s</p></td></tr><tr><td colspan="1" rowspan="1"><p>PHP 8.3, OPcache on, JIT tracing</p></td><td colspan="1" rowspan="1"><p><strong>0.84s</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p>PHP 8.3, OPcache on, JIT function</p></td><td colspan="1" rowspan="1"><p>1.12s</p></td></tr></tbody></table></div><p>JIT Tracing ускорил Мандельброт в <strong>5 раз</strong>. Потрясающий результат! Но кто в вашем web-приложении считает Мандельброт?</p><hr><h3>Бенчмарк 2: Реальный CI4 request</h3><p>Результаты на типичном API endpoint (среднее по 10,000 запросов):</p><div class="tmiRichText__table-wrapper"><table style="width: 596px;"><colgroup><col style="width:367px;"><col style="width:124px;"><col style="width:105px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Конфигурация</p></th><th colspan="1" rowspan="1"><p>Среднее время</p></th><th colspan="1" rowspan="1"><p>P99</p></th></tr><tr><td colspan="1" rowspan="1"><p>OPcache on, JIT off</p></td><td colspan="1" rowspan="1"><p>42.3ms</p></td><td colspan="1" rowspan="1"><p>89ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>OPcache on, JIT tracing</p></td><td colspan="1" rowspan="1"><p>41.8ms</p></td><td colspan="1" rowspan="1"><p>87ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>OPcache on, JIT function</p></td><td colspan="1" rowspan="1"><p>42.1ms</p></td><td colspan="1" rowspan="1"><p>88ms</p></td></tr></tbody></table></div><p>Разница: <strong>1.2%</strong>. Это в пределах погрешности измерений.</p><hr><h3>Почему JIT не помогает веб-приложениям</h3><p>Типичный PHP web-request проводит время примерно так:</p><ul><li><p>~45% — ожидание БД (I/O bound)</p></li><li><p>~25% — сетевые операции (Redis, внешние API)</p></li><li><p>~15% — OPcache-оптимизированный PHP-код</p></li><li><p>~10% — сериализация/десериализация (JSON, etc.)</p></li><li><p>~5% — собственно бизнес-логика с вычислениями</p></li></ul><p>JIT помогает именно с CPU-bound вычислениями. Но в web-запросе CPU-bound части — это ~20% от общего времени. Максимальное теоретическое ускорение: <strong>16%</strong>. На практике — 1-3%.</p><hr><h3>Правильная конфигурация для highload CI4</h3><pre spellcheck="" class="tmiCode language-ini" data-language="ini"><code>[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
</code></pre><p>И обязательно — preload для CI4:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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))-&gt;getFileName());
    }
}
</code></pre><p>С preload время ответа на первые запросы после деплоя сократилось с 200ms до 45ms. Это важнее JIT.</p><hr><h3>Итог: стоит ли включать JIT?</h3><p><strong>Включайте, если:</strong></p><ul><li><p>У вас PHP 8.1+ (в 8.0 JIT был сыроват)</p></li><li><p>Есть CPU-bound операции (обработка данных, вычисления)</p></li><li><p>Достаточно памяти для JIT buffer</p></li></ul><p><strong>Не ждите чудес, если:</strong></p><ul><li><p>Приложение I/O-bound (БД, Redis, внешние API)</p></li><li><p>Это стандартное CRUD web-приложение</p></li><li><p>Надеетесь что JIT заменит оптимизацию запросов</p></li></ul><p>Прежде чем думать о JIT, убедитесь что правильно настроен OPcache, нет N+1 запросов, есть кэширование и нормальные индексы в БД. Это даст 10-100× ускорение. JIT — ещё 1-5% сверху. Но 5% при 10 миллионах запросов в сутки — тоже деньги. Поэтому включайте. Просто знайте зачем. <span class="tmiEmoji" title="">📊</span></p><hr>]]></description><guid isPermaLink="false">80</guid><pubDate>Sat, 21 Mar 2026 15:49:39 +0000</pubDate></item><item><title>&#x41A;&#x430;&#x43A; &#x43C;&#x44B; &#x43F;&#x435;&#x440;&#x435;&#x436;&#x438;&#x43B;&#x438; Black Friday: postmortem, &#x43A;&#x43E;&#x442;&#x43E;&#x440;&#x44B;&#x439; &#x44F; &#x43D;&#x435; &#x445;&#x43E;&#x442;&#x435;&#x43B; &#x43F;&#x438;&#x441;&#x430;&#x442;&#x44C;</title><link>https://ithub.uno/blogs/entry/77-kak-my-perezhili-black-friday-postmortem-kotoryj-ya-ne-hotel-pisat/</link><description><![CDATA[<p>Это не та история, которой гордятся. Это история, которую рассказывают тихо, за пивом, другим DevOps'ам — чтобы они не совершили те же ошибки. Но знаете что? Я решил рассказать её громко. Потому что честность важнее репутации, а реальные истории учат лучше, чем придуманные кейсы.</p><p>Black Friday. Наш e-commerce на CI4. Трафик × 15 от обычного. Что могло пойти не так?</p><p>Спойлер: всё.</p><hr><h3>Подготовка (которой, как оказалось, было недостаточно)</h3><p>За три недели до BF мы провели "подготовку". По тем временам нам казалось, что мы сделали всё правильно:</p><p><span class="tmiEmoji" title="">✅</span> Провели нагрузочный тест на 5× нормального трафика<br><span class="tmiEmoji" title="">✅</span> Настроили автомасштабирование k8s<br><span class="tmiEmoji" title="">✅</span> Прогрели CDN-кэш для статики<br><span class="tmiEmoji" title="">✅</span> Оптимизировали топ-20 медленных запросов<br><span class="tmiEmoji" title="">✅</span> Увеличили connection pool MySQL<br><span class="tmiEmoji" title="">✅</span> Настроили алерты</p><p>Что мы не сделали (и о чём потом пожалели):</p><p><span class="tmiEmoji" title="">❌</span> Не протестировали 15× трафика (только 5×)<br><span class="tmiEmoji" title="">❌</span> Не протестировали scenario с высоким числом одновременных checkout операций<br><span class="tmiEmoji" title="">❌</span> Не проверили поведение при частичном отказе зависимостей<br><span class="tmiEmoji" title="">❌</span> Не подготовили runbook для дежурной команды</p><hr><h3>Хронология событий</h3><p><strong>00:00 — Midnight madness старт</strong></p><p>Трафик начал расти в 23:45. К полуночи — ×4 от нормы. Всё отлично, k8s масштабируется, метрики в норме, team в Teams чатится позитивно.</p><p><strong>00:23 — Первый звонок</strong></p><p></p><p><code><span class="tmiEmoji" title="">🔴</span> ALERT: Checkout error rate &gt; 5% </code></p><p>5% checkout'ов не проходят. Это много. Начинаем копать.</p><p>В логах:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>Deadlock found when trying to get lock; try restarting transaction
</code></pre><p>MySQL deadlock. Два процесса пытались обновить одну и ту же запись в таблице <code>inventory</code> (остатки товаров) одновременно. При ×4 трафике вероятность коллизии выросла критически.</p><p>Быстрое решение: добавили SELECT ... FOR UPDATE с retry-логикой:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>public function decrementStock(int $productId, int $quantity): bool
{
    $maxRetries = 3;
    $retryDelay = 100; // ms

    for ($attempt = 1; $attempt &lt;= $maxRetries; $attempt++) {
        $this-&gt;db-&gt;transStart();

        try {
            $product = $this-&gt;db
                -&gt;table('inventory')
                -&gt;where('product_id', $productId)
                -&gt;lockForUpdate()
                -&gt;get()
                -&gt;getRowArray();

            if ($product['stock'] &lt; $quantity) {
                $this-&gt;db-&gt;transRollback();
                return false;
            }

            $this-&gt;db-&gt;table('inventory')
                -&gt;where('product_id', $productId)
                -&gt;update(['stock' =&gt; $product['stock'] - $quantity]);

            $this-&gt;db-&gt;transCommit();
            return true;

        } catch (\Exception $e) {
            $this-&gt;db-&gt;transRollback();

            if ($attempt &lt; $maxRetries &amp;&amp; str_contains($e-&gt;getMessage(), 'Deadlock')) {
                usleep($retryDelay * 1000 * $attempt);
                continue;
            }

            throw $e;
        }
    }

    return false;
}
</code></pre><p>Деплой. Прошло. Продолжаем.</p><p><strong>01:47 — Главное событие</strong></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Database primary unreachable </code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: All checkout endpoints down </code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Payment service timeout </code></p><p>MySQL primary упал. Не лёг — упал. Полностью. Причина выяснилась позже: диск заполнился из-за бинарных логов (binary log retention не был настроен для ситуации с × 15 write операциями).</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>df -h /var/lib/mysql
# Filesystem: 100% used (512GB / 512GB)
</code></pre><p>512 гигабайт. Всё. Диск кончился. MySQL не может писать — MySQL падает. Элегантно, ничего не скажешь.</p><p>Автоматический failover сработал — реплика стала primary. Это заняло 23 секунды. 23 секунды — 100% ошибок на checkout. При трафике BF это $147,000 потерянной выручки. За двадцать три секунды.</p><p>Затем выяснилось: наша реплика не была настроена на роль primary. У неё не было некоторых критических stored procedures. Checkout стал работать, но с ошибками 15%.</p><p>Следующие 40 минут команда в поту накатывала stored procedures на новый primary, чистила binlogs на упавшем сервере (он был ещё нужен), настраивала новую репликацию.</p><p><strong>02:47 — Всё относительно стабильно</strong></p><p>Error rate 2.3%. Для BF — терпимо, но не хорошо.</p><p><strong>04:15 — Redis OOM</strong></p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>OOM command not allowed when used memory &gt; 'maxmemory'
</code></pre><p>Redis закончил память. Eviction policy была <code>noeviction</code> — вместо того чтобы выкидывать старые ключи, Redis начал отклонять все write-операции. Приложение посыпалось.</p><p>Быстрый фикс:</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>redis-cli CONFIG SET maxmemory-policy allkeys-lru
</code></pre><p>LRU eviction включён. Redis начал вытеснять старые ключи. Кэш-хиты упали с 94% до 61%, нагрузка на MySQL снова выросла, но хотя бы приложение работало.</p><hr><h3>Постмортем: что пошло не так</h3><p><strong>Причина 1: Мы не тестировали реальный сценарий</strong></p><p>Нагрузочный тест в ×5 — это не BF при ×15. Мы протестировали "всё нормально", а не "всё горит". Нужно было делать chaos testing: убивать primary во время нагрузки, заполнять диск, устраивать OOM Redis.</p><p><strong>Причина 2: MySQL binlog retention</strong></p><pre spellcheck="" class="tmiCode language-sql" data-language="SQL"><code>-- Должно быть настроено!
SET GLOBAL binlog_expire_logs_seconds = 86400; -- 24 часа
</code></pre><p>Это одна строчка. ОДНА. И она бы предотвратила заполнение диска.</p><p><strong>Причина 3: Replica не была готова к роли primary</strong></p><p>Наша "репликация" была настроена для read scaling, а не для failover. Stored procedures, triggers, специфичные настройки — ничего из этого не дублировалось. Это фундаментальная ошибка в архитектуре HA.</p><p><strong>Причина 4: Redis maxmemory-policy noeviction</strong></p><p>Кто-то (я) настроил <code>noeviction</code> "чтобы данные не терялись". Логика благородная. Результат катастрофический. В production eviction лучше, чем полный отказ сервиса.</p><hr><h3>Что изменилось после</h3><p><strong>1. GameDay — обязательная практика</strong></p><p>Раз в квартал мы проводим "день катастрофы": специально ломаем production-like окружение и смотрим как команда реагирует. Сценарии: упал primary MySQL, заполнился диск, OOM Redis, одна нода k8s недоступна.</p><p><strong>2. Runbook для каждого critical alert</strong></p><p>Каждый алерт в PagerDuty теперь ссылается на confluence-страницу с пошаговым runbook. Дежурный не должен думать — он должен читать и выполнять.</p><p><strong>3. Pre-BF чеклист</strong></p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code># Automated pre-event checks
./scripts/pre-event-check.sh

# Checks:
# ✓ MySQL disk usage &lt; 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
</code></pre><hr><h3>Финансовые итоги</h3><p>Общие потери от инцидентов BF: ~$230,000 (прямые потери выручки + компенсации + репутационный ущерб).</p><p>Следующий год, после всех изменений: BF прошёл без единого critical инцидента. Error rate — 0.08%. Выручка выросла на 340% по сравнению с прошлым BF.</p><p>Цена нормальной подготовки: 3 недели работы команды + $15,000 на gameday инфраструктуру.</p><p>Разница: 230,000 vs 15,000. Выбор очевиден.</p><p>Делитесь похожими историями — на ithub.uno такие postmortem'ы читают и обсуждают живее всего. Потому что в них — настоящий опыт. <span class="tmiEmoji" title="">💀</span><span class="tmiEmoji" title="">➡️</span><span class="tmiEmoji" title="">🧠</span></p><hr>]]></description><guid isPermaLink="false">77</guid><pubDate>Sat, 21 Mar 2026 15:47:58 +0000</pubDate></item><item><title>N+1 &#x437;&#x430;&#x43F;&#x440;&#x43E;&#x441;&#x43E;&#x432;: &#x443;&#x431;&#x438;&#x439;&#x446;&#x430; &#x43F;&#x440;&#x43E;&#x438;&#x437;&#x432;&#x43E;&#x434;&#x438;&#x442;&#x435;&#x43B;&#x44C;&#x43D;&#x43E;&#x441;&#x442;&#x438;, &#x43A;&#x43E;&#x442;&#x43E;&#x440;&#x43E;&#x433;&#x43E; &#x432;&#x441;&#x435; &#x437;&#x43D;&#x430;&#x44E;&#x442; &#x438; &#x43D;&#x438;&#x43A;&#x442;&#x43E; &#x43D;&#x435; &#x437;&#x430;&#x43C;&#x435;&#x447;&#x430;&#x435;&#x442;</title><link>https://ithub.uno/blogs/entry/74-n1-zaprosov-ubijca-proizvoditelnosti-kotorogo-vse-znayut-i-nikto-ne-zamechaet/</link><description><![CDATA[<p>Есть проблемы, о которых говорят на каждой конференции, пишут в каждом учебнике и которые всё равно продолжают жить в каждом втором продакшн-проекте. N+1 — именно такая. Это как тараканы: знаешь о них, ведёшь с ними борьбу, думаешь что победил — а потом открываешь новый модуль и привет.</p><p>Сегодня расскажу про реальный кейс из нашего highload-проекта на PHP 8.2 + CodeIgniter 4. И покажу, как мы с этим боролись системно, а не точечными заплатками.</p><hr><h3>Что такое N+1 на практике</h3><p>Теория все знают. Загружаешь список из N объектов, потом для каждого делаешь ещё один запрос. Итого 1 + N запросов вместо 1-2. При N=100 это 101 запрос вместо 2. Ничего страшного, да? Нет.</p><p>Вот реальный пример из нашего кода. Страница со списком заказов:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// OrderModel.php — выглядит невинно
public function getOrdersList(int $page = 1): array
{
    $orders = $this-&gt;paginate(50);
    
    foreach ($orders as &amp;$order) {
        // Запрос #1 ... #50: грузим пользователя
        $order['user'] = model('UserModel')-&gt;find($order['user_id']);
        
        // Запрос #51 ... #100: грузим товары заказа
        $order['items'] = model('OrderItemModel')
            -&gt;where('order_id', $order['id'])
            -&gt;findAll();
        
        // Запрос #101 ... #150: грузим статус доставки
        $order['delivery'] = model('DeliveryModel')
            -&gt;where('order_id', $order['id'])
            -&gt;first();
    }
    
    return $orders;
}
</code></pre><p>50 заказов на странице. Итого: 1 (список) + 50 (пользователи) + 50 (товары) + 50 (доставка) = <strong>151 запрос</strong>. На страницу. Которую открывают 500 раз в минуту. Итого 75,500 запросов в минуту только на эту одну страницу.</p><p>MySQL рыдал. Тихо, но рыдал.</p><hr><h3>Обнаружение: логирование запросов в CI4</h3><p>Первым шагом была инструментация. CI4 позволяет логировать все запросы через Toolbar, но в highload нам нужно что-то более production-ready.</p><p>Мы написали EventSubscriber, который считает запросы на request:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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-&gt;getDuration(6);

        if ($duration &gt; 0.1) { // 100ms threshold
            self::$slowQueries[] = [
                'sql'      =&gt; $query-&gt;getQuery(),
                'duration' =&gt; $duration,
                'trace'    =&gt; debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10),
            ];
        }

        if (self::$queryCount &gt; 50) {
            log_message('warning', sprintf(
                'N+1 suspicion: %d queries for %s %s',
                self::$queryCount,
                service('request')-&gt;getMethod(),
                service('request')-&gt;getUri()-&gt;getPath()
            ));
        }
    }

    public static function getReport(): array
    {
        return [
            'total'        =&gt; self::$queryCount,
            'slow_queries' =&gt; self::$slowQueries,
        ];
    }
}
</code></pre><p>Через неделю мы нашли 23 endpoint'а с N+1. Некоторые делали <strong>до 800 запросов за один HTTP request</strong>. Один из них — статистическая страница для администратора — делал 1,247 запросов. Нет, это не опечатка.</p><hr><h3>Решение 1: Eager Loading через Query Builder</h3><p>В CI4 нет встроенного ORM с eager loading как в Laravel. Но это не повод делать N+1. Пишем вручную — это даже лучше, потому что контролируешь каждый запрос:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?php

namespace App\Models;

class OrderModel extends Model
{
    public function getOrdersWithRelations(int $page = 1): array
    {
        // Запрос 1: список заказов
        $orders = $this-&gt;paginate(50);
        
        if (empty($orders)) {
            return [];
        }

        $orderIds = array_column($orders, 'id');
        $userIds  = array_unique(array_column($orders, 'user_id'));

        // Запрос 2: все пользователи одним запросом
        $users = model('UserModel')
            -&gt;whereIn('id', $userIds)
            -&gt;findAll();
        $usersMap = array_column($users, null, 'id');

        // Запрос 3: все товары заказов одним запросом
        $items = model('OrderItemModel')
            -&gt;whereIn('order_id', $orderIds)
            -&gt;findAll();
        $itemsMap = [];
        foreach ($items as $item) {
            $itemsMap[$item['order_id']][] = $item;
        }

        // Запрос 4: вся доставка одним запросом
        $deliveries = model('DeliveryModel')
            -&gt;whereIn('order_id', $orderIds)
            -&gt;findAll();
        $deliveriesMap = array_column($deliveries, null, 'order_id');

        // Собираем результат в памяти
        foreach ($orders as &amp;$order) {
            $order['user']     = $usersMap[$order['user_id']] ?? null;
            $order['items']    = $itemsMap[$order['id']] ?? [];
            $order['delivery'] = $deliveriesMap[$order['id']] ?? null;
        }

        return $orders;
    }
}
</code></pre><p>151 запрос → 4 запроса. Время ответа страницы: с 3.2 секунды до 87 миллисекунд. Разница в 37 раз. Буквально изменением подхода к написанию одного метода.</p><hr><h3>Решение 2: Автоматическое обнаружение в CI4 Filter</h3><p>Чтобы N+1 не возвращались незаметно, добавили Filter для development/staging:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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 &gt;= self::WARNING_THRESHOLD) {
            $level = $count &gt;= self::ERROR_THRESHOLD ? 'error' : 'warning';

            log_message($level, sprintf(
                '[QueryAnalyzer] %s %s: %d queries',
                $request-&gt;getMethod(),
                $request-&gt;getUri()-&gt;getPath(),
                $count
            ));

            if (ENVIRONMENT === 'development') {
                $response-&gt;setHeader('X-Query-Count', (string) $count);
                $response-&gt;setHeader('X-Query-Warning', $count &gt;= self::ERROR_THRESHOLD ? 'N+1_DETECTED' : 'HIGH_QUERIES');
            }
        }

        return $response;
    }
}
</code></pre><p>Теперь любой новый endpoint с N+1 автоматически логируется с уровнем error. Это попадает в наш ELK стек, алертинг срабатывает, приходит уведомление. Разработчик узнаёт о проблеме ещё на code review этапе, а не когда MySQL упал в прод.</p><hr><h3>Цифры до и после</h3><div class="tmiRichText__table-wrapper"><table style="width: 1466px;"><colgroup><col style="width:182px;"><col style="width:162px;"><col style="width:214px;"><col style="width:233px;"><col style="width:675px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Endpoint</p></th><th colspan="1" rowspan="1"><p>Запросов ДО</p></th><th colspan="1" rowspan="1"><p>Запросов ПОСЛЕ</p></th><th colspan="1" rowspan="1"><p>Время ответа ДО</p></th><th colspan="1" rowspan="1"><p>Время ПОСЛЕ</p></th></tr><tr><td colspan="1" rowspan="1"><p>Список заказов</p></td><td colspan="1" rowspan="1"><p>151</p></td><td colspan="1" rowspan="1"><p>4</p></td><td colspan="1" rowspan="1"><p>3200ms</p></td><td colspan="1" rowspan="1"><p>87ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>Профиль пользователя</p></td><td colspan="1" rowspan="1"><p>89</p></td><td colspan="1" rowspan="1"><p>3</p></td><td colspan="1" rowspan="1"><p>1800ms</p></td><td colspan="1" rowspan="1"><p>45ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>Каталог товаров</p></td><td colspan="1" rowspan="1"><p>347</p></td><td colspan="1" rowspan="1"><p>5</p></td><td colspan="1" rowspan="1"><p>8900ms</p></td><td colspan="1" rowspan="1"><p>210ms</p></td></tr><tr><td colspan="1" rowspan="1"><p>Статистика</p></td><td colspan="1" rowspan="1"><p>1247</p></td><td colspan="1" rowspan="1"><p>12</p></td><td colspan="1" rowspan="1"><p>31000ms</p></td><td colspan="1" rowspan="1"><p>890ms</p></td></tr></tbody></table></div><p>Суммарная нагрузка на MySQL упала на <strong>78%</strong>. Не шучу. Просто убрали N+1 — и почти вдвое освободили ресурсы БД.</p><hr><h3>Главный вывод</h3><p>N+1 — это не ошибка джуниоров. Это системная проблема, которая возникает когда нет инструментов для её обнаружения и нет культуры её предотвращения. Добавьте автоматическое логирование числа запросов. Сделайте Code Review check на паттерны N+1. И помните: каждый <code>.find()</code> внутри цикла — это потенциальная бомба.</p><p>Удачи вашим базам данных. <span class="tmiEmoji" title="">🗄️</span></p><hr>]]></description><guid isPermaLink="false">74</guid><pubDate>Sat, 21 Mar 2026 15:46:03 +0000</pubDate></item><item><title>Kubernetes &#x432; &#x43F;&#x440;&#x43E;&#x434;&#x430;&#x43A;&#x448;&#x43D;&#x435;: 18 &#x43C;&#x435;&#x441;&#x44F;&#x446;&#x435;&#x432;, 47 &#x438;&#x43D;&#x446;&#x438;&#x434;&#x435;&#x43D;&#x442;&#x43E;&#x432; &#x438; &#x43E;&#x434;&#x43D;&#x43E; &#x43F;&#x440;&#x43E;&#x441;&#x432;&#x435;&#x442;&#x43B;&#x435;&#x43D;&#x438;&#x435;</title><link>https://ithub.uno/blogs/entry/71-kubernetes-v-prodakshne-18-mesyacev-47-incidentov-i-odno-prosvetlenie/</link><description><![CDATA[<p>Прежде чем начать, хочу сказать одну важную вещь: я люблю Kubernetes. Искренне. Как любят сложного человека — за глубину, за то, что никогда не знаешь чего ожидать, за то, что каждый день чему-то учишься. И одновременно хочется иногда взять его и... ну, вы понимаете.</p><p>Восемнадцать месяцев в продакшне с k8s. Сорок семь инцидентов в PagerDuty. Из них тридцать один — "это мы сами виноваты". Остальные шестнадцать — "это k8s виноват, но мы неправильно его настроили". Итого: сорок семь раз мы были виноваты сами. Добро пожаловать в правду.</p><hr><h3>Начало: эйфория</h3><p>Мы переехали на k8s с bare-metal + ansible + systemd. По тем временам это был большой шаг. Наконец-то: декларативная конфигурация, автомасштабирование, rolling updates без даунтайма, самолечение. Красота!</p><p>Первая неделя прошла в написании манифестов. Я писал их как поэт — вдохновенно, не жалея строк. YAML цвёл. Deployments, Services, ConfigMaps, Secrets, Ingress — всё было прекрасно.</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>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"
</code></pre><p><code>image: myapp:latest</code> — это первая из ошибок, которая позже стоила нам часа даунтайма. Тег <code>latest</code> — это зло. Это не версия, это "бог знает что". При следующем деплое k8s может подтянуть другой образ на разные ноды, и у вас будет кластер, где половина подов — старая версия, а половина — новая, и они несовместимы. Всегда используйте immutable теги: git sha, semver, timestamp. Всегда.</p><hr><h3>Инцидент #7: OOMKiller приходит ночью</h3><p>Наше PHP приложение — CI4 с тяжёлой бизнес-логикой. PHP-FPM воркеры потребляют память по-разному в зависимости от endpoint'а. Лёгкий API — 40MB. Тяжёлый отчёт — 380MB. Мы выставили <code>limits.memory: 512Mi</code> и думали, что этого хватит.</p><p>Ночью запустился cron-джоб, который генерировал ежемесячные отчёты. Каждый воркер под отчёт — ~380MB. PHP-FPM с 8 воркерами = 3GB. Лимит пода — 512MB.</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>OOMKilled
Exit Code: 137
</code></pre><p>Под убит. k8s перезапускает. Новый под опять запускает отчёт. Опять OOMKilled. Restart loop. Кейс называется CrashLoopBackOff, и выглядит он в логах примерно так:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>pod/php-app-7d9f8c-xk2p9  0/1  CrashLoopBackOff  14  47m
</code></pre><p>Четырнадцать рестартов за 47 минут. k8s упорно пытался поднять под, PHP упорно пытался сожрать память, OOMKiller упорно его убивал. Это было похоже на зомби-апокалипсис в миниатюре.</p><p><strong>Решение: разделение воркеров</strong></p><p>Мы разделили PHP-FPM на два пула:</p><pre spellcheck="" class="tmiCode language-ini" data-language="ini"><code>; /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
</code></pre><p>И создали отдельный Deployment для heavy-операций с увеличенными лимитами:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code># heavy-deployment.yaml
resources:
  requests:
    memory: "512Mi"
    cpu: "500m"
  limits:
    memory: "2Gi"
    cpu: "2000m"
</code></pre><p>Nginx роутит по prefix:</p><pre spellcheck="" class="tmiCode language-nginx" data-language="Nginx"><code>location /api/reports/ {
    fastcgi_pass heavy-php-fpm:9001;
}

location / {
    fastcgi_pass www-php-fpm:9000;
}
</code></pre><p>Элегантно? Не очень. Работает? Да.</p><hr><h3>Инцидент #19: Liveness probe убивает прод</h3><p>Это был шедевр. Мы настроили liveness probe — k8s регулярно проверяет, жив ли под, и если нет — убивает и перезапускает:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>livenessProbe:
  httpGet:
    path: /health
    port: 80
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3
</code></pre><p>Endpoint <code>/health</code> возвращал 200 и JSON со статусами всех зависимостей: Redis, MySQL, очередь. Казалось бы — отлично.</p><p>И вот в один прекрасный день MySQL реплика начала лагать (проблема с дисками). Запросы к реплике стали занимать 15-20 секунд. Наш <code>/health</code> endpoint проверял реплику, таймаут probe — 5 секунд. Итог:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>Liveness probe failed: Get "http://10.244.2.5/health": context deadline exceeded
</code></pre><p>k8s решил, что под нездоров. Убил его. Поднял новый. Новый тоже упёрся в лагающую реплику. Тоже убит. В какой-то момент k8s убивал поды быстрее, чем они успевали принять трафик.</p><p>Мы устроили <strong>собственноручный DDoS на наш прод с помощью liveness probe</strong>. Это надо уметь.</p><p><strong>Решение:</strong></p><p>Разделили liveness и readiness:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code># 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
</code></pre><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// CI4 Controller
class HealthController extends BaseController
{
    public function live(): ResponseInterface
    {
        // Только базовая проверка процесса
        return $this-&gt;response-&gt;setJSON(['status' =&gt; 'ok']);
    }

    public function ready(): ResponseInterface
    {
        $checks = [
            'database' =&gt; $this-&gt;checkDatabase(),
            'redis'    =&gt; $this-&gt;checkRedis(),
            'queue'    =&gt; $this-&gt;checkQueue(),
        ];

        $healthy = !in_array(false, $checks, true);

        return $this-&gt;response
            -&gt;setStatusCode($healthy ? 200 : 503)
            -&gt;setJSON([
                'status' =&gt; $healthy ? 'ready' : 'degraded',
                'checks' =&gt; $checks,
            ]);
    }
}
</code></pre><p>Теперь при проблемах с репликой поды переставали получать трафик (readiness failed), но не убивались (liveness — ok). Через 20 минут реплика восстановилась, readiness снова позеленела, трафик вернулся. Без единой 500-й ошибки.</p><hr><h3>Инцидент #31: HPA и смерть от масштабирования</h3><p>Horizontal Pod Autoscaler — прекрасная вещь. Трафик растёт — поды добавляются. Трафик падает — поды убираются. Автоматически. Без участия человека.</p><p>Кроме случаев, когда всё идёт не так.</p><p>Мы настроили HPA по CPU:</p><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>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
</code></pre><p>Ждём нагрузочного теста. Трафик растёт. HPA видит CPU 85% → добавляет поды. Новые поды стартуют, прогревают OPcache, пока прогреваются — CPU у них 90% → HPA добавляет ещё поды. Те тоже прогреваются → CPU опять высокий → ещё поды.</p><p>Через 3 минуты у нас было 28 подов из максимальных 30. Они все одновременно прогревали OPcache, коннектились к MySQL, коннектились к Redis. MySQL задыхался от 28×20 = 560 новых соединений. Redis cluster не успевал. Прод лёг под весом собственного масштабирования.</p><p><strong>Решение:</strong></p><ol><li><p>Добавить cooldown и stabilization:</p></li></ol><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>behavior:
  scaleUp:
    stabilizationWindowSeconds: 60
    policies:
      - type: Pods
        value: 2  # Не более 2 подов за раз
        periodSeconds: 60
  scaleDown:
    stabilizationWindowSeconds: 300
</code></pre><ol start="2"><li><p>Использовать custom metrics (RPS), а не CPU:</p></li></ol><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "300"
</code></pre><ol start="3"><li><p>Добавить PHP-FPM warming в startup probe:</p></li></ol><pre spellcheck="" class="tmiCode language-yaml" data-language="YAML"><code>startupProbe:
  httpGet:
    path: /warmup
    port: 80
  failureThreshold: 30
  periodSeconds: 5
</code></pre><p>Endpoint <code>/warmup</code> прогревал OPcache и пул соединений. Пока startupProbe не вернула 200 — под не получал трафик и не учитывался в метриках HPA.</p><hr><h3>Просветление</h3><p>После 18 месяцев и 47 инцидентов я пришёл к простому выводу: <strong>Kubernetes не упрощает жизнь. Он переносит сложность из "как запустить" в "как правильно настроить".</strong> И это совершенно другой уровень сложности — более тонкий, более коварный, и требующий понимания системы на глубоком уровне.</p><p>Ключевые правила, которые я вколотил в стену рядом с рабочим столом:</p><ul><li><p><strong>Никогда </strong><code>image:latest</code> — только иммутабельные теги</p></li><li><p><strong>Liveness ≠ Readiness</strong> — это разные вещи с разной ценой ошибки</p></li><li><p><strong>Resource limits — это не потолок, это граница жизни и смерти пода</strong></p></li><li><p><strong>HPA с CPU — ловушка для PHP</strong>. Используйте RPS или custom metrics</p></li><li><p><strong>Один под не значит один процесс</strong> — PHP-FPM это N воркеров</p></li><li><p><strong>Chaos engineering</strong> — убивайте поды намеренно, пока это не сделает production</p></li></ul><p>И последнее: k8s — это не серебряная пуля. Это мощный инструмент с огромным количеством движущихся частей. Уважайте его, изучайте, читайте исходники при необходимости. И обязательно найдите коммьюнити — на ithub.uno, на форумах, на конференциях. Потому что некоторые грабли лучше подбирать чужим лбом.</p><p>До следующего CrashLoopBackOff. <span class="tmiEmoji" title="">🤕</span></p><hr>]]></description><guid isPermaLink="false">71</guid><pubDate>Sat, 21 Mar 2026 15:43:46 +0000</pubDate></item><item><title>Redis Cluster: &#x43A;&#x430;&#x43A; &#x44F; &#x43F;&#x43E;&#x442;&#x435;&#x440;&#x44F;&#x43B; &#x434;&#x430;&#x43D;&#x43D;&#x44B;&#x435;, &#x43D;&#x430;&#x448;&#x451;&#x43B; &#x438;&#x445; &#x43E;&#x431;&#x440;&#x430;&#x442;&#x43D;&#x43E; &#x438; &#x43F;&#x43E;&#x441;&#x435;&#x434;&#x435;&#x43B; &#x434;&#x432;&#x430;&#x436;&#x434;&#x44B;</title><link>https://ithub.uno/blogs/entry/68-redis-cluster-kak-ya-poteryal-dannye-nashyol-ih-obratno-i-posedel-dvazhdy/</link><description><![CDATA[<p>Есть вещи, которые меняют тебя как специалиста. Первый деплой в прод. Первый incident report, который ты пишешь в 3 ночи. И первый раз, когда ты видишь в логах Redis: <code>CLUSTERDOWN Hash slot not served</code>. Вот это последнее — особенное. После такого начинаешь иначе смотреть на жизнь, на архитектуру и на документацию, которую ты "почти дочитал".</p><p>Сегодня расскажу про Redis Cluster в highload-продакшне. Без прикрас, без маркетинговых буклетов. Только боль, инсайты и несколько команд, которые спасли мне карьеру.</p><hr><h3>Контекст: зачем вообще Redis Cluster</h3><p>К тому моменту мы уже года полтора успешно жили на одном Redis-инстансе с репликой. Всё было хорошо: 50GB данных, ~80,000 ops/sec в пике, latency стабильно под 1ms. Идиллия.</p><p>Потом случился бизнес. Нас купили, влили денег, пользователей стало в пять раз больше. Нагрузка выросла до 380,000 ops/sec. Один Redis задыхался. CPU на инстансе — 94% (Redis однопоточный в плане основного event loop, напоминаю). Latency поползла вверх — 8ms, 15ms, 40ms...</p><p>Решение очевидное: Redis Cluster. Шардирование данных по hash slots (всего 16384 слота) на несколько нод. Я читал документацию. Я смотрел туториалы. Я думал, что готов.</p><p>Я не был готов.</p><hr><h3>Первая попытка: наивная</h3><p>Поднял кластер из 3 мастеров + 3 реплик. Конфигурация нод:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>redis1 (master) — слоты 0-5460
redis2 (master) — слоты 5461-10922
redis3 (master) — слоты 10923-16383
redis4 (replica) — реплицирует redis1
redis5 (replica) — реплицирует redis2
redis6 (replica) — реплицирует redis3
</code></pre><p>Всё прекрасно работало на стейджинге. В прод переехали ночью. Первые два часа — тишина и красивые графики.</p><p>Потом началось.</p><p>Наш код активно использовал <code>MGET</code>, <code>MSET</code> и пайплайны. И вот тут — сюрприз из документации, которую я "почти дочитал": <strong>в Redis Cluster мульти-ключевые операции работают только если все ключи находятся в одном hash slot</strong>.</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>CROSSSLOT Keys in request don't hash to the same slot
</code></pre><p>Это сообщение я запомнил навсегда. Потому что половина нашего кода сыпала им как из ведра.</p><hr><h3>Погружение в hash tags</h3><p>Redis Cluster использует CRC16 от имени ключа для определения слота. Но если в ключе есть фигурные скобки <code>{}</code>, то для вычисления слота используется только содержимое скобок — это называется hash tag.</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code># Эти ключи попадут в РАЗНЫЕ слоты:
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
</code></pre><p>Казалось бы, просто добавить фигурные скобки и всё. Но у нас было 200+ мест в коде с генерацией ключей. И это был PHP-монолит, переписанный на CI4 модули. Кайф.</p><p>Мы написали специальный класс-обёртку:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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;
    }
}
</code></pre><p>И CI4 Cache Driver, который умеет работать с Cluster:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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-&gt;redis = new \RedisCluster(
            null,
            $config-&gt;redisClusterNodes,
            $config-&gt;redisTimeout,
            $config-&gt;redisReadTimeout,
            true, // persistent
            $config-&gt;redisAuth
        );

        $this-&gt;redis-&gt;setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY);
    }

    public function getMultiple(array $keys, mixed $default = null): array
    {
        // Группируем ключи по слотам для batch-операций
        $slotGroups = $this-&gt;groupBySlot($keys);
        $result = [];

        foreach ($slotGroups as $slotKeys) {
            $values = $this-&gt;redis-&gt;mget($slotKeys);
            foreach ($slotKeys as $i =&gt; $key) {
                $result[$key] = $values[$i] !== false
                    ? $this-&gt;unserialize($values[$i])
                    : $default;
            }
        }

        return $result;
    }

    private function groupBySlot(array $keys): array
    {
        $groups = [];
        foreach ($keys as $key) {
            $slot = $this-&gt;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) &amp; 0x3FFF; // 16384 слота
    }
}
</code></pre><hr><h3>Второй уровень ада: failover</h3><p>Мы починили CROSSSLOT. Живём. Счастливы. Три недели тишины.</p><p>Потом у нас умер сервер. Не виртуалка, не под — физический сервер. Просто взял и умер в 14:22 в среду. На нём жили redis2 (мастер) и redis5 (реплика redis1, то есть другого мастера).</p><p>Вот тут начался настоящий thriller.</p><p>Redis Cluster должен автоматически делать failover: реплика замечает, что мастер недоступен, объявляет выборы, становится новым мастером. Это работает. Но есть нюанс, который я знал теоретически и совершенно недооценил практически.</p><p><strong>Время failover — от 15 до 30 секунд.</strong></p><p>Пятнадцать секунд, в течение которых слоты 5461-10922 не обслуживаются. При 380,000 ops/sec. Можете себе представить, как выглядел error rate в эти 15 секунд?</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>Error rate: 0.1% → 34% → 67% → 89% → 67% → 12% → 0.3%
</code></pre><p>Наш PHP-код в CI4 при ошибке Redis просто падал с exception. Никакого graceful degradation. Никакого fallback на БД. Просто 500-е ответы.</p><p>Вот это был момент просветления.</p><hr><h3>Решение: Circuit Breaker для Redis</h3><p>Мы реализовали паттерн Circuit Breaker специально для Redis. В CI4 это элегантно делается через Service Container:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>&lt;?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-&gt;isOpen()) {
            return $this-&gt;fallback-&gt;get($key, $default);
        }

        try {
            $value = $this-&gt;redis-&gt;get($key);
            $this-&gt;onSuccess();
            return $value ?? $default;
        } catch (\Throwable $e) {
            $this-&gt;onFailure($e);
            return $this-&gt;fallback-&gt;get($key, $default);
        }
    }

    public function set(string $key, mixed $value, int $ttl = 0): bool
    {
        if ($this-&gt;isOpen()) {
            return $this-&gt;fallback-&gt;set($key, $value, $ttl);
        }

        try {
            $result = $this-&gt;redis-&gt;save($key, $value, $ttl);
            $this-&gt;onSuccess();
            return $result;
        } catch (\Throwable $e) {
            $this-&gt;onFailure($e);
            return $this-&gt;fallback-&gt;set($key, $value, $ttl);
        }
    }

    private function isOpen(): bool
    {
        if ($this-&gt;state === 'open') {
            if (time() - $this-&gt;lastFailureTime &gt; self::RECOVERY_TIMEOUT) {
                $this-&gt;state = 'half-open';
                $this-&gt;halfOpenCalls = 0;
                return false;
            }
            return true;
        }
        return false;
    }

    private function onSuccess(): void
    {
        if ($this-&gt;state === 'half-open') {
            $this-&gt;halfOpenCalls++;
            if ($this-&gt;halfOpenCalls &gt;= self::HALF_OPEN_MAX_CALLS) {
                $this-&gt;state = 'closed';
                $this-&gt;failureCount = 0;
            }
        } elseif ($this-&gt;state === 'closed') {
            $this-&gt;failureCount = max(0, $this-&gt;failureCount - 1);
        }
    }

    private function onFailure(\Throwable $e): void
    {
        $this-&gt;lastFailureTime = time();
        $this-&gt;failureCount++;

        if ($this-&gt;state === 'half-open' || $this-&gt;failureCount &gt;= self::FAILURE_THRESHOLD) {
            $this-&gt;state = 'open';
            log_message('critical', 'Redis Circuit Breaker OPEN: ' . $e-&gt;getMessage());
        }
    }
}
</code></pre><p>Регистрируем в Services:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// 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
    );
}
</code></pre><p>Теперь при падении Redis кластера приложение продолжало работать — медленнее, с filesystem кэшем, но без 500-х ошибок. Error rate во время следующего (учинённого намеренно!) failover теста: <strong>0.8%</strong>. Против 89% до.</p><hr><h3>Третий круг: Memory fragmentation</h3><p>Это тихий убийца. Redis работает, данные записываются и читаются, но потихоньку mem_fragmentation_ratio ползёт вверх.</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>redis-cli --cluster call all-nodes INFO memory | grep mem_fragmentation_ratio
</code></pre><p>Через полгода работы я увидел значение <strong>2.47</strong>. Норма — от 1.0 до 1.5. Значение 2.47 означает, что Redis использует в 2.47 раза больше памяти, чем реально нужно для данных. На наших 80GB инстансах это ~50GB впустую.</p><p>Причина — интенсивные операции записи/удаления ключей с разным TTL, что приводит к фрагментации heap у jemalloc.</p><p>Решение — Active defragmentation:</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>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
</code></pre><p>Через сутки mem_fragmentation_ratio опустился до 1.18. Мы вернули ~45GB памяти. Без перезапуска. В прод.</p><hr><h3>Итог и выводы</h3><p>Redis Cluster — мощнейший инструмент, но он требует понимания на уровне "читал исходники, а не только README". Мои главные уроки:</p><ol><li><p><strong>Hash tags планируйте заранее</strong>, не когда уже CROSSSLOT в логах</p></li><li><p><strong>Failover длится 15-30 секунд</strong> — ваш код должен это переживать</p></li><li><p><strong>Circuit Breaker — обязательный паттерн</strong>, не опциональный</p></li><li><p><strong>Мониторьте mem_fragmentation_ratio</strong> — без этого потеряете память</p></li><li><p><strong>Multi-key операции — только в одном слоте</strong> — это не баг, это дизайн</p></li><li><p><strong>Latency в cluster выше</strong>, чем в standalone — заложите это в SLA</p></li></ol><p>Если вы тоже занимаетесь highload и Redis — заходите на ithub.uno, там есть живые обсуждения именно таких кейсов. Без теории ради теории, только production experience.</p><p>Удачи с кластерами. И пусть ваши слоты всегда будут served. <span class="tmiEmoji" title="">🔴</span><span class="tmiEmoji" title="">✅</span></p><hr>]]></description><guid isPermaLink="false">68</guid><pubDate>Sat, 21 Mar 2026 15:42:18 +0000</pubDate></item><item><title>&#x41F;&#x44F;&#x442;&#x43D;&#x438;&#x446;&#x430;, 23:47. &#x41F;&#x440;&#x43E;&#x434; &#x43B;&#x435;&#x436;&#x438;&#x442;. &#x42F; &#x432; &#x432;&#x430;&#x43D;&#x43D;&#x43E;&#x439;. &#x41A;&#x43B;&#x430;&#x441;&#x441;&#x438;&#x43A;&#x430;.</title><link>https://ithub.uno/blogs/entry/65-pyatnica-2347-prod-lezhit-ya-v-vannoj-klassika/</link><description><![CDATA[<p>Привет, коллеги по несчастью. Меня зовут Максим, я продуктовый DevOps с десятью годами шрамов на психике и подгоревшим нервным окончанием там, где у нормальных людей находится чувство покоя. Сегодня я расскажу вам историю, которую в каждой IT-компании мира знают наизусть, но всё равно каждый раз проживают как первый раз. Историю о том, как прод падает именно тогда, когда тебе это меньше всего нужно.</p><p>Итак. Декабрь. Пятница. Мы только что задеплоили «маленький hotfix» — ну там, буквально пять строчек, ничего серьёзного. Я уже мысленно дома, уже открываю холодильник, уже слышу шипение открываемой банки. И тут — дзынь. Алерт в Telegram. Потом ещё один. Потом ещё пять. Потом просто поток, как будто кто-то открыл кран с тревогами.</p><p></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Response time &gt; 30s</code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Error rate 78% </code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Database connections exhausted </code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Redis timeout </code></p><p><code><span class="tmiEmoji" title="">🔴</span> CRITICAL: Payment service unreachable </code></p><p>Пять алертов за 40 секунд. Это рекорд, кстати. Я горжусь.</p><hr><h3>Анатомия катастрофы</h3><p>Теперь давайте по-серьёзному, потому что случай был действительно интересный с технической точки зрения, и на ithub.uno такие постморtem-разборы ценятся.</p><p>Итак, что мы имели на тот момент:</p><ul><li><p><strong>Стек:</strong> PHP 8.2, CodeIgniter 4.5, MySQL 8.0 (кластер primary + 2 replica), Redis 7.0 Cluster, Nginx, всё это добро в k8s на трёх нодах</p></li><li><p><strong>Трафик:</strong> ~3500 RPS в пике, средний — около 800 RPS</p></li><li><p><strong>Hotfix:</strong> изменили одну строчку в модели, которая отвечала за выборку пользовательских настроек</p></li></ul><p>Что могло пойти не так? Всё. Абсолютно всё.</p><p>Первое, что я сделал — зашёл на Grafana. Там картина маслом: RPS упал с 800 до 120, латентность взлетела с 80ms до 28 секунд (ДВАДЦАТИ ВОСЬМИ, Карл!), количество активных connections к MySQL упёрлось в потолок — 500/500, CPU на всех подах — 95%+.</p><p>Классический симптом. Я такое уже видел. Это называется «connection pool exhaustion» в сочетании с «slow query лавиной». Но причина была нетривиальной.</p><hr><h3>Копаем</h3><p>Первым делом — slow query log на MySQL:</p><pre spellcheck="" class="tmiCode language-sql" data-language="SQL"><code>SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;
</code></pre><p>Через 30 секунд смотрю лог и вижу:</p><pre spellcheck="" class="tmiCode language-sql" data-language="SQL"><code>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
</code></pre><p>Запрос сам по себе несложный. Выполняется за 0.003 секунды. Но... <code>EXPLAIN</code> показывает <code>Using temporary; Using filesort</code> на таблице <code>cart</code>. И вот тут начинается детективная история.</p><p>Дело в том, что за час до деплоя наш аналитик (земля ему пухом) запустил миграцию, которая добавила в таблицу <code>cart</code> новое поле <code>meta_json TEXT</code>. При этом индекс <code>idx_cart_user_status</code> не пересоздавался. Он просто... перестал эффективно работать после изменения статистики таблицы. MySQL решил, что full scan выгоднее. При этом таблица <code>cart</code> содержала 47 миллионов строк.</p><p>А наш «маленький hotfix»? Он убрал кэширование этого запроса. Буквально одну строку:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// Было:
return cache()-&gt;remember('user_data_' . $userId, 300, fn() =&gt; $this-&gt;buildUserData($userId));

// Стало (hotfix убрал кэш "для дебага"):
return $this-&gt;buildUserData($userId);
</code></pre><p>И вот оно. Идеальный шторм. Медленный запрос × отсутствие кэша × высокий трафик = прод в нокауте.</p><hr><h3>Решение в боевых условиях</h3><p>Времени на красивые решения нет. Алгоритм действий:</p><p><strong>Шаг 1: Feature flag</strong></p><p>У нас есть система feature flags на Redis. Мгновенно включаю maintenance mode для новых пользователей, пропуская залогиненных:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// CI4 Filter
class MaintenanceFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        if (cache()-&gt;get('maintenance_mode') &amp;&amp; !auth()-&gt;check()) {
            return redirect()-&gt;to('/maintenance');
        }
    }
}
</code></pre><p>Трафик упал на 40%. Дышим.</p><p><strong>Шаг 2: Откат</strong></p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>kubectl rollout undo deployment/app --to-revision=15
</code></pre><p>Это не решение проблемы с индексом, но снимает острую боль — кэш вернулся, запросы снова летают.</p><p><strong>Шаг 3: Экстренное создание индекса</strong></p><p>Пока прод восстанавливается, параллельно:</p><pre spellcheck="" class="tmiCode language-sql" data-language="SQL"><code>-- На реплике сначала тестируем
CREATE INDEX idx_cart_user_status_new
ON cart(user_id, status)
INCLUDE (id, created_at, meta_json)
ALGORITHM=INPLACE, LOCK=NONE;
</code></pre><p><code>ALGORITHM=INPLACE, LOCK=NONE</code> — это не просто красивые слова. На таблице в 47 миллионов строк это разница между «индекс создаётся 4 минуты без локов» и «БД заблокирована на 25 минут, прощайте».</p><p>Через 6 минут индекс на реплике. Тестируем — запрос летает за 1.2ms. Прогоняем на primary. Ещё 4 минуты. Готово.</p><p><strong>Шаг 4: Хотфикс хотфикса</strong></p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>return cache()-&gt;remember('user_data_' . $userId, 300, fn() =&gt; $this-&gt;buildUserData($userId));
</code></pre><p>Да, просто вернули строку обратно. Иногда лучшее решение — отмотать назад.</p><hr><h3>Постмортем и уроки</h3><p>Через два дня я провёл постмортем. Вот ключевые выводы, которые я теперь вколачиваю в голову каждому новому разработчику:</p><p><strong>1. Никогда не убирайте кэш "для дебага" в прод</strong> Это как снять шлем "чтобы лучше видеть". Дебажьте на стейджинге. Там специально и создано это место.</p><p><strong>2. Любая миграция схемы БД требует аудита индексов</strong> Мы внедрили правило: к каждому PR с миграцией прилагается <code>EXPLAIN</code> до и после на production-like данных (у нас есть анонимизированный дамп).</p><p><strong>3. Алерты должны быть actionable</strong> Пять одновременных алертов — это не пять проблем. Это одна проблема с пятью симптомами. Мы перенастроили alertmanager с группировкой и подавлением дублей.</p><p><strong>4. Connection pool — ваш первый друг и первый враг</strong> В CI4 мы теперь явно конфигурируем пул:</p><pre spellcheck="" class="tmiCode language-php" data-language="PHP"><code>// app/Config/Database.php
public array $default = [
    'DBDriver' =&gt; 'MySQLi',
    'hostname' =&gt; env('DB_HOST'),
    'pconnect'  =&gt; false, // persistent connections OFF в highload!
    'DBDebug'   =&gt; false,
    // ...
];
</code></pre><p>Persistent connections в highload — это бомба замедленного действия. Отключайте.</p><p><strong>5. Chaos engineering — не роскошь, а необходимость</strong> После этого инцидента мы раз в квартал намеренно убиваем случайный под в прод-кластере. Да, в проде. Нет, это не безумие — это единственный способ убедиться, что система действительно resilient.</p><hr><h3>Финал</h3><p>В 01:23 прод поднялся полностью. Показатели вернулись к норме. Я наконец открыл ту банку. Она была тёплой.</p><p>Но знаете что? Этот инцидент стоил нам примерно $4,000 потерянной выручки и несколько седых волос. Зато мы получили бесценный опыт и полностью переработали процесс деплоя. Теперь у нас есть автоматическая проверка slow queries перед каждым деплоем, обязательный review индексов при миграциях и — самое главное — правило: <strong>никаких деплоев в пятницу после 18:00</strong>.</p><p>Это правило нарушают только те, кто ещё не прожил свою первую пятничную аварию. После первой — никогда.</p><hr>]]></description><guid isPermaLink="false">65</guid><pubDate>Sat, 21 Mar 2026 15:40:23 +0000</pubDate></item><item><title>&#x423;&#x43F;&#x441;... &#x41E;&#x434;&#x438;&#x43D; git push --force, &#x438; &#x43F;&#x43E;&#x43B;&#x433;&#x43E;&#x434;&#x430; &#x440;&#x430;&#x431;&#x43E;&#x442;&#x44B; &#x43A;&#x43E;&#x43C;&#x430;&#x43D;&#x434;&#x44B; &#x438;&#x441;&#x43F;&#x430;&#x440;&#x438;&#x43B;&#x438;&#x441;&#x44C; &#x437;&#x430; &#x441;&#x435;&#x43A;&#x443;&#x43D;&#x434;&#x443;</title><link>https://ithub.uno/blogs/entry/64-ups-odin-git-push-force-i-polgoda-raboty-komandy-isparilis-za-sekundu/</link><description><![CDATA[<p>Есть команды, которые надо вводить с холодной головой и полным осознанием последствий. <code>rm -rf /</code> — очевидный пример. Но среди разработчиков и DevOps есть своя версия этой русской рулетки — <code>git push --force</code>.</p><p>Я работал тогда в продуктовой компании — делали SaaS-платформу для управления проектами. Небольшая команда, человек двенадцать, хороший продукт, живые клиенты, нормальный процесс разработки. Мы использовали GitHub, feature-ветки, pull requests, code review — всё как у взрослых.</p><p>Репозиторий был защищён: ветка <code>main</code> заблокирована от прямых пушей, обязательный PR с одобрением от двух ревьюеров. Всё серьёзно. Но была одна маленькая деталь, которую мы упустили: ветка <code>develop</code> — наша основная рабочая ветка, куда мерджились все фичи перед выходом в <code>main</code> — была защищена только от обычного <code>push</code>. Не от <code>push --force</code>.</p><p>Это упущение жило полтора года. До того утра.</p><hr><p>Антон — старший разработчик, умный, опытный, пять лет в команде — в среду утром пришёл на работу в слегка раздражённом состоянии. Накануне вечером работал из дома, что-то переделывал в своей feature-ветке, несколько раз rebase'ил на свежий <code>develop</code>, история коммитов запуталась. Он хотел сделать красиво — squash коммиты, причесать историю, залить обратно.</p><p>Он сделал <code>git rebase -i HEAD~15</code>. Причесал. Сделал <code>git push origin feature/my-branch</code>. Получил ошибку — ветка расходилась с remote из-за rebase. Это нормально. Написал <code>git push --force origin feature/my-branch</code>.</p><p>И тут автодополнение в его терминале сыграло злую шутку.</p><p>Он не заметил. Нажал Enter. Терминал отработал молниеносно.</p><p><code>git push --force origin develop</code></p><p>Ветка <code>develop</code> теперь содержала только его пятнадцать причёсанных коммитов. Полгода работы двенадцати разработчиков — восемьсот с лишним коммитов, десятки смёрджённых feature-веток — перестали существовать в remote.</p><hr><p>Первым заметил Кирилл — он в это время делал <code>git pull origin develop</code> и увидел странное сообщение о том, что его локальная ветка «впереди» remote на 847 коммитов. Написал в слак: «ребят, что-то странное с develop».</p><p>Я в это время пил кофе. Зашёл в GitHub. Посмотрел на историю <code>develop</code>. Увидел пятнадцать коммитов за подписью Антона.</p><p>Поставил кружку. Написал в общий чат: «стоп, никто не делайте git pull и не трогайте develop».</p><p>Антон в этот момент только что дошёл до своего рабочего места — потому что через тридцать секунд в чате появилось его сообщение: «ребята, кажется, я что-то сломал».</p><hr><p>Восстановление заняло примерно двадцать минут и было почти триумфальным.</p><p>Git — распределённая система. Это означает, что у каждого разработчика в локальном репозитории была актуальная копия <code>develop</code> на момент последнего <code>git fetch</code>. У Кирилла — который как раз делал <code>git pull</code> в момент инцидента — локальная ветка содержала все 847 коммитов.</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code># На машине Кирилла:
git log origin/develop..develop --oneline | wc -l
# 847

# Пришлось временно снять все защиты с ветки в GitHub
# и залить обратно правильную историю
git push --force origin develop
</code></pre><p>Через двадцать минут <code>develop</code> снова содержал все 847 коммитов. Все локальные репозитории сделали <code>git fetch</code>. Ничего не потерялось.</p><hr><p>Антон написал мне в личку: «Максим, я понимаю что случилось. Хочу объяснить». Мы созвонились. Он рассказал про автодополнение, про то, как перепутал ветки. Голос у него был как у человека, который ещё не понял, уволен он или нет.</p><p>Я сказал ему три вещи. Первое: никто ничего не потерял, всё восстановлено, все живы. Второе: это системная ошибка, не личная — защита ветки была настроена неправильно, и это моя ответственность как DevOps. Третье: мы немедленно это исправим.</p><p>В тот же день мы включили защиту от force push на <code>develop</code>. Добавили в onboarding раздел «Никогда не используй <code>git push --force</code> без явного указания feature-ветки». Везде заменили <code>--force</code> на <code>--force-with-lease</code> — он проверяет что remote не изменился с момента вашего последнего fetch, и отказывает если кто-то уже запушил.</p><p>Антон остался в команде. Ещё через полгода стал тимлидом.</p><hr><p>Самый важный вывод из этой истории не технический. Технический прост: защищайте все ветки от force push, используйте <code>--force-with-lease</code>, настройте алиасы.</p><p>Важный вывод другой: реакция команды на инцидент определяет культуру команды. Можно было устроить показательную порку. Вместо этого мы исправили систему — и получили разработчика, который с тех пор параноидально аккуратен с git и научил этому ещё троих новых.</p><p>Ошибки надо исправлять в системе. Не в людях.</p><p>А <code>git push --force</code> без явного указания ветки — это как ходить с заряженным пистолетом без предохранителя. Рано или поздно что-то нажмётся не то.</p>]]></description><guid isPermaLink="false">64</guid><pubDate>Sun, 22 Feb 2026 14:47:58 +0000</pubDate></item><item><title>Rate limiting &#x437;&#x430;&#x431;&#x43B;&#x43E;&#x43A;&#x438;&#x440;&#x43E;&#x432;&#x430;&#x43B; &#x43D;&#x430;&#x448; &#x43D;&#x430;&#x433;&#x440;&#x443;&#x437;&#x43E;&#x447;&#x43D;&#x44B;&#x439; &#x442;&#x435;&#x441;&#x442;, &#x43F;&#x440;&#x438;&#x43D;&#x44F;&#x432; &#x435;&#x433;&#x43E; &#x437;&#x430; DDoS-&#x430;&#x442;&#x430;&#x43A;&#x443;</title><link>https://ithub.uno/blogs/entry/61-rate-limiting-zablokiroval-nash-nagruzochnyj-test-prinyav-ego-za-ddos-ataku/</link><description><![CDATA[<p>Эта история не о катастрофе — она о тех моментах, когда система работает именно так, как ты настроил, но совсем не так, как ты хотел.</p><p>Мы запускали новый высоконагруженный сервис — рекомендательный движок. Перед запуском нужно было провести нагрузочный тест: убедиться что сервис держит планируемые 500 rps.</p><p>Я накануне настроил nginx с rate limiting: 100 rps с одного IP, burst 200. Это защита от DDoS. Всё правильно, всё продуманно.</p><p>На следующий день Вася из QA запустил нагрузочный тест с помощью k6 с офисного сервера. Офисный сервер имеет один внешний IP-адрес.</p><p>Тест начался. Вася смотрел на метрики k6 — ошибки сыпались сразу: <code>503 Service Unavailable</code>. Он написал мне: «Максим, сервис не выдерживает нагрузку, уже при 150 rps всё падает».</p><p>Я зашёл в логи nginx. Увидел километры строк:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>[error] limiting requests, excess: 102.840 by zone "api_limit"
</code></pre><p>Наш офисный сервер, с которого шёл тест, получил rate limit — и все 500 запросов в секунду, начиная со 101-го получали 503. Сервис при этом работал абсолютно нормально — его никто по-настоящему не нагружал.</p><p>Я минуту просто смотрел в экран. Потом засмеялся — впервые за долгое время по-настоящему засмеялся в рабочее время.</p><p>Мы добавили IP офисного нагрузочного сервера в whitelist. Провели нормальный тест. Сервис держал 800 rps без проблем. Все были довольны.</p><p>Но главный урок этой истории не технический. Главный урок — это то, что перед тем как диагностировать проблему, надо убедиться что смотришь в нужное место. Вася видел ошибки и думал что сервис не справляется. Я видел ошибки и знал что это rate limit. Разница — в том, что смотрели на разные части системы.</p><p>Коммуникация между QA и DevOps о том, с каких IP будет идти нагрузочный тест — теперь обязательный пункт в чеклисте перед любым нагрузочным тестированием. На <a rel="external nofollow" href="https://ithub.uno">ithub.uno</a> есть хорошая статья про то, как правильно организовать этот процесс — рекомендую.</p>]]></description><guid isPermaLink="false">61</guid><pubDate>Sun, 22 Feb 2026 14:43:38 +0000</pubDate></item><item><title>&#x414;&#x435;&#x432; &#x43D;&#x430;&#x43F;&#x438;&#x441;&#x430;&#x43B; &#xAB;TODO: &#x443;&#x431;&#x440;&#x430;&#x442;&#x44C; &#x43F;&#x435;&#x440;&#x435;&#x434; &#x43F;&#x440;&#x43E;&#x434;-&#x434;&#x435;&#x43F;&#x43B;&#x43E;&#x435;&#x43C;&#xBB; &#x2014; &#x438; &#x44D;&#x442;&#x43E; &#x43F;&#x440;&#x43E;&#x43B;&#x435;&#x436;&#x430;&#x43B;&#x43E; &#x432; &#x43F;&#x440;&#x43E;&#x434;&#x435; &#x434;&#x432;&#x430; &#x433;&#x43E;&#x434;&#x430;</title><link>https://ithub.uno/blogs/entry/58-dev-napisal-%C2%ABtodo-ubrat-pered-prod-deploem%C2%BB-i-eto-prolezhalo-v-prode-dva-goda/</link><description><![CDATA[<p>Есть комментарии в коде, которые являются либо руководством к действию, либо тихим криком о помощи. Комментарии с «TODO» — это обещания, которые редко выполняются.</p><p>Денис, бэкенд-разработчик с которым я пересекался на одном проекте, рассказал мне эту историю как предупреждение. История произошла на его предыдущем месте работы.</p><p>Был endpoint <code>/api/debug/users</code> — он возвращал список всех пользователей с email, именами и датами регистрации. Без всякой авторизации. Создан во время разработки, чтобы фронтенд-разработчик мог быстро проверять данные.</p><p>В коде стоял комментарий:</p><pre spellcheck="" class="tmiCode language-python" data-language="Python"><code># TODO: УБРАТЬ ПЕРЕД ДЕПЛОЕМ В ПРОД!!!
# Только для разработки, не для продакшна
@app.route('/api/debug/users')
def debug_users():
    users = User.query.all()
    return jsonify([u.to_dict() for u in users])
</code></pre><p>Три восклицательных знака. Заглавные буквы. Всё честно.</p><p>Разработчик сделал деплой. TODO не убрал — дедлайн, «потом». Потом не наступило.</p><p>Endpoint жил в проде тихо и незаметно два года. Через два года при проведении security audit penetration tester обнаружил его за тридцать секунд работы с Burp Suite. База данных на тот момент содержала информацию о 340,000 пользователей. Всё это время данные были доступны любому, кто знал URL.</p><p>Конца истории Денис не знает — его к тому времени уже не было в той компании. Знает только что был большой скандал и несколько уволенных.</p><p>В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны <code>TODO.*прод</code>, <code>TODO.*prod</code>, <code>REMOVE BEFORE</code>, <code>DEBUG ONLY</code>. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.</p>]]></description><guid isPermaLink="false">58</guid><pubDate>Sun, 22 Feb 2026 14:42:03 +0000</pubDate></item><item><title>&#x421;&#x43A;&#x440;&#x438;&#x43F;&#x442; &#x431;&#x44D;&#x43A;&#x430;&#x43F;&#x430; &#x440;&#x430;&#x431;&#x43E;&#x442;&#x430;&#x43B; &#x438;&#x434;&#x435;&#x430;&#x43B;&#x44C;&#x43D;&#x43E;. &#x41A;&#x440;&#x43E;&#x43C;&#x435; &#x43E;&#x434;&#x43D;&#x43E;&#x439; &#x434;&#x435;&#x442;&#x430;&#x43B;&#x438;: &#x43E;&#x43D; &#x43D;&#x438;&#x447;&#x435;&#x433;&#x43E; &#x43D;&#x435; &#x431;&#x44D;&#x43A;&#x430;&#x43F;&#x438;&#x43B;</title><link>https://ithub.uno/blogs/entry/55-skript-bekapa-rabotal-idealno-krome-odnoj-detali-on-nichego-ne-bekapil/</link><description><![CDATA[<p>Самый коварный вид отказа систем — когда всё выглядит как работает, но не работает. Бэкап-система в этом смысле особенно опасна: вы никогда не проверяете её по-настоящему, пока не нужно восстановиться.</p><p>Мой коллега Паша — педантичный и аккуратный инженер — настраивал резервное копирование для CRM-системы. Написал скрипт на bash: каждую ночь mysqldump, gzip, upload на S3. Всё логируется. При успехе в Slack приходит уведомление «Backup completed: 2.3GB».</p><p>Скрипт работал восемь месяцев. Каждую ночь в Slack приходило: «Backup completed: 2.3GB». Красиво. Надёжно. Профессионально.</p><p>Потом упал продакшн-сервер. Физически — сгорел диск. Паша пошёл восстанавливаться из бэкапа.</p><p>Достал последний архив с S3. Распаковал. Открыл. Внутри был SQL-дамп базы данных <code>information_schema</code>. Не <code>crm_production</code>. А <code>information_schema</code> — системная база MySQL с метаданными: список таблиц, колонок, индексов. Никаких реальных данных.</p><p>Паша открыл скрипт. Нашёл строку:</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>mysqldump -u backup -p$PASS $DATABASE | gzip &gt; backup.sql.gz
</code></pre><p>Переменная <code>$DATABASE</code>. Посмотрел где она определяется:</p><pre spellcheck="" class="tmiCode language-bash" data-language="Bash"><code>DATABASE=${DB_NAME:-information_schema}
</code></pre><p>Переменная <code>DB_NAME</code> должна была передаваться через environment. Но при настройке cron-job он забыл добавить эту переменную. Cron запускал скрипт без переменной — скрипт использовал дефолтное значение <code>information_schema</code> — дамп создавался, весил 2.3GB (много метаданных за восемь месяцев), улетал на S3. Полное видимое благополучие.</p><p>Восемь месяцев данных CRM были потеряны безвозвратно.</p><p>После этого у нас появилось правило: бэкап-скрипт обязан проверять содержимое архива после создания. Не просто «файл существует» — а «внутри есть CREATE TABLE для нужных таблиц, INSERT строк больше нуля». И раз в месяц — тестовое восстановление в отдельную базу с проверкой количества записей.</p><p>Если вы сейчас читаете это и у вас есть бэкап-система, которую вы ни разу не проверяли восстановлением — сделайте это сегодня. Не завтра. Сегодня.</p>]]></description><guid isPermaLink="false">55</guid><pubDate>Sun, 22 Feb 2026 14:36:46 +0000</pubDate></item><item><title>&#x42F; &#x443;&#x434;&#x430;&#x43B;&#x438;&#x43B; &#x43F;&#x440;&#x43E;&#x434;&#x430;&#x43A;&#x448;&#x43D;-&#x43D;&#x435;&#x439;&#x43C;&#x441;&#x43F;&#x435;&#x439;&#x441; &#x432; Kubernetes, &#x43D;&#x430;&#x436;&#x430;&#x432; &#x43D;&#x435; &#x442;&#x443; &#x43A;&#x43D;&#x43E;&#x43F;&#x43A;&#x443;. &#x414;&#x430;&#x443;&#x43D;&#x442;&#x430;&#x439;&#x43C; &#x2014; 7 &#x43C;&#x438;&#x43D;&#x443;&#x442; 20 &#x441;&#x435;&#x43A;&#x443;&#x43D;&#x434;</title><link>https://ithub.uno/blogs/entry/52-ya-udalil-prodakshn-nejmspejs-v-kubernetes-nazhav-ne-tu-knopku-dauntajm-7-minut-20-sekund/</link><description><![CDATA[<p>Это история о том, почему production дашборды должны быть красного цвета, а staging — зелёного. И почему вкладки браузера надо называть.</p><p>Пятница. Последний рабочий день перед длинными майскими. Я чистил старый staging кластер Kubernetes — там скопился мусор за несколько месяцев. Namespace за namespace, удаляю deployment'ы, PVC, сервисы. Всё идёт хорошо. В соседней вкладке открыт продакшн дашборд — краем глаза поглядываю на метрики.</p><p>Не помню точно как это случилось — кажется, я переключился между вкладками не обратив внимания. Увидел список namespace в дашборде. Увидел namespace с именем <code>legacy-services</code> — его я и собирался удалить в staging. Нажал Delete. Подтвердил (да, там был confirm dialog — и я его подтвердил, потому что только что двадцать раз делал то же самое).</p><p>Через тридцать секунд в слаке началось:</p><p><code>#alerts: CRITICAL: payment-service down</code> <code>#alerts: CRITICAL: auth-service down</code> <code>#alerts: CRITICAL: user-service down</code></p><p>Я перевёл взгляд на адресную строку. Там был URL продакшн кластера.</p><p><code>legacy-services</code> в продакшне — это было название, которое выбрал кто-то три года назад. В нём жило восемь критических сервисов.</p><p>Дальше начался самый быстрый деплой в моей карьере. ArgoCD хранил все манифесты, кластер был жив — я удалил только namespace с содержимым, но не сам кластер. Запустил sync для всех приложений через ArgoCD.</p><p>Kubernetes начал поднимать поды. Stateless сервисы встали за две-три минуты. Payment-service — за пять, потому что у него была initContainer-миграция. Auth-service — за четыре.</p><p>Общее время даунтайма: семь минут двадцать секунд.</p><p>Что изменилось: все дашборды теперь имеют цветовую маркировку — продакшн красным, staging жёлтым, dev зелёным. Это реализовано через плагин kubie — при переключении в prod-контекст терминал меняет цвет prompt на красный. И главное — удаление namespace теперь требует ввода имени namespace вручную. Никаких кнопок «Delete» без явного подтверждения текстом.</p><hr>]]></description><guid isPermaLink="false">52</guid><pubDate>Sun, 22 Feb 2026 14:33:19 +0000</pubDate></item><item><title>&#x422;&#x440;&#x438; &#x434;&#x43D;&#x44F; &#x44F; &#x438;&#x441;&#x43A;&#x430;&#x43B; &#x431;&#x430;&#x433; &#x432; &#x43A;&#x43E;&#x434;&#x435; &#x2014; &#x43E;&#x43A;&#x430;&#x437;&#x430;&#x43B;&#x43E;&#x441;&#x44C;, &#x441;&#x435;&#x440;&#x432;&#x435;&#x440; &#x436;&#x438;&#x43B; &#x432; &#x434;&#x440;&#x443;&#x433;&#x43E;&#x43C; &#x447;&#x430;&#x441;&#x43E;&#x432;&#x43E;&#x43C; &#x43F;&#x43E;&#x44F;&#x441;&#x435;</title><link>https://ithub.uno/blogs/entry/49-tri-dnya-ya-iskal-bag-v-kode-okazalos-server-zhil-v-drugom-chasovom-poyase/</link><description><![CDATA[<p>Есть баги, которые тихо портят данные и обнаруживаются через месяцы. А есть такие, которые существуют годами, всех раздражают, но никто не может их воспроизвести — потому что они зависят от часового пояса.</p><p>Та система занималась планированием задач. Пользователь создаёт задачу на «завтра в 10:00» — система её выполняет в нужное время. Казалось бы, простейшая логика.</p><p>Жалобы начались в марте. Клиенты из Екатеринбурга писали: «задачи выполняются не вовремя, иногда с опозданием на два часа». Я смотрел на логи — по нашим данным задачи выполнялись точно в срок. Поддержка закрывала тикеты: «не воспроизводится». Клиенты злились.</p><p>Я взялся за это в начале апреля. Провёл два дня, читая код планировщика. Нашёл несколько мест где работа с временем выглядела подозрительно, отрефакторил — баг остался. Третий день — ревью всех datetime операций в системе. Ничего.</p><p>На четвёртый день Игорь из поддержки сказал фразу, которая сразу всё объяснила:</p><p>— Слушай, я заметил, что жалуются только пользователи из Екатеринбурга, Омска и вот этих городов...</p><p>Он показал список. Я посмотрел на карту. Это были города в часовых поясах UTC+5 и восточнее.</p><p>Зашёл на продакшн-сервер. Ввёл <code>date</code>. Увидел: <code>Thu Apr 4 14:23:11 UTC 2024</code>. Сервер работал в UTC. Это нормально. Проблема была в другом: когда пользователь из Екатеринбурга (UTC+5) создавал задачу на «завтра 10:00» через браузер, фронтенд отправлял строку <code>2024-04-05 10:00:00</code> — без timezone offset. Backend принимал её как UTC и сохранял в базу. Потом выполнял задачу в 10:00 UTC — что для пользователя в UTC+5 было 15:00 по местному времени.</p><p>Пять часов разницы. Это проявилось только когда начали приходить пользователи из регионов — потому что первые клиенты и команда разработки были из Москвы (UTC+3), и у них разница была три часа, а не пять.</p><p>Фикс: фронтенд теперь всегда отправляет datetime с timezone offset. Backend парсит только aware datetime объекты. На Хабре есть статья «Никогда не используйте naive datetime» — я её знал. Просто не я писал тот фрагмент. Теперь у нас есть линтер-правило, которое не даёт мержить код с naive datetime объектами.</p>]]></description><guid isPermaLink="false">49</guid><pubDate>Sun, 22 Feb 2026 14:31:37 +0000</pubDate></item><item><title>&#x41D;&#x430;&#x448; &#xAB;&#x443;&#x43C;&#x43D;&#x44B;&#x439;&#xBB; &#x430;&#x432;&#x442;&#x43E;&#x441;&#x43A;&#x435;&#x439;&#x43B;&#x438;&#x43D;&#x433; &#x432; 3 &#x447;&#x430;&#x441;&#x430; &#x43D;&#x43E;&#x447;&#x438; &#x440;&#x430;&#x441;&#x43A;&#x440;&#x443;&#x442;&#x438;&#x43B; 847 &#x438;&#x43D;&#x441;&#x442;&#x430;&#x43D;&#x441;&#x43E;&#x432; &#x438; &#x432;&#x44B;&#x436;&#x440;&#x430;&#x43B; &#x432;&#x435;&#x441;&#x44C; AWS-&#x431;&#x44E;&#x434;&#x436;&#x435;&#x442; &#x437;&#x430; 4 &#x447;&#x430;&#x441;&#x430;</title><link>https://ithub.uno/blogs/entry/46-nash-%C2%ABumnyj%C2%BB-avtoskejling-v-3-chasa-nochi-raskrutil-847-instansov-i-vyzhral-ves-aws-byudzhet-za-4-chasa/</link><description><![CDATA[<p>Автоматизация — это прекрасно. Автоматизация без ограничений — это финансовая катастрофа. Я усвоил этот урок очень конкретным способом: через счёт от AWS на $23,000 за четыре ночных часа.</p><p>Мы настраивали горизонтальный автоскейлинг для API сервиса. Логика была простая: если CPU выше 70% — добавляем инстансы. Работало замечательно в рабочие часы. Что мы не предусмотрели — верхний лимит. Мы установили <code>minReplicas: 2</code> и забыли про <code>maxReplicas</code>. В Kubernetes HPA это означает «масштабируй сколько нужно».</p><p>В 2:47 ночи наш сервис получил DDoS-атаку. Не особо сложную — просто поток запросов, каждый из которых немного нагружал CPU. Автоскейлер увидел рост CPU и начал добавлять поды. Поды поднимались, нагрузка на каждый снижалась — но общий поток атаки оставался постоянным. Автоскейлер видел всё ещё высокую нагрузку и добавлял ещё. И ещё.</p><p>В 4:15 у нас работало 847 инстансов одного сервиса. Нода-группа в AWS автоматически масштабировала EC2 — тоже без ограничений. Именно в этот момент сработал billing alert и разбудил меня.</p><p>Я зашёл в AWS console полусонный. Увидел цифру. Проснулся мгновенно.</p><p>Мы остановили атаку через WAF (пришлось поднять и настроить с нуля, потому что «руки не доходили» раньше — за десять минут). Потом убили лишние инстансы. Написали в AWS поддержку — они вернули около $18,000 как «goodwill credit», потому что это был явно аномальный spike. $5,000 мы всё же заплатили.</p><p>Что изменили: <code>maxReplicas</code> в каждом HPA, budget alerts с автоматическим отключением при превышении, WAF с базовыми rate-limit правилами — теперь это первое, что поднимается для нового сервиса. И чеклист «Перед включением автоскейлинга», который мы опубликовали в нашей вики и в комьюнити на <a rel="external nofollow" href="https://ithub.uno">ithub.uno</a>.</p>]]></description><guid isPermaLink="false">46</guid><pubDate>Sun, 22 Feb 2026 14:30:05 +0000</pubDate></item><item><title>Kubernetes &#x43D;&#x435; &#x434;&#x430;&#x43B; &#x43C;&#x43D;&#x435; &#x441;&#x43B;&#x43E;&#x43C;&#x430;&#x442;&#x44C; &#x43F;&#x440;&#x43E;&#x434; &#x2014; &#x438; &#x44D;&#x442;&#x43E; &#x431;&#x44B;&#x43B; &#x43B;&#x443;&#x447;&#x448;&#x438;&#x439; &#x431;&#x430;&#x433; &#x432; &#x43C;&#x43E;&#x435;&#x439; &#x43A;&#x430;&#x440;&#x44C;&#x435;&#x440;&#x435;</title><link>https://ithub.uno/blogs/entry/43-kubernetes-ne-dal-mne-slomat-prod-i-eto-byl-luchshij-bag-v-moej-karere/</link><description><![CDATA[<p>Я долго не понимал, почему Kubernetes такой педантичный. Зачем все эти liveness probes, resource limits, PodDisruptionBudget — когда можно просто запустить контейнер и пусть работает? Потом был один день, который изменил моё отношение радикально.</p><p>Мы деплоили крупное обновление — новая версия API с переработанной системой авторизации. Дата релиза была согласована с бизнесом, пресс-релиз готов, маркетинг ждёт. Всё тщательно проверено на стейджинге. Я жму deploy.</p><p>Kubernetes начинает rolling update. Первые поды поднимаются — и тут Kubernetes останавливает деплой. Просто стоп. Ни один новый под не создаётся, старые не удаляются.</p><p>Открываю <code>kubectl describe pod</code> — там написано: <code>Readiness probe failed</code>. Злюсь. Открываю логи пода. Вижу ошибку подключения к базе данных. Думаю: ну и что, это временная ошибка при старте, он бы сам восстановился. Хочу вручную форсировать деплой.</p><p>Но что-то заставляет меня сначала проверить само соединение с базой. Открываю dashboard PostgreSQL — и вижу, что на новой версии приложения миграция схемы прошла неправильно. Один из индексов создался с ошибкой, из-за чего конкретный запрос в <code>/api/v2/auth/check</code> — тот самый, который проверяет readiness probe — возвращал 500.</p><p>Если бы Kubernetes не остановил деплой, то старые поды с рабочей авторизацией были бы убиты, а новые — со сломанной — встали бы вместо них. Все пользователи получили бы 500 при попытке войти. Прямо в день анонса.</p><p>Kubernetes оказался умнее меня. Его педантичность — которая меня так раздражала — спасла релиз.</p><p>Мы откатили миграцию, исправили скрипт, прогнали ещё раз на стейджинге, задержали деплой на два часа. Бизнес поворчал — потом сказал спасибо, когда я объяснил альтернативу.</p><p>С того дня я стал большим фанатом readiness probes. Не просто <code>/healthz</code> с ответом 200 — а настоящая проверка: соединение с базой, доступность зависимостей, корректность конфигурации.</p>]]></description><guid isPermaLink="false">43</guid><pubDate>Sun, 22 Feb 2026 14:27:35 +0000</pubDate></item><item><title>&#x42F; &#x43C;&#x43E;&#x43D;&#x438;&#x442;&#x43E;&#x440;&#x438;&#x43B; &#x43D;&#x435; &#x442;&#x43E;&#x442; &#x441;&#x435;&#x440;&#x432;&#x435;&#x440; &#x434;&#x432;&#x435; &#x43D;&#x435;&#x434;&#x435;&#x43B;&#x438; &#x2014; &#x43F;&#x43E;&#x43A;&#x430; &#x43D;&#x430;&#x441;&#x442;&#x43E;&#x44F;&#x449;&#x438;&#x439; &#x442;&#x438;&#x445;&#x43E; &#x443;&#x43C;&#x438;&#x440;&#x430;&#x43B;</title><link>https://ithub.uno/blogs/entry/40-ya-monitoril-ne-tot-server-dve-nedeli-poka-nastoyashij-tiho-umiral/</link><description><![CDATA[<p>Это история о том, как можно делать всё правильно — и всё равно облажаться. Потому что правильные действия, направленные не туда — это хуже бездействия.</p><p>Два года назад мы запускали новый микросервис — агрегатор данных для аналитики. Я настроил мониторинг: Prometheus, Grafana, alertmanager, всё по классике. Дашборд выглядел прекрасно. Зелёный. Живой. Метрики бежали в реальном времени.</p><p>Через неделю аналитики начали жаловаться: данные в отчётах иногда выглядят странно, какие-то пропуски. Я смотрел на дашборд — сервис работает, ошибок нет, очередь обрабатывается.</p><p>Ещё через неделю жалобы участились. Я снова смотрел на мониторинг. Снова — всё хорошо. Начал думать, что проблема в данных источника.</p><p>На четырнадцатый день Саша из аналитики подошла ко мне с конкретным примером: вот событие, которое должно было попасть в базу вчера в 14:32 — его нет. Вот ещё пять таких событий за последние две недели.</p><p>Я зашёл непосредственно на сервер, посмотрел логи — и увидел сотни ошибок коннекта к базе данных. Каждую минуту. Все последние две недели.</p><p>Но мониторинг показывал зелёный!</p><p>Через десять минут я нашёл причину. При настройке мониторинга я указал IP-адрес сервера вручную. Потом — за день до запуска — инфраструктурная команда переехала на новые машины и IP поменялся. Я обновил конфиг сервиса, но забыл обновить конфиг Prometheus. Prometheus две недели радостно скрейпил метрики другого сервера, которому достался старый IP.</p><p>Все эти две недели я смотрел на графики совершенно нормально работающего чужого сервера. Пока наш тихо терял данные.</p><p>Пропущенные события восстановить не удалось. После этого я перешёл на service discovery в Prometheus — никаких статических IP. Только DNS-имена или автоматическое обнаружение. И добавил тест: alertmanager должен прислать тестовый алерт при старте — чтобы убедиться, что нотификации реально доходят.</p><hr>]]></description><guid isPermaLink="false">40</guid><pubDate>Sun, 22 Feb 2026 14:25:20 +0000</pubDate></item><item><title>&#xAB;&#x41F;&#x440;&#x43E;&#x441;&#x442;&#x43E; &#x43F;&#x435;&#x440;&#x435;&#x437;&#x430;&#x433;&#x440;&#x443;&#x437;&#x438;&#x442;&#x435; &#x441;&#x435;&#x440;&#x432;&#x435;&#x440;&#xBB; &#x2014; &#x441;&#x43A;&#x430;&#x437;&#x430;&#x43B; &#x43C;&#x435;&#x43D;&#x435;&#x434;&#x436;&#x435;&#x440;. &#x421;&#x435;&#x440;&#x432;&#x435;&#x440; &#x43D;&#x435; &#x43F;&#x43E;&#x434;&#x43D;&#x44F;&#x43B;&#x441;&#x44F;</title><link>https://ithub.uno/blogs/entry/37-%C2%ABprosto-perezagruzite-server%C2%BB-skazal-menedzher-server-ne-podnyalsya/</link><description><![CDATA[<p>Есть категория менеджеров, которые искренне верят, что перезагрузка решает все проблемы. Эту веру они несут через годы опыта работы с Windows на домашнем компьютере. Столкновение этой веры с реальностью продакшн-сервера — зрелище одновременно трагическое и поучительное.</p><p>Эту историю рассказал мне Антон — DevOps в среднем онлайн-ретейлере — в баре, после второго бокала, с видом человека, который прошёл терапию, но ещё не полностью.</p><p>Была пятница, около шести вечера. Их основной сервер начал подтормаживать — latency выросла раза в три. Антон уже нашёл проблему: утечка соединений в пуле базы данных, накопившаяся за неделю. Требовалось примерно двадцать минут на аккуратный фикс без рестарта.</p><p>Тут появился продакт-менеджер Геннадий Витальевич.</p><p>— Антон, у нас тормозит. Долго ещё? У меня через час встреча с клиентом.</p><p>— Геннадий Витальевич, я нашёл проблему, фикс займёт минут двадцать, перезагрузка не нужна—</p><p>— Просто перезагрузите сервер. Всегда помогает.</p><p>— Нет, правда, в данном случае лучше не надо, потому что после перезагрузки будет несколько минут простоя—</p><p>— Антон. Перезагрузите. Сервер.</p><p>Антон перезагрузил сервер.</p><p>Что он не знал — и что Геннадий Витальевич знать не мог — так это то, что накануне ночью обновился GRUB через автоматические обновления Ubuntu. Обновление прошло нормально, но файл конфигурации GRUB получил неверный UUID для корневого раздела. Система прекрасно работала — до первой перезагрузки.</p><p>Сервер ушёл на перезагрузку. И не вернулся.</p><p>Антон пять минут смотрел на консоль Hetzner с ошибкой <code>Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)</code>. Потом ещё пять минут просто сидел, не двигаясь.</p><p>Дальнейшее — двухчасовая спасательная операция через rescue mode, ручное восстановление GRUB, три созвона с поддержкой хостинга и один звонок от директора с вопросом «что происходит». Геннадий Витальевич на встречу с клиентом опоздал на час пятнадцать.</p><p>После этого в их компании появилось официальное правило: любая перезагрузка продакшн-сервера должна быть согласована с дежурным инженером, который проверяет чеклист из восьми пунктов. Геннадий Витальевич правило подписал. Говорят, без энтузиазма.</p>]]></description><guid isPermaLink="false">37</guid><pubDate>Sun, 22 Feb 2026 14:22:54 +0000</pubDate></item><item><title>&#x42F; &#x441;&#x43B;&#x443;&#x447;&#x430;&#x439;&#x43D;&#x43E; &#x437;&#x430;&#x434;&#x435;&#x43F;&#x43B;&#x43E;&#x438;&#x43B; &#x432; &#x43F;&#x440;&#x43E;&#x434; &#x43A;&#x43E;&#x43D;&#x444;&#x438;&#x433; &#x441; &#x43F;&#x430;&#x440;&#x43E;&#x43B;&#x435;&#x43C; &#xAB;test123&#xBB; &#x2014; &#x438; &#x43E;&#x43D; &#x442;&#x430;&#x43C; &#x436;&#x438;&#x43B; &#x442;&#x440;&#x438; &#x433;&#x43E;&#x434;&#x430;</title><link>https://ithub.uno/blogs/entry/34-ya-sluchajno-zadeploil-v-prod-konfig-s-parolem-%C2%ABtest123%C2%BB-i-on-tam-zhil-tri-goda/</link><description><![CDATA[<p>Эту историю я долго не хотел рассказывать. Не потому что она страшная — потому что она embarrassing. Но потом я прочитал пост на <a rel="external nofollow" href="https://ithub.uno">ithub.uno</a> о том, что культура безопасности строится на открытости, а не на замалчивании. И решился.</p><p>Было это в 2019-м. Я настраивал GitLab CI для небольшого проекта — сервис рассылки уведомлений. В процессе отладки пайплайна мне нужен был быстрый тест. Я создал файл <code>.env.test</code> с заглушками: <code>DB_PASSWORD=test123</code>, <code>API_KEY=dummy_key_for_testing</code>, <code>SMTP_PASSWORD=test123</code>. Потом написал <code>.gitlab-ci.yml</code>, который деплоил приложение, и указал в нём <code>cp .env.test .env</code> — чтобы для CI это работало.</p><p>Commit. Push. Пайплайн прошёл. Всё работает.</p><p>Я написал нормальный продакшн конфиг, залил через GitLab Secrets как положено — и на этом моя работа с тем проектом закончилась. Меня перевели на другой.</p><p>Проект жил своей жизнью. Рассылки уходили. Никто не проверял, откуда именно берутся credentials.</p><p>Через три года я вернулся на тот проект для аудита. Открыл конфиг на сервере. Увидел <code>DB_PASSWORD=test123</code>. Мне понадобилось секунд тридцать, чтобы понять что произошло.</p><p>Оказывается, команда <code>cp .env.test .env</code> в пайплайне выполнялась после загрузки GitLab Secrets и перезаписывала их. Три года подряд, при каждом деплое, на сервер улетал файл с паролем <code>test123</code>.</p><p>Самое парадоксальное: база данных была доступна только изнутри приватной сети, поэтому этот пароль де-факто ни на что не влиял. Но это — счастливое стечение обстоятельств, а не правильная архитектура.</p><p>Я провёл полный security audit. Нашёл ещё две похожих проблемы — других инженеров, таких же «временных» решений, ставших постоянными. Написал документ «Как мы храним секреты» и внедрил pre-commit hook, который ищет в коде паттерны типа <code>password=test</code>, <code>key=dummy</code>, <code>secret=123</code>.</p><p>Но <code>test123</code> в том конфиге я помню с фотографической точностью.
</p><p>Три восклицательных знака. Заглавные буквы. Всё честно.</p><p>Разработчик сделал деплой. TODO не убрал — дедлайн, «потом». Потом не наступило.</p><p>Endpoint жил в проде тихо и незаметно два года. Через два года при проведении security audit penetration tester обнаружил его за тридцать секунд работы с Burp Suite. База данных на тот момент содержала информацию о 340,000 пользователей. Всё это время данные были доступны любому, кто знал URL.</p><p>Конца истории Денис не знает — его к тому времени уже не было в той компании. Знает только что был большой скандал и несколько уволенных.</p><p>В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны <code>TODO.*прод</code>, <code>TODO.*prod</code>, <code>REMOVE BEFORE</code>, <code>DEBUG ONLY</code>. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.</p>]]></description><guid isPermaLink="false">34</guid><pubDate>Sun, 22 Feb 2026 14:19:26 +0000</pubDate></item><item><title>&#x41F;&#x440;&#x43E;&#x434;&#x430;&#x43A;&#x448;&#x43D; &#x443;&#x43F;&#x430;&#x43B; &#x432; 23:58 &#x432; &#x43D;&#x43E;&#x432;&#x43E;&#x433;&#x43E;&#x434;&#x43D;&#x44E;&#x44E; &#x43D;&#x43E;&#x447;&#x44C; &#x2014; &#x438; &#x44F; &#x43F;&#x440;&#x43E;&#x43F;&#x443;&#x441;&#x442;&#x438;&#x43B; &#x431;&#x43E;&#x439; &#x43A;&#x443;&#x440;&#x430;&#x43D;&#x442;&#x43E;&#x432;</title><link>https://ithub.uno/blogs/entry/31-prodakshn-upal-v-2358-v-novogodnyuyu-noch-i-ya-propustil-boj-kurantov/</link><description><![CDATA[<p>В этой индустрии есть негласный закон: если что-то может сломаться в самый неподходящий момент — оно сломается именно тогда. Новогодняя ночь — идеальный момент для проверки этого закона.</p><p>Я работал в финтех-компании. Мы делали платёжный шлюз. Нагрузка в новый год — одна из пиковых: все переводят деньги, покупают подарки в последний момент, пьют шампанское и одновременно пытаются провести транзакцию.</p><p>31 декабря, примерно в 22:00 я сидел у родителей. Оливье, телевизор, ощущение что ты наконец человек, а не придаток к ноутбуку. Дежурство официально было у Димы. Я был «вторым уровнем».</p><p>В 23:58 мне позвонил Дима. Голос у него был такой, что я сразу встал из-за стола и вышел в коридор.</p><p>— Макс, у нас лежит. Всё. Payment gateway не отвечает. Метрики нормальные, сервисы запущены, но транзакции не проходят.</p><p>Я открыл ноутбук прямо в коридоре, на тумбочке с телефонным аппаратом эпохи СССР. Зашёл в Grafana — всё зелёное. CPU нормальный, память нормальная, сетевой трафик... стоп. Входящий — есть. Исходящий — ноль. Абсолютный ноль. Сервисы запущены, слушают порты, принимают соединения — но ничего не отправляют в ответ.</p><p>В это время в телевизоре начали бить куранты. Моя мама заглянула в коридор с бокалом шампанского. Я сделал жест «одну минуту» — что в нашей профессии означает «от тридцати минут до нескольких часов».</p><p>Нашёл проблему через двадцать две минуты нового года. Оказалось, в 23:55 сработал cron-job, который запускался раз в год 31 декабря для «очистки годовых логов». Скрипт удалял log-файлы старше 365 дней — разумная идея. Но через glob-паттерн он также захватывал конфигурационный файл SSL-сертификатов. Сертификаты физически никуда не делись, но конфиг, который указывал на них — исчез. Nginx перечитал конфигурацию и тихо перестал устанавливать TLS-соединения с upstream банковским API, требовавшим mutual TLS.</p><p>Фикс занял четыре минуты: восстановить конфиг из git, перезапустить nginx, убедиться что транзакции пошли.</p><p>Я вернулся к столу в 00:31. Шампанское было тёплым. Оливье съели без меня.</p><p>А тот cron-job мы заменили нормальным logrotate с явными паттернами. И добавили тест: после каждого cron-задания запускается smoke-test платёжной цепочки. Каждую ночь. Включая 31 декабря.ии. Знает только что был большой скандал и несколько уволенных.</p><p>В нашей команде после этой истории появился CI-шаг: grep по всему коду на паттерны <code>TODO.*прод</code>, <code>TODO.*prod</code>, <code>REMOVE BEFORE</code>, <code>DEBUG ONLY</code>. Если находит — пайплайн падает с ошибкой. Работает без единого ложного срабатывания уже полтора года.</p>]]></description><guid isPermaLink="false">31</guid><pubDate>Sun, 22 Feb 2026 14:16:01 +0000</pubDate></item><item><title>&#xAB;&#x423;&#x434;&#x430;&#x43B;&#x438; &#x432;&#x440;&#x435;&#x43C;&#x435;&#x43D;&#x43D;&#x44B;&#x435; &#x444;&#x430;&#x439;&#x43B;&#x44B;&#xBB; &#x2014; &#x438; &#x44F; &#x434;&#x440;&#x43E;&#x43F;&#x43D;&#x443;&#x43B; &#x43D;&#x430;&#x444;&#x438;&#x433; &#x432;&#x441;&#x44E; &#x431;&#x430;&#x437;&#x443; &#x434;&#x430;&#x43D;&#x43D;&#x44B;&#x445;</title><link>https://ithub.uno/blogs/entry/28-%C2%ABudali-vremennye-fajly%C2%BB-i-ya-dropnul-nafig-vsyu-bazu-dannyh/</link><description><![CDATA[<p>Это был мой второй месяц на новом месте. Я ещё не до конца понимал архитектуру системы, но уже вполне уверенно держался — учился быстро, читал документацию запоем, задавал правильные вопросы. Тогда я считал, что всё идёт хорошо.</p><p>Было обычное утро вторника. Технический директор Андрей зашёл ко мне с просьбой, которая казалась абсолютно невинной:</p><p>— Макс, у нас заканчивается место на prod-db-01. Там где-то есть временные файлы от старых бэкапов, почисти, пожалуйста.</p><p>— Хорошо, посмотрю.</p><p>Я зашёл на сервер. Открыл <code>df -h</code>. Действительно — диск забит под 94%. Начал смотреть, где место. <code>du -sh /*</code> — ничего подозрительного. Тогда запустил <code>find / -name "*.tmp" -type f</code> — и вот оно, целая папка <code>/var/backup/temp/</code> с файлами с расширением <code>.tmp</code> общим весом около 180 гигабайт.</p><p><em>Временные файлы. Именно то, о чём говорил Андрей.</em></p><p>Я выполнил <code>rm -rf /var/backup/temp/</code>. Получил ошибку прав. Повторил с <code>sudo</code>. Команда отработала молниеносно — что меня немного удивило: для 180 гигабайт это подозрительно быстро. Но я не придал этому значения.</p><p>Через двадцать минут ко мне подошёл бэкенд-разработчик Слава с характерным выражением лица человека, которому только что сообщили о смерти любимого питомца:</p><p>— Макс, у нас приложение упало. API не отвечает вообще. База говорит «connection refused».</p><p>Я зашёл на сервер. Попробовал сделать простейший SELECT — и тут у меня похолодело внутри:</p><pre spellcheck="" class="tmiCode language-plaintext" data-language="Простой текст"><code>ERROR: could not open file "base/16384/1259": No such file or directory
</code></pre><p>Через три минуты до меня дошло: папка <code>/var/backup/temp/</code> — это была не папка с временными бэкапами. Это был симлинк. Симлинк на <code>/var/lib/postgresql/14/main/base/</code>. Кто-то, видимо в процессе миграции полгода назад, создал символическую ссылку с историческим названием, и она там тихо жила. А я, такой молодец, удалил через неё всю директорию с данными PostgreSQL. Всю. До последнего файла.</p><p>База данных была жива. Процесс работал. Но данных больше не существовало физически.</p><p>Следующие два часа я провёл в состоянии, которое сложно описать словами. Это не паника — паника это когда хаотично двигаешься. Я наоборот — сидел совершенно неподвижно и методично восстанавливал базу из последнего бэкапа. Бэкап был. Слава богу, бэкап был. Последний — в 03:00 ночи. Мы потеряли восемь часов транзакций.</p><p>Потом было долгое молчаливое совещание с Андреем. Он не кричал. Он вообще почти не говорил. Это было хуже крика.</p><p>Я написал подробный post-mortem. Ввёл правило: перед любым <code>rm</code> на проде обязательно проверять <code>ls -la</code> и <code>file</code> — убедиться, что это не симлинк. Добавил в онбординг пункт о том, как работают симлинки в Linux.</p><p>Восемь часов данных так и не восстановили. Клиенты получили извинения. Я остался работать — Андрей оказался человеком, который верит в то, что ошибки надо исправлять, а не наказывать за них. Но ту папку с симлинком я помню до сих пор.</p>]]></description><guid isPermaLink="false">28</guid><pubDate>Sun, 22 Feb 2026 14:12:05 +0000</pubDate></item><item><title>&#x41D;&#x435;&#x43C;&#x43D;&#x43E;&#x436;&#x43A;&#x43E; &#x43E; &#x444;&#x440;&#x43E;&#x43D;&#x442;&#x435;&#x43D;&#x434;&#x435;, &#x43E;&#x441;&#x43D;&#x43E;&#x432;&#x43D;&#x44B;&#x435; &#x43C;&#x43E;&#x43C;&#x435;&#x43D;&#x442;&#x44B; &#x43F;&#x440;&#x43E;&#x44F;&#x441;&#x43D;&#x438;&#x43C; &#x432; &#x44D;&#x442;&#x43E;&#x43C; &#x431;&#x43B;&#x43E;&#x433;&#x435;</title><link>https://ithub.uno/blogs/entry/21-nemnozhko-o-frontende-osnovnye-momenty-proyasnim-v-etom-bloge/</link><description><![CDATA[<p>Представьте: вы сидите за монитором, на экране уже есть кнопки, текст, картинки. Всё вроде работает. И тут менеджер кидает новую фичу: «Сделай модалку с формой для отзывов». Казалось бы — мелочь. Но как это сделать так, чтобы не сломать весь фронтенд? Давайте разберём, что реально происходит, когда фронтенд-разработчик «ковыряет» код на React.</p><hr><h2><span class="tmiEmoji" title="">💻</span> Фронтенд – это то, что видит пользователь</h2><p>Фронтенд — это интерфейс, всё, что юзер видит и трогает: кнопки, меню, формы, анимации.</p><p>Фронтенд-разработчик не просто «рисует сайт». Он создаёт <strong>UX</strong>, разбивает интерфейс на <strong>компоненты</strong>, следит за <strong>рендерингом</strong>, а ещё иногда сражается с багами, которые появляются из ниоткуда.</p><hr><h2><span class="tmiEmoji" title="">⚛️</span> React – библиотека для сборки UI</h2><p>React — это как конструктор LEGO, только в мире фронтенда. Каждый <strong>компонент</strong> — это независимая деталь: кнопка, форма, карточка товара.</p><p>Используя React, мы можем комбинировать эти кубики, создавать <strong>реактивные интерфейсы</strong> и управлять их <strong>состояниями (state)</strong>.</p><hr><h2><span class="tmiEmoji" title="">🧩</span> Фича – это новая функция, не меньше</h2><p>Когда фронтендер говорит «прикрутить фичу», это не просто «добавить элемент». Это продумать:</p><ul><li><p>структуру <strong>компонентов</strong>,</p></li><li><p><strong>state management</strong>,</p></li><li><p>логику <strong>валидации</strong>,</p></li><li><p>а иногда и асинхронные запросы к серверу через <strong>API</strong>.</p></li></ul><p>В нашем примере фича — это модалка с формой отзывов.</p><hr><h2><span class="tmiEmoji" title="">🪟</span> Модальное окно – всплывающее окно поверх сайта</h2><p>Модалка — это <strong>UI overlay</strong>, который блокирует взаимодействие с остальной страницей до закрытия.</p><p>Каждый фронтендер знает, что модалки могут стать <strong>хаотичными</strong>: неправильное состояние, баги с <strong>z-index</strong>, проблемы с <strong>focus trap</strong> — и вот уже страница ведёт себя как капризный робот.</p><hr><h2>Компоненты, state и хуки</h2><ul><li><p><strong>Компонент</strong> — это атом интерфейса. Вместо монолитного кода у нас маленькие, тестируемые и переиспользуемые куски.</p></li><li><p><strong>State</strong> — внутренняя память компонента. Открыт модал или закрыт? Какие данные ввёл пользователь? Это всё хранится в state.</p></li><li><p><strong>useState</strong> — встроенный React Hook, который позволяет создавать и менять state. Ошибки с ним приводят к странному <strong>перерендеру</strong> или багам, которые тяжело отловить.</p></li></ul><hr><h2><span class="tmiEmoji" title="">🧾</span> Разметка и стили</h2><ul><li><p><strong>HTML</strong> = структура страницы</p></li><li><p><strong>CSS/SCSS</strong> = как эта структура выглядит</p></li><li><p><strong>JS/TS</strong> = как она реагирует на действия юзера</p></li></ul><p>«Накидать разметку» — это не просто вставить теги. Нужно учитывать <strong>accessibility</strong>, <strong>semantic HTML</strong>, а ещё то, как всё будет работать с React и его <strong>виртуальным DOM</strong>.</p><hr><h2><span class="tmiEmoji" title="">✅</span> Валидация формы</h2><p>Валидация проверяет, корректно ли пользователь заполняет поля.<br>Для фронтендера это значит:</p><ul><li><p>подключить <strong>либы типа Yup или Zod</strong>,</p></li><li><p>написать кастомные <strong>валидаторы</strong>,</p></li><li><p>позаботиться о <strong>UX ошибок</strong>: показать подсказки, подсветить поля, не дать юзеру сломать приложение.</p></li></ul><hr><h2><span class="tmiEmoji" title="">⚙️</span> Логика и дебаг</h2><p><strong>Логика</strong> — это последовательность действий кода: что происходит при клике, при вводе, при отправке формы.</p><p><strong>Дебаг</strong> — это целая философия. Консоль браузера, <strong>React DevTools</strong>, <strong>breakpoints</strong>, <strong>network tab</strong>, иногда даже прямой просмотр <strong>state в Redux</strong>.</p><p>Типичная ошибка фронтендера:</p><pre spellcheck="" class="tmiCode" data-language="js"><code>TypeError: Cannot read property 'value' of null</code></pre><p>Означает: «Ты пытаешься достучаться до элемента, которого нет». Скорее всего, забыл добавить <code>id</code> или неправильно прокинул ref.</p><hr><h2><span class="tmiEmoji" title="">🧪</span> Тестирование</h2><p>После исправления багов наступает этап <strong>QA на фронтенде</strong>:</p><ul><li><p>кликаем все кнопки,</p></li><li><p>вводим разные данные,</p></li><li><p>проверяем в разных браузерах,</p></li><li><p>убеждаемся, что модалка не ломает остальной UI.</p></li></ul><p>Только после этого можно быть уверенным: фича рабочая.</p><hr><h2><span class="tmiEmoji" title="">🌐</span> Итог</h2><p>Даже маленькая задача вроде модалки включает десятки понятий: <strong>компоненты, state, рендеринг, хуки, валидацию, дебаг, логику, тесты</strong>.</p><p>И чем больше ковыряешь фронтенд, тем больше начинаешь ценить эти термины.</p><p>Главное — не бояться лезть в код и разбираться, почему что-то ломается. Потому что именно так становится настоящим фронтендером.</p>]]></description><guid isPermaLink="false">21</guid><pubDate>Fri, 06 Feb 2026 18:01:35 +0000</pubDate></item><item><title>&#x41A;&#x430;&#x43A; &#x43F;&#x440;&#x43E;&#x439;&#x442;&#x438; &#x442;&#x435;&#x445;&#x43D;&#x438;&#x447;&#x435;&#x441;&#x43A;&#x43E;&#x435; &#x441;&#x43E;&#x431;&#x435;&#x441;&#x435;&#x434;&#x43E;&#x432;&#x430;&#x43D;&#x438;&#x435; &#x438; &#x43D;&#x435; &#x432;&#x44B;&#x433;&#x43B;&#x44F;&#x434;&#x435;&#x442;&#x44C; &#x43A;&#x430;&#x43A; &#x441;&#x442;&#x443;&#x434;&#x435;&#x43D;&#x442;, &#x43A;&#x43E;&#x442;&#x43E;&#x440;&#x44B;&#x439; &#x443;&#x447;&#x438;&#x43B; &#x448;&#x43F;&#x430;&#x440;&#x433;&#x430;&#x43B;&#x43A;&#x438;</title><link>https://ithub.uno/blogs/entry/18-kak-projti-tehnicheskoe-sobesedovanie-i-ne-vyglyadet-kak-student-kotoryj-uchil-shpargalki/</link><description><![CDATA[<p>Технические интервью пугают не сложностью задач, а <strong>неопределённостью</strong>. Кажется, что могут спросить абсолютно всё — и поэтому многие начинают готовиться хаотично: читают десятки статей, пересматривают сотни видео, зубрят определения. Результат? Знаний больше, а уверенности — меньше.</p><p>На самом деле, техническое собеседование устроено <strong>гораздо проще и предсказуемее</strong>, чем кажется. Если понимать, что проверяют на самом деле, подготовка превращается из хаотичного марафона в точечный и эффективный процесс.</p><hr><h2>Что реально проверяют на техническом интервью</h2><p>Смотря на разные компании, подход почти всегда одинаковый:</p><ol><li><p><strong>База и понимание, а не зубрёжка.</strong><br>Интервьюера интересует не «знаешь ли ты все методы массива», а <strong>понимаешь ли, как работает код</strong>. Часто просят объяснить понятие своими словами или разобрать на примере. И сразу видно, где теория заучена, а где есть настоящее понимание.</p></li><li><p><strong>Умение думать, а не угадывать.</strong><br>Даже если точного ответа нет, логическая цепочка рассуждений и умение задавать уточняющие вопросы сильно выигрывают у молчания или попыток вспомнить «правильную формулировку».</p></li><li><p><strong>Опыт применения знаний.</strong><br>Коммерческий проект, учебный проект — не важно. Главное, чтобы ты мог рассказать, <strong>где и как применял то, о чём говоришь</strong>. Пустые слова сразу считываются.</p></li></ol><hr><h2>Почему большинство проваливается</h2><ul><li><p><strong>Готовятся как к экзамену.</strong><br>Заученные ответы срабатывают до первого уточняющего вопроса. Как только интервьюер меняет формулировку или просит пример — «картонная» подготовка разваливается.</p></li><li><p><strong>Перекос в теорию.</strong><br>Можно часами объяснять, что такое замыкания или REST, но теряться при вопросе «покажи, как это выглядит в реальном проекте». Для работодателя это сигнал: знания не применялись на практике.</p></li><li><p><strong>Страх сказать «не знаю».</strong><br>Многие пытаются выкрутиться и уходят в общие слова, а честное «не знаю, но могу подумать» выглядит сильнее и честнее.</p></li></ul><hr><h2>Как готовиться правильно</h2><ol><li><p><strong>Ориентируйся на вакансии.</strong><br>Реальные описания вакансий показывают, что повторяется из раза в раз. Разбирай эти темы глубоко, не поверхностно.</p></li><li><p><strong>Объясняй вслух.</strong><br>Пока знания живут в голове, кажется, что всё понятно. Попробуй проговаривать темы — сразу видно пробелы. Это один из самых эффективных способов подготовки.</p></li><li><p><strong>Проекты как опора разговора.</strong><br>Проект нужен не для резюме, а как база для диалога. Ты должен уметь рассказать, что делал, почему выбрал подход и с какими трудностями столкнулся. Даже учебный проект может выглядеть убедительно, если ты понимаешь его.</p></li><li><p><strong>Тренируй сам формат интервью.</strong><br>Стресс, паузы, прямые вопросы — к этому нужно привыкнуть. Те, кто имитировал собеседования или уже проходил их, почти всегда выглядят сильнее.</p></li></ol><hr><h2>Итог</h2><p>Техническое интервью — это <strong>не тест на идеальность</strong>, а способ понять, можно ли с тобой работать как с инженером.</p><p>Если ты:</p><ul><li><p>понимаешь базу,</p></li><li><p>умеешь рассуждать,</p></li><li><p>не боишься признавать пробелы,</p></li><li><p>можешь рассказать про свой код —</p></li></ul><p>…то ты уже на <strong>практически стопроцентной дороге к успеху</strong>.</p>]]></description><guid isPermaLink="false">18</guid><pubDate>Fri, 06 Feb 2026 17:57:32 +0000</pubDate></item></channel></rss>
