enhance(kubernetes): fetch job pod copies from the runner over the pod network (#1354)

The Kubernetes backend copies actions and workspaces into job containers with `tar` over `pods/exec`, so every byte goes through the API server. On managed clusters that often sits in other zones than the nodes, so each byte can cross zones twice, and the copies load the API server.

With `kubernetes.transfer.mode: auto` or `direct`, an init container copies `gitea-runner` into the job pod, and the job container fetches directories and copies of 64 KiB or more gzipped from the runner over the pod network. `auto` falls back to the API server for a job pod that cannot reach the runner, `direct` fails the job.

Copies can hold secrets, so each is served once, over TLS 1.3 pinned to a certificate the runner generates, only to the job pod's IPs.

Copying setup-go@v5, cache@v4, setup-java@v4 and build-push-action@v6 sends 11.6 MB through the API server on main and 532 B with this PR.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1354
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
This commit is contained in:
authored and silverwind committed 2026-10-31 08:00:36 +00:00
1 parent f4741df90d
commit 2e72cf902a
12 files changed
+667 -9

No files matched your search

+3
View File
@@ -58,6 +58,9 @@ func Execute(ctx context.Context) {
// ./gitea-runner bug-report
rootCmd.AddCommand(loadBugReportCmd())
// ./gitea-runner kubernetes-transfer, run inside Kubernetes job pods
rootCmd.AddCommand(loadKubernetesTransferCmd())
// ./gitea-runner config
rootCmd.AddCommand(loadConfigCmd(&configFile))
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"gitea.com/gitea/runner/act/container"
"github.com/spf13/cobra"
)
// loadKubernetesTransferCmd is what job pods run when kubernetes.transfer is on: the init container installs the binary, the job container fetches with it.
func loadKubernetesTransferCmd() *cobra.Command {
transferCmd := &cobra.Command{
Use: container.KubernetesTransferCommand,
Short: "Copy files from the runner into a Kubernetes job pod",
Hidden: true,
}
transferCmd.AddCommand(&cobra.Command{
Use: "install <path>",
Short: "Copy this binary to path",
Args: cobra.ExactArgs(1),
RunE: func(_cmd *cobra.Command, args []string) error {
return container.InstallKubernetesTransferHelper(args[0])
},
}, &cobra.Command{
Use: "fetch <dir>",
Short: "Extract the tar stream the transfer request on stdin points to into dir",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return container.FetchKubernetesTransfer(cmd.Context(), cmd.InOrStdin(), args[0])
},
})
return transferCmd
}
+5
View File
@@ -415,6 +415,11 @@ func (r *Runner) kubernetesOptions(runsOn []string) container.KubernetesOptions
PodTemplates: []map[string]any{r.cfg.Kubernetes.PodTemplate},
RunnerUUID: r.uuid,
}
if transfer := r.cfg.Kubernetes.Transfer; transfer.Mode == config.KubernetesTransferModeAuto || transfer.Mode == config.KubernetesTransferModeDirect {
options.Transfer = &container.KubernetesTransfer{
Host: transfer.Host, Port: transfer.Port, Image: transfer.Image, Fallback: transfer.Mode == config.KubernetesTransferModeAuto,
}
}
names := r.labels.Names()
for index, name := range runsOn {
if template, ok := r.cfg.Kubernetes.PodTemplates[name]; ok && slices.Contains(names, name) && !slices.Contains(runsOn[:index], name) {
+12
View File
@@ -21,6 +21,7 @@ import (
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/runner"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
@@ -276,6 +277,17 @@ func TestRunnerKubernetesOptionsLayerTheLabelPodTemplatesInRunsOnOrder(t *testin
require.Equal(t, []map[string]any{{"base": true}, {"large": true}, {"gpu": true}}, r.kubernetesOptions([]string{"linux", "large", "other", "gpu", "large"}).PodTemplates)
}
func TestRunnerKubernetesOptionsTransfer(t *testing.T) {
kubernetesRunner := &Runner{cfg: &config.Config{}}
kubernetesRunner.cfg.Kubernetes.Transfer = config.KubernetesTransfer{Mode: config.KubernetesTransferModeAPI, Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest"}
require.Nil(t, kubernetesRunner.kubernetesOptions(nil).Transfer)
kubernetesRunner.cfg.Kubernetes.Transfer.Mode = config.KubernetesTransferModeAuto
require.Equal(t, &container.KubernetesTransfer{Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest", Fallback: true}, kubernetesRunner.kubernetesOptions(nil).Transfer)
kubernetesRunner.cfg.Kubernetes.Transfer.Mode = config.KubernetesTransferModeDirect
require.Equal(t, &container.KubernetesTransfer{Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest"}, kubernetesRunner.kubernetesOptions(nil).Transfer)
}
// Proxy variables are assembled per task, because a job's service containers have to be
// reached directly and they are only known once the workflow is parsed.
func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
+12
View File
@@ -306,6 +306,18 @@ kubernetes:
#pod_template: {}
# Pod templates merged over pod_template, in runs-on order, for jobs whose runs-on has the label they are keyed by.
#pod_templates: {}
# Have job pods fetch directories, such as actions, and copies of 64 KiB or more gzipped from the runner over the pod network,
# instead of the runner streaming them through the API server. An init container copies gitea-runner from image into each job pod.
# api: through the API server (default). auto: over the pod network, through the API server for a job pod that
# cannot reach the runner. direct: over the pod network only, so a broken setup fails jobs instead of quietly
# costing API server traffic again.
#transfer:
# mode: api
# # Address and port the runner listens on and job pods reach it at, the outbound IP and a free port when empty.
# host: ""
# port: 0
# # Image with gitea-runner on its PATH, the runner pod's image when it has a single container and runs in namespace.
# image: ""
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
+25
View File
@@ -210,6 +210,25 @@ type Kubernetes struct {
Namespace string `yaml:"namespace"` // Namespace of job pods, empty uses the service account's, else the kubeconfig context's, else default.
PodTemplate map[string]any `yaml:"pod_template"` // PodTemplate is a pod manifest the runner merges its own onto.
PodTemplates map[string]map[string]any `yaml:"pod_templates"` // PodTemplates are merged over PodTemplate, in runs-on order, for jobs whose runs-on has the label they are keyed by.
Transfer KubernetesTransfer `yaml:"transfer"` // Transfer has job pods fetch copied files from the runner over the pod network instead of through the API server.
}
// Kubernetes transfer modes: how files the runner copies reach job pods.
const (
KubernetesTransferModeAPI = "api" // through the API server
KubernetesTransferModeAuto = "auto" // over the pod network, through the API server for a job pod that cannot reach the runner
KubernetesTransferModeDirect = "direct" // over the pod network only, a copy that cannot reach the runner fails
)
// KubernetesTransferModes are the valid kubernetes.transfer.mode values.
var KubernetesTransferModes = []string{KubernetesTransferModeAPI, KubernetesTransferModeAuto, KubernetesTransferModeDirect}
// KubernetesTransfer configures copying files into job pods over the pod network.
type KubernetesTransfer struct {
Mode string `yaml:"mode"` // Mode is one of KubernetesTransferModes, api when empty.
Host string `yaml:"host"` // Host is the address the runner listens on and job pods reach it at, empty uses the outbound IP.
Port uint16 `yaml:"port"` // Port the runner listens on for job pods, 0 picks a free one.
Image string `yaml:"image"` // Image has gitea-runner on its PATH, empty uses the image of a single-container runner pod.
}
// Metrics represents the configuration for the Prometheus metrics endpoint.
@@ -346,6 +365,12 @@ func LoadDefault(file string) (*Config, error) {
if !slices.Contains(ToolCacheModes, cfg.Runner.ToolCacheMode) {
return nil, fmt.Errorf("invalid runner.tool_cache_mode %q: must be one of %q", cfg.Runner.ToolCacheMode, ToolCacheModes)
}
if cfg.Kubernetes.Transfer.Mode == "" {
cfg.Kubernetes.Transfer.Mode = KubernetesTransferModeAPI
}
if !slices.Contains(KubernetesTransferModes, cfg.Kubernetes.Transfer.Mode) {
return nil, fmt.Errorf("invalid kubernetes.transfer.mode %q: must be one of %q", cfg.Kubernetes.Transfer.Mode, KubernetesTransferModes)
}
if cfg.Runner.LogReportLevel != "" {
if _, err := logging.ParseLevel(cfg.Runner.LogReportLevel); err != nil {
return nil, fmt.Errorf("invalid runner.log_report_level: %w", err)
+13
View File
@@ -140,6 +140,19 @@ func TestLoadDefault_ToolCacheMode(t *testing.T) {
assert.Contains(t, err.Error(), "tool_cache_mode")
}
func TestLoadDefault_KubernetesTransferMode(t *testing.T) {
cfg, err := LoadDefault(write(t, "kubernetes:\n transfer:\n host: 10.0.0.5\n"))
require.NoError(t, err)
assert.Equal(t, KubernetesTransferModeAPI, cfg.Kubernetes.Transfer.Mode, "copies stay on the API server unless asked")
cfg, err = LoadDefault(write(t, "kubernetes:\n transfer:\n mode: direct\n"))
require.NoError(t, err)
assert.Equal(t, KubernetesTransferModeDirect, cfg.Kubernetes.Transfer.Mode)
_, err = LoadDefault(write(t, "kubernetes:\n transfer:\n mode: fast\n"))
require.ErrorContains(t, err, "kubernetes.transfer.mode")
}
func TestLoadDefault_RejectsInvalidLogReportLevel(t *testing.T) {
_, err := LoadDefault(write(t, "runner:\n log_report_level: loud\n"))
require.ErrorContains(t, err, "runner.log_report_level")