Skip to content

Commit ca508b6

Browse files
ohotnikov.ivanlexfrei
authored andcommitted
feat(cozystack): add DRBD-oriented sysctl and etcd backend defaults
Cozystack nodes always run DRBD (the drbd module is loaded unconditionally), and DRBD reconnect storms — node reboots, resync — exhaust TCP ports under the default Talos sysctl profile. Ship the network tuning that resolved this on production clusters, plus a tunable etcd backend quota for LINSTOR-heavy control planes. Always-on machine.sysctls (cozystack preset): - tcp_orphan_retries=3, tcp_fin_timeout=30 reclaim orphaned and FIN-WAIT sockets faster so a reconnect storm cannot outrun cleanup. - netdev_max_backlog/budget/budget_usecs widen the receive backlog so bursty replication traffic isn't dropped under load. Opt-in machine.sysctls tcp_keepalive_{time,intvl,probes}, gated by tcpKeepaliveTuning (default off): the triplet is kernel-wide and shortens idle-socket failure detection for every long-lived TCP connection on the node, not just DRBD. DRBD already detects dead peers in seconds via its own protocol-level ping, so this is a generic socket backstop rather than a DRBD requirement — hence off by default. cluster.etcd.extraArgs.quota-backend-bytes, tunable via etcd.quotaBackendBytes (default 8GiB, etcd's documented upper bound): raises etcd's 2GiB backend ceiling so a control plane holding many DRBD-resource CRDs in aggregate does not trip the NOSPACE alarm. It is a ceiling not a reservation, emitted only on controlplane nodes; blank it to fall back to etcd's own default. This governs total DB size, not single-object size — per-object writes stay bounded by kube-apiserver's fixed 3MiB request-body limit, which has no configuration knob. The extraSysctls collision guard now covers the new preset-owned keys (the keepalive triplet only while the toggle is on), and the manual test plan gains a scenario exercising every branch. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent c3d7ace commit ca508b6

6 files changed

Lines changed: 319 additions & 8 deletions

File tree

charts/cozystack/templates/_helpers.tpl

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,43 @@ machine:
5858
{{- toYaml . | nindent 6 }}
5959
{{- end }}
6060
{{- /* extraSysctls MUST NOT collide with the preset's built-in
61-
sysctls; same rationale as extraKubeletExtraArgs. */ -}}
61+
sysctls; same rationale as extraKubeletExtraArgs. $builtinSysctls
62+
is the single source of truth for the preset-owned keys — keep
63+
it in sync with the literal sysctls block rendered further down.
64+
65+
Always-on DRBD/LINSTOR tuning: Cozystack always runs DRBD (the
66+
drbd module is loaded unconditionally below), and these knobs
67+
resolve the TCP-port exhaustion the Cozystack team observed on
68+
production clusters under DRBD reconnect storms (node reboots,
69+
resync). tcp_orphan_retries/tcp_fin_timeout speed up reclamation
70+
of orphaned and FIN-WAIT sockets so a reconnect storm cannot
71+
outrun cleanup; netdev_* widen the receive backlog so bursty
72+
replication traffic isn't dropped under load.
73+
74+
vm.nr_hugepages is treated as preset-owned even when its gate
75+
(.Values.nr_hugepages) is inactive, so operators always route it
76+
through the dedicated `nr_hugepages` key. The tcp_keepalive_*
77+
triplet is preset-owned only while .Values.tcpKeepaliveTuning is
78+
set (see below), so it can be operator-supplied via extraSysctls
79+
when the toggle is off. */ -}}
80+
{{- $builtinSysctls := list
81+
"vm.nr_hugepages"
82+
"net.ipv4.neigh.default.gc_thresh1"
83+
"net.ipv4.neigh.default.gc_thresh2"
84+
"net.ipv4.neigh.default.gc_thresh3"
85+
"net.ipv4.tcp_orphan_retries"
86+
"net.ipv4.tcp_fin_timeout"
87+
"net.core.netdev_max_backlog"
88+
"net.core.netdev_budget"
89+
"net.core.netdev_budget_usecs" }}
90+
{{- if $.Values.tcpKeepaliveTuning }}
91+
{{- $builtinSysctls = concat $builtinSysctls (list
92+
"net.ipv4.tcp_keepalive_time"
93+
"net.ipv4.tcp_keepalive_intvl"
94+
"net.ipv4.tcp_keepalive_probes") }}
95+
{{- end }}
6296
{{- range $k, $_ := .Values.extraSysctls }}
63-
{{- if or (eq $k "vm.nr_hugepages") (eq $k "net.ipv4.neigh.default.gc_thresh1") (eq $k "net.ipv4.neigh.default.gc_thresh2") (eq $k "net.ipv4.neigh.default.gc_thresh3") }}
97+
{{- if has $k $builtinSysctls }}
6498
{{- fail (printf "values.yaml: extraSysctls.%s collides with the cozystack preset's built-in machine.sysctls — keys never override (yaml.v3 rejects duplicate map keys on decode). Remove the entry from extraSysctls, or fork the chart preset if you need a different default." $k) }}
6599
{{- end }}
66100
{{- end }}
@@ -71,6 +105,16 @@ machine:
71105
net.ipv4.neigh.default.gc_thresh1: "4096"
72106
net.ipv4.neigh.default.gc_thresh2: "8192"
73107
net.ipv4.neigh.default.gc_thresh3: "16384"
108+
net.ipv4.tcp_orphan_retries: "3"
109+
net.ipv4.tcp_fin_timeout: "30"
110+
net.core.netdev_max_backlog: "5000"
111+
net.core.netdev_budget: "600"
112+
net.core.netdev_budget_usecs: "8000"
113+
{{- if $.Values.tcpKeepaliveTuning }}
114+
net.ipv4.tcp_keepalive_time: "600"
115+
net.ipv4.tcp_keepalive_intvl: "10"
116+
net.ipv4.tcp_keepalive_probes: "6"
117+
{{- end }}
74118
{{- with .Values.extraSysctls }}
75119
{{- toYaml . | nindent 4 }}
76120
{{- end }}
@@ -187,6 +231,20 @@ cluster:
187231
- {{ . }}
188232
{{- end }}
189233
{{- end }}
234+
{{- /* etcd backend quota, tunable via values. Raises etcd's 2GiB
235+
default backend ceiling so a LINSTOR-heavy control plane —
236+
thousands of DRBD-resource CRDs in aggregate — does not trip
237+
etcd's NOSPACE alarm and drop into read-only mode. This is a
238+
ceiling, not a reservation: a small cluster's DB stays small
239+
and costs no extra RAM/disk. 8GiB is etcd's documented upper
240+
bound (it warns above that). Blank the value to fall back to
241+
etcd's own default. Note: this governs total DB size, not the
242+
size of any single object — per-object writes are still gated
243+
by kube-apiserver's fixed 3MiB request-body limit. */ -}}
244+
{{- with (.Values.etcd | default dict).quotaBackendBytes }}
245+
extraArgs:
246+
quota-backend-bytes: {{ . | quote }}
247+
{{- end }}
190248
{{- end }}
191249
{{- end }}
192250

charts/cozystack/values.yaml

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,31 @@ certSANs: []
103103
nr_hugepages: 0
104104
allocateNodeCIDRs: true
105105

106+
# Opt-in aggressive TCP keepalive tuning, OFF by default. When true the
107+
# preset adds net.ipv4.tcp_keepalive_time=600 / intvl=10 / probes=6,
108+
# reaping a dead idle socket in ~660s instead of the kernel default
109+
# ~2h. These sysctls are kernel-wide: they change failure detection for
110+
# EVERY long-lived idle TCP connection on the node (NFS mounts, DB
111+
# clients, MQ consumers), not just DRBD. DRBD already detects dead peers
112+
# in seconds via its own protocol-level ping (ping-int/ping-timeout), so
113+
# this is a generic socket backstop rather than a DRBD requirement —
114+
# hence opt-in. Enable on clusters where faster node-wide dead-socket
115+
# detection is worth the shorter idle timeout.
116+
tcpKeepaliveTuning: false
117+
118+
# Control-plane etcd tuning. Emitted only on controlplane nodes.
119+
etcd:
120+
# Max etcd backend DB size before etcd raises a NOSPACE alarm and
121+
# rejects writes. The default raises etcd's own 2GiB ceiling to 8GiB
122+
# (etcd's documented upper bound) so a LINSTOR-heavy cluster — many
123+
# DRBD-resource CRDs in aggregate — doesn't exhaust the backend. This
124+
# is a ceiling, not a reservation: a small DB stays small and costs no
125+
# extra RAM/disk, so it is safe to leave at the default. Set to "" to
126+
# fall back to etcd's built-in default. Governs total DB size only —
127+
# single-object writes are gated by kube-apiserver's fixed 3MiB
128+
# request-body limit, which is not configurable.
129+
quotaBackendBytes: "8589934592"
130+
106131
# Operator-supplied extension points: each `extra*` key ADDS to a
107132
# load-bearing default the cozystack preset ships. For every key
108133
# below: leaving it at the empty default is a no-op; the preset's
@@ -135,11 +160,17 @@ extraKernelModules: []
135160
extraKubeletExtraArgs: {}
136161

137162
# Extra kernel sysctls added to machine.sysctls alongside the
138-
# preset's built-ins (net.ipv4.neigh.default.gc_thresh{1,2,3} and
139-
# vm.nr_hugepages — the latter is gated by the dedicated `nr_hugepages`
140-
# values key above; set THAT key, not extraSysctls.vm.nr_hugepages,
141-
# even when the gate is currently inactive — the collision check
142-
# treats vm.nr_hugepages as preset-owned regardless of the gate state).
163+
# preset's built-ins. Preset-owned keys (collision-protected):
164+
# - net.ipv4.neigh.default.gc_thresh{1,2,3}
165+
# - vm.nr_hugepages (gated by the dedicated `nr_hugepages` key above;
166+
# set THAT key, not extraSysctls.vm.nr_hugepages — the collision
167+
# check treats vm.nr_hugepages as preset-owned regardless of gate)
168+
# - net.ipv4.tcp_orphan_retries, net.ipv4.tcp_fin_timeout,
169+
# net.core.netdev_max_backlog, net.core.netdev_budget,
170+
# net.core.netdev_budget_usecs (always-on DRBD/LINSTOR tuning)
171+
# - net.ipv4.tcp_keepalive_{time,intvl,probes} — preset-owned ONLY
172+
# while `tcpKeepaliveTuning` below is true; when it is false these
173+
# three are free for you to set here.
143174
# Operator keys must be DISJOINT from the built-in set; a collision
144175
# fails the render. Values must be YAML strings (Talos expects
145176
# strings even for numeric sysctls). Example:

docs/manual-test-plan.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ Expected:
277277

278278
- `.machine.kernel.modules` lists the built-in six (`openvswitch`, `drbd` with `usermode_helper=disabled`, `zfs`, `spl`, `vfio_pci`, `vfio_iommu_type1`) AND `nf_conntrack` — append, never override.
279279
- `.machine.kubelet.extraConfig` carries the built-ins (`cpuManagerPolicy: static`, `maxPods: 512`) AND `feature-gates: NodeSwap=true`. Operator keys MUST NOT collide with built-ins; a collision fails the render at template time.
280-
- `.machine.sysctls` carries the built-in `gc_thresh1/2/3` and `vm.nr_hugepages` (when set) AND `net.core.somaxconn`. Same rejection-on-collision rule.
280+
- `.machine.sysctls` carries the built-in `gc_thresh1/2/3`, the always-on DRBD tuning (`net.ipv4.tcp_orphan_retries`, `net.ipv4.tcp_fin_timeout`, `net.core.netdev_max_backlog`, `net.core.netdev_budget`, `net.core.netdev_budget_usecs`), `vm.nr_hugepages` (when set), AND `net.core.somaxconn`. Same rejection-on-collision rule (the DRBD keys are preset-owned too). See B9 for the keepalive triplet and etcd quota.
281281
- `.machine.files[].path` lists `/etc/cri/conf.d/20-customization.part`, `/etc/lvm/lvm.conf`, AND `/etc/example/operator.conf`.
282282

283283
Verify the rejection path explicitly. Set an operator key that collides with a built-in (e.g. `extraSysctls: { "net.ipv4.neigh.default.gc_thresh1": "9000" }`) and re-render:
@@ -316,6 +316,51 @@ Expected: all four prints "OK". Set any single `extra*` key non-empty, re-render
316316

317317
Regression anchor: contract tests `TestContract_Machine_Extra*_Generic_NonEmptyEmitsBlock` pin the on-state for each block; `TestContract_Machine_NoCozystackOpinionsOnGeneric` pins the off-state at default values. A regression that emits an empty `modules: []` / `sysctls: {}` / `files: []` block in the default render would fail the latter.
318318

319+
### B9. DRBD sysctl tuning, opt-in TCP keepalive, and etcd backend quota (cozystack)
320+
321+
The cozystack preset ships always-on DRBD/LINSTOR network sysctls, an opt-in aggressive TCP-keepalive triplet, and a tunable etcd backend quota. Render the cozystack controlplane preset at defaults:
322+
323+
```bash
324+
talm template -f nodes/controlplane-0.yaml > /tmp/render.yaml
325+
yq '.machine.sysctls' /tmp/render.yaml
326+
yq '.cluster.etcd' /tmp/render.yaml
327+
```
328+
329+
Expected at defaults:
330+
331+
- `.machine.sysctls` includes `net.ipv4.tcp_orphan_retries: "3"`, `net.ipv4.tcp_fin_timeout: "30"`, `net.core.netdev_max_backlog: "5000"`, `net.core.netdev_budget: "600"`, `net.core.netdev_budget_usecs: "8000"` (always on).
332+
- `.machine.sysctls` does NOT contain any `net.ipv4.tcp_keepalive_*` key — the triplet is opt-in (`tcpKeepaliveTuning: false` by default).
333+
- `.cluster.etcd.extraArgs.quota-backend-bytes` is `"8589934592"` (8 GiB) on a controlplane render.
334+
335+
Render the worker preset (`yq '.cluster.etcd' /tmp/render-worker.yaml``null`): the whole `etcd` block, and thus `quota-backend-bytes`, is controlplane-only.
336+
337+
Enable the keepalive triplet and lower the etcd quota:
338+
339+
```yaml
340+
# values.yaml additions:
341+
tcpKeepaliveTuning: true
342+
etcd:
343+
quotaBackendBytes: "2147483648" # 2 GiB
344+
```
345+
346+
Re-render and verify:
347+
348+
- `.machine.sysctls` now also carries `net.ipv4.tcp_keepalive_time: "600"`, `net.ipv4.tcp_keepalive_intvl: "10"`, `net.ipv4.tcp_keepalive_probes: "6"`.
349+
- `.cluster.etcd.extraArgs.quota-backend-bytes` is now `"2147483648"`.
350+
351+
Blank the quota to fall back to etcd's own default:
352+
353+
```bash
354+
# values.yaml: etcd: { quotaBackendBytes: "" }
355+
talm template -f nodes/controlplane-0.yaml | yq '.cluster.etcd.extraArgs'
356+
```
357+
358+
Expected: `null` — a blank quota omits the `extraArgs` block entirely rather than emitting an empty value.
359+
360+
Collision check: with `tcpKeepaliveTuning: true`, set `extraSysctls: { "net.ipv4.tcp_keepalive_time": "1200" }` and re-render — the render fails with the `collides with the cozystack preset's built-in machine.sysctls` error (the keepalive keys become preset-owned once the toggle is on). With `tcpKeepaliveTuning: false`, the same `extraSysctls` entry is accepted and renders as the sole keepalive sysctl.
361+
362+
Regression anchor: `TestContract_Machine_Sysctls_DRBDTuning_Cozystack`, `TestContract_Machine_Sysctls_TCPKeepalive_*`, and `TestContract_Cluster_Etcd_QuotaBackendBytes_*` pin every branch above; `TestContract_Machine_Sysctls_DRBDTuning_AbsentOnGeneric` / `TestContract_Cluster_Etcd_QuotaBackendBytes_AbsentOnGeneric` pin that the generic preset stays free of these opinions.
363+
319364
## C. Apply (auth path)
320365

321366
This section is the smoke-test for the apply pipe itself; the per-gate matrix lives in **Section C-safety** below.

pkg/engine/contract_cluster_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ func TestContract_Cluster_NoControlplaneBlocksOnWorker(t *testing.T) {
220220
assertNotContains(t, out, "controllerManager:")
221221
assertNotContains(t, out, "scheduler:")
222222
assertNotContains(t, out, "etcd:")
223+
assertNotContains(t, out, "quota-backend-bytes")
223224
assertNotContains(t, out, "allowSchedulingOnControlPlanes")
224225
assertNotContains(t, out, "proxy:")
225226
assertNotContains(t, out, "discovery:")
@@ -371,6 +372,64 @@ func TestContract_Cluster_DiscoveryDisabled_Cozystack(t *testing.T) {
371372
}
372373
}
373374

375+
// Contract: cozystack control-plane nodes raise etcd's backend quota to
376+
// 8GiB (values.etcd.quotaBackendBytes default) via etcd.extraArgs. This
377+
// lifts etcd's own 2GiB ceiling so a LINSTOR-heavy cluster doesn't trip
378+
// the NOSPACE alarm. Quoted string (Talos etcd extraArgs are string
379+
// maps). Controlplane only — the block sits inside the controlplane
380+
// guard, so worker configs never carry it.
381+
func TestContract_Cluster_Etcd_QuotaBackendBytes_Default_Cozystack(t *testing.T) {
382+
for _, cell := range cozystackControlplaneCells() {
383+
t.Run(cell.name, func(t *testing.T) {
384+
out := renderChartTemplate(t, cell.chartPath, cell.templateFile, cell.talosVersion)
385+
assertContains(t, out, "etcd:")
386+
assertContains(t, out, "extraArgs:")
387+
assertContains(t, out, `quota-backend-bytes: "8589934592"`)
388+
})
389+
}
390+
}
391+
392+
// Contract: the quota is tunable — an operator override on
393+
// etcd.quotaBackendBytes renders verbatim, so a small cluster can lower
394+
// the ceiling (or a larger one raise it) without forking the chart.
395+
func TestContract_Cluster_Etcd_QuotaBackendBytes_Tunable_Cozystack(t *testing.T) {
396+
out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{
397+
"advertisedSubnets": []any{testAdvertisedSubnet},
398+
"etcd": map[string]any{
399+
"quotaBackendBytes": "2147483648",
400+
},
401+
})
402+
assertContains(t, out, `quota-backend-bytes: "2147483648"`)
403+
assertNotContains(t, out, `quota-backend-bytes: "8589934592"`)
404+
}
405+
406+
// Contract: blanking etcd.quotaBackendBytes omits the extraArg entirely
407+
// (falls back to etcd's built-in default) rather than emitting an empty
408+
// or malformed value. The whole etcd.extraArgs block is gated on a
409+
// non-empty quota.
410+
func TestContract_Cluster_Etcd_QuotaBackendBytes_OmittedWhenBlank_Cozystack(t *testing.T) {
411+
out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{
412+
"advertisedSubnets": []any{testAdvertisedSubnet},
413+
"etcd": map[string]any{
414+
"quotaBackendBytes": "",
415+
},
416+
})
417+
assertContains(t, out, "etcd:")
418+
assertNotContains(t, out, "quota-backend-bytes")
419+
}
420+
421+
// Contract: the generic preset carries no etcd quota opinion — a
422+
// regression that leaked the cozystack default into the generic helper
423+
// would surface here.
424+
func TestContract_Cluster_Etcd_QuotaBackendBytes_AbsentOnGeneric(t *testing.T) {
425+
for _, cell := range genericControlplaneCells() {
426+
t.Run(cell.name, func(t *testing.T) {
427+
out := renderChartTemplate(t, cell.chartPath, cell.templateFile, cell.talosVersion)
428+
assertNotContains(t, out, "quota-backend-bytes")
429+
})
430+
}
431+
}
432+
374433
// === generic-only contracts: pin that generic stays minimal ===
375434

376435
// Contract: generic chart does NOT expose clusterDomain in values.yaml

0 commit comments

Comments
 (0)