Kubernetes runs containers on a cluster from declarative
manifests. Stellar Control runs there from the image stellar-control (see
Docker), delivered by Stellar Systems from the registry given with the
delivery, and NATS from its official image. This page gives an example manifest, to adapt to
your cluster: its ingress controller, storage classes, secret store and monitoring. It follows
the Nomad job of Stellar Control: the same image, commands, environment, ports and
health checks.
What the manifest runs#
| Object | Name | Role |
|---|---|---|
| Namespace | stellar | Everything below |
| ConfigMap | nats-config | Configuration of the NATS server: JetStream on its volume, the WebSocket for the edges |
| StatefulSet, PersistentVolumeClaim | nats, data-nats-0 | NATS with JetStream, its storage kept across restarts |
| Services | nats (client 4222, monitoring 8222, WebSocket 8443), nats-headless | NATS in the cluster |
| ConfigMap | stellar-config | Global configuration of Stellar Control |
| ConfigMap | stellar-repository | Git remote and branch of the configuration repository |
| Deployment | stellar-control | The services of the core in one container, after the publication of the configuration |
| Service, Ingress | stellar-control | The API, the web console and their WebSockets over HTTPS |
| Ingress | nats-websocket | The NATS WebSocket over HTTPS, for drivers, transports and gateways on other networks |
| Deployment | simulator-sim-1 | A simulated target of the example configuration |
| CronJob (suspended) | stellar-publish | Publishes the configuration again after a change |
The manifest#
Replace the placeholders <…>: the image (<registry>/stellar-control:<version>, an
immutable tag), the URL of the configuration repository, and the host names
control.example.org and nats.example.org.
# Stellar Control on Kubernetes: NATS with JetStream, the services of the core in one container,
# a simulated target. Replace the placeholders <…>, then: kubectl apply -f stellar.yaml
apiVersion: v1
kind: Namespace
metadata:
name: stellar
---
# NATS: its configuration. JetStream on the volume of the StatefulSet; the WebSocket for the edges
# on other networks, behind the Ingress that terminates its TLS.
apiVersion: v1
kind: ConfigMap
metadata:
name: nats-config
namespace: stellar
data:
nats-server.conf: |
server_name: stellar-nats
listen: 0.0.0.0:4222
http: 0.0.0.0:8222
jetstream {
store_dir: /data/jetstream
max_file_store: 8GB # within the volume of 10 Gi below
}
websocket {
port: 8443
no_tls: true # TLS terminated by the Ingress
}
---
apiVersion: v1
kind: Service
metadata:
name: nats-headless
namespace: stellar
spec:
clusterIP: None
selector:
app.kubernetes.io/name: nats
ports:
- name: client
port: 4222
---
apiVersion: v1
kind: Service
metadata:
name: nats
namespace: stellar
spec:
selector:
app.kubernetes.io/name: nats
ports:
- name: client
port: 4222
- name: monitoring
port: 8222
- name: websocket
port: 8443
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nats
namespace: stellar
spec:
serviceName: nats-headless
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nats
template:
metadata:
labels:
app.kubernetes.io/name: nats
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
terminationGracePeriodSeconds: 30
containers:
- name: nats
image: docker.io/library/nats:2.12.15
args: ["-c", "/etc/nats/nats-server.conf"]
ports:
- name: client
containerPort: 4222
- name: monitoring
containerPort: 8222
- name: websocket
containerPort: 8443
livenessProbe:
httpGet:
path: /healthz
port: monitoring
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: monitoring
periodSeconds: 5
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
memory: 512Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/nats
readOnly: true
- name: data
mountPath: /data
volumes:
- name: config
configMap:
name: nats-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
# The global configuration of Stellar Control; the Deployments set NATS, the API and the metrics
# through the environment, as the Nomad job does.
apiVersion: v1
kind: ConfigMap
metadata:
name: stellar-config
namespace: stellar
data:
stellar.yaml: |
archive:
streams:
# JetStream reserves this volume on the disk of the server: keep it within its storage.
max_age: 7d
max_bytes: 256MB
observability:
log_format: json
log_level: info
---
# The configuration repository: its Git remote (a private one needs credentials, see git-sync).
apiVersion: v1
kind: ConfigMap
metadata:
name: stellar-repository
namespace: stellar
data:
GITSYNC_REPO: <https URL of the configuration repository>
GITSYNC_REF: main
---
# The services of the core in one container. One replica: the editor keeps its drafts in its pod.
apiVersion: apps/v1
kind: Deployment
metadata:
name: stellar-control
namespace: stellar
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: stellar-control
template:
metadata:
labels:
app.kubernetes.io/name: stellar-control
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9100"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
initContainers:
# The configuration repository, once, at the commit of GITSYNC_REF.
- name: git-sync
image: registry.k8s.io/git-sync/git-sync:v4.7.1
args: ["--root=/git", "--link=config", "--one-time"]
envFrom:
- configMapRef:
name: stellar-repository
env:
- name: HOME
value: /tmp
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
volumeMounts:
- name: repository
mountPath: /git
- name: tmp
mountPath: /tmp
# Compiled and published before the services start, as the prestart task publish-config
# of the Nomad job.
- name: publish-config
image: <registry>/stellar-control:<version>
command: ["stellar", "compile", "/git/config", "--publish", "nats://nats:4222",
"--output", "/tmp/snapshot.ir"]
env:
- name: STELLAR_CONFIG
value: /etc/stellar/stellar.yaml
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 128Mi
volumeMounts:
- name: config
mountPath: /etc/stellar
readOnly: true
- name: repository
mountPath: /git
readOnly: true
- name: home
mountPath: /var/lib/stellar
- name: tmp
mountPath: /tmp
containers:
- name: stellar-control
image: <registry>/stellar-control:<version>
command: ["stellar-control"]
env:
- name: STELLAR_CONFIG
value: /etc/stellar/stellar.yaml
- name: STELLAR__NATS__URL
value: nats://nats:4222
- name: STELLAR__API__LISTEN
value: 0.0.0.0:8080
- name: STELLAR__API__WEB_DIR
value: /usr/share/stellar/web
- name: STELLAR__OBSERVABILITY__METRICS_ADDR
value: 0.0.0.0:9100
- name: STELLAR_INSTANCE
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 9100
livenessProbe:
httpGet:
path: /healthz
port: metrics
periodSeconds: 10
readinessProbe:
httpGet:
path: /v1/openapi.json
port: http
periodSeconds: 10
timeoutSeconds: 2
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 200m
memory: 384Mi
limits:
memory: 384Mi
volumeMounts:
- name: config
mountPath: /etc/stellar
readOnly: true
- name: home
mountPath: /var/lib/stellar
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: stellar-config
- name: repository
emptyDir: {}
- name: home
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: stellar-control
namespace: stellar
labels:
app.kubernetes.io/name: stellar-control
spec:
selector:
app.kubernetes.io/name: stellar-control
ports:
- name: http
port: 8080
- name: metrics
port: 9100
---
# The API, the web console and their WebSockets over HTTPS.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: stellar-control
namespace: stellar
annotations:
# ingress-nginx: WebSockets kept open for an hour without a message.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts: [control.example.org]
secretName: stellar-control-tls
rules:
- host: control.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: stellar-control
port:
name: http
---
# The NATS WebSocket for the drivers, transports and gateways on other networks
# (wss://nats.example.org), and the leaf nodes of their benches.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nats-websocket
namespace: stellar
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts: [nats.example.org]
secretName: nats-websocket-tls
rules:
- host: nats.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nats
port:
name: websocket
---
# A simulated target of the example configuration: sim-1 behind its gateway sim-gw-1.
apiVersion: apps/v1
kind: Deployment
metadata:
name: simulator-sim-1
namespace: stellar
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: simulator-sim-1
template:
metadata:
labels:
app.kubernetes.io/name: simulator-sim-1
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
initContainers:
- name: git-sync
image: registry.k8s.io/git-sync/git-sync:v4.7.1
args: ["--root=/git", "--link=config", "--one-time"]
envFrom:
- configMapRef:
name: stellar-repository
env:
- name: HOME
value: /tmp
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: repository
mountPath: /git
- name: tmp
mountPath: /tmp
containers:
- name: simulator
image: <registry>/stellar-control:<version>
command: ["stellar-simulator"]
args: ["--simulation", "platform-v3-sim", "--target", "sim-1",
"--gateway", "sim-gw-1", "--driver", "platform-v3-sim-1"]
env:
- name: STELLAR_REPOSITORY
value: /git/config
- name: STELLAR_NATS_URL
value: nats://nats:4222
- name: RUST_LOG
value: info
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 64Mi
volumeMounts:
- name: repository
mountPath: /git
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: repository
emptyDir: {}
- name: tmp
emptyDir: {}
---
# Publishes the configuration repository again after a change, without restarting the services:
# kubectl -n stellar create job --from=cronjob/stellar-publish publish-$(date +%s)
apiVersion: batch/v1
kind: CronJob
metadata:
name: stellar-publish
namespace: stellar
spec:
schedule: "0 0 1 1 *"
suspend: true # never on a schedule: a template of Jobs
jobTemplate:
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
initContainers:
- name: git-sync
image: registry.k8s.io/git-sync/git-sync:v4.7.1
args: ["--root=/git", "--link=config", "--one-time"]
envFrom:
- configMapRef:
name: stellar-repository
env:
- name: HOME
value: /tmp
volumeMounts:
- name: repository
mountPath: /git
- name: tmp
mountPath: /tmp
containers:
- name: publish-config
image: <registry>/stellar-control:<version>
command: ["stellar", "compile", "/git/config", "--publish", "nats://nats:4222",
"--output", "/tmp/snapshot.ir"]
env:
- name: STELLAR_CONFIG
value: /etc/stellar/stellar.yaml
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/stellar
readOnly: true
- name: repository
mountPath: /git
readOnly: true
- name: home
mountPath: /var/lib/stellar
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: stellar-config
- name: repository
emptyDir: {}
- name: home
emptyDir: {}
- name: tmp
emptyDir: {}kubectl apply -f stellar.yaml
kubectl -n stellar rollout status statefulset/nats
kubectl -n stellar rollout status deployment/stellar-control
kubectl -n stellar logs deployment/stellar-control -c publish-config # the publication
kubectl -n stellar logs deployment/stellar-control # the services
kubectl -n stellar port-forward service/stellar-control 8080:8080 # without the IngressThe API and the web console answer on https://control.example.org/ (or
http://127.0.0.1:8080/ through the port forward). Until a configuration is published, the API
answers 503 on what needs it: see Running the Services.
NATS#
The example runs one NATS server as a
StatefulSet: a stable
name, and its JetStream storage on a PersistentVolumeClaim of the default storage class, kept
when the pod is replaced. Keep max_file_store within the volume, and the archive of Stellar
Control (archive.streams.max_bytes, archive.max_bytes) within max_file_store (see
Storage of a deployment).
For production, the recommended way is the official Helm chart of NATS,
nats-io/k8s: a cluster of three servers with JetStream
replicated, TLS, the lame duck mode at shutdown, a NATS box and the Prometheus exporter. Give it
the same settings: JetStream on file storage, the resolver of your accounts, and the WebSocket
when edges connect from other networks. Point STELLAR__NATS__URL at its Service. See
NATS and JetStream for what Stellar Control keeps there and its replication
(1 in development, 3 in production).
The configuration#
Global configuration. The ConfigMap stellar-config is mounted as
/etc/stellar/stellar.yaml (STELLAR_CONFIG). As in the Nomad job, the address of NATS, the
listen addresses and the directory of the web console come from the environment
(STELLAR__NATS__URL, STELLAR__API__LISTEN, STELLAR__API__WEB_DIR,
STELLAR__OBSERVABILITY__METRICS_ADDR): any key can be set so, see
Environment overrides. Each pod names its
instance after itself (STELLAR_INSTANCE).
Configuration repository. The init container git-sync
(kubernetes/git-sync) clones the remote of the
ConfigMap stellar-repository at GITSYNC_REF into a volume of the pod, and stops
(--one-time). For a private repository, give it a read-only token from a Secret
(GITSYNC_USERNAME, GITSYNC_PASSWORD) or an SSH key (--ssh-key-file). A volume holding the
repository (a PersistentVolumeClaim filled by your pipeline) works as well: mount it in place of
the emptyDir.
Publication. The init container publish-config runs
stellar compile /git/config --publish nats://nats:4222 before the services start, as the
prestart task publish-config of the Nomad job: every start of the pod publishes the
repository as it is at GITSYNC_REF. To publish a change without restarting the services, run a
Job from the suspended CronJob stellar-publish:
kubectl -n stellar create job --from=cronjob/stellar-publish publish-$(date +%s)
kubectl -n stellar logs job/publish-<…> -c publish-configA pipeline that reaches NATS can publish instead (stellar compile --publish, see
Running the Services). A new snapshot applies to each
target at a safe point, never in the middle of a run or a pass.
NATS accounts and credentials#
In production, NATS has an operator, an account for Stellar Control with a signing key, and its users (see Setting up a server and NATS Accounts and Credentials). On Kubernetes they are Secrets, filled from your secret store rather than written in a file of a repository:
# The NATS identities made with nsc (see NATS and JetStream, "Setting up a server").
apiVersion: v1
kind: Secret
metadata:
name: nats-resolver
namespace: stellar
stringData:
resolver.conf: |
<the resolver.conf of nsc generate config --mem-resolver>
---
apiVersion: v1
kind: Secret
metadata:
name: stellar-nats
namespace: stellar
stringData:
services.creds: |
<the admin.creds of nsc>
bootstrap.creds: |
<the bootstrap.creds of nsc>
signing.nk: |
<the seed of the signing key of the account, SA…>
account: <the public key of the account, A…>The NATS server includes the resolver: mount the Secret nats-resolver at
/etc/nats-resolver in the StatefulSet and add include "../nats-resolver/resolver.conf" to
nats-server.conf. Stellar Control then connects with the credentials of the services, and its
reconciler signs the JWTs of the drivers, transports and gateways with the signing key, which it
refuses when it is readable by others: an init container copies it with mode 0400 into a volume
in memory. The additions to the Deployment stellar-control:
# Additions to the Deployment stellar-control:
# kubectl -n stellar patch deployment stellar-control --patch-file nats-auth-patch.yaml
spec:
template:
spec:
initContainers:
# The reconciler refuses a signing key readable by others: a copy of mode 0400, owned
# by the user of the image, in a volume in memory.
- name: signing-key
image: <registry>/stellar-control:<version>
command: ["install", "-m", "0400", "/etc/stellar-nats/signing.nk", "/run/stellar/signing.nk"]
volumeMounts:
- name: nats-secrets
mountPath: /etc/stellar-nats
readOnly: true
- name: signing-key
mountPath: /run/stellar
- name: publish-config
env:
- name: STELLAR_NATS_CREDENTIALS
value: /etc/stellar-nats/services.creds
volumeMounts:
- name: nats-secrets
mountPath: /etc/stellar-nats
readOnly: true
containers:
- name: stellar-control
env:
- name: STELLAR__NATS__CREDENTIALS
value: /etc/stellar-nats/services.creds
- name: STELLAR__RECONCILER__SIGNING_KEY_FILE
value: /run/stellar/signing.nk
- name: STELLAR__RECONCILER__ACCOUNT
valueFrom:
secretKeyRef:
name: stellar-nats
key: account
volumeMounts:
- name: nats-secrets
mountPath: /etc/stellar-nats
readOnly: true
- name: signing-key
mountPath: /run/stellar
readOnly: true
volumes:
- name: nats-secrets
secret:
secretName: stellar-nats
defaultMode: 0440
- name: signing-key
emptyDir:
medium: MemoryThe simulator, like every driver, transport and gateway, starts with the bootstrap credentials:
STELLAR_NATS_CREDENTIALS=/etc/stellar-nats/bootstrap.creds, with the Secret mounted the same
way; the Job stellar-publish with the credentials of the services. With TLS to NATS, mount the
CA from a Secret or ConfigMap and set STELLAR__NATS__TLS__CA (services) and STELLAR_NATS_CA
(the CLI, the simulator), with a tls:// URL.
People sign in through an OpenID Connect provider with the STELLAR__AUTH__… variables of
Identity and Roles, as the auth_… variables of the Nomad job set
them.
Exposing it#
The API and the web console. The Ingress stellar-control routes the host to the port 8080
of the Service, with TLS from the Secret stellar-control-tls (from cert-manager, for instance).
The web console and the CLI use WebSockets on the same host: the annotations of the example keep
them open with ingress-nginx; other controllers have their own timeouts. See
Ingress.
NATS for the edges on other networks. Drivers, transports and gateways on a bench network
that only lets HTTPS out connect to the NATS WebSocket, wss://nats.example.org:443, or through
a leaf node on site (see Edges behind a firewall). Two
ways to expose it with TLS:
- Through the Ingress (the example): the Ingress
nats-websocketterminates TLS and passes the WebSocket to the port 8443 of NATS, which then listens without TLS (no_tls: true) inside the cluster only. - NATS terminating TLS: a
websocket { tls { … } }block with the certificate from a Secret (as in the Nomad job), and a Service of typeLoadBalanceron 443, or a TLS passthrough of the ingress controller. The same holds for a leaf node port (7422) with TLS.
The client port 4222 stays inside the cluster: the Service nats is a ClusterIP.
Probes#
| Container | Liveness | Readiness |
|---|---|---|
stellar-control, and each service | GET /healthz on the metrics port 9100: ok while the process runs | GET /v1/openapi.json on 8080 (the API), as the health check of the Nomad job |
nats | GET /healthz on 8222 | GET /healthz on 8222 |
A service started before NATS answers retries its connection in the background, so its pod starts in any order; the publication, in an init container, retries until NATS answers through the restarts of the pod. See probes.
Metrics#
Each container of Stellar Control serves /metrics in the Prometheus format on port 9100. The
pod template carries the prometheus.io/scrape, prometheus.io/port and prometheus.io/path
annotations that many Prometheus configurations read. With the Prometheus Operator, a
ServiceMonitor on the port metrics of the Service:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: stellar-control
namespace: stellar
spec:
selector:
matchLabels:
app.kubernetes.io/name: stellar-control
endpoints:
- port: metrics
path: /metrics
interval: 30sNATS serves its own monitoring on 8222 (/varz, /jsz); the Helm chart of NATS adds its
Prometheus exporter. See Observability for the metrics and the suggested
alerts.
Separate services and scaling#
The all-in-one stellar-control runs with one replica, as in the Nomad job (group mcs, 0
to 1). To scale, run one Deployment per service instead, from the same image and environment,
each with its command:
| Deployment | Command | Replicas | How they share the work |
|---|---|---|---|
stellar-api | stellar-api | 1 or more | All active, stateless: the Service spreads the requests; the Ingress points to it; readiness on /v1/openapi.json |
stellar-executor | stellar-executor | 1 or more | All active: runs spread by identifier, targets held by leases |
stellar-scheduler | stellar-scheduler | 1 or more | All active: each run submitted once |
stellar-reconciler | stellar-reconciler | 1, or 2–3 for failover | Single active: one leader, the others stand by; the init containers git-sync and publish-config go here |
stellar-compute, stellar-alarms, stellar-transfers | same | 1, or 2–3 for failover | Single active: one holds the lease, the others take over within reconciler.leader_ttl (5 s); no load sharing |
stellar-editor | stellar-editor | exactly 1, strategy Recreate | Its drafts are working copies on its disk: a PersistentVolumeClaim on editor.workdir keeps them |
simulator-<target> | stellar-simulator | exactly 1 per simulated target | One driver and gateway instance per target |
With a separate editor, give it STELLAR__EDITOR__LISTEN=0.0.0.0:8090 and a Service, and the
API STELLAR__API__EDITOR_URL=http://<that Service>:8090. A
HorizontalPodAutoscaler
suits stellar-api only: the standby replicas of the single-active services add availability,
not capacity. See Running the Services.
The web editor#
The all-in-one starts the web editor when editor.repository is set,
as the Nomad job does: STELLAR__EDITOR__REPOSITORY (the Git remote of the configuration
repository), STELLAR__EDITOR__FORGE__KIND and the forge, STELLAR__EDITOR__LISTEN=127.0.0.1:8090,
STELLAR__API__EDITOR_URL=http://127.0.0.1:8090, and its service token in STELLAR_FORGE_TOKEN
from a Secret. Its working copies are in /var/lib/stellar/editor, on the volume home: an
emptyDir loses the drafts not yet proposed at each restart, a PersistentVolumeClaim keeps them.
Hardened containers#
As in the Nomad job, the containers of Stellar Control run as the user of the image (10001),
with a read-only root file system, no capability and no privilege escalation; the writable
places are volumes of the pod (/tmp, /var/lib/stellar). Add
NetworkPolicies
so that only the pods of Stellar Control and the ingress controller reach NATS, and only the
ingress controller reaches the API.