feat: translate docker-options --sysctl on the k3s scheduler

The `docker-local` scheduler supports `--sysctl` for free because docker options are passed verbatim to `docker run`, but the k3s scheduler silently dropped it. Namespaced sysctls now render into the pod's `securityContext.sysctls` for deployments, cron jobs, and one-off runs. A sysctl the kernel does not namespace fails the deploy instead of being dropped, since it cannot take effect within a pod regardless of what was requested.
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-07 02:38:46 -04:00
parent f050726f1a
commit cd1089500b
9 changed files with 530 additions and 0 deletions

View File

@@ -1754,12 +1754,62 @@ func getStartCommand(input StartCommandInput) (StartCommandOutput, error) {
}, nil
}
// namespacedSysctlPrefixes are the sysctl prefixes the kernel maintains per-namespace
var namespacedSysctlPrefixes = []string{
"net.",
"kernel.shm",
"kernel.msg",
"fs.mqueue.",
}
// isNamespacedSysctl reports whether a sysctl is maintained per-namespace by the
// kernel and can therefore be set on a pod spec. Sysctls outside these subtrees
// hold a single value shared by the entire machine, and kubelet rejects them.
func isNamespacedSysctl(name string) bool {
if name == "kernel.sem" {
return true
}
for _, prefix := range namespacedSysctlPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// parseSysctls converts docker-option key=value pairs into sysctls sorted by name,
// rejecting any sysctl that cannot take effect within a pod's namespaces.
func parseSysctls(values []string) ([]Sysctl, error) {
sysctls := []Sysctl{}
for _, value := range values {
name, sysctlValue, found := strings.Cut(value, "=")
if !found || name == "" {
return nil, fmt.Errorf("Invalid --sysctl value, expected name=value: %s", value)
}
if !isNamespacedSysctl(name) {
return nil, fmt.Errorf("Sysctl %s is not namespaced and cannot be set on a pod, it must be applied at the node level instead", name)
}
sysctls = append(sysctls, Sysctl{Name: name, Value: sysctlValue})
}
sort.Slice(sysctls, func(i int, j int) bool {
return sysctls[i].Name < sysctls[j].Name
})
return sysctls, nil
}
func getSecurityContext(appName string, phase string) (SecurityContext, error) {
securityContext := SecurityContext{}
deployOptions, err := dockeroptions.GetSpecifiedDockerOptionsForPhase(appName, phase, []string{
"--cap-add",
"--cap-drop",
"--privileged",
"--sysctl",
})
if err != nil {
return SecurityContext{}, fmt.Errorf("Error getting deploy options: %w", err)
@@ -1782,6 +1832,13 @@ func getSecurityContext(appName string, phase string) (SecurityContext, error) {
}
securityContext.Capabilities.Drop = capabilities
}
if sysctlOptions, ok := deployOptions["--sysctl"]; ok {
sysctls, err := parseSysctls(sysctlOptions)
if err != nil {
return SecurityContext{}, err
}
securityContext.Sysctls = sysctls
}
return securityContext, nil
}

View File

@@ -255,3 +255,105 @@ func TestNodeLabelsDoesNotMutatePackageLabels(t *testing.T) {
t.Errorf("nodeLabels() mutated WorkerLabels: len = %d, want %d", len(WorkerLabels), workerBefore)
}
}
func TestIsNamespacedSysctl(t *testing.T) {
cases := []struct {
name string
want bool
}{
{name: "net.core.somaxconn", want: true},
{name: "net.ipv4.tcp_rmem", want: true},
{name: "kernel.shm_rmid_forced", want: true},
{name: "kernel.shmmax", want: true},
{name: "kernel.msgmax", want: true},
{name: "kernel.sem", want: true},
{name: "fs.mqueue.msg_max", want: true},
{name: "vm.max_map_count", want: false},
{name: "vm.swappiness", want: false},
{name: "kernel.pid_max", want: false},
{name: "kernel.semaphore", want: false},
{name: "fs.file-max", want: false},
{name: "", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isNamespacedSysctl(tc.name); got != tc.want {
t.Errorf("isNamespacedSysctl(%q) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
func TestParseSysctls(t *testing.T) {
cases := []struct {
name string
values []string
want []Sysctl
wantErr bool
}{
{
name: "empty input",
values: []string{},
want: []Sysctl{},
},
{
name: "single namespaced sysctl",
values: []string{"net.core.somaxconn=1024"},
want: []Sysctl{{Name: "net.core.somaxconn", Value: "1024"}},
},
{
name: "sorted by name regardless of input order",
values: []string{"net.core.somaxconn=1024", "kernel.sem=250", "fs.mqueue.msg_max=20"},
want: []Sysctl{
{Name: "fs.mqueue.msg_max", Value: "20"},
{Name: "kernel.sem", Value: "250"},
{Name: "net.core.somaxconn", Value: "1024"},
},
},
{
name: "value containing an equals sign is preserved",
values: []string{"net.ipv4.tcp_rmem=4096 87380 6291456"},
want: []Sysctl{{Name: "net.ipv4.tcp_rmem", Value: "4096 87380 6291456"}},
},
{
name: "non-namespaced sysctl is rejected",
values: []string{"vm.max_map_count=262144"},
wantErr: true,
},
{
name: "missing equals sign is rejected",
values: []string{"net.core.somaxconn"},
wantErr: true,
},
{
name: "empty name is rejected",
values: []string{"=1024"},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseSysctls(tc.values)
if tc.wantErr {
if err == nil {
t.Fatalf("parseSysctls(%v) expected an error, got %v", tc.values, got)
}
return
}
if err != nil {
t.Fatalf("parseSysctls(%v) unexpected error: %v", tc.values, err)
}
if len(got) != len(tc.want) {
t.Fatalf("parseSysctls(%v) = %v, want %v", tc.values, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("parseSysctls(%v)[%d] = %v, want %v", tc.values, i, got[i], tc.want[i])
}
}
})
}
}

View File

@@ -394,6 +394,32 @@ type SecurityContext struct {
Capabilities SecurityContextCapabilities `yaml:"capabilities,omitempty"`
// Privileged contains the privileged flag for a process
Privileged bool `yaml:"privileged,omitempty"`
// Sysctls contains the namespaced kernel sysctls for a process
Sysctls []Sysctl `yaml:"sysctls,omitempty"`
}
// Sysctl contains a single kernel sysctl key/value pair
type Sysctl struct {
// Name is the name of the sysctl
Name string `yaml:"name"`
// Value is the value the sysctl is set to
Value string `yaml:"value"`
}
// ToCoreV1PodSecurityContext converts the sysctls to a corev1.PodSecurityContext,
// returning nil when no sysctls are configured so an empty security context does
// not churn the pod template hash and trigger a spurious rollout.
func (s SecurityContext) ToCoreV1PodSecurityContext() *corev1.PodSecurityContext {
if len(s.Sysctls) == 0 {
return nil
}
sysctls := make([]corev1.Sysctl, len(s.Sysctls))
for i, sysctl := range s.Sysctls {
sysctls[i] = corev1.Sysctl{Name: sysctl.Name, Value: sysctl.Value}
}
return &corev1.PodSecurityContext{Sysctls: sysctls}
}
// ToCoreV1SecurityContext converts the security context to a corev1.SecurityContext
@@ -533,6 +559,7 @@ func templateKubernetesJob(input Job) (batchv1.Job, error) {
},
},
RestartPolicy: corev1.RestartPolicyNever,
SecurityContext: input.SecurityContext.ToCoreV1PodSecurityContext(),
ServiceAccountName: input.AppName,
},
},

View File

@@ -0,0 +1,199 @@
package scheduler_k3s
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/engine"
)
func TestToCoreV1PodSecurityContext(t *testing.T) {
t.Run("nil when no sysctls are configured", func(t *testing.T) {
securityContext := SecurityContext{Privileged: true}
if got := securityContext.ToCoreV1PodSecurityContext(); got != nil {
t.Errorf("ToCoreV1PodSecurityContext() = %v, want nil", got)
}
})
t.Run("nil for an empty sysctl slice", func(t *testing.T) {
securityContext := SecurityContext{Sysctls: []Sysctl{}}
if got := securityContext.ToCoreV1PodSecurityContext(); got != nil {
t.Errorf("ToCoreV1PodSecurityContext() = %v, want nil", got)
}
})
t.Run("preserves order and values", func(t *testing.T) {
securityContext := SecurityContext{
Sysctls: []Sysctl{
{Name: "kernel.sem", Value: "250"},
{Name: "net.core.somaxconn", Value: "1024"},
},
}
got := securityContext.ToCoreV1PodSecurityContext()
if got == nil {
t.Fatal("ToCoreV1PodSecurityContext() = nil, want a security context")
}
if len(got.Sysctls) != 2 {
t.Fatalf("ToCoreV1PodSecurityContext() has %d sysctls, want 2", len(got.Sysctls))
}
if got.Sysctls[0].Name != "kernel.sem" || got.Sysctls[0].Value != "250" {
t.Errorf("ToCoreV1PodSecurityContext() sysctls[0] = %v, want kernel.sem=250", got.Sysctls[0])
}
if got.Sysctls[1].Name != "net.core.somaxconn" || got.Sysctls[1].Value != "1024" {
t.Errorf("ToCoreV1PodSecurityContext() sysctls[1] = %v, want net.core.somaxconn=1024", got.Sysctls[1])
}
})
}
func renderDeploymentTemplate(t *testing.T, globalValues map[string]interface{}) string {
t.Helper()
chartDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(chartDir, "templates"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
chartYAML := []byte("apiVersion: v2\nname: test\nversion: 0.0.1\n")
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), chartYAML, 0o644); err != nil {
t.Fatalf("write Chart.yaml: %v", err)
}
for _, name := range []string{"deployment.yaml", "_helpers.tpl"} {
contents, err := templates.ReadFile("templates/chart/" + name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(chartDir, "templates", name), contents, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
loaded, err := loader.Load(chartDir)
if err != nil {
t.Fatalf("load chart: %v", err)
}
global := map[string]interface{}{
"app_name": "myapp",
"deployment_id": "1",
"namespace": "myapp",
"image": map[string]interface{}{
"name": "myapp:latest",
"type": "dockerfile",
},
}
for key, value := range globalValues {
global[key] = value
}
values := map[string]interface{}{
"global": global,
"processes": map[string]interface{}{
"worker": map[string]interface{}{
"args": []interface{}{"echo", "hello"},
"replicas": 1,
},
},
}
renderValues, err := chartutil.ToRenderValues(loaded, values, chartutil.ReleaseOptions{Name: "test", Namespace: "default"}, nil)
if err != nil {
t.Fatalf("ToRenderValues: %v", err)
}
rendered, err := engine.Render(loaded, renderValues)
if err != nil {
t.Fatalf("render: %v", err)
}
for name, content := range rendered {
if filepath.Base(name) == "deployment.yaml" {
return content
}
}
t.Fatalf("deployment.yaml not rendered; got: %v", rendered)
return ""
}
// TestDeploymentSysctlsRendering asserts the pod-level securityContext.sysctls
// block only appears when sysctls are configured, and that numeric values are
// quoted. Kubernetes types sysctls[].value as a string, so an unquoted 1024
// renders as a YAML integer and the API server rejects the manifest.
func TestDeploymentSysctlsRendering(t *testing.T) {
t.Run("absent when no security context is set", func(t *testing.T) {
manifest := renderDeploymentTemplate(t, map[string]interface{}{})
if strings.Contains(manifest, "sysctls:") {
t.Errorf("rendered deployment unexpectedly contains sysctls:\n%s", manifest)
}
})
t.Run("absent when the security context has no sysctls", func(t *testing.T) {
manifest := renderDeploymentTemplate(t, map[string]interface{}{
"security_context": map[string]interface{}{"privileged": true},
})
if strings.Contains(manifest, "sysctls:") {
t.Errorf("rendered deployment unexpectedly contains sysctls:\n%s", manifest)
}
})
t.Run("numeric values are quoted", func(t *testing.T) {
manifest := renderDeploymentTemplate(t, map[string]interface{}{
"security_context": map[string]interface{}{
"sysctls": []interface{}{
map[string]interface{}{"name": "net.core.somaxconn", "value": "1024"},
},
},
})
if !strings.Contains(manifest, "- name: net.core.somaxconn") {
t.Errorf("rendered deployment missing sysctl name:\n%s", manifest)
}
if !strings.Contains(manifest, `value: "1024"`) {
t.Errorf("rendered deployment did not quote the sysctl value:\n%s", manifest)
}
})
t.Run("sysctls land on the pod spec, not the container", func(t *testing.T) {
manifest := renderDeploymentTemplate(t, map[string]interface{}{
"security_context": map[string]interface{}{
"sysctls": []interface{}{
map[string]interface{}{"name": "net.core.somaxconn", "value": "1024"},
},
},
})
var doc map[string]interface{}
decoder := yaml.NewDecoder(strings.NewReader(manifest))
for {
if err := decoder.Decode(&doc); err != nil {
if errors.Is(err, io.EOF) {
break
}
t.Fatalf("decode manifest: %v", err)
}
if doc == nil {
continue
}
spec := doc["spec"].(map[string]interface{})
template := spec["template"].(map[string]interface{})
podSpec := template["spec"].(map[string]interface{})
securityContext, ok := podSpec["securityContext"].(map[string]interface{})
if !ok {
t.Fatalf("pod spec has no securityContext:\n%s", manifest)
}
if _, ok := securityContext["sysctls"]; !ok {
t.Errorf("pod securityContext has no sysctls:\n%s", manifest)
}
}
})
}

View File

@@ -73,6 +73,14 @@ spec:
{{ include "print.labels" (dict "config" $.Values.global "key" "pod") | indent 12 }}
{{ include "print.labels" (dict "config" $config "key" "pod") | indent 12 }}
spec:
{{- if and (hasKey $.Values.global "security_context") $.Values.global.security_context.sysctls }}
securityContext:
sysctls:
{{- range $.Values.global.security_context.sysctls }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- end }}
containers:
- args:
{{- range $config.args }}

View File

@@ -58,6 +58,14 @@ spec:
{{ include "print.labels" (dict "config" $.Values.global "key" "pod") | indent 8 }}
{{ include "print.labels" (dict "config" $config "key" "pod") | indent 8 }}
spec:
{{- if and (hasKey $.Values.global "security_context") $.Values.global.security_context.sysctls }}
securityContext:
sysctls:
{{- range $.Values.global.security_context.sysctls }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- end }}
containers:
- args:
{{- range $config.args }}