mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
This is the companion to #496 In order to grant access to `/dev/kvm` we have to either: - use a device plugin - use a DRA driver - use an NRI plugin An NRI plugin is highly privileged in it's own right for all pods on the host and is difficult to ship portably at the moment. DRA is promising, but not enough functionality is GA yet at our current 1.35+ target. Device plugin fits reasonably well. We do wind up publishing an ~arbitrarily high limit, which has some cost in kubelet memory, but otherwise is relatively clean. This approach is what kata uses currently. Their device plugin is not available unbundled, and we anyhow have a per-node daemonset. atlet is taught to sniff if /dev/kvm appears on the host at all, so we can also stop using the manually labeled nodes for microVM class and instead schedule to the KVM + TUN devices on nodes that advertise them. Later we can migrate to device plugin by using I implemented that already, but I don't think it's worth merging at the moment. We would want consumable capacity to be on by default. We can migrate later without changing the pod spec by using `extendedResourceName`. https://github.com/agent-substrate/substrate/compare/main...BenTheElder:substrate:ateom-microvm-dra NOTE: I confirmed with upstream that device plugin is not going anywhere despite being "v1beta1", it's GA in all but name. It won't receive new features but we don't really need anyhow. We'll move to DRA down the line. --- By doing this, we can drop `privileged: true` from the uVM ateom pods. We can also drop the `ate.dev/sandboxClass` node label hacks, reducing friction to deploy.
299 lines
10 KiB
Go
299 lines
10 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package deviceplugin advertises host device nodes (for example /dev/kvm) to
|
|
// kubelet as extended resources, so a worker pod can be granted just those
|
|
// devices instead of running privileged.
|
|
//
|
|
// Device access is gated by the cgroup v2 device controller, which denies by
|
|
// default before DAC is consulted: no capability, hostPath mount, or
|
|
// supplemental group grants it. Kubelet's device manager adds the narrow allow
|
|
// rule, emitting the device node and a matching cgroup allow for each device.
|
|
package deviceplugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
pluginapi "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1"
|
|
)
|
|
|
|
// deviceCount is how many allocatable units of each device are advertised.
|
|
// These are shareable pseudo-devices, so the count means nothing except as an
|
|
// exhaustion limit; keep it far above any max-pods-per-node setting so device
|
|
// accounting never constrains scheduling.
|
|
const deviceCount = 4096
|
|
|
|
// registerTimeout bounds a single registration attempt against kubelet.
|
|
const registerTimeout = 30 * time.Second
|
|
|
|
// Extended resource names advertised by atelet and requested by worker pods.
|
|
// Both sides import these so the strings cannot drift apart.
|
|
const (
|
|
// ResourceKVM grants /dev/kvm, which the micro-VM runtime needs to create a
|
|
// VM (cloud-hypervisor fails with EPERM on VmCreate without it).
|
|
ResourceKVM = "ate.dev/kvm"
|
|
)
|
|
|
|
// SandboxDevices are the host devices a sandbox runtime needs a grant for.
|
|
// atelet advertises whichever of these exist on its node.
|
|
//
|
|
// Only devices the container runtime denies by default belong here. The
|
|
// micro-VM runtime also opens /dev/net/tun, but that is in the runtime's
|
|
// default allow-list, so the worker gets it as an ordinary bind mount instead.
|
|
var SandboxDevices = []HostDevice{
|
|
{ResourceName: ResourceKVM, Path: "/dev/kvm"},
|
|
}
|
|
|
|
// HostDevice is a device node advertised to kubelet under ResourceName.
|
|
type HostDevice struct {
|
|
// ResourceName is the fully-qualified extended resource name pods request,
|
|
// e.g. "ate.dev/kvm".
|
|
ResourceName string
|
|
// Path is the device node, e.g. "/dev/kvm". It is exposed to the container
|
|
// at the same path.
|
|
Path string
|
|
}
|
|
|
|
// Present reports whether the device node exists on the node as a character
|
|
// device. devRoot is where the node's /dev is mounted for inspection, our own
|
|
// container having a minimal /dev of its own; Allocate still reports Path,
|
|
// which kubelet resolves on the node.
|
|
func (d HostDevice) Present(devRoot string) bool {
|
|
fi, err := os.Stat(d.resolve(devRoot))
|
|
return err == nil && fi.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
// resolve maps the device's host path into devRoot ("/dev/kvm" ->
|
|
// "<devRoot>/kvm").
|
|
func (d HostDevice) resolve(devRoot string) string {
|
|
return filepath.Join(devRoot, strings.TrimPrefix(d.Path, "/dev/"))
|
|
}
|
|
|
|
// Available returns the subset of devs present on this node, looking under
|
|
// devRoot. atelet runs on every node, so advertising a resource only where its
|
|
// device exists keeps pods requesting it off nodes that cannot run them.
|
|
func Available(devs []HostDevice, devRoot string) []HostDevice {
|
|
out := make([]HostDevice, 0, len(devs))
|
|
for _, d := range devs {
|
|
if d.Present(devRoot) {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Plugin serves the kubelet device plugin API for a single HostDevice.
|
|
type Plugin struct {
|
|
pluginapi.UnimplementedDevicePluginServer
|
|
|
|
dev HostDevice
|
|
socket string
|
|
devices []*pluginapi.Device
|
|
}
|
|
|
|
var _ pluginapi.DevicePluginServer = (*Plugin)(nil)
|
|
|
|
// New builds a Plugin for dev. Call Run to serve it.
|
|
func New(dev HostDevice) *Plugin {
|
|
devices := make([]*pluginapi.Device, 0, deviceCount)
|
|
for i := range deviceCount {
|
|
devices = append(devices, &pluginapi.Device{
|
|
ID: fmt.Sprintf("%s-%d", filepath.Base(dev.Path), i),
|
|
Health: pluginapi.Healthy,
|
|
})
|
|
}
|
|
return &Plugin{
|
|
dev: dev,
|
|
// One socket per resource, in the directory kubelet watches. The name is
|
|
// derived from the resource so two plugins never collide.
|
|
socket: filepath.Join(pluginapi.DevicePluginPath, socketName(dev.ResourceName)),
|
|
devices: devices,
|
|
}
|
|
}
|
|
|
|
// socketName maps a resource name to a socket filename ("ate.dev/kvm" ->
|
|
// "ate.dev-kvm.sock").
|
|
func socketName(resourceName string) string {
|
|
return filepath.Base(filepath.Dir(resourceName)) + "-" + filepath.Base(resourceName) + ".sock"
|
|
}
|
|
|
|
// Run serves the plugin and keeps it registered until ctx is cancelled. Kubelet
|
|
// forgets registered plugins when it restarts and signals that by recreating its
|
|
// socket, so re-register then or the resource disappears from the node.
|
|
func (p *Plugin) Run(ctx context.Context) error {
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return fmt.Errorf("while creating fsnotify watcher: %w", err)
|
|
}
|
|
defer watcher.Close()
|
|
if err := watcher.Add(pluginapi.DevicePluginPath); err != nil {
|
|
return fmt.Errorf("while watching %q: %w", pluginapi.DevicePluginPath, err)
|
|
}
|
|
|
|
for ctx.Err() == nil {
|
|
srv, err := p.serveAndRegister(ctx)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Device plugin registration failed; retrying",
|
|
slog.String("resource", p.dev.ResourceName), slog.Any("err", err))
|
|
if !sleepCtx(ctx, 5*time.Second) {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
slog.InfoContext(ctx, "Device plugin registered",
|
|
slog.String("resource", p.dev.ResourceName), slog.String("device", p.dev.Path))
|
|
|
|
p.waitForKubeletRestart(ctx, watcher)
|
|
srv.Stop()
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// serveAndRegister starts the gRPC server on the plugin socket and registers it
|
|
// with kubelet. The returned server is stopped by the caller.
|
|
func (p *Plugin) serveAndRegister(ctx context.Context) (*grpc.Server, error) {
|
|
// A leftover socket from a previous run would make Listen fail with EADDRINUSE.
|
|
if err := os.Remove(p.socket); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("while removing stale socket %q: %w", p.socket, err)
|
|
}
|
|
lis, err := net.Listen("unix", p.socket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while listening on %q: %w", p.socket, err)
|
|
}
|
|
|
|
srv := grpc.NewServer()
|
|
pluginapi.RegisterDevicePluginServer(srv, p)
|
|
go func() {
|
|
if err := srv.Serve(lis); err != nil {
|
|
slog.ErrorContext(ctx, "Device plugin server stopped",
|
|
slog.String("resource", p.dev.ResourceName), slog.Any("err", err))
|
|
}
|
|
}()
|
|
|
|
if err := p.register(ctx); err != nil {
|
|
srv.Stop()
|
|
return nil, err
|
|
}
|
|
return srv, nil
|
|
}
|
|
|
|
// register tells kubelet which resource this plugin's socket serves.
|
|
func (p *Plugin) register(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, registerTimeout)
|
|
defer cancel()
|
|
|
|
conn, err := grpc.NewClient("unix://"+pluginapi.KubeletSocket,
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
return fmt.Errorf("while connecting to kubelet: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
_, err = pluginapi.NewRegistrationClient(conn).Register(ctx, &pluginapi.RegisterRequest{
|
|
Version: pluginapi.Version,
|
|
Endpoint: filepath.Base(p.socket),
|
|
ResourceName: p.dev.ResourceName,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("while registering %q with kubelet: %w", p.dev.ResourceName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitForKubeletRestart blocks until kubelet recreates its socket (meaning it
|
|
// restarted and dropped our registration) or ctx is cancelled.
|
|
func (p *Plugin) waitForKubeletRestart(ctx context.Context, watcher *fsnotify.Watcher) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event, ok := <-watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if event.Name == pluginapi.KubeletSocket && event.Has(fsnotify.Create) {
|
|
slog.InfoContext(ctx, "Kubelet restarted; re-registering device plugin",
|
|
slog.String("resource", p.dev.ResourceName))
|
|
return
|
|
}
|
|
case err, ok := <-watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.ErrorContext(ctx, "Device plugin socket watch error",
|
|
slog.String("resource", p.dev.ResourceName), slog.Any("err", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetDevicePluginOptions implements the device plugin API; the defaults are
|
|
// correct here.
|
|
func (p *Plugin) GetDevicePluginOptions(context.Context, *pluginapi.Empty) (*pluginapi.DevicePluginOptions, error) {
|
|
return &pluginapi.DevicePluginOptions{}, nil
|
|
}
|
|
|
|
// ListAndWatch streams the device list to kubelet. The set is static, so send it
|
|
// once and hold the stream open until shutdown.
|
|
func (p *Plugin) ListAndWatch(_ *pluginapi.Empty, stream pluginapi.DevicePlugin_ListAndWatchServer) error {
|
|
if err := stream.Send(&pluginapi.ListAndWatchResponse{Devices: p.devices}); err != nil {
|
|
return fmt.Errorf("while sending device list: %w", err)
|
|
}
|
|
<-stream.Context().Done()
|
|
return nil
|
|
}
|
|
|
|
// Allocate returns the device node for each requested container. Kubelet turns
|
|
// each DeviceSpec into a device node plus a matching cgroup allow, so the
|
|
// container gets this device and no other.
|
|
func (p *Plugin) Allocate(_ context.Context, req *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
|
|
resp := &pluginapi.AllocateResponse{
|
|
ContainerResponses: make([]*pluginapi.ContainerAllocateResponse, 0, len(req.GetContainerRequests())),
|
|
}
|
|
for range req.GetContainerRequests() {
|
|
resp.ContainerResponses = append(resp.ContainerResponses, &pluginapi.ContainerAllocateResponse{
|
|
Devices: []*pluginapi.DeviceSpec{{
|
|
HostPath: p.dev.Path,
|
|
ContainerPath: p.dev.Path,
|
|
// Read/write, but not mknod: the container is handed this node,
|
|
// not the ability to mint new ones.
|
|
Permissions: "rw",
|
|
}},
|
|
})
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// sleepCtx waits for d, returning false if ctx is cancelled first.
|
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-t.C:
|
|
return true
|
|
}
|
|
}
|