Jak Webová Hlídka řeší monitorování?

// obsah 10
  1. 01 Problém: synchronní monolit
  2. 02 Architektonické rozhodnutí: proč RabbitMQ
  3. 03 High-level architektura
  4. 04 Klíčový pattern: Request ID Mapping
  5. 05 Jak to funguje v kódu
  6. 06 Infrastruktura: Symfony Messenger + systemd
  7. 07 Proč Node.js workery a ne PHP?
  8. 08 Celkový flow: co se stane, když kliknu na "Zkontrolovat"
  9. 09 Výsledky
  10. 10 Poznámky

Když jsem začínal s Webovou Hlídkou, měl jsem jeden PHP monolit, který dělal všechno. Přijal request, zkontroloval web, uložil výsledek, poslal notifikaci. Synchronně.

Fungovalo to, dokud jsem monitoroval 5 webů. Ale co když jich je 200? A každý má 4-5 různých typů kontrol – HTTP status, SSL certifikát, PageSpeed, SEO, bezpečnostní hlavičky?

Najednou jde o tisíc kontrol, které se musí provést v rozumném čase. A PHP na to prostě nestačí – ne proto, že by byl špatný jazyk, ale proto, že synchronní zpracování je tu fundamentálně špatný přístup.

Problém: synchronní monolit

Původní architektura vypadala nějak takhle:

graph LR

subgraph "Symfony monolit"

A[Cron trigger] --> B[Spusť kontrolu]

B --> C[Čekej na odpověď

~2-5 sekund]

C --> D[Ulož výsledek]

D --> E[Další kontrola]

E --> B

end

style A fill:#ef4444,color:#fff

style C fill:#ef4444,color:#fff

Jednoduchá matematika: 200 webů x 5 kontrol = 1000 kontrol. Při průměru 3 sekundy na kontrolu je to 50 minut na jedno kolo.

Některé kontroly jsou navíc výpočetně náročné. PageSpeed analýza potřebuje headless Chrome. Bezpečnostní scan prochází hlavičky, certifikáty, přesměrování. To nejsou věci, které chcete dělat přímo v PHP procesu obsluhujícím API.

Architektonické rozhodnutí: proč RabbitMQ

Než jsem sáhl po RabbitMQ, zvažoval jsem alternativy:

Přístup Výhoda Proč ne
Redis queues Už ho mám na caching Žádné garance doručení, ztráta zpráv při restartu
Database polling Nejjednodušší Polling = zbytečná zátěž DB, špatná latence
Kafka Vysoký throughput Overkill pro stovky zpráv na minutu, složitý provoz
RabbitMQ Garance doručení, routing, persistent zprávy Nová služba k provozu

RabbitMQ vyhrál díky třem věcem:
1. Garance doručení – zpráva se neztratí, i když worker spadne
2. Nativní routing – každý typ kontroly může mít vlastní frontu
3. Polyglot – workery můžu psát v čemkoliv, komunikace je přes AMQP

Klíčová myšlenka byla oddělit zadání úkolu od provedení úkolu. Symfony jen řekne „zkontroluj tenhle web“ a jde dál. Skutečnou práci udělá specializovaný worker.

High-level architektura

graph TB

subgraph "API vrstva"

API[Symfony API

REST endpoints]

CRON[Cron scheduler

Plánované kontroly]

end

subgraph "Message layer"

ROUTER[MonitoringServerRouter

Routing podle typu kontroly]

MESSENGER[Symfony Messenger

5 message busů]

end

subgraph "RabbitMQ broker"

direction TB

Q1[website_check_requests]

Q2[security_check_requests]

Q3[pagespeed_check_requests]

Q4[seo_check_requests]

Q5[screenshot_check_requests]

R1[website_check_results]

R2[security_check_results]

R3[pagespeed_check_results]

R4[seo_check_results]

R5[screenshot_check_results]

end

subgraph "Externí workery (Node.js)"

W1[Website Server

HTTP, Ping, DNS]

W2[Security Server

SSL, Headers]

W3[Performance Server

PageSpeed, CWV]

W4[SEO Server

Meta, Structured Data]

W5[Screenshot Server

Headless Chrome]

end

subgraph "Zpracování výsledků"

HANDLER[ExternalWorkerMessageHandler

Mapování requestId → checkId]

DB[(PostgreSQL)]

NOTIFY[Notifikace

Email, Slack, Webhook]

end

API --> ROUTER

CRON --> ROUTER

ROUTER --> MESSENGER

MESSENGER --> Q1 & Q2 & Q3 & Q4 & Q5

Q1 --> W1

Q2 --> W2

Q3 --> W3

Q4 --> W4

Q5 --> W5

W1 --> R1

W2 --> R2

W3 --> R3

W4 --> R4

W5 --> R5

R1 & R2 & R3 & R4 & R5 --> HANDLER

HANDLER --> DB

HANDLER --> NOTIFY

Proč oddělené fronty pro každý typ? Protože každý typ kontroly má jiný výkonnostní profil:

  • HTTP check – rychlý (~200 ms), levný na resources
  • SSL check – střední (~1 s), parsování certifikátů
  • PageSpeed – pomalý (~5-10 s), potřebuje headless Chrome a hodně paměti
  • Screenshot – pomalý (~3-5 s), opět headless Chrome

Kdybych měl jednu společnou frontu, pomalý PageSpeed by blokoval rychlé HTTP kontroly. S oddělenými frontami běží paralelně a nezávisle.

Klíčový pattern: Request ID Mapping

Symfony a Node.js workery jsou dva úplně oddělené světy s odlišnými doménovými modely. Potřeboval jsem correlation pattern – způsob, jak spárovat požadavek s odpovědí skrz systémy.

sequenceDiagram

    participant S as Symfony

    participant DB as PostgreSQL

    participant MQ as RabbitMQ

    participant W as Node.js Worker

    S->>S: Vygeneruje requestId (UUID v4)

    S->>DB: Uloží mapování<br/>requestId → checkId

    S->>MQ: Publikuje {requestId, url, options}

    Note over S: Hotovo za ~10 ms<br/>API vrátí response

    MQ->>W: Worker vyzvedne zprávu

    W->>W: Provede kontrolu (HTTP, SSL, PageSpeed...)

    W->>MQ: Vrátí {requestId, status, data}

    MQ->>S: Result worker vyzvedne výsledek

    S->>DB: Lookup: requestId → checkId

    S->>DB: Uloží MonitoringResult

    S->>S: Vyhodnotí incidenty + notifikace

Proč nemůžu poslat rovnou checkId? Protože worker je izolovaný od domény. Nezná a nepotřebuje znát interní strukturu Symfony aplikace. Dostane URL a requestId, vrátí výsledek s requestId. Čím méně o sobě systémy ví, tím lépe – loose coupling v praxi.

Jak to funguje v kódu

Router – Single Responsibility pro směrování

Srdcem systému je MonitoringServerRouter. Má jednu zodpovědnost: podle typu kontroly rozhodnout, do které fronty zpráva patří.

class MonitoringServerRouter
{
    private const TYPE_TO_SERVER_MAP = [
        // Website Availability Server
        'HTTP'             => 'website',
        'HTTP_STATUS'      => 'website',
        'PING'             => 'website',
        'DNS_RESOLUTION'   => 'website',

        // Security Monitoring Server
        'SSL'              => 'security',
        'SSL_CERTIFICATE'  => 'security',
        'SECURITY_HEADERS' => 'security',

        // Performance Monitoring Server
        'PERFORMANCE'      => 'performance',
        'PAGE_SPEED'       => 'performance',

        // SEO Monitoring Server
        'SEO'              => 'seo',
        'META_TAGS'        => 'seo',
    ];

    public function routeCheckRequest(
        MonitoringCheck $check, 
        int $priority = 0
    ): void {
        $server = $this->getServerForCheckType($check->getType());
        $message = $this->createRequestMessage($check, $server, $priority);
        $this->messageBus->dispatch($message);
    }
}

Messenger se pak postará o to, aby zpráva skončila ve správné RabbitMQ frontě:

# messenger.yaml - deklarativní routing
routing:
    'App\...\WebsiteCheckRequestMessage': website_check_requests
    'App\...\SecurityCheckRequestMessage': security_check_requests
    'App\...\PerformanceCheckRequestMessage': pagespeed_check_requests
    'App\...\SeoCheckRequestMessage': seo_check_requests

Přidání nového typu kontroly znamená, že je potřeba přidat řádek do mapy a nový message class + nová fronta. Žádná změna v existujícím kódu. Open/Closed principle.

Publisher – Anti-Corruption Layer

ExternalRabbitMQPublisher funguje jako anti-corruption layer mezi interním doménovým modelem a externím API workerů. Transformuje interní zprávu na formát, kterému worker rozumí:

class ExternalRabbitMQPublisher
{
    private function sendToExternalRabbitMQ(
        WebsiteCheckRequestMessage $message
    ): void {
        // 1. Korelační ID pro párování request ↔ response
        $requestId = Uuid::v4()->__toString();

        // 2. Persistence mapování v DB
        $mapping = new CheckRequestMapping(
            $requestId, 
            $message->getCheckId()
        );
        $this->mappingRepository->save($mapping);

        // 3. DTO ve formátu externího workera
        $requestDTO = new WebsiteCheckRequestDTO(
            requestId: $requestId,
            url: $message->getWebsiteUrl(),
            timestamp: $message->getRequestedAt()->format('c'),
            priority: (string) $message->getPriority(),
            options: $message->getConfiguration()
        );

        // 4. Publikace s delivery_mode: 2 (persistent)
        $exchange->publish(
            json_encode($requestDTO->toArray()),
            $message->getRequestQueue(),
            AMQP_NOPARAM,
            [
                'content_type'  => 'application/json',
                'delivery_mode' => 2,
            ]
        );
    }
}

To delivery_mode => 2 je zásadní. Zpráva je díky tomu persistentní, takže pokud RabbitMQ spadne, tak zpráva přežije restart.

Handler – zpracování výsledků

Když worker dokončí kontrolu, pošle výsledek do result fronty. Na straně Symfony ho vyzvedne handler:

#[AsMessageHandler]
final readonly class ExternalWorkerMessageHandler
{
    public function __invoke(ExternalWorkerMessage $message): void
    {
        $requestId = $message->getRequestId();

        // Correlation lookup: requestId → checkId
        $mapping = $this->mappingRepository->findByRequestId($requestId);
        $check = $this->checkRepository->findById($mapping->getCheckId());

        // Normalizace externího formátu na interní
        $internalStatus = match (strtolower($message->getStatus())) {
            'success', 'completed' => 'SUCCESS',
            'error', 'failure'     => 'FAILURE',
            default                => 'ERROR',
        };

        // Dispatch do command busu → uložení, incidenty, notifikace
        $this->commandBus->dispatch(
            new ProcessMonitoringResultCommand(
                $this->createResultMessage($message->getData(), ...)
            )
        );
    }
}

Infrastruktura: Symfony Messenger + systemd

Message Bus architektura

Nepoužívám jeden bus, ale pět. Každý má jinou zodpovědnost a jiný middleware:

graph LR

subgraph "Symfony Messenger - 5 busů"

CB[command.bus

Zápis]

QB[query.bus

Čtení + AuthZ]

EB[event.bus

Doménové události]

AB[async.bus

Async operace]

XB[external.bus

Komunikace s workery]

end

CB --> |"sync"| COMMANDS[Commands

RunCheck, PauseCheck]

QB --> |"sync"| QUERIES[Queries

GetResults, GetDashboard]

EB --> |"sync"| EVENTS[Events

CheckStatusChanged, IncidentCreated]

XB --> |"middleware"| EXTERNAL[ExternalWorkerMiddleware]

EXTERNAL --> RABBIT[RabbitMQ]

style XB fill:#8b5cf6,color:#fff

style RABBIT fill:#ff6600,color:#fff

CQRS oddělení přes oddělené busy. Command bus pro zápis, query bus pro čtení s autorizačním middleware. External bus má vlastní middleware, který řeší serializaci a konverzi formátů pro externí workery.

Workery jako systemd služby

# webovahlidka-result-workers.service
[Service]
ExecStart=/usr/bin/php bin/console messenger:consume \
    website_check_results \
    security_check_results \
    pagespeed_check_results \
    seo_check_results \
    screenshot_check_results \
    --time-limit=3600 \
    --memory-limit=128M

Restart=always
RestartSec=5
MemoryMax=256M

Klíčové parametry:
--time-limit=3600 – worker se po hodině restartuje (prevence memory leaků v long-running PHP procesech)
--memory-limit=128M – pojistka na úrovni aplikace
MemoryMax=256M – hard limit na úrovni OS
Restart=always + RestartSec=5 – self-healing, pokud spadne

Proč Node.js workery a ne PHP?

Tady jsem udělal rozhodnutí, že půjdu cestou – polyglot architektury. Jednoduše proto, že Node.JS se hodí více pro práci HTTP, než PHP a hlavně umí pracovat s Chrome.

graph TB

subgraph "PHP / Symfony"

A[REST API]

B[Business logika]

C[CQRS + DDD]

D[Persistence]

end

subgraph "Node.js"

E[Headless Chrome

Puppeteer]

F[Async I/O

síťové kontroly]

G[PageSpeed analýza]

end

subgraph "RabbitMQ"

H[AMQP protokol

JSON zprávy]

end

A --> H

H --> E & F & G

E & F & G --> H

H --> B

style H fill:#ff6600,color:#fff
  • PHP/Symfony – silný v business logice, DDD, CQRS, API, persistence
  • Node.js – silný v headless Chrome (Puppeteer), async I/O, síťových operacích

Díky RabbitMQ je jedno, v čem je worker napsaný. Formát zprávy je JSON, protokol AMQP. Zítra můžu přidat Go worker pro nějaký specifický typ kontroly a nemusím změnit ani řádek v Symfony.

Celkový flow: co se stane, když kliknu na „Zkontrolovat“

sequenceDiagram

    actor U as Uživatel

    participant API as Symfony API

    participant R as MonitoringServerRouter

    participant PUB as ExternalRabbitMQPublisher

    participant DB as PostgreSQL

    participant MQ as RabbitMQ

    participant W as Node.js Worker

    participant RW as Result Worker (PHP)

    U->>API: POST /api/websites/{id}/checks/{checkId}/run

    API->>R: routeCheckRequest(check, priority)

    R->>R: Určí server podle typu<br/>(HTTP → website, SSL → security, ...)

    R->>PUB: dispatch(WebsiteCheckRequestMessage)

    PUB->>DB: Uloží CheckRequestMapping

    PUB->>MQ: Publikuje do website_check_requests

    API-->>U: 202 Accepted (~10 ms)

    Note over MQ,W: Asynchronní zpracování

    MQ->>W: Konzumuje zprávu

    W->>W: HTTP request na cílový web

    W->>MQ: Výsledek → website_check_results

    MQ->>RW: Konzumuje výsledek

    RW->>DB: Lookup requestId → checkId

    RW->>DB: INSERT MonitoringResult

    RW->>RW: Vyhodnotí status change

    opt Status se změnil

        RW->>RW: Vytvoří incident

        RW->>U: Notifikace (email/Slack)

    end

Od kliknutí po API response: ~10 ms. Samotná kontrola běží na pozadí, uživatel nemusí čekat.

Výsledky

Metrika Před (synchronně) Po (RabbitMQ)
Odpověď API 2-5 sekund ~10 ms
1000 kontrol ~50 minut ~2 minuty
Škálování Vertikální (větší server) Horizontální (víc workerů)
Výpadek workera Spadne celá aplikace Zprávy čekají ve frontě
Přidání nového typu Změna v monolitu Nová fronta + nový worker

Poslední dva body jsou z pohledu architektury nejdůležitější. Fault isolation – když spadne PageSpeed worker, HTTP kontroly jedou dál, a nový typ kontroly je nová fronta a nový worker, bez zásahu do existujícího kódu – nebo spíš minimálního zásahu.

Poznámky

Pár věcí, které jsem se na tomhle projektu naučil:

  1. Oddělte fronty podle výkonnostního profilu – head-of-line blocking, pomalé operace nesmí blokovat rychlé.

  2. Correlation ID pattern – když pracujete s externím systémem asynchronně, potřebujete spolehlivý způsob, jak spárovat request s response. Databázové mapování je nejjednodušší a nejspolehlivější.

  3. Anti-corruption layer šetří starosti – publisher transformuje interní doménový model na externě srozumitelné DTO. Worker neví nic o Symfony, Symfony neví nic o implementaci workera. Čistá hranice.

  4. Persistent zprávydelivery_mode: 2 je povinnost pro cokoliv, kde vám záleží na datech, aby se neztratila ve vzduchoprázdnu.

  5. Polyglot architektura přes message broker – nemusíte vše psát v jednom jazyce. RabbitMQ (nebo jakýkoliv broker) vám dá svobodu použít správný nástroj pro správný úkol.

  6. Nemusíte mít vše hned v microservices – moje řešení není čisté microservices. Je to monolit s externími workery propojenými přes message broker. Je to takový pragmatický hybrid. Získáváte benefity asynchronního zpracování bez komplexity plných microservices.

A to je podle mě to nejdůležitější poučení. Nemusíte přepisovat celou aplikaci. Stačí identifikovat bottleneck a ten jeden kus vytáhnout do asynchronního zpracování. Zbytek monolitu může zůstat, jak je, zatím :)

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony a Reactem, zajímám se architekturu softwaru. Žiju v Českých Budějovicích.