Repository navigation
Add runtimeClassName to support sandboxed runtimes - #999
Draft
marcleblanc2 wants to merge 10 commits into
Draft
marcleblanc2 wants to merge 10 commits into
marcleblanc2 wants to merge 10 commits into
Conversation
Add sourcegraph.runtimeClassName, with a per-service <serviceName>.runtimeClassName override, to the sourcegraph, sourcegraph-migrator, sourcegraph-executor-k8s, and sourcegraph-executor-dind charts, so pods can run under a RuntimeClass such as gVisor, Kata Containers, or EKS Nitro Sandboxes. An empty per-service value opts that service out of the global one, for pods which need the host, such as cadvisor and node-exporter. Add executor.kubernetesJob.runtimeClassName to the k8s executor chart, which sets EXECUTOR_KUBERNETES_RUNTIME_CLASS for job pods. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
marcleblanc2
marked this pull request as draft
November 11, 2026 00:53
Example override files for the sourcegraph and k8s executor charts that run every pod, and executor job pods, with runtimeClassName isolated-sandbox: network limit annotations, memory requests raised to the limits, and cadvisor and node-exporter kept on the default runtime. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
Tested on a complete instance with the bundled databases, Redis, blobstore, OpenTelemetry, and Jaeger in sandboxes: - openTelemetry.nodeSelector, so otel-agent pods do not stay Pending on nodes without sandbox support - No CPU limit on the database pods' init container - Jaeger memory request equal to its limit Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
… the k8s executor Secret name Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a1c75c-2680-718f-950a-b474d79c9e64 Co-authored-by: Amp <amp@ampcode.com>
…ner's mount condition removed Tested on EKS: with the policy as generated, the dind container is denied at CreateContainer; with only its context.mounts condition removed, both containers of the executor pod are Ready.
marcleblanc2
marked this pull request as ready for review
November 11, 2026 07:01
marcleblanc2
enabled auto-merge (squash)
November 11, 2026 07:02
This was referenced Nov 13, 2026
marcleblanc2
marked this pull request as draft
November 13, 2026 10:23
auto-merge was automatically disabled
November 13, 2026 10:23
Pull request was converted to draft
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Open issues
Found in review on 2026-11-13 (review thread), at
084f32a. The values work as described; this PR is a draft until the first is fixed.mainincharts/sourcegraph/CHANGELOG.mdandREADME.md. The templates, values, and tests merge cleanly. Fix: rebase, keep both sides, and rerun./scripts/helm-docs.shexecutor.kubernetesJob.runtimeClassNameon a running release does nothing until the executor pod restarts. The value only changes the ConfigMap, and nothing restarts the pod. Reproduced on EKS: after the upgrade the ConfigMap saidisolated-sandbox, the executor pod was not replaced, and the next job pod ran with no RuntimeClass. After deleting the executor pod, job pods ran withisolated-sandbox. The value exists to sandbox untrusted code, so a silent no-op matters. Fix: a checksum annotation of the ConfigMap on the executor pod, or say in the value's description and the changelog that the pod needs a restartsourcegraphand migrator charts only; the k8s and dind executor charts have their own copy of the helper, and no test of eithersourcegraph.runtimeClassNameis described as "all pods"; job pods are excluded, which only the job value's description says--set sourcegraph.runtimeClassName=123, renders a manifest the API rejects;priorityClassNamehas the same flawTested in this review on the EKS test instance
m5, by upgrading frommainto this branch merged ontomain:EXECUTOR_KUBERNETES_RUNTIME_CLASSConfigMap entrysyntectServer.runtimeClassName: isolated-sandbox: Auto Mode added a sandbox node, the pod became Ready on it in under 2 minutes with its own guest kernel, and no other workload changedexecutor.kubernetesJob.runtimeClassName: isolated-sandbox: after the executor restart in issue 2, a batch spec completed in a sandboxed job podNot re-tested:
confidential-sandbox, the dind and migrator charts, and the global value on running pods.Problem
Our Helm charts do not yet support
runtimeClassName, so they cannot run under a sandboxed runtime such as gVisor, Kata Containers, or EKS Nitro SandboxesThe k8s executor chart has no value for
EXECUTOR_KUBERNETES_RUNTIME_CLASS, so sandboxing job pods means repeating the wholeexecutor.extraEnvlistSolution
sourcegraph.runtimeClassNamein all four charts, with a per-service<serviceName>.runtimeClassNameoverride, modelled onpriorityClassNameUnlike
priorityClassName, an empty<serviceName>.runtimeClassNameopts that service out of the global valuecadvisor(privileged, host paths) andnode-exporter(hostPID, host paths) need the host, so a global sandbox runtime needs a way to skip themThe charts do not opt any service out by default, since which pods a RuntimeClass can run depends on the runtime
executor.kubernetesJob.runtimeClassNamein the k8s executor chart setsEXECUTOR_KUBERNETES_RUNTIME_CLASS, which the executor has supported since 7.3.0sourcegraph.runtimeClassName, like the otherkubernetesJob.*scheduling valuesIn the dind chart,
dind.gVisor.enabledstill defaults executor pods toruntimeClassName: gvisor;executor.runtimeClassNameorsourcegraph.runtimeClassNamenow replaces it, for clusters where the gVisor RuntimeClass has another nameExample overrides for EKS Nitro Sandboxes, in
examples/aws-eks-nitro-sandboxes/of each of the four chartsoverride.yamlforisolated-sandboxoverride-confidential.yamlforconfidential-sandbox, used on top ofoverride.yaml. It shows where each pod's policy goes, and lists the steps and policy edits that were needed; the policies themselves must be generated per deploymentChart tests
New
tests/runtimeClassName_test.yamlasserts on all 21sourcegraphworkloads: nothing renders by default, and the global value renders on every one. It also covers the test connection pod, a service override, and the empty-value opt-out. Removing the line from one template fails the suiteTests added to the migrator, k8s executor, and dind executor suites for the override, the global fallback, the opt-out, the job pod ConfigMap value, and the gVisor default and its override
helm unitteston all four charts: 43 suites, 267 tests pass.scripts/ci/lint.shpasses../scripts/helm-docs.shleaves no diffhelm templatewith default values is byte-identical toorigin/mainforsourcegraphand the dind executor, with and withoutdind.gVisor.enabled. The k8s executor differs only by the newEXECUTOR_KUBERNETES_RUNTIME_CLASS: ""ConfigMap entry, which the executor treats as unsetkubeconform -strictpasses on all four charts rendered withsourcegraph.runtimeClassNamesetThe example overrides render with
helm templateoverride.yaml: every workload getsisolated-sandboxand the network annotations, andcadvisorandnode-exporterare not renderedWith
override-confidential.yamlon top: every workload getsconfidential-sandboxand its own policy annotation, and the job pod annotations parse as JSONEKS Nitro Sandboxes tests
Every pod of all four charts ran in both sandbox RuntimeClasses, on an EKS Auto Mode cluster (Kubernetes 1.37):
isolated-sandboxconfidential-sandboxsourcegraphTwo test instances:
Instance 1: the
sourcegraphand k8s executor charts, with external PostgreSQL, Redis, and S3.isolated-sandboxonlyInstance 2: all four charts, with the bundled databases, Redis, and blobstore, plus OpenTelemetry, Jaeger, and syntactic-code-intel. Both RuntimeClasses
The cluster needed no change: Auto Mode added sandbox nodes on its own, and no NodePool, StorageClass, or PVC change was needed.
The sections below give, for each RuntimeClass and each chart, what to set and what was tested. The example overrides hold the same values.
isolated-sandboxEvery chart
To deploy:
Set
sourcegraph.runtimeClassName: isolated-sandboxRaise the network limits. A sandbox defaults to 100 Mbps and 1,500 packets per second each way, read once at pod creation
sourcegraph.podAnnotationswith the foursandbox.amazonaws.com/{ingress,egress}-{bandwidth,packets-per-second}annotations covers every chart podSize pods by their requests, because the requests size the microVM and the limits do not
Memory is the sum of the containers' memory requests. A container is OOM-killed once the pod uses more than that, even below its limit, so set each memory request to its limit
vCPUs are the sum of the containers' CPU requests, rounded up, with a minimum of 1. CPU limits add none: a 100m request gets 1 vCPU whatever its limit
Each pod adds 256Mi of memory overhead, which counts against a ResourceQuota
sourcegraphchartTo deploy:
Disable
cadvisorandnode-exporterwithenabled: falseIn a sandbox,
cadvisorfails to start withopen /var/lib/docker: no such file or directoryIn a sandbox,
node-exporterruns, but reports its own microVM (1 CPU, 240 MB) instead of the node (4 CPUs, 16 GB)On the default runtime (
runtimeClassName: "") both run, butcadvisordoes not see containers that run in sandboxes. On a node with 19 sandbox pods, it reported only itself andnode-exporter, so the container metrics on Sourcegraph's dashboards are empty for sandboxed pods.kubectl topand the kubelet's/stats/summarystill report themWith OpenTelemetry enabled, set
openTelemetry.nodeSelectortoeks.amazonaws.com/nitro-sandbox: "true"otel-agentgets a pod that staysPendingon every node that has no sandbox supportWith the bundled databases, remove the init container's CPU limit with
alpine.resources.limits.cpu: null. With the default 10m limit, the first database init took over 13 minutespgsqlandcodeintel-dbsetshared_buffersto 1GB, so their memory requests must stay above thatTested:
Pods
Instance 1: all 9 pods are Ready with no restarts on Nitro Sandbox nodes: frontend, worker, gitserver, indexed-search, searcher, syntect-server, precise-code-intel-worker, prometheus, and grafana. Each reports its own guest kernel and an
.enclaveproduct nameInstance 2: every pod runs in a sandbox, including the bundled databases, Redis, blobstore,
otel-agent, the collector, Jaeger, and syntactic-code-intel-workerSearch: indexed, unindexed, symbol, commit, and diff searches return results, and so does syntax highlighting
Migrations: the frontend's migrator init container completed.
helm testpassedCode navigation: definitions, references, and hover resolve from precise uploads. Syntactic indexing uploads completed
Code Insights backfilled a series
Monitoring: traces from 8 services reach Jaeger through
otel-agentand the collector. Prometheus scrapes every target except Jaeger's/metrics, which returns 404, and Grafana serves its dashboardsStorage: EBS volumes mount with the chart's
ReadWriteOncePVCs, including indexed-search's two containers on one volume. After deleting all 11 pods that have an EBS volume, they moved to other sandbox nodes with their data intactService account tokens work with no extra config: IRSA credentials (frontend to S3 and RDS, Grafana's RDS token sidecar) and the Kubernetes API token
Migrator chart
To deploy: nothing beyond the steps for every chart
Tested: the job completed with the chart's default resources
k8s executor chart
To deploy:
Set
executor.kubernetesJob.runtimeClassName: isolated-sandboxfor job podsGive job pods the network annotations as JSON in
KUBERNETES_JOB_POD_ANNOTATIONS, throughexecutor.extraEnv; the chart has no value for itWith
executor.frontendExistingSecret, name the Secretexecutor-<queueName>, orexecutor-and thequeueNamesjoined with-: the Deployment reads that name, whatever the value is set toTested:
The executor pod is Ready, and creates Jobs with its Kubernetes API token
Batch Changes: a 3-repository server-side batch spec (instance 1) and a 2-repository one (instance 2) completed, one sandboxed job pod per repository, with
batchChanges.nativeServerSideExecutionset in site configuration. Each diff records the job's guest kernelAuto-indexing:
scip-gojobs completed in sandboxed job pods on both instances, and precise-code-intel-worker processed their uploadsdind executor chart
To deploy:
executor.resources.requestsfor the jobs too (tested with 2 CPUs and 4G). Jobs run as containers inside the executor pod's microVM, and the chart has no resources value for thedindcontainerTested:
The privileged
dindcontainer runs in a sandboxAuto-indexing: a
scip-gojob completedBatch Changes: a 2-repository batch spec completed
confidential-sandboxEverything under
isolated-sandboxapplies, with the other RuntimeClass name. On top of that, every pod needs a policy.Every chart
To deploy:
Generate a Cedar policy for every pod, with
eks-nitro-sandbox-genpolicy. Confidential mode denies everything a policy does not allow, and rejects a pod with nosandbox.amazonaws.com/policyannotationRender the chart, and generate one policy per workload from its manifest:
eks-nitro-sandbox-genpolicy --profile confidential --allow-additional-env --output-file-b64 policy.b64 manifest.yaml--allow-additional-envis needed because the charts have noenableServiceLinksvalue, so the kubelet adds Service env vars that the manifest does not listThe generator rejects the charts' octal modes (
defaultMode: 0777). Piping the manifest throughkubectl create --dry-run=client -o yaml -f -turns them into decimalsThe generator has no permit for a StatefulSet's
volumeClaimTemplates. Add each claim to the manifest's pod volumes as apersistentVolumeClaimvolume before generatingThe generator is a Linux binary, and reads each image from its registry. Docker Hub's anonymous limit of 100 requests per hour ran out part way through the
sourcegraphchartEdit the policies that pin a value the sandbox does not present; each chart's section lists them. Decode with
base64 -d policy.b64 | gunzip, edit theadmission_policyandruntime_policytext, and encode witheks-nitro-sandbox-genpolicy --encode-envelopeSet each policy as the
sandbox.amazonaws.com/policyannotation in that service'spodAnnotationsRegenerate the policies on every chart upgrade, and on any values change that alters a pod. A policy pins each container's image digest, command, env, mounts, user, and groups
Allow 512Mi of memory overhead per pod, instead of 256Mi
Expect
kubectl execto be denied by the generated policy.kubectl logsworksRemove the policy annotation when moving a pod back to
isolated-sandbox. It is enforced there too: a pod that kept its confidential policy was denied atSetupSandboxsourcegraphchartTo deploy: the policies from
eks-nitro-sandbox-genpolicy0.1.4274.0 did not admit 15 of the 19 workloads. Each of these needed an edit:$(POD_NAME)(10 workloads)fsGroup, and noemptyDirvolume (prometheus, grafana, indexed-search, both Redis)fsGroup; the sandbox presents-1context.fsgroupto-1/dev/shm(pgsql)/dev/shmmountsubPathmount (jaeger)context.mountsconditionsrunAsUserwithoutrunAsGroup(pgsql-exporterin codeintel-db and codeinsights-db)context.run_as_groupto 0, andcontext.additional_gidsto 0 and thefsGroupmountPaththat ends with/(codeinsights-db)/on the volume's mount path and the mount source; the sandbox drops it from both/from bothTested, with the edited policies:
Pods: every pod became Ready, except 3 of 7
otel-agentpods, which stayedPendingwithInsufficient memoryon sandbox nodes that were fullSearch: indexed and unindexed searches return results, and so does syntax highlighting
Code navigation: precise code navigation resolves
Code Insights: a query returned results
Not run:
helm test. Its pod takes no pod annotations, so it cannot carry a policyMigrator chart
To deploy: widen the policy's hostname pin from
migrator-<5 characters>-*tomigrator-*, in bothadmission_policyandruntime_policyThe chart gives the Job a new random name on every render, and the generated policy pins that name as the pod's hostname. The pod from the next render was denied at
SetupSandboxThe generator's
--no-pin-hostnameflag was not triedTested: with the widened pin, the job completed
k8s executor chart
To deploy:
The executor pod needs no policy edit
For job pods, set
executor.kubernetesJob.runtimeClassName: confidential-sandbox, and add the policy annotation toKUBERNETES_JOB_POD_ANNOTATIONS. Every job pod then gets the same policyBuild the job policy by hand, because job pods have no manifest to generate from
Save a job pod of each kind of job, and remove the fields the API server added
Generate a policy from each, with
--no-pin-args --no-pin-env --no-pin-hostnameadded, because those three differ on every jobMerge the policies by joining their
admission_policyandruntime_policytext. A pod is admitted when any one permit matchesGenerate the job policy before the executor's own policy, which pins the value of that env var
Tested:
The executor pod became Ready and registered with its generated policy, unedited
A policy made from a batch job denied an auto-indexing job. A job policy still pins the container names, image digests, working directories, and mounts, so it admits one kind of job
With the batch and auto-indexing policies merged into one, a 2-repository batch spec and two
scip-gojobs completedA batch spec with two steps was still denied, at
CreateContainerfor the second step. Each number of steps, step image, and indexer needs its own permitsdind executor chart
To deploy:
Before generating the executor's policy, change
emptyDir:toemptyDir: {}on theexecutor-scratchvolume in the manifest. Whenexecutor.storage.sizeis unset, the chart renders it with no value, and the generator then expects a pod with noemptyDirvolume. With the policy generated as is, the pod was denied atSetupSandboxEdit the executor pod's policy before deploying it. As generated, it does not admit the
dindcontainer: the pod stayed at 1/2 Ready, withpolicy denied Agent::Action::"CreateContainer" on container "dind"Cause: the policy has one
CreateContainerpermit per container, under// Container: executorand// Container: dind. Each permit has acontext.mounts == [...]condition, which lists every mount the container must have, with its source path. Thedindcontainer mountsdaemon.jsonfrom thedocker-configConfigMap withsubPath, and the source path the generator writes for that mount is not the one the sandbox presents. The jaeger pod in thesourcegraphchart fails the same wayEdit: in the
admission_policytext, delete the wholecontext.mounts == [...] &&condition from the// Container: dindpermit. Leave theexecutorpermit as it is: that container has nosubPathmount, and was admitted with its condition in placeResult: the policy no longer checks the
dindcontainer's mounts. Its other conditions still applyprivate-docker-registryneeds no policy editJobs need no policy: they run as containers inside the executor pod's microVM, not as pods
Tested:
Both containers of the executor pod are Ready, including the privileged
dindcontainer, and so isprivate-docker-registryAuto-indexing: a
scip-gojob completedBatch Changes: a 2-repository batch spec completed
The jobs ran with the
context.mountsconditions of both containers removed. With only thedindcontainer's removed, both containers were Ready; the jobs were not run againAttestation
Tested with a test pod, not a chart pod: a container image encrypted with a KMS key, which KMS only decrypts for a sandbox that proves it runs the expected policy.
Setup: a
busyboximage encrypted withskopeoandnitro-sandbox-keyprovider(encrypted images)The pod's role, through EKS Pod Identity, has no IAM policy
The key policy allows
kms:Decryptonly whenkms:RecipientAttestation:PCR17equals the value the generator prints for the pod's policy, andkms:RecipientAttestation:PCR8:ServiceNameiseks.amazonaws.comThe pod's policy was generated with
--with-pod-identityThe pod started and read the files of the decrypted layer. CloudTrail's
Decryptevent carries the sandbox's attestation document, and its PCR17 is the predicted valueThe same pod, with one comment line added to its policy, failed to start: KMS answered
AccessDeniedExceptiontokms:DecryptCluster issues found
None of these come from the charts.
A pod with an EBS volume can crash-loop with
cannot take exclusive claim on device ... Device or resource busy. The cause was thecloudwatch-agentDaemonSet of theamazon-cloudwatch-observabilityEKS add-onThe agent mounts the node's
/without mount propagation. When its pod starts after the volumes are mounted on the node, it keeps its own copies of those mounts, which keeps the devices busyDeleting the agent pod on that node fixed all 11 crash-looping pods; the new agent pod held no EBS mounts. It did not happen again when the agent was already running before the volumes attached
The chart's
cadvisormounts/the same way. It was not seen to cause thisSandbox pods can get stuck
PendingwithInsufficient memory. kube-scheduler counts 256Mi more memory for each running sandbox pod than EKS Auto Mode does, so Auto Mode thinks a pending pod fits on an existing node, and does not add one. Two pods hit this after Auto Mode consolidated nodes. Not resolvedeks.amazonaws.com/nitro-sandboxrefused new pods with 21 sandbox pods runningOther limits are in the AWS considerations: no
hostNetwork, 100 DNS queries per second and 4,096 outbound connections per pod by default, and growing a PVC or changing CPU or memory needs a pod restartNot tested
Other sandboxed runtimes, such as gVisor and Kata Containers
In
confidential-sandbox:helm test, container image signature verification, encrypted Sourcegraph images, and job policies for other indexers or batch specs with more than one stepAmp threads