15 KiB
SQS Operator — План реализации
Дата: 2026-04-07
Агент: Claude Opus 4.6
Цель
Managed SQS-совместимый сервис очередей сообщений. Каждый тенант облачного провайдера получает изолированный инстанс (ElasticMQ). Работает через стандартный AWS SDK (Go/Python/Java/JS) — меняется только endpoint.
Решения (согласованы с пользователем)
- Модель: инстанс на тенанта (Вариант A) — изоляция, падение одного не влияет на остальных
- Backend: ElasticMQ Native (GraalVM) —
softwaremill/elasticmq-native - Routing: path-based —
sqs.kube5s.ru/sqs/{tenant}/... - Auth: Bearer token (существующий у тенанта), абстрагирован для будущей замены на ЛК
- Namespace: существующий
sless-fn-{tenant}— ElasticMQ pod рядом с функциями тенанта - DNS:
sqs.kube5s.ru→ 185.247.187.147 (создано, резолвится) - Persistence: H2 (встроенная в ElasticMQ), через PVC
- Config:
SQS_EXTERNAL_HOSTв ConfigMap оператора — настраиваемый хост (dev → prod)
Архитектура
Terraform "sless_queue_service"
→ REST API (sless-operator :9090)
→ POST /api/v1/queue-services
→ создаёт CRD QueueService в K8s
→ QueueServiceReconciler (контроллер в sless-operator)
→ создаёт в namespace sless-fn-{tenant}:
- ConfigMap (elasticmq.conf)
- PVC (persistence H2)
- Deployment (ElasticMQ Native pod)
- Service (ClusterIP :9324)
- Secret (accessKey/secretKey для тенанта)
→ Ingress на sqs.kube5s.ru/sqs/{tenant}/ → Service :9324
→ Status.Endpoint = https://sqs.kube5s.ru/sqs/{tenant}
→ Status.Phase = Ready
Клиент использует:
import boto3
sqs = boto3.client(sqs,
endpoint_url=https://sqs.kube5s.ru/sqs/my-tenant,
aws_access_key_id=xxx,
aws_secret_access_key=yyy,
region_name=ru-msk-1)
queue = sqs.create_queue(QueueName=my-queue)
sqs.send_message(QueueUrl=queue[QueueUrl], MessageBody=hello)
Этапы реализации
Этап 1: CRD QueueService
Файл: api/v1alpha1/queueservice_types.go
// QueueServiceSpec — желаемое состояние инстанса очередей тенанта
type QueueServiceSpec struct {
// TenantID — уникальный ID тенанта облачного провайдера
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$`
TenantID string `json:"tenantId"`
// MemoryMB — лимит RAM для ElasticMQ (default: 64)
// +kubebuilder:default=64
// +kubebuilder:validation:Minimum=32
// +kubebuilder:validation:Maximum=1024
MemoryMB int32 `json:"memoryMB,omitempty"`
// StorageMB — размер PVC для H2 persistence (default: 512)
// +kubebuilder:default=512
// +kubebuilder:validation:Minimum=128
// +kubebuilder:validation:Maximum=10240
StorageMB int32 `json:"storageMB,omitempty"`
// Persistence — включить сохранение сообщений на диск (default: true)
// Если false — только in-memory, сообщения теряются при рестарте
// +kubebuilder:default=true
Persistence bool `json:"persistence"`
}
// QueueServicePhase — фаза жизненного цикла инстанса
type QueueServicePhase string
const (
QueueServicePhasePending QueueServicePhase = "Pending"
QueueServicePhaseProvisioning QueueServicePhase = "Provisioning"
QueueServicePhaseReady QueueServicePhase = "Ready"
QueueServicePhaseFailed QueueServicePhase = "Failed"
QueueServicePhaseDeleting QueueServicePhase = "Deleting"
)
// QueueServiceStatus — наблюдаемое состояние
type QueueServiceStatus struct {
Phase QueueServicePhase `json:"phase,omitempty"`
Endpoint string `json:"endpoint,omitempty"` // https://sqs.kube5s.ru/sqs/{tenantId}
SecretName string `json:"secretName,omitempty"` // имя Secret с credentials
Message string `json:"message,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
ReadyAt *metav1.Time `json:"readyAt,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="TenantID",type=string,JSONPath=`.spec.tenantId`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type QueueService struct { ... }
type QueueServiceList struct { ... }
Действия:
- Создать файл
api/v1alpha1/queueservice_types.go - Добавить
init()→SchemeBuilder.Register(&QueueService{}, &QueueServiceList{}) - Запустить
make manifests— сгенерирует CRD YAML + deepcopy - Применить CRD:
kubectl apply -f config/crd/bases/
Этап 2: ElasticMQ Config Generator
Файл: internal/sqs/elasticmq_config.go
Генерирует HOCON конфиг для ElasticMQ:
func GenerateElasticMQConfig(tenantID, externalHost string, persistence bool) string
Содержимое конфига:
include classpath("application.conf")
node-address {
protocol = https
host = {SQS_EXTERNAL_HOST}
port = 443
context-path = "/sqs/{tenantID}"
}
rest-sqs {
enabled = true
bind-port = 9324
bind-hostname = "0.0.0.0"
sqs-limits = strict
}
messages-storage {
enabled = {persistence} // true/false
uri = "jdbc:h2:/data/elasticmq"
}
aws {
region = ru-msk-1
accountId = {tenantID}
}
Этап 3: Credentials Generator
Файл: internal/sqs/credentials.go
// GenerateSQSCredentials — создаёт пару accessKey/secretKey для тенанта.
// accessKey: SQSAK{tenantID}_{random8}
// secretKey: crypto/rand 32 bytes → base64
func GenerateSQSCredentials(tenantID string) (accessKey, secretKey string, err error)
Этап 4: Controller
Файл: controllers/queueservice_controller.go
Структура:
type QueueServiceReconciler struct {
client.Client
Scheme *runtime.Scheme
KubeClient kubernetes.Interface
SQSExternalHost string // из env SQS_EXTERNAL_HOST
Log logr.Logger
}
Reconcile loop:
1. GET QueueService CR
2. IF deleting:
a. Delete Deployment sqs-{tenantId}
b. Delete Service sqs-svc-{tenantId}
c. Delete ConfigMap sqs-cfg-{tenantId}
d. Delete Secret sqs-creds-{tenantId}
e. НЕ удалять PVC (данные сохраняются, удаляются вручную)
f. Remove finalizer sless.kube5s.ru/sqs-finalizer
g. RETURN
3. IF no finalizer → add finalizer, set Phase=Pending
4. IF Phase=Pending:
a. Ensure namespace sless-fn-{tenantId} exists
b. Generate credentials → create Secret sqs-creds-{tenantId}
c. Generate elasticmq.conf → create ConfigMap sqs-cfg-{tenantId}
d. Create PVC sqs-data-{tenantId} (StorageMB)
e. Set Phase=Provisioning, requeue
5. IF Phase=Provisioning:
a. Create/Update Deployment sqs-{tenantId}:
- image: softwaremill/elasticmq-native:1.7.1
- container port: 9324
- volumeMounts:
- sqs-cfg-{tenantId} → /opt/elasticmq/custom.conf (subPath)
- sqs-data-{tenantId} → /data
- env: JAVA_TOOL_OPTIONS=-Dconfig.file=/opt/elasticmq/custom.conf
- resources: requests 10m/32Mi, limits 500m/{MemoryMB}Mi
- readinessProbe: httpGet /health :9324 (period: 5s)
- livenessProbe: httpGet /health :9324 (period: 10s)
b. Create Service sqs-svc-{tenantId} → port 9324
c. Check: is Deployment Ready? (availableReplicas >= 1)
- No → requeue after 3s
- Yes → set Phase=Ready, Endpoint, ReadyAt
6. IF Phase=Ready:
a. Check Deployment health (availableReplicas)
b. If unhealthy → Phase=Failed + Message
7. IF Phase=Failed:
a. Check if Deployment recovered → Phase=Ready
b. Else requeue after 30s
RBAC markers:
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=queueservices,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=queueservices/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=queueservices/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create
SetupWithManager — watch QueueService, own Deployment/Service/ConfigMap/Secret/PVC.
Этап 5: Ingress
Подход: один Ingress на sqs.kube5s.ru с path-based routing.
Варианты: A) Контроллер создаёт отдельный Ingress на каждого тенанта:
# Ingress sqs-ing-{tenantId} в ns sless-fn-{tenantId}
spec:
rules:
- host: sqs.kube5s.ru
http:
paths:
- path: /sqs/{tenantId}
pathType: Prefix
backend:
service:
name: sqs-svc-{tenantId}
port: 9324
tls:
- hosts: [sqs.kube5s.ru]
secretName: sqs-kube5s-ru-tls
B) Один Ingress + nginx rewrite в оператор, оператор проксирует.
Рекомендация: вариант A — по Ingress на тенанта. Nginx Ingress Controller мержит правила автоматически.
RBAC добавить: networking.k8s.io/ingresses
Этап 6: Регистрация в main.go
- Добавить в
internal/config/config.go:
SQSExternalHost string // env SQS_EXTERNAL_HOST, default: "sqs.kube5s.ru"
- В
main.go— зарегистрировать контроллер:
if err = (&controllers.QueueServiceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()),
SQSExternalHost: cfg.SQSExternalHost,
}).SetupWithManager(mgr); err != nil {
log.Error("unable to create controller", "controller", "QueueService", "err", err)
os.Exit(1)
}
Этап 7: REST API Handlers
Файл: internal/api/handler/queueservice_handler.go
Эндпоинты:
POST /api/v1/queue-services — создать QueueService CR
GET /api/v1/queue-services — список QueueService для тенанта (по namespace)
GET /api/v1/queue-services/{name} — статус конкретного инстанса
DELETE /api/v1/queue-services/{name} — удалить QueueService CR
POST body:
{
"name": "my-queues",
"memoryMB": 64,
"storageMB": 512,
"persistence": true
}
GET response:
{
"name": "my-queues",
"phase": "Ready",
"endpoint": "https://sqs.kube5s.ru/sqs/my-tenant",
"accessKey": "SQSAKmy-tenant_a1b2c3d4",
"secretKey": "...",
"createdAt": "2026-04-07T13:00:00Z"
}
Auth: существующий middleware (Bearer token → namespace mapping).
Добавить routes в internal/api/router.go.
Этап 8: Deployment
Файл: deployments/k8s/operator.yaml
Добавить в ConfigMap:
SQS_EXTERNAL_HOST: sqs.kube5s.ru
SQS_ELASTICMQ_IMAGE: softwaremill/elasticmq-native:1.7.1
RBAC: обновить ClusterRole (или использовать make manifests → config/rbac/role.yaml).
Применить новый CRD:
kubectl apply -f config/crd/bases/sless.kube5s.ru_queueservices.yaml
Этап 9: Сборка + Деплой + Тест
make manifests— генерация CRD + RBACgo build -o bin/sless-operator .→ Docker build → pushkubectl apply -f deployments/k8s/operator.yaml- Тест:
# Создать инстанс через API
curl -X POST https://sless.kube5s.ru/api/v1/queue-services \
-H "Authorization: Bearer $TOKEN" \
-d memoryMB:64
# Дождаться Ready
curl https://sless.kube5s.ru/api/v1/queue-services/test-qs \
-H "Authorization: Bearer $TOKEN"
# Проверить SQS API через AWS CLI
aws sqs create-queue \
--queue-name test-queue \
--endpoint-url https://sqs.kube5s.ru/sqs/test-tenant \
--region ru-msk-1
aws sqs send-message \
--queue-url https://sqs.kube5s.ru/sqs/test-tenant/queue/test-queue \
--message-body "hello from managed SQS" \
--endpoint-url https://sqs.kube5s.ru/sqs/test-tenant \
--region ru-msk-1
Этап 10: Terraform Resource (отдельная репа)
resource "sless_queue_service" "main" {
name = "production-queues"
memory_mb = 128
storage_mb = 1024
}
output "sqs_endpoint" {
value = sless_queue_service.main.endpoint
}
output "sqs_access_key" {
value = sless_queue_service.main.access_key
sensitive = true
}
Файловая карта (новые файлы)
| # | Файл | Назначение |
|---|---|---|
| 1 | api/v1alpha1/queueservice_types.go |
CRD types |
| 2 | internal/sqs/elasticmq_config.go |
Генератор HOCON конфига |
| 3 | internal/sqs/credentials.go |
Генератор accessKey/secretKey |
| 4 | controllers/queueservice_controller.go |
Reconciler |
| 5 | internal/api/handler/queueservice_handler.go |
REST API handlers |
| 6 | internal/api/router.go |
Добавить routes (модификация) |
| 7 | internal/config/config.go |
Добавить SQSExternalHost (модификация) |
| 8 | main.go |
Регистрация контроллера (модификация) |
| 9 | deployments/k8s/operator.yaml |
ConfigMap + RBAC (модификация) |
Зависимости (go.mod)
- Новых зависимостей НЕТ. Всё уже есть: controller-runtime, client-go, kubernetes.
Открытые вопросы (для будущего)
- Auth sidecar (AWS Signature V4) — пока Bearer token, потом если надо
- Мониторинг (Prometheus metrics per tenant) — после MVP
- Autoscaling (вертикальный — увеличить memory по нагрузке) — после MVP
- Backup/restore PVC — после MVP