Visitar URL original
worker: add Hyper-V isolation and image caching to the Windows containerd runtime by kurtmc · Pull Request #9786 · concourse/concourse · GitHub
Skip to content

worker: add Hyper-V isolation and image caching to the Windows containerd runtime - #9786

Open
kurtmc wants to merge 8 commits into
concourse:masterfrom
kurtmc:windows-containerd-hyperv-isolation
Open

kurtmc wants to merge 8 commits into
concourse:masterfrom
kurtmc:windows-containerd-hyperv-isolation

Conversation

@kurtmc

@kurtmc kurtmc commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Changes proposed by this PR

Stacked on #9766. Relates to #4365. Only the last three commits are new, and this compare view shows them without #9766.

This follows up two review comments by @alicul on #9766, the question about Hyper-V isolation and the request to keep unpacked images. Testing them also turned up a bug in #9766.

  • Each Windows container gets its own stdio pipes. containerd names a process's stdio pipes after the process ID alone, every task step runs its process with the ID task, and pipe names are global to a Windows host. On worker: add an experimental containerd runtime for Windows workers #9766 a task that starts while another task is running on the same worker fails with failed to create stdin pipe \\.\pipe\ctr-task-stdin: Access is denied. The pipes are now named after the container handle and the process ID. This commit stands alone and can move into worker: add an experimental containerd runtime for Windows workers #9766.
  • Imported images are kept between containers. The runtime stores each image it imports in containerd's image store, named by its digest. The import also labels each blob with the blobs it refers to, which containerd's garbage collector needs in order to keep the content and the unpacked layers of a stored image. A later container on the same image no longer copies or unpacks anything. Nothing removes a stored image, so a worker's disk holds every image version the worker has run until the worker is replaced. This closes the first known gap listed on worker: add an experimental containerd runtime for Windows workers #9766.
  • Hyper-V isolation behind a flag. --containerd-isolation=process|hyperv defaults to process. With hyperv, each container boots in its own utility VM that runs the image's kernel, and unpacking skips the snapshotter platform check, so a Server 2022 worker runs an ltsc2019 image. --containerd-hyperv-cpus and --containerd-hyperv-memory-mb size the utility VM. Unset, the runtime's defaults of 2 processors and 1024 MB apply. A container's memory limit sizes its utility VM in place of the memory flag. The worker needs the Hyper-V role.

Notes to reviewer

How I tested it, on one web node with a Linux worker and three Windows Server 2022 workers (build 20348, m8i.xlarge with 4 processors and 16 GB, gp3 disks, Defender excluding the work directory) running containerd 2.3.7. Two of the Windows workers have the Hyper-V role. On EC2 that runs through nested virtualization, so every Hyper-V number below includes that cost.

Hyper-V against process isolation

Each mode ran the same task 5 or 6 times on each of the two Hyper-V hosts, 32 builds in all with no failures. A ratio compares a mode with process isolation on the same host, and a range spans the two hosts. Container start comes from 64 builds of a one-line task. Memory was read with four idle containers on one host, or three at the 8192 MB size.

process hyperv, default size hyperv, 4 processors and 8192 MB
Container start, image layers already unpacked 7 to 8 s 10 to 12 s 10 to 12 s
Hashing on one thread 1x 1.01 to 1.04x 1.02 to 1.03x
Hashing on all processors 1x 0.49x 0.98 to 1.02x
Starting 500 processes 1x 0.71 to 0.74x 0.65 to 0.74x
Downloading 512 MB 1x 0.62 to 0.63x 0.60 to 0.62x
Writing a 1 GiB file to a volume 1x 0.93x 0.90x
10,000 small files on a volume, create 1x 0.28 to 0.32x 0.24 to 0.28x
10,000 small files on a volume, read 1x 0.07 to 0.08x 0.06 to 0.07x
10,000 small files on a volume, delete 1x 0.13 to 0.16x 0.12 to 0.14x
10,000 small files in the container's own filesystem, create 1x 5.6x 5.6 to 6.1x
Idle host memory held per container 89 MB 548 MB 752 MB
Host memory committed per container 182 MB 1183 MB 8414 MB
  • Mounted volumes are the main cost, and every input, output and cache is one. The shim shares a mounted directory into the utility VM over SMB. I do not know why creating small files in the container's own filesystem is faster under hyperv.
  • The host commits a utility VM's whole memory when the VM starts. A fourth 8192 MB utility VM on a 16 GB host failed. Windows logged The paging file is too small for this operation to complete, and the build showed only failed to create shim task: ttrpc: closed.
  • A task with container_limits: {memory: 256MB} booted a 254 MiB utility VM with 78 MiB free for the task.
  • Without the Hyper-V role the worker starts, and each container fails with hcs::GetServiceProperties: Catastrophic failure.
  • ltsc2019 and ltsc2025 nanoserver images ran on a Server 2022 worker under hyperv. process rejected both with snapshotter windows does not support platform. Microsoft's version compatibility page documents Hyper-V isolation for images older than the host, so I would not rely on the ltsc2025 result.

Stored images

  • With three workers each starting a nanoserver container at once, after every earlier container was gone, the container started in 11 to 12 seconds under process and 14 seconds under hyperv. Without this commit it took 19 to 21 and 27 to 29 seconds. That is 5 builds per host on each side.
  • One build at a time, the container started in 6 to 7 seconds under process and 9 to 11 seconds under hyperv.
  • The first build on an image is as slow as before. Concourse still streams the image's volume to the worker for every build.
  • With no container left, each worker's containerd kept one image, with 117 MB of content and 546 MB of unpacked layers for nanoserver:ltsc2022. Before this commit it kept nothing.
  • The stored image survives a worker restart.
  • process still rejects an ltsc2019 image that hyperv stored on the same worker, and runs an ltsc2022 image that hyperv stored.

Stdio pipes

  • TestProcessIOCreatorKeepsContainersApart passes on Server 2022 and fails there without the fix.
  • Four tasks started together on one worker. Before the fix, three of the four failed under process and under hyperv. After it, all four passed in both modes.

go test of ./worker/runtime, ./worker/runtime/spec and ./worker/runtime/libcontainerd passes on Linux. I ran the new Windows-only test on a Server 2022 host and did not rerun the other Windows tests.

The containerd Garden backend only builds on Linux. Before it can build
for Windows, the parts that only make sense on Linux need files of their
own.

Move the default PATH handling, the stdin CloseIO handling, the runc
process listing used by the killer and the /etc/passwd based rootfs
manager into _linux.go files. Move PrivilegedMode and rootfsDir out of
the Linux-only spec.go into privileged_mode.go and rootfs.go. The
backend now calls defaultNetwork, defaultSeccompProfiles and
defaultTaskOpts instead of naming CNI, the seccomp profiles and
WithNoNewKeyring itself. loadResources moves from worker_linux.go to
worker.go.

Linux behaviour does not change.

concourse#4365

Signed-off-by: Kurt McAlpine <kurt@linux.com>
Windows workers only have the houdini runtime, which runs every step
directly on the host. Add containerd as an opt-in runtime for Windows
workers with `--runtime containerd`. houdini stays the default, and the
flag description marks the Windows containerd runtime as experimental.

The worker starts containerd.exe on a named pipe and runs the existing
Garden backend against it. The shared runtime files now build for
linux || windows, with Windows versions of the Linux-only pieces:

- spec_windows.go turns a Garden container spec into a Windows OCI
  spec. It mounts init.exe (cmd/init/windows) into the container as the
  init process, converts the POSIX paths the web node sends
  (/tmp/build/...) to C:\ paths, and maps CPU shares and memory limits
  to Windows resources.
- prepareRootfs converts the streamed rootfs volume into a Windows base
  layer with hcsshim and creates the scratch layer that the runhcs shim
  needs. Flattened multi-layer images carry registry deltas and machine
  state from the containers that built them. It merges the last layer's
  delta hives into the base hives and removes that state before it
  processes the layer.
- HCNNetwork gives each container a network namespace and an endpoint
  on a NAT HNS network, and creates the network when it is missing.
  `--containerd-network-name`, `--containerd-network-pool` and
  `--containerd-network-gateway` configure it.
- The killer reads the runhcs process details, and stdin stays open
  after start because the runhcs shim closes it as soon as CloseIO is
  called.
- rootfsDir accepts raw:// URIs whose Windows path the web node
  percent-encoded.
- CmdRunner kills containerd when the shutdown signal cannot be
  delivered, which is always the case on Windows.

Windows workers also register the resource types in `--resource-types`,
as Linux workers do.

concourse#4365

Signed-off-by: Kurt McAlpine <kurt@linux.com>
A Windows image built on a case-sensitive filesystem can hold two paths
that differ only in case. Extracted onto a case-insensitive filesystem,
the second entry overwrites the first, and that fails when the first
entry is read-only.

When creating a regular file fails with a permission error, make the
existing file writable and create it again.

Signed-off-by: Kurt McAlpine <kurt@linux.com>
A streamed rootfs volume loses NTFS security descriptors, reparse point
metadata and the per-layer registry state. Servercore images need these
to boot. A natively pulled servercore image boots, and the same image
streamed as a volume fails with driver initialization errors.

Tasks on Windows workers can now set `rootfs_uri: oci://<image-ref>`.
The Windows spec records the reference in an annotation, and the
backend creates the container with Client.NewContainerWithImage. That
pulls the image through containerd and lets the Windows snapshotter
manage the layers, so pulled images are cached across containers.
Container.Delete removes the container's snapshot.

concourse#4365

Signed-off-by: Kurt McAlpine <kurt@linux.com>
The Windows containerd runtime had two ways to get a container's rootfs.
A streamed rootfs volume was flattened into a single base layer. That
drops the NTFS security descriptors and file attributes that Windows
layers keep in their tar headers, and all but the last layer's registry
hive deltas, so servercore images failed to boot. An oci:// rootfs made
containerd pull the image itself, which bypasses resources and their
credentials, versions and caching.

The registry-image resource now writes the rootfs of a Windows image as
an OCI image layout. The Windows runtime copies the layout's blobs into
containerd's content store under a lease, unpacks them with the windows
snapshotter, and creates the container on a snapshot of the image. A
Windows image reaches a task the way any image does: through a get step
or a worker's resource types, streamed between workers as a volume.

Unpacking first checks that the snapshotter supports the image's
platform, so an image for another Windows build fails before any layer
is extracted. Layers stay unpacked while a container uses them, so
containers that share an image share its layers. Deleting a container
removes its snapshot. When creating the container fails, the runtime
deletes the network namespace it attached.

This removes the oci:// rootfs scheme, the rootfs flattening and the
registry hive merging that flattening needed.

concourse#4365

Signed-off-by: Kurt McAlpine <kurt@linux.com>
containerd names a process's stdio pipes on Windows after the process
ID alone, as \\.\pipe\ctr-<id>-stdin and so on. Every task step runs
its process with the ID "task", and pipe names are global to the host.
A task that started on a worker while another task was running failed
with:

    task exec: failed to create stdin pipe \\.\pipe\ctr-task-stdin:
    open \\.\pipe\ctr-task-stdin: Access is denied.

The Windows runtime now names the pipes after the container handle and
the process ID. The ID that containerd knows the process by is
unchanged.

concourse#4365
…ntime

A Windows worker ran every container with process isolation, which
shares the host's kernel, so it could only run images built for the
host's Windows version.

`--containerd-isolation=hyperv` boots each container in its own utility
VM, which runs the image's kernel. Unpacking skips the snapshotter
platform check for these containers, because that check requires the
image to match the host's Windows build. A Server 2022 worker ran a
Server 2019 nanoserver image this way. With process isolation the same
image failed the platform check.

`--containerd-hyperv-cpus` and `--containerd-hyperv-memory-mb` size the
utility VM. Unset, the runtime's defaults of 2 processors and 1024 MB
apply. A container's memory limit sizes its utility VM in place of the
memory flag.

The host commits a utility VM's whole memory when the VM starts, so the
VM size times the number of containers running at once has to fit the
host's commit limit. Past the limit a container fails to create.

The worker needs the Hyper-V role. Without it the worker starts, and
each container fails to create.

concourse#4365
The Windows runtime unpacked an image under a lease and never stored
it. Once the last container on the image was deleted, nothing referred
to the image's content or its unpacked layers, and containerd's garbage
collector removed them. The next build copied and unpacked the whole
image again, inside the worker's create lock.

The runtime now stores the image in containerd's image store, named by
its digest. Importing an image layout labels each blob with the blobs
it refers to, which garbage collection needs in order to keep the
content of a stored image. A later container on the same image skips
the copy and the unpack.

Nothing removes a stored image. The worker's disk has to hold every
image the worker has run, and replacing the worker reclaims it.

concourse#4365
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant