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