From c68582d69bdb03f96961968efa0fbff6a275b95f Mon Sep 17 00:00:00 2001 From: Cyrill Troxler Date: Fri, 3 Apr 2026 12:14:04 +0200 Subject: [PATCH 1/5] feat: override target addr in activator This allows to configure the activator to connect to any provided address instead of localhost. --- activator/activator.go | 110 +++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 25 deletions(-) diff --git a/activator/activator.go b/activator/activator.go index 036527c..dd2d55f 100644 --- a/activator/activator.go +++ b/activator/activator.go @@ -29,28 +29,38 @@ import ( ) type Server struct { - listeners []net.Listener - ports []uint16 - quit chan any - wg sync.WaitGroup - connHook ConnHook - restoreHook RestoreHook - connectTimeout time.Duration - proxyTimeout time.Duration - proxyCancel context.CancelFunc - ns ns.NetNS - maps bpfMaps - sandboxPid int - started bool - peekBufferSize int - lastAddr string - kubeletAddr *netip.Addr + listeners []net.Listener + ports []uint16 + quit chan any + wg sync.WaitGroup + connHook ConnHook + restoreHook RestoreHook + connectTimeout time.Duration + proxyTimeout time.Duration + proxyCancel context.CancelFunc + ns ns.NetNS + maps bpfMaps + sandboxPid int + started bool + peekBufferSize int + lastAddr string + forwardToTarget bool + targetAddr string + kubeletAddr *netip.Addr } type ConnHook func(net.Conn) (conn net.Conn, cont bool, err error) type RestoreHook func() error -func NewServer(ctx context.Context, nn ns.NetNS) (*Server, error) { +type Option func(s *Server) + +func SetTargetAddr(addr string) Option { + return func(s *Server) { + s.targetAddr = addr + } +} + +func NewServer(ctx context.Context, nn ns.NetNS, opts ...Option) (*Server, error) { s := &Server{ quit: make(chan any), connectTimeout: time.Second * 5, @@ -164,6 +174,16 @@ func (s *Server) SetPeekBufferSize(size int) { s.peekBufferSize = size } +// ForwardToTarget instructs the activator to forward any incoming traffic to +// the specified address. The connHook and restoreHook will both be disabled. +func (s *Server) ForwardToTarget(addr string) { + // disable hooks + s.connHook = func(c net.Conn) (net.Conn, bool, error) { return c, true, nil } + s.restoreHook = func() error { return nil } + s.targetAddr = addr + s.forwardToTarget = true +} + func (s *Server) listen(ctx context.Context, port uint16) (int, error) { // use a random free port for our proxy cfg := net.ListenConfig{} @@ -308,16 +328,26 @@ func (s *Server) handleConnection(ctx context.Context, netConn net.Conn, port ui func (s *Server) connect(ctx context.Context, port uint16, remoteAddr *net.TCPAddr) (net.Conn, error) { var backendConn net.Conn - // use v4/v6 local and backend addr depending on remoteAddr type - addr := loopbackV4(0) backendAddr := loopbackV4(port) - if remoteAddr.IP.To4() == nil { - addr = loopbackV6(0) - backendAddr = loopbackV6(port) - } dialer := net.Dialer{ - LocalAddr: addr, - Timeout: s.connectTimeout, + Timeout: s.connectTimeout, + } + if s.forwardToTarget { + targetAddr, err := net.ResolveTCPAddr("tcp", s.targetAddr+":0") + if err != nil { + return nil, fmt.Errorf("parsing target addr: %w", err) + } + targetAddr.Port = int(port) + backendAddr = targetAddr + log.G(ctx).Infof("connecting to target address %s", backendAddr.String()) + } else { + // use v4/v6 local and backend addr depending on remoteAddr type + addr := loopbackV4(0) + if remoteAddr.IP.To4() == nil { + addr = loopbackV6(0) + backendAddr = loopbackV6(port) + } + dialer.LocalAddr = addr } ticker := time.NewTicker(time.Millisecond) @@ -625,3 +655,33 @@ func (s *Server) GetKubeletAddr(isV6 bool) (*netip.Addr, error) { s.kubeletAddr = ptr.To(netip.AddrFrom4(value)) return ptr.To(netip.AddrFrom4(value)), nil } + +func GetSandboxIPs(ifaceName string) ([]netip.Addr, error) { + ips := []netip.Addr{} + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return ips, fmt.Errorf("could not get interface: %w", err) + } + addrs, err := iface.Addrs() + if err != nil { + return ips, fmt.Errorf("could not get interface addrs: %w", err) + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + // no need to track link local addresses + if ipnet.IP.IsLinkLocalUnicast() { + continue + } + ip, ok := netip.AddrFromSlice(ipnet.IP) + if !ok { + return ips, fmt.Errorf("unable to convert net.IP to netip.Addr: %s", ipnet.IP) + } + // use Unmap as the ipv4 might be mapped in v6 + ips = append(ips, ip.Unmap()) + } + } + if len(ips) == 0 { + return ips, fmt.Errorf("sandbox IPs not found") + } + return ips, nil +} From 7e093ac440580a5ab45b58e99e9ea5f492c4feed Mon Sep 17 00:00:00 2001 From: Cyrill Troxler Date: Tue, 12 May 2026 15:21:39 +0800 Subject: [PATCH 2/5] feat: auto-migrate on low capacity --- activator/activator.go | 8 + api/node/v1/node.pb.go | 192 ++++++++++++++---- api/node/v1/node.proto | 62 +++--- api/node/v1/node_ttrpc.pb.go | 17 ++ api/runtime/v1/types.go | 3 + api/shim/v1/config.go | 11 + cmd/manager/main.go | 11 + .../runtime.zeropod.ctrox.dev_migrations.yaml | 3 + config/migration-manager/rbac.yaml | 11 + e2e/migration_test.go | 80 +++++++- e2e/setup_test.go | 13 +- manager/capacity/capacity.go | 104 ++++++++++ manager/capacity/capacity_test.go | 85 ++++++++ manager/node/service.go | 139 ++++++++++++- manager/node/service_test.go | 163 +++++++++++++++ manager/pod_controller.go | 85 ++++++-- manager/pod_controller_test.go | 10 + manager/pod_scaler.go | 8 +- manager/pod_scaler_test.go | 2 +- shim/container.go | 24 ++- shim/evac.go | 58 +++++- shim/restore.go | 45 ++++ shim/restore_capacity.go | 34 ++++ shim/task/service_zeropod.go | 10 + 24 files changed, 1082 insertions(+), 96 deletions(-) create mode 100644 manager/capacity/capacity.go create mode 100644 manager/capacity/capacity_test.go create mode 100644 shim/restore_capacity.go diff --git a/activator/activator.go b/activator/activator.go index dd2d55f..4550e2a 100644 --- a/activator/activator.go +++ b/activator/activator.go @@ -339,6 +339,9 @@ func (s *Server) connect(ctx context.Context, port uint16, remoteAddr *net.TCPAd } targetAddr.Port = int(port) backendAddr = targetAddr + // if we dial a remote target we want a smaller timeout as we might run + // into an io timeout instead of connection refused + dialer.Timeout = time.Millisecond * 10 log.G(ctx).Infof("connecting to target address %s", backendAddr.String()) } else { // use v4/v6 local and backend addr depending on remoteAddr type @@ -371,6 +374,11 @@ func (s *Server) connect(ctx context.Context, port uint16, remoteAddr *net.TCPAd // executed program might not be ready yet, so retry in a bit. continue } + var operr *net.OpError + if errors.As(err, &operr) && operr.Temporary() { + log.G(ctx).Errorf("temporary operr: %s", operr) + continue + } return nil, fmt.Errorf("unable to connect to process: %s", err) } diff --git a/api/node/v1/node.pb.go b/api/node/v1/node.pb.go index 6a67223..0cde6fa 100644 --- a/api/node/v1/node.pb.go +++ b/api/node/v1/node.pb.go @@ -221,6 +221,7 @@ type PodInfo struct { Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` ContainerName string `protobuf:"bytes,3,opt,name=container_name,json=containerName,proto3" json:"container_name,omitempty"` Ports []int32 `protobuf:"varint,4,rep,packed,name=ports,proto3" json:"ports,omitempty"` + Ip string `protobuf:"bytes,5,opt,name=ip,proto3" json:"ip,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -283,6 +284,13 @@ func (x *PodInfo) GetPorts() []int32 { return nil } +func (x *PodInfo) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + type MigrationInfo struct { state protoimpl.MessageState `protogen:"open.v1"` ImageId string `protobuf:"bytes,1,opt,name=image_id,json=imageId,proto3" json:"image_id,omitempty"` @@ -531,6 +539,102 @@ func (x *PullImageRequest) GetImageId() string { return "" } +type RestoreCapacityRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PodInfo *PodInfo `protobuf:"bytes,1,opt,name=pod_info,json=podInfo,proto3" json:"pod_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreCapacityRequest) Reset() { + *x = RestoreCapacityRequest{} + mi := &file_node_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreCapacityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreCapacityRequest) ProtoMessage() {} + +func (x *RestoreCapacityRequest) ProtoReflect() protoreflect.Message { + mi := &file_node_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreCapacityRequest.ProtoReflect.Descriptor instead. +func (*RestoreCapacityRequest) Descriptor() ([]byte, []int) { + return file_node_proto_rawDescGZIP(), []int{9} +} + +func (x *RestoreCapacityRequest) GetPodInfo() *PodInfo { + if x != nil { + return x.PodInfo + } + return nil +} + +type RestoreCapacityResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + RedirectAddr string `protobuf:"bytes,2,opt,name=redirect_addr,json=redirectAddr,proto3" json:"redirect_addr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreCapacityResponse) Reset() { + *x = RestoreCapacityResponse{} + mi := &file_node_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreCapacityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreCapacityResponse) ProtoMessage() {} + +func (x *RestoreCapacityResponse) ProtoReflect() protoreflect.Message { + mi := &file_node_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreCapacityResponse.ProtoReflect.Descriptor instead. +func (*RestoreCapacityResponse) Descriptor() ([]byte, []int) { + return file_node_proto_rawDescGZIP(), []int{10} +} + +func (x *RestoreCapacityResponse) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *RestoreCapacityResponse) GetRedirectAddr() string { + if x != nil { + return x.RedirectAddr + } + return "" +} + var File_node_proto protoreflect.FileDescriptor const file_node_proto_rawDesc = "" + @@ -546,12 +650,13 @@ const file_node_proto_rawDesc = "" + "\bpod_info\x18\x01 \x01(\v2\x18.zeropod.node.v1.PodInfoR\apodInfo\x12E\n" + "\x0emigration_info\x18\x02 \x01(\v2\x1e.zeropod.node.v1.MigrationInfoR\rmigrationInfo\"X\n" + "\x0fRestoreResponse\x12E\n" + - "\x0emigration_info\x18\x01 \x01(\v2\x1e.zeropod.node.v1.MigrationInfoR\rmigrationInfo\"x\n" + + "\x0emigration_info\x18\x01 \x01(\v2\x1e.zeropod.node.v1.MigrationInfoR\rmigrationInfo\"\x88\x01\n" + "\aPodInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + "\tnamespace\x18\x02 \x01(\tR\tnamespace\x12%\n" + "\x0econtainer_name\x18\x03 \x01(\tR\rcontainerName\x12\x14\n" + - "\x05ports\x18\x04 \x03(\x05R\x05ports\"\xbd\x02\n" + + "\x05ports\x18\x04 \x03(\x05R\x05ports\x12\x0e\n" + + "\x02ip\x18\x05 \x01(\tR\x02ip\"\xbd\x02\n" + "\rMigrationInfo\x12\x19\n" + "\bimage_id\x18\x01 \x01(\tR\aimageId\x12\x1d\n" + "\n" + @@ -570,14 +675,20 @@ const file_node_proto_rawDesc = "" + "\x04port\x18\x03 \x01(\x05R\x04port\x12\x10\n" + "\x03tls\x18\x04 \x01(\bR\x03tls\"-\n" + "\x10PullImageRequest\x12\x19\n" + - "\bimage_id\x18\x01 \x01(\tR\aimageId2\xd6\x03\n" + + "\bimage_id\x18\x01 \x01(\tR\aimageId\"M\n" + + "\x16RestoreCapacityRequest\x123\n" + + "\bpod_info\x18\x01 \x01(\v2\x18.zeropod.node.v1.PodInfoR\apodInfo\"X\n" + + "\x17RestoreCapacityResponse\x12\x18\n" + + "\aallowed\x18\x01 \x01(\bR\aallowed\x12#\n" + + "\rredirect_addr\x18\x02 \x01(\tR\fredirectAddr2\xbc\x04\n" + "\x04Node\x12C\n" + "\x04Evac\x12\x1c.zeropod.node.v1.EvacRequest\x1a\x1d.zeropod.node.v1.EvacResponse\x12J\n" + "\vPrepareEvac\x12\x1c.zeropod.node.v1.EvacRequest\x1a\x1d.zeropod.node.v1.EvacResponse\x12L\n" + "\aRestore\x12\x1f.zeropod.node.v1.RestoreRequest\x1a .zeropod.node.v1.RestoreResponse\x12R\n" + "\rFinishRestore\x12\x1f.zeropod.node.v1.RestoreRequest\x1a .zeropod.node.v1.RestoreResponse\x12Q\n" + "\x10NewCriuLazyPages\x12%.zeropod.node.v1.CriuLazyPagesRequest\x1a\x16.google.protobuf.Empty\x12H\n" + - "\tPullImage\x12!.zeropod.node.v1.PullImageRequest\x1a\x16.zeropod.node.v1.Image0\x01B*Z(github.com/ctrox/zeropod/api/node/v1/;v1b\x06proto3" + "\tPullImage\x12!.zeropod.node.v1.PullImageRequest\x1a\x16.zeropod.node.v1.Image0\x01\x12d\n" + + "\x0fRestoreCapacity\x12'.zeropod.node.v1.RestoreCapacityRequest\x1a(.zeropod.node.v1.RestoreCapacityResponseB*Z(github.com/ctrox/zeropod/api/node/v1/;v1b\x06proto3" var ( file_node_proto_rawDescOnce sync.Once @@ -591,47 +702,52 @@ func file_node_proto_rawDescGZIP() []byte { return file_node_proto_rawDescData } -var file_node_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_node_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_node_proto_goTypes = []any{ - (*EvacRequest)(nil), // 0: zeropod.node.v1.EvacRequest - (*EvacResponse)(nil), // 1: zeropod.node.v1.EvacResponse - (*RestoreRequest)(nil), // 2: zeropod.node.v1.RestoreRequest - (*RestoreResponse)(nil), // 3: zeropod.node.v1.RestoreResponse - (*PodInfo)(nil), // 4: zeropod.node.v1.PodInfo - (*MigrationInfo)(nil), // 5: zeropod.node.v1.MigrationInfo - (*Image)(nil), // 6: zeropod.node.v1.Image - (*CriuLazyPagesRequest)(nil), // 7: zeropod.node.v1.CriuLazyPagesRequest - (*PullImageRequest)(nil), // 8: zeropod.node.v1.PullImageRequest - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (*EvacRequest)(nil), // 0: zeropod.node.v1.EvacRequest + (*EvacResponse)(nil), // 1: zeropod.node.v1.EvacResponse + (*RestoreRequest)(nil), // 2: zeropod.node.v1.RestoreRequest + (*RestoreResponse)(nil), // 3: zeropod.node.v1.RestoreResponse + (*PodInfo)(nil), // 4: zeropod.node.v1.PodInfo + (*MigrationInfo)(nil), // 5: zeropod.node.v1.MigrationInfo + (*Image)(nil), // 6: zeropod.node.v1.Image + (*CriuLazyPagesRequest)(nil), // 7: zeropod.node.v1.CriuLazyPagesRequest + (*PullImageRequest)(nil), // 8: zeropod.node.v1.PullImageRequest + (*RestoreCapacityRequest)(nil), // 9: zeropod.node.v1.RestoreCapacityRequest + (*RestoreCapacityResponse)(nil), // 10: zeropod.node.v1.RestoreCapacityResponse + (*emptypb.Empty)(nil), // 11: google.protobuf.Empty + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_node_proto_depIdxs = []int32{ 4, // 0: zeropod.node.v1.EvacRequest.pod_info:type_name -> zeropod.node.v1.PodInfo 5, // 1: zeropod.node.v1.EvacRequest.migration_info:type_name -> zeropod.node.v1.MigrationInfo - 9, // 2: zeropod.node.v1.EvacResponse.empty:type_name -> google.protobuf.Empty + 11, // 2: zeropod.node.v1.EvacResponse.empty:type_name -> google.protobuf.Empty 4, // 3: zeropod.node.v1.RestoreRequest.pod_info:type_name -> zeropod.node.v1.PodInfo 5, // 4: zeropod.node.v1.RestoreRequest.migration_info:type_name -> zeropod.node.v1.MigrationInfo 5, // 5: zeropod.node.v1.RestoreResponse.migration_info:type_name -> zeropod.node.v1.MigrationInfo - 10, // 6: zeropod.node.v1.MigrationInfo.paused_at:type_name -> google.protobuf.Timestamp - 10, // 7: zeropod.node.v1.MigrationInfo.restore_start:type_name -> google.protobuf.Timestamp - 10, // 8: zeropod.node.v1.MigrationInfo.restore_end:type_name -> google.protobuf.Timestamp - 0, // 9: zeropod.node.v1.Node.Evac:input_type -> zeropod.node.v1.EvacRequest - 0, // 10: zeropod.node.v1.Node.PrepareEvac:input_type -> zeropod.node.v1.EvacRequest - 2, // 11: zeropod.node.v1.Node.Restore:input_type -> zeropod.node.v1.RestoreRequest - 2, // 12: zeropod.node.v1.Node.FinishRestore:input_type -> zeropod.node.v1.RestoreRequest - 7, // 13: zeropod.node.v1.Node.NewCriuLazyPages:input_type -> zeropod.node.v1.CriuLazyPagesRequest - 8, // 14: zeropod.node.v1.Node.PullImage:input_type -> zeropod.node.v1.PullImageRequest - 1, // 15: zeropod.node.v1.Node.Evac:output_type -> zeropod.node.v1.EvacResponse - 1, // 16: zeropod.node.v1.Node.PrepareEvac:output_type -> zeropod.node.v1.EvacResponse - 3, // 17: zeropod.node.v1.Node.Restore:output_type -> zeropod.node.v1.RestoreResponse - 3, // 18: zeropod.node.v1.Node.FinishRestore:output_type -> zeropod.node.v1.RestoreResponse - 9, // 19: zeropod.node.v1.Node.NewCriuLazyPages:output_type -> google.protobuf.Empty - 6, // 20: zeropod.node.v1.Node.PullImage:output_type -> zeropod.node.v1.Image - 15, // [15:21] is the sub-list for method output_type - 9, // [9:15] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 12, // 6: zeropod.node.v1.MigrationInfo.paused_at:type_name -> google.protobuf.Timestamp + 12, // 7: zeropod.node.v1.MigrationInfo.restore_start:type_name -> google.protobuf.Timestamp + 12, // 8: zeropod.node.v1.MigrationInfo.restore_end:type_name -> google.protobuf.Timestamp + 4, // 9: zeropod.node.v1.RestoreCapacityRequest.pod_info:type_name -> zeropod.node.v1.PodInfo + 0, // 10: zeropod.node.v1.Node.Evac:input_type -> zeropod.node.v1.EvacRequest + 0, // 11: zeropod.node.v1.Node.PrepareEvac:input_type -> zeropod.node.v1.EvacRequest + 2, // 12: zeropod.node.v1.Node.Restore:input_type -> zeropod.node.v1.RestoreRequest + 2, // 13: zeropod.node.v1.Node.FinishRestore:input_type -> zeropod.node.v1.RestoreRequest + 7, // 14: zeropod.node.v1.Node.NewCriuLazyPages:input_type -> zeropod.node.v1.CriuLazyPagesRequest + 8, // 15: zeropod.node.v1.Node.PullImage:input_type -> zeropod.node.v1.PullImageRequest + 9, // 16: zeropod.node.v1.Node.RestoreCapacity:input_type -> zeropod.node.v1.RestoreCapacityRequest + 1, // 17: zeropod.node.v1.Node.Evac:output_type -> zeropod.node.v1.EvacResponse + 1, // 18: zeropod.node.v1.Node.PrepareEvac:output_type -> zeropod.node.v1.EvacResponse + 3, // 19: zeropod.node.v1.Node.Restore:output_type -> zeropod.node.v1.RestoreResponse + 3, // 20: zeropod.node.v1.Node.FinishRestore:output_type -> zeropod.node.v1.RestoreResponse + 11, // 21: zeropod.node.v1.Node.NewCriuLazyPages:output_type -> google.protobuf.Empty + 6, // 22: zeropod.node.v1.Node.PullImage:output_type -> zeropod.node.v1.Image + 10, // 23: zeropod.node.v1.Node.RestoreCapacity:output_type -> zeropod.node.v1.RestoreCapacityResponse + 17, // [17:24] is the sub-list for method output_type + 10, // [10:17] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_node_proto_init() } @@ -645,7 +761,7 @@ func file_node_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_node_proto_rawDesc), len(file_node_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/api/node/v1/node.proto b/api/node/v1/node.proto index 92a0e7f..c41b32f 100644 --- a/api/node/v1/node.proto +++ b/api/node/v1/node.proto @@ -1,53 +1,56 @@ syntax = "proto3"; package zeropod.node.v1; -option go_package = "github.com/ctrox/zeropod/api/node/v1/;v1"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +option go_package = "github.com/ctrox/zeropod/api/node/v1/;v1"; + service Node { - rpc Evac(EvacRequest) returns (EvacResponse); - rpc PrepareEvac(EvacRequest) returns (EvacResponse); - rpc Restore(RestoreRequest) returns (RestoreResponse); - rpc FinishRestore(RestoreRequest) returns (RestoreResponse); - rpc NewCriuLazyPages(CriuLazyPagesRequest) returns (google.protobuf.Empty); - rpc PullImage(PullImageRequest) returns (stream Image); + rpc Evac(EvacRequest) returns (EvacResponse); + rpc PrepareEvac(EvacRequest) returns (EvacResponse); + rpc Restore(RestoreRequest) returns (RestoreResponse); + rpc FinishRestore(RestoreRequest) returns (RestoreResponse); + rpc NewCriuLazyPages(CriuLazyPagesRequest) returns (google.protobuf.Empty); + rpc PullImage(PullImageRequest) returns (stream Image); + rpc RestoreCapacity(RestoreCapacityRequest) returns (RestoreCapacityResponse); } message EvacRequest { - PodInfo pod_info = 1; - MigrationInfo migration_info = 2; + PodInfo pod_info = 1; + MigrationInfo migration_info = 2; } message EvacResponse { - google.protobuf.Empty empty = 1; + google.protobuf.Empty empty = 1; } message RestoreRequest { - PodInfo pod_info = 1; - MigrationInfo migration_info = 2; + PodInfo pod_info = 1; + MigrationInfo migration_info = 2; } message RestoreResponse { - MigrationInfo migration_info = 1; + MigrationInfo migration_info = 1; } message PodInfo { - string name = 1; - string namespace = 2; - string container_name = 3; - repeated int32 ports = 4; + string name = 1; + string namespace = 2; + string container_name = 3; + repeated int32 ports = 4; + string ip = 5; } message MigrationInfo { - string image_id = 1; - string bundle_dir = 2; - bool live_migration = 3; - google.protobuf.Timestamp paused_at = 4; - google.protobuf.Timestamp restore_start = 5; - google.protobuf.Timestamp restore_end = 6; - repeated int32 ports = 7; + string image_id = 1; + string bundle_dir = 2; + bool live_migration = 3; + google.protobuf.Timestamp paused_at = 4; + google.protobuf.Timestamp restore_start = 5; + google.protobuf.Timestamp restore_end = 6; + repeated int32 ports = 7; } message Image { @@ -62,5 +65,14 @@ message CriuLazyPagesRequest { } message PullImageRequest { - string image_id = 1; + string image_id = 1; +} + +message RestoreCapacityRequest { + PodInfo pod_info = 1; +} + +message RestoreCapacityResponse { + bool allowed = 1; + string redirect_addr = 2; } diff --git a/api/node/v1/node_ttrpc.pb.go b/api/node/v1/node_ttrpc.pb.go index 71ba246..60fa3e7 100644 --- a/api/node/v1/node_ttrpc.pb.go +++ b/api/node/v1/node_ttrpc.pb.go @@ -15,6 +15,7 @@ type NodeService interface { FinishRestore(context.Context, *RestoreRequest) (*RestoreResponse, error) NewCriuLazyPages(context.Context, *CriuLazyPagesRequest) (*emptypb.Empty, error) PullImage(context.Context, *PullImageRequest, Node_PullImageServer) error + RestoreCapacity(context.Context, *RestoreCapacityRequest) (*RestoreCapacityResponse, error) } type Node_PullImageServer interface { @@ -68,6 +69,13 @@ func RegisterNodeService(srv *ttrpc.Server, svc NodeService) { } return svc.NewCriuLazyPages(ctx, &req) }, + "RestoreCapacity": func(ctx context.Context, unmarshal func(interface{}) error) (interface{}, error) { + var req RestoreCapacityRequest + if err := unmarshal(&req); err != nil { + return nil, err + } + return svc.RestoreCapacity(ctx, &req) + }, }, Streams: map[string]ttrpc.Stream{ "PullImage": { @@ -92,6 +100,7 @@ type NodeClient interface { FinishRestore(context.Context, *RestoreRequest) (*RestoreResponse, error) NewCriuLazyPages(context.Context, *CriuLazyPagesRequest) (*emptypb.Empty, error) PullImage(context.Context, *PullImageRequest) (Node_PullImageClient, error) + RestoreCapacity(context.Context, *RestoreCapacityRequest) (*RestoreCapacityResponse, error) } type nodeClient struct { @@ -172,3 +181,11 @@ func (x *nodePullImageClient) Recv() (*Image, error) { } return m, nil } + +func (c *nodeClient) RestoreCapacity(ctx context.Context, req *RestoreCapacityRequest) (*RestoreCapacityResponse, error) { + var resp RestoreCapacityResponse + if err := c.client.Call(ctx, "zeropod.node.v1.Node", "RestoreCapacity", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/api/runtime/v1/types.go b/api/runtime/v1/types.go index 2c01645..46e25b1 100644 --- a/api/runtime/v1/types.go +++ b/api/runtime/v1/types.go @@ -41,6 +41,9 @@ type MigrationSpec struct { // TargetPod of the migration // +optional TargetPod string `json:"targetPod,omitempty"` + // TargetPodIP of the migration + // +optional + TargetPodIP string `json:"targetPodIP,omitempty"` // PodTemplateHash of the source pod. This is used to find a suitable target // pod. PodTemplateHash string `json:"podTemplateHash"` diff --git a/api/shim/v1/config.go b/api/shim/v1/config.go index d4135e9..b509845 100644 --- a/api/shim/v1/config.go +++ b/api/shim/v1/config.go @@ -281,3 +281,14 @@ func (cfg Config) LiveMigrationEnabled() bool { func (cfg Config) AnyMigrationEnabled() bool { return cfg.migrationEnabled() || cfg.LiveMigrationEnabled() } + +func AnyMigrationEnabled(annotations map[string]string) bool { + _, migrate := annotations[MigrateAnnotationKey] + _, liveMigrate := annotations[LiveMigrateAnnotationKey] + return migrate || liveMigrate +} + +func LiveMigrationEnabled(annotations map[string]string) bool { + _, ok := annotations[LiveMigrateAnnotationKey] + return ok +} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 4221d42..e11bb03 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -21,6 +21,7 @@ import ( nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/runtime/v1" "github.com/ctrox/zeropod/manager" + "github.com/ctrox/zeropod/manager/capacity" "github.com/ctrox/zeropod/manager/node" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -52,6 +53,7 @@ var ( migrationServersTimeout = flag.Duration("migration-servers-timeout", time.Second*10, "how long to wait for migration servers") migrationClaimTimeout = flag.Duration("migration-claim-timeout", time.Second*10, "how long to wait for migration to be claimed") migrationReadyTimeout = flag.Duration("migration-ready-timeout", time.Minute*5, "how long to wait for migration to be ready") + evictionTimeout = flag.Duration("eviction-timeout", time.Minute, "how long to wait for eviction") autoGCMigrations = flag.Bool("auto-gc-migrations", true, "automatically garbage collect migrations when owning pod is deleted") version = "" @@ -161,6 +163,7 @@ func main() { } }() + cap := capacity.NewNodeTracker() nodeServer, err := node.NewServer( *nodeServerAddr, mgr.GetClient(), @@ -170,7 +173,9 @@ func main() { MigrationServers: *migrationServersTimeout, MigrationClaim: *migrationClaimTimeout, MigrationReady: *migrationReadyTimeout, + EvictionTimeout: *evictionTimeout, }, + cap, ) if err != nil { log.Error("creating node server", "err", err) @@ -183,6 +188,7 @@ func main() { manager.AutoGCMigrations(*autoGCMigrations), manager.RegisterPodLabeller(labeller), manager.RegisterPodScaler(scaler), + manager.CapacityTracker(cap), ); err != nil { log.Error("running pod controller", "error", err) } @@ -233,6 +239,11 @@ func newControllerManager() (ctrlmanager.Manager, error) { "spec.nodeName": nodeName, }), }, + &corev1.Node{}: cache.ByObject{ + Field: fields.SelectorFromSet(fields.Set{ + "metadata.name": nodeName, + }), + }, }, }, }) diff --git a/config/crds/runtime.zeropod.ctrox.dev_migrations.yaml b/config/crds/runtime.zeropod.ctrox.dev_migrations.yaml index fc59aa3..957d7f2 100644 --- a/config/crds/runtime.zeropod.ctrox.dev_migrations.yaml +++ b/config/crds/runtime.zeropod.ctrox.dev_migrations.yaml @@ -124,6 +124,9 @@ spec: targetPod: description: TargetPod of the migration type: string + targetPodIP: + description: TargetPodIP of the migration + type: string required: - containers - podTemplateHash diff --git a/config/migration-manager/rbac.yaml b/config/migration-manager/rbac.yaml index 6a25fba..e309337 100644 --- a/config/migration-manager/rbac.yaml +++ b/config/migration-manager/rbac.yaml @@ -24,6 +24,17 @@ rules: - get - list - watch + # required for eviction + - delete + - apiGroups: + - "" + resources: + - nodes + verbs: + - update + - get + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/e2e/migration_test.go b/e2e/migration_test.go index a47d6c1..c3d00fc 100644 --- a/e2e/migration_test.go +++ b/e2e/migration_test.go @@ -12,16 +12,21 @@ import ( v1 "github.com/ctrox/zeropod/api/runtime/v1" shimv1 "github.com/ctrox/zeropod/api/shim/v1" "github.com/ctrox/zeropod/manager" + "github.com/ctrox/zeropod/manager/capacity" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ) type testCase struct { deploy *appsv1.Deployment svc *corev1.Service sameNode bool + noCordon bool migrationCount int liveMigration bool expectDataNotMigrated bool @@ -29,6 +34,7 @@ type testCase struct { afterMigration func(t *testing.T, tc testCase) expectFailed bool skipWaitScaledDown bool + migrateByReq bool } func TestMigration(t *testing.T) { @@ -94,6 +100,54 @@ func TestMigration(t *testing.T) { expectDataNotMigrated: true, migrationCount: 1, }, + "node capacity eviction": { + deploy: freezerDeployment( + "capacity-eviction", "default", 1, + migrateAnnotation("freezer"), + scaleDownAfter(time.Second), + resources(corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("100Mi"), + }, + }), + ), + svc: testService(8080), + noCordon: true, + liveMigration: false, + migrationCount: 1, + migrateByReq: true, + beforeMigration: func(t *testing.T) { + defaultBeforeMigration(t) + pod := migrationPod(t, freezerDeployment("capacity-eviction", "default", 1)) + waitUntilScaledDown(t, ctx, e2e.client, pod) + node := &corev1.Node{} + require.NoError(t, e2e.client.Get(ctx, types.NamespacedName{Name: pod.Spec.NodeName}, node)) + nodeMemory := node.Status.Capacity.Memory() + // add node capacity +1Gi so out pod is over capacity + nodeMemory.Add(resource.MustParse("1Gi")) + assert.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + pod = migrationPod(t, freezerDeployment("capacity-eviction", "default", 1)) + pod.Annotations[manager.MemoryAnnotationKey] = fmt.Sprintf(`{"freezer":"%s"}`, nodeMemory.String()) + return e2e.client.Update(ctx, pod) + })) + }, + afterMigration: func(t *testing.T, tc testCase) { + defaultAfterMigration(t, tc) + nodeList := &corev1.NodeList{} + require.NoError(t, e2e.client.List(ctx, nodeList)) + expectedTainted := 1 + taintedNodes := 0 + for _, node := range nodeList.Items { + if ok, _ := capacity.HasTaint(&node); ok { + taintedNodes++ + } + capacity.RemoveTaint(&node) + require.NoError(t, e2e.client.Update(ctx, &node)) + } + assert.Equal(t, expectedTainted, taintedNodes) + }, + }, } migrate := func(t *testing.T, ctx context.Context, e2e *e2eConfig, tc testCase) { @@ -103,17 +157,27 @@ func TestMigration(t *testing.T) { } pod := pods[0] - if tc.sameNode { - uncordon := cordonOtherNodes(t, ctx, e2e.client, pod.Spec.NodeName) - defer uncordon() - } else { - uncordon := cordonNode(t, ctx, e2e.client, pod.Spec.NodeName) - defer uncordon() + if !tc.noCordon { + if tc.sameNode { + uncordon := cordonOtherNodes(t, ctx, e2e.client, pod.Spec.NodeName) + defer uncordon() + } else { + uncordon := cordonNode(t, ctx, e2e.client, pod.Spec.NodeName) + defer uncordon() + } } if !tc.liveMigration && !tc.skipWaitScaledDown { waitUntilScaledDown(t, ctx, e2e.client, &pod) } - require.NoError(t, e2e.client.Delete(ctx, &pod)) + if tc.migrateByReq { + beforeEvictReq := time.Now() + f, err := freezerRead(e2e.port) + assert.NoError(t, err) + assert.Equal(t, t.Name(), f.Data, "freezer memory has persisted eviction migration") + t.Logf("evict request took %s", time.Since(beforeEvictReq)) + } else { + require.NoError(t, e2e.client.Delete(ctx, &pod)) + } assert.Eventually(t, func() bool { pods := podsOfDeployment(t, e2e.client, tc.deploy) if len(pods) != 1 { @@ -164,9 +228,9 @@ func TestMigration(t *testing.T) { probeHTTP(t, e2e.url()) for range tc.migrationCount { + writePodData(t, migrationPod(t, tc.deploy)) tc.beforeMigration(t) checkCtx, cancel := context.WithCancel(ctx) - writePodData(t, migrationPod(t, tc.deploy)) defer cancel() if tc.liveMigration { go func() { diff --git a/e2e/setup_test.go b/e2e/setup_test.go index 0ce2ab8..64e2891 100644 --- a/e2e/setup_test.go +++ b/e2e/setup_test.go @@ -541,7 +541,7 @@ func createPodAndWait(t testing.TB, ctx context.Context, client client.Client, p } } -func freezerDeployment(name, namespace string, memoryGiB int, opts ...podOption) *appsv1.Deployment { +func freezerDeployment(name, namespace string, memoryMiB int, opts ...podOption) *appsv1.Deployment { deploy := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -569,7 +569,7 @@ func freezerDeployment(name, namespace string, memoryGiB int, opts ...podOption) Image: "ghcr.io/ctrox/zeropod-freezer", ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/freezer"}, - Args: []string{"-memory", strconv.Itoa(memoryGiB)}, + Args: []string{"-memory", strconv.Itoa(memoryMiB)}, Ports: []corev1.ContainerPort{{ Name: "freezer", ContainerPort: 8080, @@ -607,13 +607,16 @@ func createDeployAndWait(t testing.TB, ctx context.Context, c client.Client, dep }, time.Minute, time.Second, "waiting for pods of deployment to be running") return func() { - c.Delete(ctx, deploy) - assert.NoError(t, err) + assert.NoError(t, c.Delete(ctx, deploy)) require.Eventually(t, func() bool { if err := c.Get(ctx, objectName(deploy), deploy); err != nil { return true } - return false + podList := &corev1.PodList{} + if err := c.List(ctx, podList, client.MatchingLabels(deploy.Spec.Selector.MatchLabels)); err != nil { + return false + } + return len(podList.Items) == 0 }, time.Minute*2, time.Second, "waiting for deployment to be deleted") } } diff --git a/manager/capacity/capacity.go b/manager/capacity/capacity.go new file mode 100644 index 0000000..6f45753 --- /dev/null +++ b/manager/capacity/capacity.go @@ -0,0 +1,104 @@ +package capacity + +import ( + "slices" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +const ( + TaintKey = "zeropod.ctrox.dev/at-capacity" + MinTaintDuration = time.Minute +) + +type Tracker interface { + Capacity(name corev1.ResourceName) resource.Quantity + Requested(name corev1.ResourceName) resource.Quantity + SetCapacity(name corev1.ResourceName, q resource.Quantity) + SetRequested(name corev1.ResourceName, q resource.Quantity) +} + +type NodeTracker struct { + capacity corev1.ResourceList + requested corev1.ResourceList + mu sync.RWMutex +} + +// Capacity returns the node capacity for the [corev1.ResourceName]. +func (c *NodeTracker) Capacity(name corev1.ResourceName) resource.Quantity { + c.mu.RLock() + defer c.mu.RUnlock() + return c.capacity[name] +} + +// Requested returns the node requested resources for the [corev1.ResourceName]. +func (c *NodeTracker) Requested(name corev1.ResourceName) resource.Quantity { + c.mu.RLock() + defer c.mu.RUnlock() + return c.requested[name] +} + +// SetCapacity sets the resource capacity of a node. +func (c *NodeTracker) SetCapacity(name corev1.ResourceName, q resource.Quantity) { + c.mu.Lock() + defer c.mu.Unlock() + c.capacity[name] = q +} + +// SetRequested sets the requested resources of a node. +func (c *NodeTracker) SetRequested(name corev1.ResourceName, q resource.Quantity) { + c.mu.Lock() + defer c.mu.Unlock() + c.requested[name] = q +} + +// NewNodeTracker creates a [NodeTracker]. +func NewNodeTracker() Tracker { + return &NodeTracker{ + capacity: corev1.ResourceList{}, + requested: corev1.ResourceList{}, + mu: sync.RWMutex{}, + } +} + +// AddTaint taints the node to not be used for scheduling if not already the case. +func AddTaint(node *corev1.Node) bool { + if slices.ContainsFunc(node.Spec.Taints, func(t corev1.Taint) bool { + return t.Key == TaintKey + }) { + return false + } + node.Spec.Taints = append(node.Spec.Taints, corev1.Taint{ + Key: TaintKey, + Effect: corev1.TaintEffectNoSchedule, + TimeAdded: ptr.To(metav1.Now()), + }) + return true +} + +// HasTaint checks for an existing taint and returns the time added if true. +func HasTaint(node *corev1.Node) (bool, time.Time) { + var taintAdded time.Time + if slices.ContainsFunc(node.Spec.Taints, func(t corev1.Taint) bool { + ok := t.Key == TaintKey + if ok && t.TimeAdded != nil { + taintAdded = t.TimeAdded.Time + } + return ok + }) { + return true, taintAdded + } + return false, taintAdded +} + +// RemoveTaint removes the taint from the node. +func RemoveTaint(node *corev1.Node) { + node.Spec.Taints = slices.DeleteFunc(node.Spec.Taints, func(t corev1.Taint) bool { + return t.Key == TaintKey + }) +} diff --git a/manager/capacity/capacity_test.go b/manager/capacity/capacity_test.go new file mode 100644 index 0000000..c619aa5 --- /dev/null +++ b/manager/capacity/capacity_test.go @@ -0,0 +1,85 @@ +package capacity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestNodeTracker(t *testing.T) { + tracker := NewNodeTracker() + assert.Empty(t, tracker.Capacity(corev1.ResourceCPU)) + assert.Empty(t, tracker.Capacity(corev1.ResourceMemory)) + assert.Empty(t, tracker.Requested(corev1.ResourceCPU)) + assert.Empty(t, tracker.Requested(corev1.ResourceMemory)) + + cpu, memory := resource.MustParse("8"), resource.MustParse("16Gi") + tracker.SetCapacity(corev1.ResourceCPU, cpu) + tracker.SetCapacity(corev1.ResourceMemory, memory) + assert.Equal(t, cpu, tracker.Capacity(corev1.ResourceCPU)) + assert.Equal(t, memory, tracker.Capacity(corev1.ResourceMemory)) + + tracker.SetRequested(corev1.ResourceCPU, cpu) + tracker.SetRequested(corev1.ResourceMemory, memory) + assert.Equal(t, cpu, tracker.Requested(corev1.ResourceCPU)) + assert.Equal(t, memory, tracker.Requested(corev1.ResourceMemory)) +} + +func TestTaint(t *testing.T) { + for name, tc := range map[string]struct { + node *corev1.Node + hasTaint bool + }{ + "no taint": { + node: &corev1.Node{}, + hasTaint: false, + }, + "existing taint": { + node: &corev1.Node{ + Spec: corev1.NodeSpec{ + Taints: []corev1.Taint{ + { + Key: TaintKey, + Effect: corev1.TaintEffectNoSchedule, + TimeAdded: ptr.To(metav1.Now()), + }, + }, + }, + }, + hasTaint: true, + }, + "different taint": { + node: &corev1.Node{ + Spec: corev1.NodeSpec{ + Taints: []corev1.Taint{ + { + Key: "foo.bar/nope", + Effect: corev1.TaintEffectNoSchedule, + TimeAdded: ptr.To(metav1.Now()), + }, + }, + }, + }, + hasTaint: false, + }, + } { + t.Run(name, func(t *testing.T) { + ok, timeAdded := HasTaint(tc.node) + assert.Equal(t, tc.hasTaint, ok) + if tc.hasTaint { + assert.NotEmpty(t, timeAdded) + assert.False(t, AddTaint(tc.node)) + } else { + assert.Empty(t, timeAdded) + assert.True(t, AddTaint(tc.node)) + } + RemoveTaint(tc.node) + ok, _ = HasTaint(tc.node) + assert.False(t, ok) + }) + } +} diff --git a/manager/node/service.go b/manager/node/service.go index 2c22744..52443e4 100644 --- a/manager/node/service.go +++ b/manager/node/service.go @@ -24,6 +24,8 @@ import ( nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/runtime/v1" shimv1 "github.com/ctrox/zeropod/api/shim/v1" + "github.com/ctrox/zeropod/manager" + "github.com/ctrox/zeropod/manager/capacity" "github.com/klauspost/compress/zstd" "github.com/mholt/archives" "google.golang.org/protobuf/types/known/emptypb" @@ -66,13 +68,14 @@ type Timeouts struct { MigrationClaim, MigrationReady, MigrationServers, + EvictionTimeout, PagesTransfer time.Duration } // NewServer starts a node server with two listeners: // * Unix socket for the shims to connect to it locally // * TCP+TLS socket to allow the other node instances to connect -func NewServer(addr string, kube client.Client, log *slog.Logger, timeouts Timeouts) (*Server, error) { +func NewServer(addr string, kube client.Client, log *slog.Logger, timeouts Timeouts, cap capacity.Tracker) (*Server, error) { host, ok := os.LookupEnv(nodev1.PodIPEnvKey) if !ok { return nil, fmt.Errorf("could not find host, env POD_IP is not set") @@ -119,6 +122,7 @@ func NewServer(addr string, kube client.Client, log *slog.Logger, timeouts Timeo pageServerTLS: true, liveMigrationSupported: liveMigrationSupported, timeouts: timeouts, + cap: cap, }) log.Info("new node server", "name", nodeName, "live_migration_supported", liveMigrationSupported) @@ -189,6 +193,7 @@ type nodeService struct { pageServerTLS bool liveMigrationSupported bool timeouts Timeouts + cap capacity.Tracker } var findMigrationBackoff = wait.Backoff{ @@ -198,6 +203,137 @@ var findMigrationBackoff = wait.Backoff{ Jitter: 0.1, } +// RestoreCapacity checks if the node has enough capacity to restore the +// container in the request. If the node is at capacity, the pod is evicted. +func (ns *nodeService) RestoreCapacity(ctx context.Context, req *nodev1.RestoreCapacityRequest) (*nodev1.RestoreCapacityResponse, error) { + log := ns.log.With("pod_name", req.PodInfo.Name, + "namespace", req.PodInfo.Namespace, + "container_name", req.PodInfo.ContainerName) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.PodInfo.Name, + Namespace: req.PodInfo.Namespace, + }, + } + if err := ns.kube.Get(ctx, client.ObjectKeyFromObject(pod), pod); err != nil { + return nil, err + } + res, err := getContainerResources(pod, req.PodInfo.ContainerName) + if err != nil { + log.Error("getting container resources", "error", err) + return &nodev1.RestoreCapacityResponse{Allowed: true}, fmt.Errorf("getting container requests: %w", err) + } + log.Debug("container resources", "cpu", res.Cpu(), "mem", res.Memory()) + if ns.hasCapacity(res) { + log.Debug("node has enough capacity to restore") + return &nodev1.RestoreCapacityResponse{Allowed: true}, nil + } + resp, err := ns.evictPod(ctx, req, pod) + if err != nil { + log.Error("evicting pod", "error", err) + return &nodev1.RestoreCapacityResponse{Allowed: true}, fmt.Errorf("evicting pod: %w", err) + } + return resp, nil +} + +func (ns *nodeService) evictPod(ctx context.Context, req *nodev1.RestoreCapacityRequest, pod *corev1.Pod) (*nodev1.RestoreCapacityResponse, error) { + log := ns.log.With("pod_name", req.PodInfo.Name, + "namespace", req.PodInfo.Namespace, + "container_name", req.PodInfo.ContainerName) + + if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + node := &corev1.Node{} + if err := ns.kube.Get(ctx, types.NamespacedName{Name: ns.nodeName}, node); err != nil { + return err + } + if needsUpdate := capacity.AddTaint(node); needsUpdate { + return ns.kube.Update(ctx, node) + } + return nil + }); err != nil { + return nil, err + } + if err := ns.kube.Delete(ctx, pod); err != nil { + return nil, err + } + ns.cap.IncEvicted() + if !shimv1.AnyMigrationEnabled(pod.Annotations) { + return &nodev1.RestoreCapacityResponse{ + Allowed: false, + }, nil + } + migration := &v1.Migration{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.PodInfo.Name, + Namespace: req.PodInfo.Namespace, + }, + } + // wait for the migration to have target pod IP + pCtx, cancel := context.WithTimeout(ctx, ns.timeouts.EvictionTimeout) + defer cancel() + log.Info("eviction waiting for migration target pod IP") + if err := pollUntilContextCancel(pCtx, func(ctx context.Context) (bool, error) { + if err := ns.kube.Get(ctx, client.ObjectKeyFromObject(migration), migration); err != nil { + if kerrors.IsNotFound(err) { + return false, nil + } + return false, err + } + if migration.Spec.TargetPodIP != "" { + return true, nil + } + return false, nil + }); err != nil { + // although we failed to get a target IP, the pod has already been + // evicted we should still tell in the response that the restore was + // denied. + log.Error("waiting for target pod IP failed", "name", migration.Name, "error", err) + } + return &nodev1.RestoreCapacityResponse{ + Allowed: false, + RedirectAddr: migration.Spec.TargetPodIP, + }, nil +} + +func getContainerResources(pod *corev1.Pod, containerName string) (corev1.ResourceList, error) { + c := corev1.Container{} + for _, container := range pod.Spec.Containers { + if container.Name == containerName { + c = container + } + } + if c.Resources.Requests == nil { + return nil, fmt.Errorf("container %s has empty requests", containerName) + } + res, err := manager.InitialRequests(c, pod.Annotations) + if err != nil { + return nil, fmt.Errorf("checking initial requests: %w", err) + } + return res, nil +} + +func (ns *nodeService) hasCapacity(res corev1.ResourceList) bool { + cpuRequested, memRequested := res.Cpu(), res.Memory() + cpuRequested.Add(ns.cap.Requested(corev1.ResourceCPU)) + memRequested.Add(ns.cap.Requested(corev1.ResourceMemory)) + cpuCap := ns.cap.Capacity(corev1.ResourceCPU) + memCap := ns.cap.Capacity(corev1.ResourceMemory) + // the capacity might be uninitialized on a fresh node, we can assume that + // an empty node has enough capacity. + if cpuCap.IsZero() || memCap.IsZero() { + return true + } + if cpuRequested.Cmp(cpuCap) > 0 { + ns.log.Info("capacity: not enough cpu", "cap", cpuCap.String(), "req", cpuRequested.String()) + return false + } + if memRequested.Cmp(memCap) > 0 { + ns.log.Info("capacity: not enough memory", "cap", memCap.String(), "req", memRequested.String()) + return false + } + return true +} + func (ns *nodeService) Restore(ctx context.Context, req *nodev1.RestoreRequest) (*nodev1.RestoreResponse, error) { log := ns.log.With("pod_name", req.PodInfo.Name, "namespace", req.PodInfo.Namespace, @@ -232,6 +368,7 @@ func (ns *nodeService) Restore(ctx context.Context, req *nodev1.RestoreRequest) if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { migration.Spec.RestoreReady = true + migration.Spec.TargetPodIP = req.PodInfo.Ip return ns.kube.Update(ctx, migration) }); err != nil { log.Error("updating migration to be ready for restore failed", "error", err) diff --git a/manager/node/service_test.go b/manager/node/service_test.go index 0c6eead..79adb0f 100644 --- a/manager/node/service_test.go +++ b/manager/node/service_test.go @@ -1,11 +1,20 @@ package node import ( + "log/slog" "testing" + nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/runtime/v1" + "github.com/ctrox/zeropod/manager" + "github.com/ctrox/zeropod/manager/capacity" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestSetContainerStatus(t *testing.T) { @@ -39,3 +48,157 @@ func TestSetContainerStatus(t *testing.T) { assert.Equal(t, v1.MigrationPhaseFailed, migration.Status.Containers[1].Condition.Phase) assert.NotEmpty(t, migration.Status.Containers[1].RestoredAt) } + +func TestRestoreCapacity(t *testing.T) { + for name, tc := range map[string]struct { + node *corev1.Node + pod *corev1.Pod + migration *v1.Migration + containerName string + allowed bool + expectedErr bool + podScaledDown bool + }{ + "no container": { + node: newNode("empty", nil), + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: "default"}, + }, + allowed: true, + expectedErr: true, + }, + "empty node": { + node: newNode("empty", nil), + pod: newPod("app1", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }), + containerName: "app1", + allowed: true, + }, + "node with enough capacity": { + node: newNode("empty", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("20Gi"), + }), + pod: newPod("app1", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("20Gi"), + }), + containerName: "app1", + allowed: true, + }, + "node at memory capacity": { + node: newNode("full-mem", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("16Gi"), + }), + pod: newPod("app1", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("20Gi"), + }), + migration: newMigration("10.0.0.1"), + containerName: "app1", + allowed: false, + }, + "pod with resources in annotation": { + node: newNode("full-mem", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("16Gi"), + }), + pod: newPod("app1", corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("20Gi"), + }), + migration: newMigration("10.0.0.1"), + containerName: "app1", + allowed: false, + podScaledDown: true, + }, + } { + t.Run(name, func(t *testing.T) { + scheme := runtime.NewScheme() + assert.NoError(t, corev1.AddToScheme(scheme)) + assert.NoError(t, v1.AddToScheme(scheme)) + if tc.podScaledDown { + assert.NoError(t, setScaledDown(tc.pod)) + } + objs := []client.Object{tc.node, tc.pod} + if tc.migration != nil { + objs = append(objs, tc.migration) + } + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + cap := capacity.NewNodeTracker() + for name, q := range tc.node.Status.Capacity { + cap.SetCapacity(name, q) + } + slog.SetLogLoggerLevel(slog.LevelDebug) + ns := &nodeService{ + kube: kube, + nodeName: tc.node.Name, + log: slog.Default(), + cap: cap, + } + resp, err := ns.RestoreCapacity(t.Context(), &nodev1.RestoreCapacityRequest{ + PodInfo: &nodev1.PodInfo{ + Name: tc.pod.Name, + Namespace: tc.pod.Namespace, + ContainerName: tc.containerName, + }, + }) + if tc.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.NotNil(t, resp) + assert.Equal(t, tc.allowed, resp.Allowed) + if tc.allowed { + assert.Empty(t, resp.RedirectAddr) + } + }) + } +} + +func newPod(containerName string, requests corev1.ResourceList) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: containerName, + Resources: corev1.ResourceRequirements{ + Requests: requests, + }, + }}, + }, + } +} + +func setScaledDown(pod *corev1.Pod) error { + if err := manager.SetAnnotations(pod); err != nil { + return err + } + for i := range pod.Spec.Containers { + pod.Spec.Containers[i].Resources.Requests[corev1.ResourceCPU] = manager.ScaledDownCPU + pod.Spec.Containers[i].Resources.Requests[corev1.ResourceMemory] = manager.ScaledDownMemory + } + return nil +} + +func newNode(name string, cap corev1.ResourceList) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: corev1.NodeStatus{ + Capacity: cap, + }, + } +} + +func newMigration(targetPodIP string) *v1.Migration { + return &v1.Migration{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default"}, + Spec: v1.MigrationSpec{ + TargetPodIP: targetPodIP, + }, + } +} diff --git a/manager/pod_controller.go b/manager/pod_controller.go index 90bfeef..6d3eff4 100644 --- a/manager/pod_controller.go +++ b/manager/pod_controller.go @@ -7,17 +7,21 @@ import ( "net/url" "os" "path" + "time" nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/runtime/v1" shimv1 "github.com/ctrox/zeropod/api/shim/v1" + "github.com/ctrox/zeropod/manager/capacity" "github.com/go-logr/logr" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" @@ -79,6 +83,12 @@ func RegisterPodScaler(handler PodHandler) PodControllerOption { } } +func CapacityTracker(cap capacity.Tracker) PodControllerOption { + return func(pr *podReconciler) { + pr.cap = cap + } +} + type podReconciler struct { kube client.Client log *slog.Logger @@ -86,6 +96,7 @@ type podReconciler struct { autoGCMigrations bool labeller PodHandler scaler PodHandler + cap capacity.Tracker } func newPodReconciler(kube client.Client, log *slog.Logger) (*podReconciler, error) { @@ -97,10 +108,16 @@ func newPodReconciler(kube client.Client, log *slog.Logger) (*podReconciler, err log: log, kube: kube, nodeName: nodeName, + cap: capacity.NewNodeTracker(), }, nil } func (r *podReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + requeueAfter, err := r.updateNodeResources(ctx) + if err != nil { + return reconcile.Result{}, err + } + if err := r.kube.Get(ctx, request.NamespacedName, &v1.Migration{}); err == nil { // migration already exists, there's nothing for us to do return reconcile.Result{}, nil @@ -126,10 +143,12 @@ func (r *podReconciler) Reconcile(ctx context.Context, request reconcile.Request if err != nil { return reconcile.Result{}, fmt.Errorf("preparing migration target: %w", err) } - return reconcile.Result{Requeue: requeue}, nil + if requeue { + requeueAfter = time.Second + } } - return reconcile.Result{}, nil + return reconcile.Result{RequeueAfter: requeueAfter}, nil } func (r podReconciler) isMigrationSource(pod *corev1.Pod) bool { @@ -154,7 +173,7 @@ func (r podReconciler) isMigrationCandidate(pod *corev1.Pod) bool { return false } - return anyMigrationEnabled(pod) + return shimv1.AnyMigrationEnabled(pod.Annotations) } func (r podReconciler) prepareMigrationSource(ctx context.Context, pod *corev1.Pod) error { @@ -262,7 +281,7 @@ func (r podReconciler) newMigration(pod *corev1.Pod) (*v1.Migration, error) { SourceNode: pod.Spec.NodeName, PodTemplateHash: pod.Labels[appsv1.DefaultDeploymentUniqueLabelKey], Containers: containers, - LiveMigration: liveMigrationEnabled(pod), + LiveMigration: shimv1.LiveMigrationEnabled(pod.Annotations), }, } if r.autoGCMigrations { @@ -301,15 +320,57 @@ func (r podReconciler) updatePodResources(ctx context.Context, pod *corev1.Pod, return nil } -func anyMigrationEnabled(pod *corev1.Pod) bool { - _, migrate := pod.Annotations[nodev1.MigrateAnnotationKey] - _, liveMigrate := pod.Annotations[nodev1.LiveMigrateAnnotationKey] - return migrate || liveMigrate -} +func (r podReconciler) updateNodeResources(ctx context.Context) (time.Duration, error) { + if r.cap == nil { + return 0, fmt.Errorf("capacity tracker is uninitialized") + } + node := &corev1.Node{} + if err := r.kube.Get(ctx, types.NamespacedName{Name: r.nodeName}, node); err != nil { + return 0, err + } + r.cap.SetCapacity(corev1.ResourceCPU, *node.Status.Capacity.Cpu()) + r.cap.SetCapacity(corev1.ResourceMemory, *node.Status.Capacity.Memory()) + podList := &corev1.PodList{} + if err := r.kube.List(ctx, podList); err != nil { + return 0, err + } + cpuRequested, memRequested := resource.Quantity{}, resource.Quantity{} + for _, pod := range podList.Items { + if pod.Spec.NodeName != r.nodeName { + continue + } + if pod.Status.Phase != corev1.PodRunning { + continue + } + if pod.Spec.Resources != nil { + memRequested.Add(*pod.Spec.Resources.Requests.Memory()) + cpuRequested.Add(*pod.Spec.Resources.Requests.Cpu()) + } + for _, container := range pod.Status.ContainerStatuses { + if container.Resources == nil { + continue + } + memRequested.Add(*container.Resources.Requests.Memory()) + cpuRequested.Add(*container.Resources.Requests.Cpu()) + } + } + r.cap.SetRequested(corev1.ResourceCPU, cpuRequested) + r.cap.SetRequested(corev1.ResourceMemory, memRequested) -func liveMigrationEnabled(pod *corev1.Pod) bool { - _, ok := pod.Annotations[nodev1.LiveMigrateAnnotationKey] - return ok + ok, timeAdded := capacity.HasTaint(node) + if ok && time.Since(timeAdded) < capacity.MinTaintDuration { + return capacity.MinTaintDuration, nil + } + if ok && node.Status.Capacity.Cpu().Cmp(cpuRequested) == 1 && + node.Status.Capacity.Memory().Cmp(memRequested) == 1 { + capacity.RemoveTaint(node) + r.log.Info("removing taint from node", "node", node.Name) + if err := r.kube.Update(ctx, node); err != nil { + return 0, fmt.Errorf("removing taint: %w", err) + } + return 0, nil + } + return 0, nil } func containerRunning(pod *corev1.Pod, containerName string) bool { diff --git a/manager/pod_controller_test.go b/manager/pod_controller_test.go index 768df17..1a7a3bf 100644 --- a/manager/pod_controller_test.go +++ b/manager/pod_controller_test.go @@ -92,6 +92,7 @@ func TestPodReconcilerMigrationSource(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Setenv(nodev1.NodeNameEnvKey, tc.nodeName) + ensureNode(t, kube, tc.nodeName) r, err := newPodReconciler(kube, slog.Default()) require.NoError(t, err) r.autoGCMigrations = tc.garbageCollection @@ -194,6 +195,7 @@ func TestPodReconcilerMigrationTarget(t *testing.T) { t.Run(name, func(t *testing.T) { require.NoError(t, kube.Create(ctx, tc.existingMigration)) t.Setenv(nodev1.NodeNameEnvKey, tc.nodeName) + ensureNode(t, kube, tc.nodeName) r, err := newPodReconciler(kube, slog.Default()) require.NoError(t, err) r.autoGCMigrations = tc.garbageCollection @@ -275,3 +277,11 @@ func setPhase(pod *corev1.Pod, phase corev1.PodPhase) *corev1.Pod { pod.Status.Phase = phase return pod } + +func ensureNode(t *testing.T, kube client.Client, nodeName string) { + if err := kube.Create(t.Context(), &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: nodeName}}); err != nil { + if !errors.IsAlreadyExists(err) { + t.Fatal(err) + } + } +} diff --git a/manager/pod_scaler.go b/manager/pod_scaler.go index 4f31903..8756f96 100644 --- a/manager/pod_scaler.go +++ b/manager/pod_scaler.go @@ -44,7 +44,7 @@ func (ps *PodScaler) reconcileContainerResources(ctx context.Context, pod *corev clog := ps.log.With("container", name, "pod", pod.Name, "namespace", pod.Namespace, "phase", phase) - if err := ps.setAnnotations(pod); err != nil { + if err := SetAnnotations(pod); err != nil { return err } @@ -60,7 +60,7 @@ func (ps *PodScaler) reconcileContainerResources(ctx context.Context, pod *corev continue } - initial, err := ps.initialRequests(container, pod.Annotations) + initial, err := InitialRequests(container, pod.Annotations) if err != nil { return fmt.Errorf("getting initial requests from pod failed: %w", err) } @@ -109,7 +109,7 @@ func (ps *PodScaler) newRequests(initial, current corev1.ResourceList, phase v1. } } -func (ps *PodScaler) initialRequests(container corev1.Container, podAnnotations map[string]string) (corev1.ResourceList, error) { +func InitialRequests(container corev1.Container, podAnnotations map[string]string) (corev1.ResourceList, error) { initial := container.DeepCopy().Resources.Requests containerCPUs := containerResource{} if cpuReq, ok := podAnnotations[CPUAnnotationKey]; ok { @@ -136,7 +136,7 @@ func (ps *PodScaler) initialRequests(container corev1.Container, podAnnotations return initial, nil } -func (ps *PodScaler) setAnnotations(pod *corev1.Pod) error { +func SetAnnotations(pod *corev1.Pod) error { containerCPUs := containerResource{} containerMemory := containerResource{} for _, container := range pod.Spec.Containers { diff --git a/manager/pod_scaler_test.go b/manager/pod_scaler_test.go index cf79803..d47c162 100644 --- a/manager/pod_scaler_test.go +++ b/manager/pod_scaler_test.go @@ -167,7 +167,7 @@ func TestHandlePod(t *testing.T) { } initialPod := newPod(resourceList) - ps.setAnnotations(initialPod) + SetAnnotations(initialPod) pod := newPod(tc.beforeEvent) pod.SetAnnotations(initialPod.GetAnnotations()) diff --git a/shim/container.go b/shim/container.go index 3ce79b5..8544c6a 100644 --- a/shim/container.go +++ b/shim/container.go @@ -7,6 +7,7 @@ import ( "os" "slices" "sync" + "sync/atomic" "time" taskAPI "github.com/containerd/containerd/api/runtime/task/v3" @@ -49,6 +50,9 @@ type Container struct { scaleDownTimer *time.Timer initTimer *time.Timer initBackoff time.Duration + evacDrainStarted atomic.Bool + drainTimer *time.Timer + drainStartTime time.Time platform stdio.Platform preRestore func() HandleStartedFunc postRestore func(*runc.Container, HandleStartedFunc) @@ -326,6 +330,16 @@ func (c *Container) Stop(ctx context.Context) { _ = c.netNS.Close() } +func (c *Container) ExitOK(ctx context.Context) { + if c.drainTimer != nil { + c.drainTimer.Stop() + c.drainTimer = nil + } + c.Process().SetExited(0) + c.InitialProcess().SetExited(0) + c.Stop(ctx) +} + func (c *Container) cleanupImage(ctx context.Context) { // with migration, the shim might exit before the image data has been // transferred to the new node. The cleanup is the responsibility of the @@ -356,6 +370,10 @@ func (c *Container) RegisterPostRestore(f func(*runc.Container, HandleStartedFun c.postRestore = f } +func (c *Container) EvacDrainStarted() bool { + return c.evacDrainStarted.Load() +} + func (c *Container) initActivator(ctx context.Context, enableRedirects bool) error { c.cancelInit() @@ -407,7 +425,7 @@ func (c *Container) initActivator(ctx context.Context, enableRedirects bool) err // initRetry returns the duration in which the next init should be retried. It // backs off exponentially with an initial wait of 100 milliseconds. func (c *Container) initRetry() time.Duration { - const initial, max = time.Millisecond * 100, time.Minute * 5 + const initial, max = time.Millisecond * 10, time.Minute * 5 c.initBackoff = min(max, c.initBackoff*2) if c.initBackoff == 0 { @@ -465,6 +483,10 @@ func (c *Container) restoreHandler(ctx context.Context) activator.RestoreHook { log.G(ctx).Info("container is already restored, ignoring request") return nil } + if errors.Is(err, ErrNoCapacity) { + log.G(ctx).Info("no capacity to restore, requests are being forwarded") + return nil + } // restore failed, this is currently unrecoverable, so we set the // process to exited and let the runtime recreate it. c.Process().SetExited(1) diff --git a/shim/evac.go b/shim/evac.go index 9d1668e..a670e36 100644 --- a/shim/evac.go +++ b/shim/evac.go @@ -15,13 +15,18 @@ import ( runcC "github.com/containerd/go-runc" "github.com/containerd/log" "github.com/containerd/ttrpc" + "github.com/ctrox/zeropod/activator" nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/shim/v1" "github.com/prometheus/procfs" "google.golang.org/protobuf/types/known/timestamppb" ) -const evacTimeout = time.Second * 10 +const ( + evacTimeout = time.Second * 10 + drainTimeout = time.Minute + drainCheckInterval = time.Second +) func (c *Container) MigrationEnabled() bool { return c.cfg.AnyMigrationEnabled() @@ -31,6 +36,11 @@ func (c *Container) Evac(ctx context.Context, scaledDown bool) error { var err error c.evacuation.Do(func() { if scaledDown { + defer func() { + log.G(ctx).Info("migration done, starting drain") + c.startConnectionDrain() + c.evacDrainStarted.Store(true) + }() err = c.evacScaledDown(ctx) return } @@ -297,3 +307,49 @@ func findUpperDir(containerID string) (string, error) { return "", fmt.Errorf("upper dir not found for container %s", containerID) } + +func (c *Container) startConnectionDrain() { + c.drainStartTime = time.Now() + c.checkConnectionDrainIn(drainCheckInterval, drainTimeout) +} + +func (c *Container) checkConnectionDrainIn(in time.Duration, timeout time.Duration) { + if c.drainTimer != nil { + c.drainTimer.Stop() + } + if c.drainTimer == nil { + c.drainTimer = time.AfterFunc(in, func() { + c.handleDrainCheck(in, timeout) + }) + return + } + c.drainTimer.Reset(in) +} + +func (c *Container) handleDrainCheck(in time.Duration, timeout time.Duration) { + if time.Now().After(c.drainStartTime.Add(timeout)) { + log.G(c.context).Info("drain timed out") + c.ExitOK(c.context) + return + } + var last time.Time + var err error + for _, port := range c.cfg.Ports { + last, err = c.activator.LastActivity(port) + if err != nil { + if errors.Is(err, activator.NoActivityRecordedErr{}) { + continue + } + log.G(c.context).Warnf("error checking for activity during drain: %s", err) + c.ExitOK(c.context) + return + } + if time.Since(last) < drainCheckInterval { + log.G(c.context).Infof("last activity was %s ago, waiting for drain to complete", time.Since(last)) + c.checkConnectionDrainIn(in, timeout) + return + } + } + log.G(c.context).Infof("no activity detected in %s, completed drain", drainCheckInterval) + c.ExitOK(c.context) +} diff --git a/shim/restore.go b/shim/restore.go index c50d60f..1b3b352 100644 --- a/shim/restore.go +++ b/shim/restore.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "net/netip" "os" "path/filepath" "time" @@ -20,6 +21,8 @@ import ( "github.com/containerd/containerd/v2/pkg/stdio" "github.com/containerd/log" "github.com/containerd/ttrpc" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/ctrox/zeropod/activator" nodev1 "github.com/ctrox/zeropod/api/node/v1" v1 "github.com/ctrox/zeropod/api/shim/v1" crio "github.com/ctrox/zeropod/shim/io" @@ -29,6 +32,7 @@ import ( var ( ErrAlreadyRestored = errors.New("container is already restored") + ErrNoCapacity = errors.New("no capacity to restore") ErrRestoreRequestFailed = errors.New("restore request failed") ErrRestoreDial = errors.New("failed to connect to node socket") ErrInvalidCheckpoint = errors.New("checkpoint data invalid") @@ -44,6 +48,16 @@ func (c *Container) Restore(ctx context.Context) (*runc.Container, process.Proce if !c.ScaledDown() { return nil, nil, ErrAlreadyRestored } + resp, err := c.restoreCapacityRequest(ctx) + if err != nil { + // log the error but continue with restoring + log.G(ctx).WithError(err).Error("requesting restore capacity") + } else if !resp.Allowed { + if resp.RedirectAddr != "" { + c.activator.ForwardToTarget(resp.RedirectAddr) + } + return nil, nil, ErrNoCapacity + } cont, p, err := c.restore(ctx) if err != nil && !c.cfg.DisableCheckpointing { log.G(ctx).Errorf("restore failed, disabling checkpointing and retrying: %s", err) @@ -215,12 +229,21 @@ func MigrationRestore(ctx context.Context, r *task.CreateTaskRequest, cfg *v1.Co } log.G(ctx).Infof("creating restore request for container: %s", cfg.ContainerName) + podIP := "" + sandboxIP, err := getSandboxIP(cfg) + if err != nil { + log.G(ctx).Warnf("getting sandbox IP failed: %s, connections won't be forwarded during migration", err) + } else { + podIP = sandboxIP.String() + } + restoreReq := &nodev1.RestoreRequest{ MigrationInfo: &nodev1.MigrationInfo{ImageId: r.ID}, PodInfo: &nodev1.PodInfo{ Name: cfg.PodName, Namespace: cfg.PodNamespace, ContainerName: cfg.ContainerName, + Ip: podIP, }, } nodeClient := nodev1.NewNodeClient(ttrpc.NewClient(conn)) @@ -331,3 +354,25 @@ func validateCheckpointData(snapshotPath string) error { _, err := os.Stat(filepath.Join(snapshotPath, "descriptors.json")) return err } + +func getSandboxIP(cfg *v1.Config) (*netip.Addr, error) { + netNSPath, err := GetNetworkNS(cfg.Spec) + if err != nil { + return nil, err + } + targetNS, err := ns.GetNS(netNSPath) + if err != nil { + return nil, err + } + var sandboxIPs []netip.Addr + if err := targetNS.Do(func(nn ns.NetNS) error { + sandboxIPs, err = activator.GetSandboxIPs("eth0") + return err + }); err != nil { + return nil, err + } + if len(sandboxIPs) == 0 { + return nil, fmt.Errorf("sandbox IP not found") + } + return &sandboxIPs[0], nil +} diff --git a/shim/restore_capacity.go b/shim/restore_capacity.go new file mode 100644 index 0000000..bd43325 --- /dev/null +++ b/shim/restore_capacity.go @@ -0,0 +1,34 @@ +package shim + +import ( + "context" + "fmt" + "net" + + "github.com/containerd/log" + "github.com/containerd/ttrpc" + nodev1 "github.com/ctrox/zeropod/api/node/v1" +) + +func (c *Container) restoreCapacityRequest(ctx context.Context) (*nodev1.RestoreCapacityResponse, error) { + conn, err := net.Dial("unix", nodev1.SocketPath) + if err != nil { + return nil, fmt.Errorf("%w: dialing node service: %w", ErrRestoreDial, err) + } + defer conn.Close() + log.G(ctx).Debugf("creating restore capacity request for container: %s", c.cfg.ContainerName) + + restoreCapReq := &nodev1.RestoreCapacityRequest{ + PodInfo: &nodev1.PodInfo{ + Name: c.cfg.PodName, + Namespace: c.cfg.PodNamespace, + ContainerName: c.cfg.ContainerName, + }, + } + nodeClient := nodev1.NewNodeClient(ttrpc.NewClient(conn)) + resp, err := nodeClient.RestoreCapacity(ctx, restoreCapReq) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRestoreRequestFailed, err) + } + return resp, nil +} diff --git a/shim/task/service_zeropod.go b/shim/task/service_zeropod.go index 632e88b..cefb89a 100644 --- a/shim/task/service_zeropod.go +++ b/shim/task/service_zeropod.go @@ -267,6 +267,11 @@ func (w *wrapper) Exec(ctx context.Context, r *taskAPI.ExecProcessRequest) (*emp log.G(ctx).Printf("got exec for scaled down container, restoring") if _, _, err := zeropodContainer.Restore(ctx); err != nil { + if errors.Is(err, zshim.ErrNoCapacity) { + log.G(ctx).Info("no capacity to restore for exec") + return &emptypb.Empty{}, fmt.Errorf("no capacity to restore for exec") + } + // restore failed, this is currently unrecoverable, so we set the // process to exited and let the runtime recreate it. zeropodContainer.Process().SetExited(1) @@ -371,6 +376,11 @@ func (w *wrapper) Kill(ctx context.Context, r *taskAPI.KillRequest) (*emptypb.Em log.G(ctx).Info("migrating instead of killing process") if err := zeropodContainer.Evac(ctx, zeropodContainer.ScaledDown()); err != nil { log.G(ctx).WithError(err).Error("evac failed, exiting normally") + } else if zeropodContainer.EvacDrainStarted() { + // after a successful evac we start the connection draining + // asynchronously. Return here to signal that we received the + // kill. + return &emptypb.Empty{}, nil } } From 60a2ee10605638b1ee9534120ba37646fad59ca1 Mon Sep 17 00:00:00 2001 From: Cyrill Troxler Date: Sun, 17 May 2026 16:43:21 +0200 Subject: [PATCH 3/5] feat: add capacity eviction metrics --- cmd/manager/main.go | 14 ++++----- manager/capacity/capacity.go | 50 ++++++++++++++++++++++++++++++- manager/capacity/capacity_test.go | 3 +- manager/capacity/noop.go | 36 ++++++++++++++++++++++ manager/node/service.go | 1 + manager/node/service_test.go | 3 +- manager/pod_controller.go | 2 +- 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 manager/capacity/noop.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index e11bb03..0d40c81 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -111,7 +111,11 @@ func main() { log.Warn("attaching redirectors failed: restoring containers on traffic is disabled", "err", err) } - mgr, err := newControllerManager() + nodeName, ok := os.LookupEnv(nodev1.NodeNameEnvKey) + if !ok { + log.Error("could not find node name, env is not set", "env", nodev1.NodeNameEnvKey) + } + mgr, err := newControllerManager(nodeName) if err != nil { log.Error("creating controller manager", "err", err) os.Exit(1) @@ -163,7 +167,7 @@ func main() { } }() - cap := capacity.NewNodeTracker() + cap := capacity.NewNodeTracker(registry, nodeName) nodeServer, err := node.NewServer( *nodeServerAddr, mgr.GetClient(), @@ -210,7 +214,7 @@ func main() { } } -func newControllerManager() (ctrlmanager.Manager, error) { +func newControllerManager(nodeName string) (ctrlmanager.Manager, error) { cfg, err := config.GetConfig() if err != nil { return nil, fmt.Errorf("getting client config: %w", err) @@ -222,10 +226,6 @@ func newControllerManager() (ctrlmanager.Manager, error) { if err := v1.AddToScheme(scheme); err != nil { return nil, err } - nodeName, ok := os.LookupEnv(nodev1.NodeNameEnvKey) - if !ok { - return nil, fmt.Errorf("could not find node name, env %s is not set", nodev1.NodeNameEnvKey) - } mgr, err := ctrlmanager.New(cfg, ctrlmanager.Options{ Scheme: scheme, Metrics: server.Options{BindAddress: "0"}, Controller: ctrlconfig.Controller{MaxConcurrentReconciles: *maxConcurrentReconciles}, diff --git a/manager/capacity/capacity.go b/manager/capacity/capacity.go index 6f45753..fde482b 100644 --- a/manager/capacity/capacity.go +++ b/manager/capacity/capacity.go @@ -1,3 +1,4 @@ +// Package capacity implements a tracker for node resource capacity. package capacity import ( @@ -5,6 +6,8 @@ import ( "sync" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,6 +17,14 @@ import ( const ( TaintKey = "zeropod.ctrox.dev/at-capacity" MinTaintDuration = time.Minute + + metricsNamespace = "zeropod" + MetricNodeCapacity = "node_capacity" + MetricNodeRequested = "node_requested" + MetricNodeEvictions = "node_evictions" + + labelNode = "node" + labelResource = "resource" ) type Tracker interface { @@ -21,12 +32,21 @@ type Tracker interface { Requested(name corev1.ResourceName) resource.Quantity SetCapacity(name corev1.ResourceName, q resource.Quantity) SetRequested(name corev1.ResourceName, q resource.Quantity) + IncEvicted() } type NodeTracker struct { + name string capacity corev1.ResourceList requested corev1.ResourceList mu sync.RWMutex + metrics metrics +} + +type metrics struct { + capacity *prometheus.GaugeVec + requested *prometheus.GaugeVec + evicted *prometheus.CounterVec } // Capacity returns the node capacity for the [corev1.ResourceName]. @@ -47,6 +67,7 @@ func (c *NodeTracker) Requested(name corev1.ResourceName) resource.Quantity { func (c *NodeTracker) SetCapacity(name corev1.ResourceName, q resource.Quantity) { c.mu.Lock() defer c.mu.Unlock() + c.metrics.capacity.With(c.metricLabels(name.String())).Set(q.AsApproximateFloat64()) c.capacity[name] = q } @@ -54,15 +75,42 @@ func (c *NodeTracker) SetCapacity(name corev1.ResourceName, q resource.Quantity) func (c *NodeTracker) SetRequested(name corev1.ResourceName, q resource.Quantity) { c.mu.Lock() defer c.mu.Unlock() + c.metrics.requested.With(c.metricLabels(name.String())).Set(q.AsApproximateFloat64()) c.requested[name] = q } +func (c *NodeTracker) IncEvicted() { + c.metrics.evicted.With(prometheus.Labels{labelNode: c.name}).Inc() +} + +func (c *NodeTracker) metricLabels(resource string) prometheus.Labels { + return prometheus.Labels{labelNode: c.name, labelResource: resource} +} + // NewNodeTracker creates a [NodeTracker]. -func NewNodeTracker() Tracker { +func NewNodeTracker(reg prometheus.Registerer, name string) Tracker { return &NodeTracker{ + name: name, capacity: corev1.ResourceList{}, requested: corev1.ResourceList{}, mu: sync.RWMutex{}, + metrics: metrics{ + capacity: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{ + Namespace: metricsNamespace, + Name: MetricNodeCapacity, + Help: "Node resource capacity.", + }, []string{labelNode, labelResource}), + requested: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{ + Namespace: metricsNamespace, + Name: MetricNodeRequested, + Help: "Node resources requested.", + }, []string{labelNode, labelResource}), + evicted: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Name: MetricNodeEvictions, + Help: "Counts node capacity evictions.", + }, []string{labelNode}), + }, } } diff --git a/manager/capacity/capacity_test.go b/manager/capacity/capacity_test.go index c619aa5..7548b99 100644 --- a/manager/capacity/capacity_test.go +++ b/manager/capacity/capacity_test.go @@ -3,6 +3,7 @@ package capacity import ( "testing" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -11,7 +12,7 @@ import ( ) func TestNodeTracker(t *testing.T) { - tracker := NewNodeTracker() + tracker := NewNodeTracker(prometheus.NewRegistry(), "name") assert.Empty(t, tracker.Capacity(corev1.ResourceCPU)) assert.Empty(t, tracker.Capacity(corev1.ResourceMemory)) assert.Empty(t, tracker.Requested(corev1.ResourceCPU)) diff --git a/manager/capacity/noop.go b/manager/capacity/noop.go new file mode 100644 index 0000000..371cf23 --- /dev/null +++ b/manager/capacity/noop.go @@ -0,0 +1,36 @@ +package capacity + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// NoopTracker implements a capacity tracker that does nothing. +type NoopTracker struct{} + +// Capacity implements [Tracker]. +func (n *NoopTracker) Capacity(name corev1.ResourceName) resource.Quantity { + return resource.Quantity{} +} + +// Requested implements [Tracker]. +func (n *NoopTracker) Requested(name corev1.ResourceName) resource.Quantity { + return resource.Quantity{} +} + +// SetCapacity implements [Tracker]. +func (n *NoopTracker) SetCapacity(name corev1.ResourceName, q resource.Quantity) { +} + +// SetRequested implements [Tracker]. +func (n *NoopTracker) SetRequested(name corev1.ResourceName, q resource.Quantity) { +} + +// IncEvicted implements [Tracker]. +func (n *NoopTracker) IncEvicted() { +} + +// NewNoopTracker creates a [NoopTracker] +func NewNoopTracker() Tracker { + return &NoopTracker{} +} diff --git a/manager/node/service.go b/manager/node/service.go index 52443e4..68c9971 100644 --- a/manager/node/service.go +++ b/manager/node/service.go @@ -289,6 +289,7 @@ func (ns *nodeService) evictPod(ctx context.Context, req *nodev1.RestoreCapacity // denied. log.Error("waiting for target pod IP failed", "name", migration.Name, "error", err) } + ns.cap.IncEvicted() return &nodev1.RestoreCapacityResponse{ Allowed: false, RedirectAddr: migration.Spec.TargetPodIP, diff --git a/manager/node/service_test.go b/manager/node/service_test.go index 79adb0f..af33d64 100644 --- a/manager/node/service_test.go +++ b/manager/node/service_test.go @@ -8,6 +8,7 @@ import ( v1 "github.com/ctrox/zeropod/api/runtime/v1" "github.com/ctrox/zeropod/manager" "github.com/ctrox/zeropod/manager/capacity" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -128,7 +129,7 @@ func TestRestoreCapacity(t *testing.T) { objs = append(objs, tc.migration) } kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() - cap := capacity.NewNodeTracker() + cap := capacity.NewNodeTracker(prometheus.NewRegistry(), "name") for name, q := range tc.node.Status.Capacity { cap.SetCapacity(name, q) } diff --git a/manager/pod_controller.go b/manager/pod_controller.go index 6d3eff4..c4290ea 100644 --- a/manager/pod_controller.go +++ b/manager/pod_controller.go @@ -108,7 +108,7 @@ func newPodReconciler(kube client.Client, log *slog.Logger) (*podReconciler, err log: log, kube: kube, nodeName: nodeName, - cap: capacity.NewNodeTracker(), + cap: capacity.NewNoopTracker(), }, nil } From 76bc4bd9d17904e5a973880d44cfd530f796257a Mon Sep 17 00:00:00 2001 From: Cyrill Troxler Date: Sun, 5 Jul 2026 11:37:54 +0200 Subject: [PATCH 4/5] refactor: simplify scaleDownTimer instead of recreating the timer, we only use reset --- shim/container.go | 73 ++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/shim/container.go b/shim/container.go index 8544c6a..d8d5641 100644 --- a/shim/container.go +++ b/shim/container.go @@ -148,44 +148,51 @@ func (c *Container) scheduleScaleDownIn(in time.Duration) { } log.G(c.context).Infof("scheduling scale down in %s", in) - timer := time.AfterFunc(in, func() { - if !c.activator.Started() { - log.G(c.context).Infof("activator not ready, delaying scale down by %s", c.initBackoff) - c.scaleDownTimer.Reset(c.initBackoff) - return - } - last, err := c.lastActivity() - if errors.Is(err, activator.NoActivityRecordedErr{}) { - log.G(c.context).Info(err) - } else if err != nil { - log.G(c.context).Warnf("unable to get last TCP activity from tracker: %s", err) - } else { - log.G(c.context).Infof("last activity was %s ago", time.Since(last)) - - if time.Since(last) < c.cfg.ScaleDownDuration { - // we want to delay the scaledown by c.cfg.ScaleDownDuration - // after the last activity - delay := c.cfg.ScaleDownDuration - time.Since(last) - // do not schedule into the past :) - if delay < 0 { - return - } - - log.G(c.context).Infof("delaying scale down by %s", delay) - c.scaleDownTimer.Reset(delay) + if c.scaleDownTimer == nil { + c.scaleDownTimer = time.AfterFunc(in, func() { + c.scaleDownCheck(in) + }) + return + } + c.scaleDownTimer.Reset(in) +} + +func (c *Container) scaleDownCheck(in time.Duration) { + if !c.activator.Started() { + log.G(c.context).Infof("activator not ready, delaying scale down by %s", c.initBackoff) + c.scaleDownTimer.Reset(c.initBackoff) + return + } + last, err := c.lastActivity() + if errors.Is(err, activator.NoActivityRecordedErr{}) { + log.G(c.context).Info(err) + } else if err != nil { + log.G(c.context).Warnf("unable to get last TCP activity from tracker: %s", err) + } else { + log.G(c.context).Infof("last activity was %s ago", time.Since(last)) + + if time.Since(last) < c.cfg.ScaleDownDuration { + // we want to delay the scaledown by c.cfg.ScaleDownDuration + // after the last activity + delay := c.cfg.ScaleDownDuration - time.Since(last) + // do not schedule into the past :) + if delay < 0 { return } + + log.G(c.context).Infof("delaying scale down by %s", delay) + c.scaleDownTimer.Reset(delay) + return } + } - log.G(c.context).Info("scaling down after scale down duration is up") + log.G(c.context).Info("scaling down after scale down duration is up") - if err := c.scaleDown(c.context); err != nil { - log.G(c.context).Errorf("scale down failed, disabling checkpointing: %s", err) - c.cfg.DisableCheckpointing = true - c.scaleDownTimer.Reset(c.cfg.ScaleDownDuration) - } - }) - c.scaleDownTimer = timer + if err := c.scaleDown(c.context); err != nil { + log.G(c.context).Errorf("scale down failed, disabling checkpointing: %s", err) + c.cfg.DisableCheckpointing = true + c.scaleDownTimer.Reset(c.cfg.ScaleDownDuration) + } } func (c *Container) CancelScaleDown() { From 07b4a6422b5e1fca469970f4edd58b85a67aecb6 Mon Sep 17 00:00:00 2001 From: Cyrill Troxler Date: Sun, 5 Jul 2026 11:49:18 +0200 Subject: [PATCH 5/5] feat: gate capacity requests behind config flag disables capacity requests by default --- api/shim/v1/config.go | 3 +++ cmd/installer/main.go | 2 ++ config/capacity-request/kustomization.yaml | 9 +++++++++ config/kind/kustomization.yaml | 1 + shim/restore_capacity.go | 3 +++ 5 files changed, 18 insertions(+) create mode 100644 config/capacity-request/kustomization.yaml diff --git a/api/shim/v1/config.go b/api/shim/v1/config.go index b509845..553ed79 100644 --- a/api/shim/v1/config.go +++ b/api/shim/v1/config.go @@ -53,6 +53,7 @@ const ( DefaultProbeBufferSize = 1024 DefaultProbeBinaryName = "kubelet" DefaultTrackerIgnoreLocalhost = true + DefaultCapacityRequest = false ) var ContainerdAnnotations = []string{ @@ -95,6 +96,7 @@ type AnnotationConfig struct { type Config struct { TrackerIgnoreLocalhost bool `json:"trackerIgnoreLocalhost"` + CapacityRequest bool `json:"capacityRequest"` AnnotationConfig `json:"-"` } @@ -225,6 +227,7 @@ func NewConfig(ctx context.Context, spec *specs.Spec) (*Config, error) { } cfg := &Config{ TrackerIgnoreLocalhost: DefaultTrackerIgnoreLocalhost, + CapacityRequest: DefaultCapacityRequest, } e, err := os.Executable() if err != nil { diff --git a/cmd/installer/main.go b/cmd/installer/main.go index fd484a5..8b00862 100644 --- a/cmd/installer/main.go +++ b/cmd/installer/main.go @@ -40,6 +40,7 @@ var ( installTimeout = flag.Duration("timeout", time.Minute, "duration the installer waits for the installation to complete") versionFlag = flag.Bool("version", false, "output version and exit") trackerIgnoreLocalhost = flag.Bool("tracker-ignore-localhost", v1.DefaultTrackerIgnoreLocalhost, "set to ignore traffic from localhost in socket tracker") + capacityRequest = flag.Bool("capacity-request", v1.DefaultCapacityRequest, "enable shim to make a capacity request before restoring") //lint:ignore U1000 kept for compatibility probeBinaryName = flag.String("probe-binary-name", v1.DefaultProbeBinaryName, "Deprecated: this is no longer used, flag will be removed in future release") @@ -233,6 +234,7 @@ func installRuntime(ctx context.Context, runtime containerRuntime) error { b, err := json.MarshalIndent(&v1.Config{ TrackerIgnoreLocalhost: *trackerIgnoreLocalhost, + CapacityRequest: *capacityRequest, }, "", " ") if err != nil { return fmt.Errorf("marshaling config: %w", err) diff --git a/config/capacity-request/kustomization.yaml b/config/capacity-request/kustomization.yaml new file mode 100644 index 0000000..0b846ae --- /dev/null +++ b/config/capacity-request/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +patches: + - patch: |- + - op: add + path: /spec/template/spec/initContainers/0/args/- + value: -capacity-request=true + target: + kind: DaemonSet diff --git a/config/kind/kustomization.yaml b/config/kind/kustomization.yaml index 49021cf..d42a672 100644 --- a/config/kind/kustomization.yaml +++ b/config/kind/kustomization.yaml @@ -6,6 +6,7 @@ components: - ../status-labels - ../status-events - ../migration-manager + - ../capacity-request images: - name: manager newName: ghcr.io/ctrox/zeropod-manager diff --git a/shim/restore_capacity.go b/shim/restore_capacity.go index bd43325..ac6df2a 100644 --- a/shim/restore_capacity.go +++ b/shim/restore_capacity.go @@ -11,6 +11,9 @@ import ( ) func (c *Container) restoreCapacityRequest(ctx context.Context) (*nodev1.RestoreCapacityResponse, error) { + if !c.cfg.CapacityRequest { + return &nodev1.RestoreCapacityResponse{Allowed: true}, nil + } conn, err := net.Dial("unix", nodev1.SocketPath) if err != nil { return nil, fmt.Errorf("%w: dialing node service: %w", ErrRestoreDial, err)