Kubernetes Deployment (Helm)
The chart lives at deploy/helm/imcore. It deploys the websocket server plus optional persist_consumer / ai_worker / sync_worker deployments, with rolling upgrades, graceful connection draining, a PodDisruptionBudget, and an HPA. Kubernetes is one of three supported shapes — compose (deploy/compose/) and systemd (deploy/systemd/ + imctl) remain first-class; pick one per environment.
Prerequisites
Multi-instance rules that the chart cannot enforce for you:
- All replicas must share one Redis and one broker (RabbitMQ, NATS, or Redis Streams) — cross-instance fan-out, presence, and conversation state depend on them. Redis/broker/DB run outside this chart (operators, cloud services, or your own manifests).
- The AI subsystem (
ai_worker, RAG, handover, call analysis) works on RabbitMQ, NATS, or Redis Streams. Redis Streams reuses the required Redis connection; production requires AOF,redis.stream.ai_claim_min_idle_mslonger than the slowest task, and amaxlensized above the recovery-window backlog. The Helm chart still bundles RabbitMQ, not NATS, as its optional in-cluster broker (see below); Redis can be both the required cache/state service and the selected broker. - Migrations are applied manually before first start — the image ships
/app/bin/migrate(see below). - High-throughput multi-instance: prefer NATS cluster, or RabbitMQ with quorum queues (deployment config, no code change).
Install
- Create the config Secret. Start from
config/app.yaml.example; app.yaml holds secrets, so it always lives in a Secret:
kubectl create namespace imcore
kubectl -n imcore create secret generic imcore-config --from-file=app.yaml
For the admin console to be reachable in-cluster, set admin.console.listen_addr: "0.0.0.0:7080" in app.yaml (default binds loopback) and see service.exposeAdmin.
- Run migrations (one-off pod; repeats are safe — the runner skips applied files):
kubectl -n imcore run imcore-migrate --rm -i --restart=Never \
--image=balalaim/imcore-basic:latest \
--overrides='{"spec":{"containers":[{"name":"imcore-migrate","image":"balalaim/imcore-basic:latest","env":[{"name":"APP_BIN","value":"migrate"}],"volumeMounts":[{"name":"config","mountPath":"/app/config/app.yaml","subPath":"app.yaml"}]}],"volumes":[{"name":"config","secret":{"secretName":"imcore-config"}}]}}'
- Install the chart:
helm install imcore deploy/helm/imcore -n imcore \
--set config.existingSecret=imcore-config \
--set persistConsumer.enabled=true
persistConsumer is required somewhere in the system for room-history persistence (binary: jetstream_consumer for NATS mode). Verify: kubectl -n imcore rollout status deploy/imcore.
Full-stack install (bundled dependencies) — PoC / offline / 信创 only
For a self-contained cluster (dev, air-gapped, 信创 单机式私有化) the chart can bundle Postgres (pgvector), Redis, RabbitMQ and MinIO as subcharts and run migrations automatically, so one command brings up the whole stack:
# 1. Create the config Secret from the bundled example (edit as needed):
cp deploy/helm/imcore/examples/app.bundled.yaml app.yaml
kubectl create secret generic imcore-config --from-file=app.yaml
# 2. Install with the bundled profile:
helm install imcore deploy/helm/imcore \
-f deploy/helm/imcore/values-bundled.yaml \
--set config.existingSecret=imcore-config
The bundled dependencies are single-instance and NOT highly available, run no backups, and are intended only for PoC / dev / offline / 信创 单机式私有化. For production, leave postgresql.enabled / redis.enabled / rabbitmq.enabled / minio.enabled at their default false and point app.yaml at external managed services (or the operators below). RabbitMQ is the bundled broker (not NATS) as a packaging choice — this chart has no bundled-NATS subchart, not because the AI subsystem needs it (it now runs on all three supported brokers, see Prerequisites above). Backup/restore of bundled data is out of scope for this chart — see docs/RUNBOOK_BACKUP_RESTORE.md.
Migrations: values-bundled.yaml sets migration.enabled=true, so a <release>-migrate Job applies the schema before app pods start, and each app pod gets a wait-for-migrations initContainer that blocks until that Job completes. You can turn on just this behavior against an external DB with --set migration.enabled=true, without enabling any bundled dependency.
Production dependency operators (reference)
For production HA, run the stateful dependencies via mature operators outside this chart instead of the bundled subcharts: CloudNativePG (Postgres), the RabbitMQ Cluster Operator, a Redis operator (e.g. Spotahome Redis Operator), and the MinIO Operator. Keep postgresql/redis/rabbitmq/minio.enabled=false and point app.yaml at the operator-provisioned endpoints.
Key values
| Value | Default | Notes |
|---|---|---|
replicaCount |
2 | Ignored when autoscaling.enabled |
image.repository / tag |
balalaim/imcore-basic / appVersion |
ACR mirror for mainland China (see values.yaml comment) |
config.existingSecret / config.inline |
— | Exactly one required; render fails otherwise. inline rolls pods on change (checksum annotation); existingSecret needs a manual rollout restart after edits |
ports.http/grpc/admin |
8080/9001/7080 | Must match app.httpPort / app.rpcPort / admin.console.listen_addr |
service.exposeAdmin |
false | Adds 7080 to the Service; pair with a NetworkPolicy, never public |
updateStrategy |
surge 1 / unavailable 0 | Hitless rollouts (below) |
preStopSleepSeconds / terminationGracePeriodSeconds |
5 / 30 | Drain budget (below) |
podDisruptionBudget.* |
enabled, maxUnavailable 1 | Rendered only when replicas > 1 (or HPA on) |
autoscaling.* |
off; CPU 70% | customMetrics appends raw HPA metrics (below) |
ingress.* |
off | ingress-nginx WebSocket annotations included |
serviceMonitor.* |
off | /metrics gating caveat (below) |
persistConsumer.* |
off | Public image includes the binary; 2+ replicas = HA |
aiWorker.* / syncWorker.* |
off | Require a custom image (aiagent / imultimate build tags) — render fails without image.repository |
env |
[] | e.g. IMCORE_GOVERNANCE_PSEUDONYM_SALT from a Secret |
Rolling upgrades and graceful drain
How a rollout stays hitless, matching the shutdown path in cmd/websocket/main.go:
maxSurge: 1, maxUnavailable: 0— a new pod must be Ready (/healthz) before any old pod is asked to stop.- The pod is removed from Service endpoints;
preStopsleepspreStopSleepSeconds(5s) so the LB/kube-proxy deprograms it while it still serves. - SIGTERM:
DrainClients(3s)notifies connected WebSocket clients (they reconnect to surviving pods and catch up), then HTTP/gRPC shutdown (5s), audit-log flush, persister stop, broker teardown. terminationGracePeriodSeconds: 30comfortably covers 2+3; only then would SIGKILL fire.
Reconnect pressure during a rollout is bounded by pod count — clients of one pod reconnect per step, not the whole fleet. websocket.max_connections_per_instance (app.yaml) sheds excess handshakes with a 503 so the LB rebalances onto the freshly-surged pod.
helm rollback follows the same choreography in reverse order of versions.
Upgrading an existing NATS deployment to AI-on-NATS
One manual step, NATS-only, one-time: gowebsocket_persist changed from a push
consumer to a pull consumer (needed so multiple jetstream_consumer replicas can
actually compete — previously a second replica panicked at startup). JetStream
does not allow converting an existing push consumer to pull in place, so the old
one must be deleted before the new binary starts:
- Scale
jetstream_consumerto 0 (and stop any websocket pod runningnats.embedded_persist_inproc: true). nats consumer rm gowebsocket gowebsocket_persist- Roll out the new image — the consumer is recreated automatically;
DeliverAllpicks up from whatever is still on the stream, so nothing within the core stream’s 5-minuteMaxAgeis lost.
Embedded NATS (nats.embedded: true) cannot follow that order — the NATS server
is the websocket pod, so once it is stopped there is nothing to run step 2
against, while nats.embedded_store_dir still holds the old push durable. The
binary panics on cannot pull subscribe to push based consumer, so the pod
crash-loops. Either start once with nats.embedded_persist_inproc: false, run
step 2 against nats.embedded_listen, and revert; or delete
nats.embedded_store_dir (costs at most 5 minutes of undelivered fan-out and no
durable business state). Full procedure: docs/RUNBOOK_BACKUP_RESTORE.md §6.3.
The two new AI streams (<stream_name>_ai_work / _ai_push) are created fresh
by natsjets.New() on first boot — no equivalent step for them, and no data
migration is needed moving AI traffic from RabbitMQ to NATS (AI subjects carry
no durable business state; that lives in the database).
PodDisruptionBudget
maxUnavailable: 1 lets node drains and cluster upgrades proceed one pod at a time instead of evicting several WebSocket pods at once (each eviction = that pod’s clients reconnecting). The template renders only with real redundancy (replicas > 1 or HPA), because a PDB over one replica deadlocks drains.
Autoscaling
CPU at 70% is the safe default. The better signal for a WebSocket server is connections per pod — export it from the built-in /metrics (imcore_current_connections) through prometheus-adapter:
# prometheus-adapter values.yaml (rules.custom)
rules:
custom:
- seriesQuery: 'imcore_current_connections{namespace!="",pod!=""}'
resources: { overrides: { namespace: {resource: namespace}, pod: {resource: pod} } }
name: { matches: "imcore_current_connections" }
metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)'
# imcore values.yaml
autoscaling:
enabled: true
customMetrics:
- type: Pods
pods:
metric: { name: imcore_current_connections }
target: { type: AverageValue, averageValue: "8000" } # from your capacity baseline
Set the target from a measured baseline (docs/CHAOS_TESTING.md), not a guess. The HPA’s scaleDown behavior is deliberately slow (1 pod / 2 min after a 5-min window) so long-lived connections don’t slosh between shrinking pods.
Metrics scraping
GET /metrics sits behind the same ops gate as /stat: source must be loopback or inside admin.allowed_cidrs, plus an admin token while admin.require_token is true (the default). For in-cluster Prometheus either:
- add the cluster pod CIDR to
admin.allowed_cidrsand keep the token →serviceMonitor.bearerTokenSecret: {name, key}, or - also set
admin.require_token: falseif the CIDR gate alone is acceptable in your threat model.
Alert rules ship in deploy/prometheus/imcore-alerts.yml.
Relationship to compose / systemd
The chart deploys imcore only. The compose stack bundles Postgres/Redis/RabbitMQ/MinIO for a single host; systemd + imctl covers bare-metal fleets. Kubernetes assumes you bring managed/operator-run dependencies. app.yaml is identical across all three — only the injection mechanism differs.