Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions deploy/k8s/base/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ rules:
- get
- list
- watch
# Leader election (#259): the scheduler competes for a Lease when
# leader_election.enabled is set. Inert otherwise.
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- create
- update
39 changes: 39 additions & 0 deletions deploy/k8s/overlays/ha/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# HA scheduler overlay (#259)

Three leader-elected scheduler replicas behind one Service, competing for a
`coordination.k8s.io/Lease` (`agentenv-system/agentenv-scheduler`). Exactly
one pod schedules and processes node reports; standbys stay liveness-healthy
and serve `LookupNode`/`GetNode` from the shared Redis bindings.

Traffic rules:

1. Node heartbeats → `agentenv-scheduler` Service (leader only).
2. Gateway writes (`Schedule`, `RecordAssignment`) → same Service.
3. Gateway reads (`LookupNode`) → same Service; served by standbys when
`scheduler.redis_addr` is set, otherwise retried onto the leader.

Deploy:

```bash
kubectl apply -k deploy/k8s/overlays/ha
```

Prerequisites:

- RBAC: the scheduler Role needs `coordination.k8s.io/leases`
get/create/update (already in `base/role.yaml`).
- Redis (recommended): set `scheduler.redis_addr` in
`config/scheduler.json`. Without it the scheduler runs the documented
degraded mode: reads retry to the leader, and failover loses routing for
pre-failover sandboxes.
- `scheduler.node_admin_api_key`: the x-api-key for node admin APIs, used by
the leader to pull node snapshots right after winning (sync-node-snapshots).

Upgrade ordering (rolling this out over a single-replica deployment):

1. Upgrade the scheduler binary first — with election off it behaves exactly
like today, and it must know the leader health service before the probe
points at it.
2. Then apply this overlay (RBAC, probe, replicas, config).

Rollback: set `leader_election.enabled` to false and scale back to 1 replica.
25 changes: 25 additions & 0 deletions deploy/k8s/overlays/ha/config/scheduler.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"log_level": "info",
"log_format": "json",
"scheduler": {
"grpc_listen_addr": ":9090",
"strategy": "round_robin",
"discovery": {
"mode": "kubernetes",
"kubernetes": {
"namespace": "agentenv-system",
"service_name": "agentenv-nodes",
"port": 8000,
"scheme": "http"
}
},
"leader_election": {
"enabled": true,
"lease_name": "agentenv-scheduler",
"lease_namespace": "agentenv-system",
"lease_duration": "15s",
"renew_deadline": "10s",
"retry_period": "2s"
}
}
}
49 changes: 49 additions & 0 deletions deploy/k8s/overlays/ha/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

# HA scheduler topology (#259): 3 leader-elected scheduler replicas behind
# one Service. Only the leader is ready (readiness probes the leader-specific
# health service), so Service endpoints contain only the leader; standbys
# stay liveness-healthy and serve reads from the shared Redis bindings.
#
# Point scheduler.redis_addr at your Redis to enable standby reads and
# failover-preserved routing; without it the scheduler runs the documented
# degraded mode (reads retry to the leader; pre-failover routing is lost).

resources:
- ../../base

generatorOptions:
disableNameSuffixHash: true

configMapGenerator:
- name: scheduler-k8s-config
behavior: replace
files:
- config/scheduler.json

patches:
- target:
kind: Deployment
name: agentenv-scheduler
patch: |-
- op: replace
path: /spec/replicas
value: 3
- op: replace
path: /spec/template/spec/containers/0/readinessProbe/exec/command
value:
- /grpc_health_probe
- -addr=127.0.0.1:9090
- -service=scheduler.v1.Scheduler/leader
Comment on lines +34 to +38

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is correct: with leader-only readiness, standbys are never in any Service's endpoints, so the standby-served read path is unreachable in the HA overlay. Worth explaining how we got here, because the two inputs genuinely conflict in Kubernetes:

The issue's desired behavior asks for leader-only endpoints: "Liveness remains healthy on standbys; readiness exposes only the leader" (#259).
The follow-up discussion asked for standby reads: "every standby replica acts as a query-only node and can serve LookupNode from the shared bindings" (#259 (comment)).
Pod readiness is global in K8S — a pod is Ready in all Services or in none — so "leader-only endpoints" and "standbys receive read traffic" cannot both hold. We implemented both requirements faithfully, which leaves the read path formally present but unreachable. We kept leader-only endpoints as the default because it is the correctness-first choice: with the gateway's pick_first connection pinning, it guarantees writes (Schedule/RecordAssignment/Heartbeat) never land on a non-leader, so there is no dual-writer window even before fencing kicks in.

Two ways to resolve it — maintainer's call:

(i) Make standby reads real: standbys stay Ready (readiness = overall serving), gateway uses round_robin + retry-on-Unavailable, so writes retry onto the leader and reads genuinely reach standbys. Cost: gateway LB/retry config, and ~2/3 of write attempts take one retry.
(ii) Drop standby reads: standbys stay pure idle, reads ride the leader only, accepting a read gap of roughly (lease detection + readiness flip) during failover. This is already today's behavior in the no-Redis degraded mode.
Interim this PR keeps leader-only endpoints. Happy to implement either direction — (i) if standby reads are worth the retry cost, (ii) if simplicity wins.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid finding, and a genuine design fork rather than a bug: it stems from two stated requirements that cannot coexist in K8S (leader-only readiness vs standbys serving reads). Full analysis with both options (make standbys Ready + gateway round_robin/retry, or drop standby reads) is in the PR discussion: #341 (comment) — leaving this open pending maintainer decision.

# sync-node-snapshots pulls authenticate against node admin APIs with
# the cluster-wide admin key; mount it from the shared agentenv-auth
# Secret, never from a ConfigMap.
- op: add
path: /spec/template/spec/containers/0/env
value:
- name: SCHEDULER_NODE_ADMIN_API_KEY
valueFrom:
secretKeyRef:
name: agentenv-auth
key: AENV_API_KEY
11 changes: 11 additions & 0 deletions services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ General config notes:
- `SCHEDULER_ARTIFACT_STORE_CAPACITY=<count>` overrides `scheduler.artifact_store_capacity` from the environment.
- `SCHEDULER_ARTIFACT_LOOKUP_NODE_LIMIT=<count>` overrides `scheduler.artifact_lookup_node_limit` from the environment.

### Leader election (HA)

`scheduler.leader_election` enables Kubernetes Lease-based leader election (#259). Off by default; with it off, the scheduler behaves exactly as a single-writer process.

- With it on, N replicas compete for a `coordination.k8s.io/Lease`. Exactly one leader schedules and processes heartbeats; standbys stay liveness-healthy, reject writes with `Unavailable`, and serve `LookupNode`/`GetNode` from the shared Redis bindings (reads are rejected too when no `redis_addr` is set, and clients retry onto the leader).
- Readiness probes should target the leader-specific health service `scheduler.v1.Scheduler/leader` (`grpc_health_probe -service=scheduler.v1.Scheduler/leader`) so Service endpoints contain only the leader. Liveness keeps probing the overall health status.
- Traffic rules: node heartbeats → scheduler Service (leader only); gateway writes (`Schedule`, `RecordAssignment`) → same Service; gateway reads (`LookupNode`) → same Service, answered by standbys when Redis is configured.
- On failover, the new leader pulls each node's admin `/nodes` snapshot (sync-node-snapshots) instead of waiting for the next heartbeat; pulls authenticate with the `x-api-key` from `SCHEDULER_NODE_ADMIN_API_KEY` — pass it via env/Secret (the HA overlay mounts the shared `agentenv-auth` Secret), not via config files.
- `redis_addr` is optional but recommended: without it, failover loses routing for pre-failover sandboxes (documented degraded mode). Under election the binding TTL is floored to `lease_duration + 90s` so bindings outlive the failover budget.
- Mutually exclusive with `--query-only`. Ready-made manifests: `deploy/k8s/overlays/ha` (see its README for upgrade ordering).

### Scheduling strategy

`scheduler.strategy` selects the algorithm used to pick a node from the eligible candidate list. Built-in strategies:
Expand Down
8 changes: 6 additions & 2 deletions services/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
Expand All @@ -37,10 +38,13 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/onsi/ginkgo/v2 v2.14.0 // indirect
github.com/onsi/gomega v1.30.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
Expand All @@ -56,5 +60,5 @@ require (
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
22 changes: 14 additions & 8 deletions services/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand All @@ -29,6 +31,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
Expand Down Expand Up @@ -71,10 +75,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY=
github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw=
github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
Expand Down Expand Up @@ -116,8 +122,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down Expand Up @@ -194,5 +200,5 @@ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMm
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
49 changes: 49 additions & 0 deletions services/scheduler/cmd/binding_ttl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"testing"
"time"

"agentenv/services/shared/config"
)

// Binding TTL floor tests (#259): under leader election the effective TTL
// must cover the failover budget (lease_duration + 90s); without election the
// configured value passes through unchanged.

func baseCfg(bindingTTL time.Duration) config.Config {
return config.Config{
Scheduler: config.SchedulerConfig{BindingTTL: bindingTTL},
}
}

func TestEffectiveBindingTTLWithoutElection(t *testing.T) {
cfg := baseCfg(30 * time.Second)
if got := effectiveBindingTTL(cfg); got != 30*time.Second {
t.Fatalf("election off: want configured TTL 30s, got %v", got)
}
}

func TestEffectiveBindingTTLWithElectionFloors(t *testing.T) {
cfg := baseCfg(30 * time.Second)
cfg.Scheduler.LeaderElection = config.SchedulerLeaderElectionConfig{
Enabled: true,
LeaseDuration: 15 * time.Second,
}
// floor = 15s + 90s = 105s > 30s
if got := effectiveBindingTTL(cfg); got != 105*time.Second {
t.Fatalf("election on: want floor 105s, got %v", got)
}
}

func TestEffectiveBindingTTLWithElectionKeepsLargerConfigured(t *testing.T) {
cfg := baseCfg(5 * time.Minute)
cfg.Scheduler.LeaderElection = config.SchedulerLeaderElectionConfig{
Enabled: true,
LeaseDuration: 15 * time.Second,
}
// floor = 105s < 5m: the larger configured value wins.
if got := effectiveBindingTTL(cfg); got != 5*time.Minute {
t.Fatalf("election on: want configured 5m (larger than floor), got %v", got)
}
}
Loading
Loading