Skip to content

Commit 74332ac

Browse files
authored
Merge pull request opensandbox-group#579 from Spground/feature/public-handle-eviction
feat(k8s): add eviction handler in pool
2 parents 3ff3c81 + 551c52c commit 74332ac

11 files changed

Lines changed: 948 additions & 32 deletions

File tree

‎kubernetes/README-ZH.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ Pool 自定义资源维护一个预热的计算资源池,以实现快速沙箱
3030
- 基于需求的自动资源分配和释放
3131
- 实时状态监控,显示总数、已分配和可用资源
3232

33+
### Pod 驱逐
34+
Pool 支持优雅的 Pod 驱逐,适用于节点维护或资源回收等场景:
35+
36+
**工作原理:**
37+
- 用户通过给 Pod 打上 `pool.opensandbox.io/evict` 标签请求驱逐
38+
- 控制器会跳过已分配给 BatchSandbox 的 Pod(保护使用中的工作负载)
39+
- 空闲 Pod 将被删除,触发池补充容量
40+
- 标记驱逐的 Pod 不会被分配给新的 BatchSandbox
41+
42+
**自定义驱逐行为:**
43+
您可以通过以下方式实现自定义驱逐策略:
44+
1. 在 Pool 上设置 `pool.opensandbox.io/eviction-handler` 标签选择您的处理器
45+
2. 实现 `EvictionHandler` 接口,包含 `NeedsEviction()` 和 `Evict()` 方法
46+
3. 在工厂函数中注册您的处理器
47+
3348
### 任务编排
3449
集成的任务管理系统,在沙箱内执行自定义工作负载:
3550
- **可选执行**:任务调度完全可选 - 可以在不带任务的情况下创建沙箱

‎kubernetes/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ The Pool custom resource maintains a pool of pre-warmed compute resources to ena
3030
- Automatic resource allocation and deallocation based on demand
3131
- Real-time status monitoring showing total, allocated, and available resources
3232

33+
### Pod Eviction
34+
Pool supports graceful pod eviction for scenarios like node maintenance or resource reclamation:
35+
36+
**How it works:**
37+
- Users label a pod with `pool.opensandbox.io/evict` to request eviction
38+
- The controller skips pods already allocated to BatchSandbox (protecting in-use workloads)
39+
- Idle pods are deleted, triggering the pool to replenish capacity
40+
- Pods marked for eviction are excluded from new allocations
41+
42+
**Custom eviction behavior:**
43+
You can implement custom eviction strategies by:
44+
1. Setting `pool.opensandbox.io/eviction-handler` label on the Pool to select your handler
45+
2. Implementing the `EvictionHandler` interface with `NeedsEviction()` and `Evict()` methods
46+
3. Registering your handler in the factory function
47+
3348
### Task Orchestration
3449
Integrated task management system that executes custom workloads within sandboxes:
3550
- **Optional Execution**: Task scheduling is completely optional - sandboxes can be created without tasks

‎kubernetes/internal/controller/allocator.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,8 @@ func (syncer *annoAllocationSyncer) GetRelease(ctx context.Context, sandbox *san
173173
type AllocSpec struct {
174174
// sandboxes need to allocate
175175
Sandboxes []*sandboxv1alpha1.BatchSandbox
176-
// pool
177-
Pool *sandboxv1alpha1.Pool
178-
// all pods of pool
176+
Pool *sandboxv1alpha1.Pool
177+
// all candidate pods
179178
Pods []*corev1.Pod
180179
}
181180

@@ -194,6 +193,7 @@ type SandboxSyncInfo struct {
194193

195194
type Allocator interface {
196195
Schedule(ctx context.Context, spec *AllocSpec) (*AllocStatus, []SandboxSyncInfo, bool, error)
196+
GetPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (map[string]string, error)
197197
PersistPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, status *AllocStatus) error
198198
SyncSandboxAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox, pods []string) error
199199
}
@@ -269,7 +269,7 @@ func (allocator *defaultAllocator) initAllocation(ctx context.Context, spec *All
269269
status := &AllocStatus{
270270
PodAllocation: make(map[string]string),
271271
}
272-
status.PodAllocation, err = allocator.getPodAllocation(ctx, spec.Pool)
272+
status.PodAllocation, err = allocator.GetPoolAllocation(ctx, spec.Pool)
273273
if err != nil {
274274
return nil, err
275275
}
@@ -421,7 +421,7 @@ func (allocator *defaultAllocator) doDeallocate(ctx context.Context, status *All
421421
return deallocate, nil
422422
}
423423

424-
func (allocator *defaultAllocator) getPodAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (map[string]string, error) {
424+
func (allocator *defaultAllocator) GetPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (map[string]string, error) {
425425
alloc, err := allocator.store.GetAllocation(ctx, pool)
426426
if err != nil {
427427
return nil, err

‎kubernetes/internal/controller/allocator_mock.go‎

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package eviction
16+
17+
import (
18+
"context"
19+
20+
corev1 "k8s.io/api/core/v1"
21+
)
22+
23+
type EvictionHandler interface {
24+
NeedsEviction(pod *corev1.Pod) bool
25+
Evict(ctx context.Context, pod *corev1.Pod) error
26+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package eviction
16+
17+
import (
18+
"context"
19+
20+
corev1 "k8s.io/api/core/v1"
21+
"sigs.k8s.io/controller-runtime/pkg/client"
22+
)
23+
24+
const LabelEvict = "pool.opensandbox.io/evict"
25+
26+
type defaultEvictionHandler struct {
27+
client client.Client
28+
}
29+
30+
func newDefaultEvictionHandler(c client.Client) EvictionHandler {
31+
return &defaultEvictionHandler{client: c}
32+
}
33+
34+
func (h *defaultEvictionHandler) NeedsEviction(pod *corev1.Pod) bool {
35+
if pod.DeletionTimestamp != nil {
36+
return false
37+
}
38+
_, ok := pod.Labels[LabelEvict]
39+
return ok
40+
}
41+
42+
func (h *defaultEvictionHandler) Evict(ctx context.Context, pod *corev1.Pod) error {
43+
if pod.DeletionTimestamp != nil {
44+
return nil
45+
}
46+
return h.client.Delete(ctx, pod)
47+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2025 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package eviction
16+
17+
import (
18+
"context"
19+
20+
"sigs.k8s.io/controller-runtime/pkg/client"
21+
22+
sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1"
23+
)
24+
25+
const LabelEvictionHandler = "pool.opensandbox.io/eviction-handler"
26+
27+
func NewEvictionHandler(_ context.Context, c client.Client, pool *sandboxv1alpha1.Pool) EvictionHandler {
28+
switch pool.Labels[LabelEvictionHandler] {
29+
default:
30+
return newDefaultEvictionHandler(c)
31+
}
32+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2025 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package eviction
16+
17+
import (
18+
"context"
19+
"testing"
20+
"time"
21+
22+
corev1 "k8s.io/api/core/v1"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/runtime"
25+
"k8s.io/apimachinery/pkg/types"
26+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
27+
28+
sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1"
29+
)
30+
31+
func newTestPod(name string, labels map[string]string, deleting bool) *corev1.Pod {
32+
pod := &corev1.Pod{
33+
ObjectMeta: metav1.ObjectMeta{
34+
Name: name,
35+
Namespace: "default",
36+
Labels: labels,
37+
},
38+
}
39+
if deleting {
40+
now := metav1.NewTime(time.Now())
41+
pod.DeletionTimestamp = &now
42+
pod.Finalizers = []string{"test-finalizer"}
43+
}
44+
return pod
45+
}
46+
47+
func TestDefaultEvictionHandler_NeedsEviction(t *testing.T) {
48+
handler := newDefaultEvictionHandler(nil)
49+
50+
tests := []struct {
51+
name string
52+
pod *corev1.Pod
53+
expect bool
54+
}{
55+
{
56+
name: "pod with eviction label",
57+
pod: newTestPod("pod-1", map[string]string{LabelEvict: ""}, false),
58+
expect: true,
59+
},
60+
{
61+
name: "pod without eviction label",
62+
pod: newTestPod("pod-2", map[string]string{"other": "label"}, false),
63+
expect: false,
64+
},
65+
{
66+
name: "pod with no labels",
67+
pod: newTestPod("pod-3", nil, false),
68+
expect: false,
69+
},
70+
{
71+
name: "pod with eviction label but already deleting",
72+
pod: newTestPod("pod-4", map[string]string{LabelEvict: ""}, true),
73+
expect: false,
74+
},
75+
{
76+
name: "pod deleting without eviction label",
77+
pod: newTestPod("pod-5", nil, true),
78+
expect: false,
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
got := handler.NeedsEviction(tt.pod)
85+
if got != tt.expect {
86+
t.Errorf("NeedsEviction() = %v, want %v", got, tt.expect)
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestDefaultEvictionHandler_Evict(t *testing.T) {
93+
scheme := runtime.NewScheme()
94+
_ = corev1.AddToScheme(scheme)
95+
96+
t.Run("deletes a pod", func(t *testing.T) {
97+
pod := newTestPod("pod-1", map[string]string{LabelEvict: ""}, false)
98+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pod).Build()
99+
handler := newDefaultEvictionHandler(c)
100+
101+
err := handler.Evict(context.Background(), pod)
102+
if err != nil {
103+
t.Fatalf("Evict() returned error: %v", err)
104+
}
105+
106+
got := &corev1.Pod{}
107+
err = c.Get(context.Background(), keyFor(pod), got)
108+
if err == nil {
109+
t.Error("expected pod to be deleted, but it still exists")
110+
}
111+
})
112+
113+
t.Run("skips pod already deleting", func(t *testing.T) {
114+
pod := newTestPod("pod-2", map[string]string{LabelEvict: ""}, true)
115+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pod).Build()
116+
handler := newDefaultEvictionHandler(c)
117+
118+
err := handler.Evict(context.Background(), pod)
119+
if err != nil {
120+
t.Fatalf("Evict() returned error: %v", err)
121+
}
122+
123+
got := &corev1.Pod{}
124+
err = c.Get(context.Background(), keyFor(pod), got)
125+
if err != nil {
126+
t.Error("expected pod to still exist since it was already deleting")
127+
}
128+
})
129+
}
130+
131+
func TestNewEvictionHandler(t *testing.T) {
132+
scheme := runtime.NewScheme()
133+
_ = corev1.AddToScheme(scheme)
134+
_ = sandboxv1alpha1.AddToScheme(scheme)
135+
c := fake.NewClientBuilder().WithScheme(scheme).Build()
136+
ctx := context.Background()
137+
138+
t.Run("returns default handler when no label", func(t *testing.T) {
139+
pool := &sandboxv1alpha1.Pool{
140+
ObjectMeta: metav1.ObjectMeta{Name: "pool-1"},
141+
}
142+
h := NewEvictionHandler(ctx, c, pool)
143+
if h == nil {
144+
t.Fatal("expected non-nil handler")
145+
}
146+
if _, ok := h.(*defaultEvictionHandler); !ok {
147+
t.Errorf("expected *defaultEvictionHandler, got %T", h)
148+
}
149+
})
150+
151+
t.Run("returns default handler for unknown label value", func(t *testing.T) {
152+
pool := &sandboxv1alpha1.Pool{
153+
ObjectMeta: metav1.ObjectMeta{
154+
Name: "pool-2",
155+
Labels: map[string]string{LabelEvictionHandler: "unknown-handler"},
156+
},
157+
}
158+
h := NewEvictionHandler(ctx, c, pool)
159+
if _, ok := h.(*defaultEvictionHandler); !ok {
160+
t.Errorf("expected *defaultEvictionHandler, got %T", h)
161+
}
162+
})
163+
}
164+
165+
func keyFor(obj metav1.Object) types.NamespacedName {
166+
return types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()}
167+
}

0 commit comments

Comments
 (0)