fix(sqs-operator): persistence, OOM, Ingress routing (v0.1.2→v0.1.4)

Problem 1: elasticmq-native does not support H2 JDBC persistence
- GraalVM native build excludes H2 driver
- Fix: switch to softwaremill/elasticmq:1.7.1 (JVM image)

Problem 2: OOMKilled — JVM requires >150MB, spec.MemoryMB=64 too low
- Fix: enforce minimum 256Mi for JVM; add -Xmx (75% of limit)

Problem 3: AccessDeniedException on /data — PVC mounted as root:root
- JVM image runs as uid=999 (elasticmq)
- Fix: add fsGroup=999 to PodSecurityContext

Problem 4: 404 through HTTPS — nginx rewrite stripped context-path
- ElasticMQ JVM listens at context-path (/sqs/{tenantId})
- Native image tolerated /, JVM does not
- Fix: remove rewrite-target annotation, use plain Prefix path

Result: S6 PASS — 8 queues + messages survived graceful pod kill
This commit is contained in:
Naeel
2026-04-07 15:34:15 +03:00
parent f8be5c8af1
commit 336ee7b869
2 changed files with 41 additions and 22 deletions
@@ -38,8 +38,9 @@ import (
const (
// sqsFinalizer — добавляется к QueueService CR для управления cleanup при удалении.
sqsFinalizer = "sqs.kube5s.ru/sqs-finalizer"
// elasticMQImage — образ ElasticMQ Native (GraalVM, ~70MB, старт <0.5с).
elasticMQImage = "softwaremill/elasticmq-native:1.7.1"
// elasticMQImage — образ ElasticMQ JVM (полный, с поддержкой H2 JDBC persistence).
// Native-образ (elasticmq-native) не включает H2 в GraalVM native image — persistence не работает.
elasticMQImage = "softwaremill/elasticmq:1.7.1"
// elasticMQPort — порт на котором ElasticMQ слушает SQS HTTP запросы.
elasticMQPort = 9324
)
@@ -379,7 +380,8 @@ func (r *QueueServiceReconciler) ensureConfigMap(ctx context.Context, qs *sqsv1a
Labels: sqsLabels(qs.Spec.TenantID),
},
Data: map[string]string{
"custom.conf": conf,
// Ключ соответствует subPath при монтировании в pod.
"elasticmq.conf": conf,
},
}
return r.Create(ctx, cm)
@@ -427,8 +429,21 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1
replicas := int32(1)
port := int32(elasticMQPort)
memLimit := resource.MustParse(fmt.Sprintf("%dMi", qs.Spec.MemoryMB))
memRequest := resource.MustParse("32Mi")
// JVM-образ ElasticMQ требует минимум ~150MB. Форсируем нижнюю границу 256Mi.
// MemoryMB из spec — лимит; request = половина лимита, но не меньше 128Mi.
memMB := qs.Spec.MemoryMB
if memMB < 256 {
memMB = 256
}
memLimit := resource.MustParse(fmt.Sprintf("%dMi", memMB))
memReqMB := memMB / 2
if memReqMB < 128 {
memReqMB = 128
}
memRequest := resource.MustParse(fmt.Sprintf("%dMi", memReqMB))
// -Xmx = 75% от лимита, чтобы JVM не выбил OOMKill при GC overhead.
xmxMB := memMB * 3 / 4
jvmOpts := fmt.Sprintf("-Xmx%dm -Xms64m", xmxMB)
cpuRequest := resource.MustParse("10m")
cpuLimit := resource.MustParse("500m")
@@ -450,6 +465,11 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1
Labels: sqsLabels(qs.Spec.TenantID),
},
Spec: corev1.PodSpec{
// fsGroup=999 — uid elasticmq в JVM-образе.
// Kubernetes при монтировании PVC сделает chown :999 на /data.
SecurityContext: &corev1.PodSecurityContext{
FSGroup: func() *int64 { v := int64(999); return &v }(),
},
Containers: []corev1.Container{
{
Name: "elasticmq",
@@ -457,12 +477,12 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1
Ports: []corev1.ContainerPort{
{ContainerPort: port, Protocol: corev1.ProtocolTCP},
},
// ElasticMQ Native читает конфиг через системное свойство JVM.
// Путь должен совпадать с mountPath в VolumeMounts.
// JVM-образ имеет ENTRYPOINT: java -Dconfig.file=/opt/elasticmq.conf -jar ...
// JAVA_TOOL_OPTIONS задаёт Xmx чтобы JVM не выбил OOMKill ниже k8s лимита.
Env: []corev1.EnvVar{
{
Name: "JAVA_TOOL_OPTIONS",
Value: "-Dconfig.file=/opt/elasticmq/custom.conf",
Value: jvmOpts,
},
},
Resources: corev1.ResourceRequirements{
@@ -477,10 +497,10 @@ func (r *QueueServiceReconciler) ensureDeployment(ctx context.Context, qs *sqsv1
},
VolumeMounts: []corev1.VolumeMount{
{
// ConfigMap монтируем как единственный файл custom.conf
// Монтируем конфиг на путь который JVM-образ читает по дефолту.
Name: "elasticmq-config",
MountPath: "/opt/elasticmq/custom.conf",
SubPath: "custom.conf",
MountPath: "/opt/elasticmq.conf",
SubPath: "elasticmq.conf",
ReadOnly: true,
},
{
@@ -574,7 +594,7 @@ func (r *QueueServiceReconciler) ensureService(ctx context.Context, qs *sqsv1alp
}
// ensureIngress создаёт Ingress с path-based routing для тенанта.
// Путь: sqs.kube5s.ru/sqs/{tenantId} → Service :9324
// Путь: sqs.kube5s.ru/sqs/{tenantId} → Service :9324 (без rewrite — ElasticMQ JVM слушает с context-path).
// Nginx Ingress Controller автоматически мерджит правила от разных Ingress объектов.
func (r *QueueServiceReconciler) ensureIngress(ctx context.Context, qs *sqsv1alpha1.QueueService, ns, name, svcName string) error {
ing := &netv1.Ingress{}
@@ -584,6 +604,7 @@ func (r *QueueServiceReconciler) ensureIngress(ctx context.Context, qs *sqsv1alp
return fmt.Errorf("get ingress: %w", err)
}
// ImplementationSpecific нужен для regex, Prefix — для простого prefix-matching без rewrite.
pathType := netv1.PathTypePrefix
svcPort := int32(elasticMQPort)
@@ -593,12 +614,10 @@ func (r *QueueServiceReconciler) ensureIngress(ctx context.Context, qs *sqsv1alp
Namespace: ns,
Labels: sqsLabels(qs.Spec.TenantID),
Annotations: map[string]string{
// Nginx rewrite: убираем prefix /sqs/{tenantId} перед проксированием в ElasticMQ.
// ElasticMQ получает чистый SQS path (например /?Action=CreateQueue).
"nginx.ingress.kubernetes.io/rewrite-target": "/$2",
// Без rewrite-target: ElasticMQ JVM слушает по полному пути /sqs/{tenantId}/...
// (context-path в конфиге определяет listen path у JVM образа, в отличие от native).
"nginx.ingress.kubernetes.io/proxy-read-timeout": "60",
"nginx.ingress.kubernetes.io/proxy-send-timeout": "60",
// Увеличиваем лимит тела запроса для больших сообщений
"nginx.ingress.kubernetes.io/proxy-body-size": "10m",
"cert-manager.io/cluster-issuer": "letsencrypt-prod",
},
@@ -618,9 +637,9 @@ func (r *QueueServiceReconciler) ensureIngress(ctx context.Context, qs *sqsv1alp
HTTP: &netv1.HTTPIngressRuleValue{
Paths: []netv1.HTTPIngressPath{
{
// Regex capture group ($2) передаётся в rewrite-target.
// /sqs/{tenantId}(/|$)(.*) → /$2
Path: "/sqs/" + qs.Spec.TenantID + "(/|$)(.*)",
// Простой prefix: /sqs/{tenantId} форвардится как есть в ElasticMQ.
// ElasticMQ JVM обрабатывает полный путь включая context-path.
Path: "/sqs/" + qs.Spec.TenantID,
PathType: &pathType,
Backend: netv1.IngressBackend{
Service: &netv1.IngressServiceBackend{
@@ -1,7 +1,7 @@
// Изменён: 2026-04-07
// elasticmq_config.go — генератор HOCON конфигурации для ElasticMQ.
// Каждый тенант получает уникальный конфиг: свой context-path, accountId, persistence.
// Конфиг монтируется в pod как ConfigMap → /opt/elasticmq/custom.conf.
// Конфиг монтируется в pod как ConfigMap → /opt/elasticmq.conf (путь из ENTRYPOINT JVM-образа).
package elasticmq