skip to content

In GitLab Runner's config.toml, what does the `executor` setting control, and how do the shell, docker, and kubernetes executors differ?

level: middleimportance: must knowfreq 72%

answer

  1. the agent polls, the executor builds the environment
  2. host process vs container vs pod per job
  3. state persists on shell, nothing persists on kubernetes
  4. image: is meaningless without a container executor

basics

~20 s

The executor decides how GitLab Runner creates the environment a job's script runs in. Shell runs commands directly on the runner host, docker starts a fresh container per job from the job's image, and kubernetes schedules a pod per job in a cluster.

solid answer

~50 s

GitLab Runner is only an agent: it polls GitLab for jobs and then hands each one to an **executor**, set per runner in `config.toml`. The `shell` executor runs the job's script as a child process on the runner host, so whatever is installed there is the build environment — fast, but state leaks between jobs and a job can read anything the runner user can. The `docker` executor starts a container from the job's `image:`, plus containers for any `services:`, and runs the script inside; each job gets a clean filesystem, and the runner's helper image handles clone, cache and artifacts. The `kubernetes` executor asks a cluster to schedule a **pod per job** — build, helper and service containers together — which gives the same isolation plus elastic capacity and no long-lived build hosts. Choice drives isolation, how caching works, and startup latency.

code

toml · 17 lines
toml
concurrent = 8

[[runners]]
  name = "linux-docker"
  url = "https://gitlab.example.com/"
  token = "glrt-EXAMPLE"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.20"
    privileged = false
    volumes = ["/cache"]

[[runners]]
  name = "macos-shell"
  url = "https://gitlab.example.com/"
  token = "glrt-EXAMPLE2"
  executor = "shell"

go deeper

for a junior

Know that the runner is an agent and the executor decides where the script actually runs: on the host, in a container, or in a cluster pod. Recall that image: needs a container executor.

for a middle

Explain the per-job lifecycle of each executor, the role of the helper container, and why state persists on shell but never on kubernetes. Name the caching consequence of each.

for a senior

Demonstrate the operational tradeoffs: warm image caches on fixed Docker runners versus elastic pods, resource requests causing Pending jobs, and which executors you would ever expose to fork merge requests.

for a principal

Own the fleet strategy — which workloads get disposable environments, how build capacity is funded and autoscaled, and how you keep one blessed executor configuration consistent across many teams instead of per-team snowflakes.

## The runner and the executor are different things `gitlab-runner` is a small agent process. It authenticates to a GitLab instance, long-polls for jobs it is eligible to run, and then delegates the actual work. What it delegates *to* is the executor, chosen per runner entry in `config.toml`: ```toml concurrent = 8 [[runners]] name = "linux-docker" url = "https://gitlab.example.com/" executor = "docker" [runners.docker] image = "alpine:3.20" privileged = false ``` One runner process can hold several `[[runners]]` entries, each with a different executor. The YAML in `.gitlab-ci.yml` never picks the executor — it picks a runner via `tags:`, and the runner's configuration decides the rest. That indirection is why `image:` is silently ignored on a shell-executor runner: there is no container to apply it to. ## shell The script is written to a temporary file and executed by a shell on the runner host, as the user the runner service runs as. There is no isolation layer at all. - **Environment** = whatever is installed on the box. Adding a build dependency means changing the machine, not the pipeline. - **State persists.** The build directory, package caches, `~/.gradle`, Docker images pulled by earlier jobs — all still there next job. That is why shell runners feel fast and why they produce "works on runner 3, fails on runner 4" bugs. - **Trust.** Any job that lands here runs with the runner user's rights on a real host. For a public project or forks, that is an unacceptable blast radius. It earns its place for jobs that genuinely need the host: driving a hypervisor, using a hardware device, or macOS/iOS builds where containers are not an option. ## docker For each job the runner pulls the job's `image:` and starts a container for the build, plus one container per entry in `services:` (databases, `docker:dind`), on a shared network so the build can reach them by alias. A separate *helper* image performs the clone, cache extraction, artifact upload and Git submodule work, so those steps do not depend on tools existing inside your build image. - **Isolation** is the container boundary: a fresh root filesystem per job, and processes namespaced from the host. It is not a security sandbox against a hostile job — a job that gets `privileged = true` or the Docker socket effectively owns the host. - **Caching** must be explicit. Because the container is destroyed, anything you want back next time has to go through `cache:` or `artifacts:`; the cache itself lands on the runner host unless you configure distributed object storage. - **Cost** is the image pull and container start per job — seconds, mitigated by a warm local image store. ## kubernetes The runner talks to a cluster API and creates a **pod per job**. The pod holds the build container (your `image:`), the helper container, and a container per service. When the job finishes the pod is deleted. - **Elasticity.** Capacity is the cluster's problem; there are no build VMs to keep patched, and idle cost approaches zero if the cluster autoscales. - **Configuration surface is large.** Namespace, service account, resource requests and limits, node selectors and tolerations all live under `[runners.kubernetes]`, and getting requests wrong shows up as jobs stuck `Pending` while the cluster finds room. - **Ephemeral by construction.** Nothing survives between jobs, so caching depends entirely on object storage, and image pulls hit every new node unless you warm them. - **Building images is harder**, since there is no Docker daemon in the pod by default — which is the DinD-versus-daemonless discussion. ## Others worth naming `docker+machine` was the classic autoscaling executor (spin a cloud VM per job or per few jobs) and is deprecated in favour of the newer autoscaling executors that use a fleeting plugin; `ssh` and `custom` also exist, `custom` letting you script the whole lifecycle for exotic environments. ## How to choose Ask three questions: *how much do I trust the code that will run here* (fork MRs push you to disposable environments), *does the job need something only the host has*, and *how spiky is demand* (bursty CI favours per-job pods or autoscaled VMs; a steady trickle favours a couple of fixed Docker runners, whose warm image cache makes them cheaper and quicker).

  • Why is `image:` in .gitlab-ci.yml ignored by some runners?
    Because `image:` only means something to an executor that creates a container — `docker`, `kubernetes` and their relatives. A shell-executor runner runs the script directly on the host, so there is nothing to apply the image to and the key is simply not honoured. If a job needs a specific toolchain image, it must be routed by `tags:` to a runner configured with a container executor.
  • What does the helper image do in the docker and kubernetes executors?
    It runs the parts of the job that are not your script: cloning the repository, restoring and saving `cache:`, downloading and uploading `artifacts:`, and handling submodules. Keeping it in a separate container means your build image does not need Git or the runner's tooling installed, and it is why a minimal `image:` still gets a working checkout.
  • When is the shell executor still the right answer?
    When the job genuinely needs the host: macOS or iOS builds, driving a hypervisor, using attached hardware, or a Windows toolchain that containers cannot serve. It is defensible for trusted, internal repositories where you control every commit — never for public projects or fork merge requests, since the job runs with the runner user's rights on a persistent machine.

saying these in an interview costs you the question

  • Thinking the executor is chosen in .gitlab-ci.yml
  • Claiming the docker executor sandboxes untrusted code securely
  • Assuming caches persist automatically between container jobs
  • Believing the kubernetes executor keeps one long-lived pod for all jobs
  • Saying shell is just docker without containers, with no downside

context