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 (
|
||||
// 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,14 +614,12 @@ 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",
|
||||
"nginx.ingress.kubernetes.io/proxy-body-size": "10m",
|
||||
"cert-manager.io/cluster-issuer": "letsencrypt-prod",
|
||||
},
|
||||
},
|
||||
Spec: netv1.IngressSpec{
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user