Wdrożenie Meilisearch – od Docker Compose do Kubernetes
Meilisearch to szybki silnik full-text search napisany w Rust, wyróżniający się bardzo niskim czasem odpowiedzi i wbudowaną tolerancją na literówki. Poniżej opisuję krok po kroku, jak został wdrożony w projekcie portfolio.
Docker Compose – środowisko developerskie
Do blog/docker-compose.yml dodany został serwis blog-meilisearch:
blog-meilisearch:
image: getmeili/meilisearch:v1.11
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY}
MEILI_ENV: development
volumes:
- blog_meilisearch_data:/meili_data
ports:
- "127.0.0.1:7700:7700"
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:7700/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- blog-internal
Serwis jest podłączony wyłącznie do sieci blog-internal — nie jest eksponowany w sieci microservices ani web. Port 7700 jest bindowany tylko na 127.0.0.1, więc jest dostępny lokalnie wyłącznie dla potrzeb deweloperskich (np. przez panel Meilisearch).
Serwisy blog-app i blog-consumer otrzymały zmienne środowiskowe wskazujące na Meilisearch oraz warunek service_healthy w depends_on, gwarantujący, że aplikacja nie uruchomi się przed gotowością silnika:
MEILISEARCH_HOST: http://blog-meilisearch:7700
MEILISEARCH_KEY: ${MEILISEARCH_MASTER_KEY}
SCOUT_DRIVER: meilisearch
Docker Compose – produkcja (docker-compose.prod.yml)
W produkcyjnym pliku docker-compose.prod.yml serwis wygląda analogicznie, z tą różnicą, że MEILI_ENV jest ustawiony na production (co wyłącza panel administracyjny Meilisearch i wymusza użycie klucza master). Port nie jest eksponowany na hoście — komunikacja odbywa się wyłącznie wewnątrz sieci blog-internal. Dane indeksu są przechowywane w nazwanym wolumenie blog_meilisearch_data.
Kubernetes
W warstwie produkcyjnej (ArgoCD + K8s) Meilisearch działa jako StatefulSet w namespace portfolio. Kluczowe elementy konfiguracji:
- StatefulSet z jedną repliką — gwarantuje stabilną tożsamość podu i stałe podpięcie wolumenu.
- Headless ClusterIP Service (
clusterIP: None) na porcie 7700 — umożliwia adresowanie podu po DNS (blog-meilisearch.portfolio.svc.cluster.local) bez load balancera. - PersistentVolumeClaim o pojemności 1 Gi (
accessModes: ReadWriteOnce) generowany przezvolumeClaimTemplates— dane indeksu przeżywają restart podu. - Klucz master key pochodzi z Kubernetes Secret (
blog-secret, kluczMEILISEARCH_MASTER_KEY), nie jest hardkodowany w manifeście. - Liveness probe i readiness probe na endpoincie
/health— Kubernetes odtwarza pod przy awarii i nie kieruje ruchu do niegotowego kontenera.
env:
- name: MEILI_ENV
value: production
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef:
name: blog-secret
key: MEILISEARCH_MASTER_KEY
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
Zmiany w serwisie blog
Zależności
Do composer.json trafiły dwie paczki:
laravel/scout— oficjalny pakiet wyszukiwania Laravel; dostarcza traitSearchablei interfejs do różnych silników.meilisearch/meilisearch-php— oficjalne PHP SDK Meilisearch, używane przez Scout pod spodem.
Trait Searchable i metody modelu Post
Model Post implementuje trait Searchable z Laravel Scout. Trzy metody kontrolują sposób indeksowania:
toSearchableArray() — buduje dokument przesyłany do Meilisearch. Tłumaczenia (PL + EN) są spłaszczane do jednej struktury płaskiej, co pozwala na wyszukiwanie w obu językach jednocześnie:
public function toSearchableArray(): array
{
$this->loadMissing(['translations', 'categories', 'tags']);
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->translations->pluck('title')->join(' | '),
'excerpt' => $this->translations->map(fn ($t) => Str::limit(strip_tags($t->excerpt ?? ''), 300))->join(' | '),
'content' => Str::limit(strip_tags($this->translations->pluck('content')->join(' ')), 3000),
'categories' => $this->categories->pluck('name')->toArray(),
'tags' => $this->tags->pluck('name')->toArray(),
'cover_image' => $this->cover_image,
'published_at' => $this->published_at?->timestamp,
];
}
shouldBeSearchable() — gate kontrolujący, które posty wchodzą do indeksu. Do Meilisearch trafiają wyłącznie posty opublikowane, z published_at <= now() i bez soft delete:
public function shouldBeSearchable(): bool
{
return $this->status === 'published'
&& $this->published_at !== null
&& $this->published_at <= now()
&& is_null($this->deleted_at);
}
makeAllSearchableUsing() — eager-loading relacji przy masowym imporcie, aby uniknąć N+1 przy php artisan scout:import.
Konfiguracja scout.php
W config/scout.php zdefiniowane zostały ustawienia per-indeks dla posts:
- searchableAttributes:
title,excerpt,content,categories,tags— kolejność wpływa na ranking wyników. - filterableAttributes:
published_at— umożliwia filtrowanie po dacie po stronie Meilisearch. - sortableAttributes:
published_at. - typoTolerance: 1 literówka dla słów ≥5 znaków, 2 literówki dla słów ≥9 znaków.
- rankingRules: domyślny zestaw Meilisearch (
words → typo → proximity → attribute → sort → exactness).
Asynchroniczne aktualizacje indeksu
Laravel Scout obsługuje kolejkowanie operacji indeksowania przez zmienną środowiskową SCOUT_QUEUE=true. Po jej ustawieniu dodanie lub usunięcie dokumentu z indeksu jest odkładane do kolejki Laravel (np. Redis), a nie wykonywane synchronicznie w trakcie żądania HTTP. To szczególnie istotne przy zapisywaniu postów przez panel admina — odpowiedź HTTP nie musi czekać na zakończenie operacji w Meilisearch.
Gdy post zmienia status (np. z draft na published), PostObserver nasłuchuje na zdarzenie updated i — przez mechanizm Scout — automatycznie kolejkuje re-indeksację. Zmiana statusu publikuje też zdarzenie do RabbitMQ (wymiana blog), informując inne serwisy o nowym poście.
Pełną re-indeksację wszystkich postów można wymusić poleceniem:
php artisan scout:import "App\Models\Post"
SearchController
Endpoint GET /api/search?q=… odpytuje jednocześnie trzy indeksy: posts (limit 5, z podświetlaniem <mark>), categories (limit 3) i tags (limit 5). W razie niedostępności Meilisearch kontroler łapie wyjątek i zwraca status 503 z pustymi kolekcjami wyników, dzięki czemu frontend nie traci działania.