Tag mismatch and executor drift on a 14-runner fleet
An e-commerce company runs GitLab self-managed with 14 group-level runners. All are Docker executor on EC2, each with concurrent = 4 in config.toml and limit = 6 in the runner settings. Eleven runners carry the tags docker and linux; three also carry prod. The 02:00 nightly pipeline fans out 180 jobs, all tagged docker, linux, prod. Overnight, about 60 jobs sit in pending for 20-30 minutes with the message that no runner matches all of the job's tags, and two of the three prod runners are paused for AMI patching. Separately, a new job using image: docker:27 with a docker:27-dind service fails with Cannot connect to the Docker daemon on seven runners and succeeds on the other seven. A shell-executor runner was registered to the group by mistake with the same tags and now leaves root-owned files in build directories.
1. Do the capacity arithmetic for the nightly pipeline, name the root cause of the pending jobs, and give the runner/tag change plan.
Read the worked answer
Arithmetic. A job runs only on a runner that has all of its tags. The 180 jobs require docker + linux + prod, so the eligible set is 3 runners, not 14. Effective parallelism per runner is min(concurrent, limit) = min(4, 6) = 4. Two of the three are paused, leaving 1 × 4 = 4 slots. At roughly 3 minutes per job, 180 jobs need about 180 ÷ 4 × 3 ≈ 135 minutes of wall clock, so a standing backlog of 60 pending jobs is exactly what the numbers predict. The 11 idle runners are excluded by tag, not by lack of capacity - the pending message names the tag set, not saturation.
Root cause. Tags are being used as a queue partition (prod) and as a capability descriptor at the same time. The nightly matrix needs neither production access nor isolation; it inherited prod by copy-paste.
Plan.
- Split runner pools by purpose: 11 runners tagged
docker,linuxfor build/test work; the 3 taggeddocker,linux,prodreserved for deployment jobs only. Deploy jobs keepprod, so an untagged runner can never deploy. - Remove
prodfrom the 180 matrix jobs and re-run the timing to confirm the backlog clears. - Unpause the two runners only after confirming disk and CPU headroom for 4 concurrent Docker executor jobs each.
- Consider raising
concurrentabove 4 only after checking instance CPU; oversubscribing makes each job slower and lengthens the batch. - Make
prodrunnersprotectedso only protected branches and tags can use them.
2. Seven of the 14 Docker-executor runners fail the `docker:27-dind` job with `Cannot connect to the Docker daemon` while seven succeed. Diagnose, propose a fix, and weigh the security trade-off.
Read the worked answer
The job is identical everywhere, so the difference is in runner configuration, not in the job. Docker-in-Docker requires the inner daemon to start inside the job container, which needs privileged = true in [runners.docker] in the affected runner's config.toml (and a kernel//dev setup that permits it). The seven working runners were provisioned with that flag; the other seven were not. A secondary cause to rule out is a mismatched DOCKER_TLS_CERTDIR / DOCKER_HOST between client and service - but that would fail consistently across all runners.
Options.
- Add
privileged = trueto the seven and give DinD builds a dedicated tag (e.g.dind). Simple, but a privileged container can escape to the host; these runners must be treated as untrusted: isolated ASG, no shared volumes, no credentials beyondCI_JOB_TOKEN, egress-restricted. - Avoid privileged entirely: rootless BuildKit or Kaniko for image builds, or
docker buildxagainst a remote builder. Slower to adopt, but removes the escape risk and lets the whole fleet stay unprivileged.
Judgement. Because the fleet also carries prod, do not enable privileged on the shared pool. Introduce a separate dind pool that carries neither prod nor deployment credentials, move the image-build jobs there by tag, and plan a migration to rootless builds.
Whichever route is chosen, make the required runner configuration part of the runner's provisioning code so the fleet cannot drift again.
3. A shell-executor runner was mistakenly registered to the group with the same tags as the Docker runners. What are the concrete risks, and what is the remediation order?
Read the worked answer
The shell executor runs job commands directly on the host, as the gitlab-runner user, in a build directory on the host filesystem. That means:
- No isolation between projects. Jobs from unrelated projects share a directory tree and a home directory, so one job can read files another job left behind, including
.npmrc,.aws,~/.docker/config.jsonor an.envthat a previous job wrote to disk. - Cross-job contamination. Artefacts and caches land in a shared path; a dirty workspace produces failures that cannot be reproduced on a clean runner.
- Persistence. Anything it wrote, including credentials exported to files, survives the job.
- Root-owned files. With
dockervolumes or a root-run step, later non-root jobs cannot clean up and fail on checkout.
Remediation order.
- Pause the runner immediately (
/admin/runnersor the API) so it stops accepting jobs while you investigate. - Identify which jobs it picked up - query the runner's jobs history - and treat any project variables those jobs used as potentially exposed. Rotate anything long-lived (deploy tokens, cloud keys);
CI_JOB_TOKENis short-lived and scoped, so it is lower priority. - Unregister the runner and tighten the registration token so a shell executor cannot be registered to the group again.
- Clean the host build directory and home directory of leftovers.
- Replace it with a Docker executor runner using a pinned image, and set
GIT_STRATEGY: fetchplus a workspace clean hook so stale files never survive between jobs.
Do not simply relabel the shell runner - tags do not fix isolation.