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:
- Oddělte fronty podle výkonnostního profilu – head-of-line blocking, pomalé operace nesmí blokovat rychlé.
-
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ší.
-
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.
-
Persistent zprávy –
delivery_mode: 2je povinnost pro cokoliv, kde vám záleží na datech, aby se neztratila ve vzduchoprázdnu. -
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.
-
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 :)