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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user