aRustyDev / aRustyDev/mdbook-htmx
docs(examples): Kubernetes with Meilisearch
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# Kubernetes with Meilisearch
Deploy mdbook-htmx with Meilisearch for server-side full-text search.
## Overview
This deployment includes:
- **Meilisearch**: Fast, typo-tolerant search engine
- **Documentation Server**: NGINX serving mdbook-htmx output
- **Indexer Job**: CronJob to keep search index updated
## Architecture
```
┌─────────────┐
│ Ingress │
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────▼──────┐┌──────▼──────┐ │
│ Docs ││ Search │ │
│ Service ││ Service │ │
└──────┬──────┘└──────┬──────┘ │
│ │ │
┌──────▼──────┐┌──────▼──────┐ │
│ Docs ││ Meilisearch │ │
│ Deployment ││ StatefulSet│ │
└─────────────┘└──────┬──────┘ │
│ │
┌──────▼──────┐ │
│ PVC │ │
│ (search idx)│ │
└─────────────┘ │
│ │
┌──────▼──────┐ │
│ Indexer │◄──────┘
│ CronJob │
└─────────────┘
```
## Meilisearch Deployment
### meilisearch-statefulset.yaml
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: meilisearch
namespace: docs
labels:
app.kubernetes.io/name: meilisearch
app.kubernetes.io/component: search
spec:
serviceName: meilisearch
replicas: 1
selector:
matchLabels:
app: meilisearch
template:
metadata:
labels:
app: meilisearch
spec:
containers:
- name: meilisearch
image: getmeili/meilisearch:v1.6
ports:
- containerPort: 7700
name: http
env:
- name: MEILI_ENV
value: production
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef:
name: meilisearch-secrets
key: master-key
- name: MEILI_NO_ANALYTICS
value: "true"
- name: MEILI_DB_PATH
value: /meili_data/data.ms
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /meili_data
livenessProbe:
httpGet:
path: /health
port: 7700
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 7700
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
securityContext:
fsGroup: 1000
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 5Gi
```
### meilisearch-service.yaml
```yaml
apiVersion: v1
kind: Service
metadata:
name: meilisearch
namespace: docs
spec:
type: ClusterIP
ports:
- port: 7700
targetPort: http
protocol: TCP
name: http
selector:
app: meilisearch
```
### meilisearch-secrets.yaml
```yaml
apiVersion: v1
kind: Secret
metadata:
name: meilisearch-secrets
namespace: docs
type: Opaque
stringData:
master-key: "your-master-key-here" # Generate with: openssl rand -base64 32
```
## Search Indexer
### indexer-configmap.yaml
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: indexer-scripts
namespace: docs
data:
index.py: |
#!/usr/bin/env python3
"""Index documentation content to Meilisearch."""
import json
import os
import hashlib
from pathlib import Path
import meilisearch
MEILI_HOST = os.environ.get('MEILI_HOST', 'http://meilisearch:7700')
MEILI_KEY = os.environ['MEILI_MASTER_KEY']
DOCS_PATH = os.environ.get('DOCS_PATH', '/docs')
INDEX_NAME = os.environ.get('INDEX_NAME', 'docs')
def main():
client = meilisearch.Client(MEILI_HOST, MEILI_KEY)
# Load search index from mdbook-htmx output
search_index_path = Path(DOCS_PATH) / 'search-index.json'
if not search_index_path.exists():
print(f"Search index not found at {search_index_path}")
return
with open(search_index_path) as f:
index_data = json.load(f)
# Transform to Meilisearch format
documents = []
for page in index_data.get('pages', []):
doc = {
'id': hashlib.md5(page['path'].encode()).hexdigest(),
'path': page['path'],
'title': page['title'],
'content': page.get('content', ''),
'headings': page.get('headings', []),
'scope': page.get('scope', 'all'),
'tags': page.get('tags', []),
}
documents.append(doc)
# Create or update index
index = client.index(INDEX_NAME)
# Configure searchable and filterable attributes
index.update_settings({
'searchableAttributes': [
'title',
'headings',
'content',
'tags',
],
'filterableAttributes': [
'scope',
'tags',
],
'sortableAttributes': [
'title',
],
'rankingRules': [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
],
})
# Add documents
task = index.add_documents(documents)
print(f"Indexing task: {task.task_uid}")
# Wait for completion
client.wait_for_task(task.task_uid)
print(f"Indexed {len(documents)} documents")
if __name__ == '__main__':
main()
```
### indexer-cronjob.yaml
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: docs-indexer
namespace: docs
spec:
schedule: "0 */6 * * *" # Every 6 hours
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
containers:
- name: indexer
image: python:3.11-slim
command: ["/bin/sh", "-c"]
args:
- |
pip install meilisearch
python /scripts/index.py
env:
- name: MEILI_HOST
value: "http://meilisearch:7700"
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef:
name: meilisearch-secrets
key: master-key
- name: DOCS_PATH
value: "/docs"
volumeMounts:
- name: scripts
mountPath: /scripts
- name: docs
mountPath: /docs
readOnly: true
volumes:
- name: scripts
configMap:
name: indexer-scripts
- name: docs
configMap:
name: docs-content
restartPolicy: OnFailure
```
## Docs Server with Search Proxy
### docs-deployment.yaml
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs
namespace: docs
spec:
replicas: 3
selector:
matchLabels:
app: docs
template:
metadata:
labels:
app: docs
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: docs-content
mountPath: /usr/share/nginx/html
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: nginx-config
configMap:
name: nginx-search-config
- name: docs-content
configMap:
name: docs-content
```
### nginx-search-config.yaml
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-search-config
namespace: docs
data:
nginx.conf: |
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Meilisearch upstream
upstream meilisearch {
server meilisearch:7700;
keepalive 32;
}
server {
listen 80;
# Static documentation
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# Proxy search requests to Meilisearch
location /search {
# Extract API key from header or use default search key
set $search_key "";
if ($http_authorization ~* "Bearer (.+)") {
set $search_key $1;
}
proxy_pass http://meilisearch/indexes/docs/search;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Add Meilisearch search API key
proxy_set_header Authorization "Bearer ${SEARCH_API_KEY}";
# CORS headers
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
if ($request_method = OPTIONS) {
return 204;
}
}
# Health check
location /healthz {
return 200 "OK\n";
}
}
}
```
## book.toml Configuration
```toml
[output.htmx.search]
enabled = true
mode = "server"
endpoint = "/search"
min_chars = 2
debounce_ms = 300
# Scope filtering
[output.htmx.search.filters]
scope = true
tags = true
```
## Search API Keys
Create a search-only API key:
```bash
# Create search-only key (no write access)
curl -X POST 'http://meilisearch:7700/keys' \
-H 'Authorization: Bearer YOUR_MASTER_KEY' \
-H 'Content-Type: application/json' \
--data-binary '{
"description": "Search-only key for docs",
"actions": ["search"],
"indexes": ["docs"],
"expiresAt": null
}'
```
Store the search key in a secret:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: meilisearch-search-key
namespace: docs
stringData:
search-key: "your-search-api-key"
```
## Ingress Configuration
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docs
namespace: docs
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/configuration-snippet: |
# Rate limit search endpoint
limit_req zone=search burst=10 nodelay;
spec:
tls:
- hosts:
- docs.example.com
secretName: docs-tls
rules:
- host: docs.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: docs
port:
number: 80
```
## Monitoring Meilisearch
### Prometheus metrics
Meilisearch exposes metrics at `/metrics`:
```yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: meilisearch
namespace: docs
spec:
selector:
matchLabels:
app: meilisearch
endpoints:
- port: http
path: /metrics
interval: 30s
```
### Key Metrics
- `meilisearch_http_requests_total`
- `meilisearch_http_response_time_seconds`
- `meilisearch_index_docs_count`
- `meilisearch_db_size_bytes`
## Resource Sizing
| Load | Meilisearch CPU | Meilisearch Memory | Docs Size |
|------|-----------------|---------------------|-----------|
| Light (<100 pages) | 100m | 256Mi | 1Gi |
| Medium (100-1000 pages) | 250m | 512Mi | 5Gi |
| Heavy (1000+ pages) | 500m | 1Gi | 10Gi |
## Next Steps
- Add [authentication](./k8s-ingress-auth.md)
- Deploy with [Helm chart](./helm-chart.md)
- Configure [Docker Compose locally](./docker-compose.md)
Contributor guide
Assessment
This issue has not been assessed yet.