Skip to content

Commit 597344e

Browse files
committed
This PR sets a preferredDuringSchedulingIgnoredDuringExecution PodAffinity
with a HostName topology. This does a pretty decent job of grouping together GameServer Pods. It does tend to distribute more widely when large groups of GameServer Pods get created, but it's worth experimenting with the first, before going the more risky route of a custom scheduler (in which we've already found some issues). We may also find as GameServers shut down at the end of sessions, they start to group together when they reschedule, as at lower load, the scheduler tends to do a better job of packing. Working towards agones-dev#368
1 parent 8bbcecb commit 597344e

8 files changed

Lines changed: 175 additions & 59 deletions

File tree

docs/scheduling_autoscaling.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
> Autoscaling is currently ongoing work within Agones. The work you see here is just the beginning.
66
7-
87
Table of Contents
98
=================
109

@@ -37,6 +36,12 @@ To facilitate autoscaling, we need to combine several piece of concepts and func
3736
Allocation scheduling refers to the order in which `GameServers`, and specifically their backing `Pods` are chosen
3837
from across the Kubernetes cluster within a given `Fleet` when [allocation](./create_fleet.md#4-allocate-a-game-server-from-the-fleet) occurs.
3938

39+
### Pod Scheduling
40+
41+
Each `GameServer` is backed by a Kubernetes [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/pod/). Pod scheduling
42+
refers to the strategy that is in place that determines which node in the Kubernetes cluster the Pod is assigned to,
43+
when it is created.
44+
4045
## Fleet Scheduling
4146

4247
There are two scheduling strategies for Fleets - each designed for different types of Kubernetes Environments.
@@ -77,6 +82,15 @@ also affect `GameServer` `Pod` scheduling, and `Fleet` scale down scheduling as
7782
Under the "Packed" strategy, allocation will prioritise allocating `GameServers` to nodes that are running on
7883
Nodes that already have allocated `GameServers` running on them.
7984

85+
#### Pod Scheduling Strategy
86+
87+
Under the "Packed" strategy, Pods will be scheduled using the [`PodAffinity`](https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#inter-pod-affinity-and-anti-affinity-beta-feature)
88+
with a `preferredDuringSchedulingIgnoredDuringExecution` affinity with [hostname](https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#interlude-built-in-node-labels)
89+
topology. This attempts to group together `GameServer` Pods within as few nodes in the cluster as it can.
90+
91+
> The default Kubernetes scheduler doesn't do a perfect job of packing, but it's a good enough job for what we need -
92+
at least at this stage.
93+
8094
### Distributed
8195

8296
```yaml
@@ -111,3 +125,8 @@ also affect `GameServer` `Pod` scheduling, and `Fleet` scaledown scheduling as w
111125

112126
Under the "Distributed" strategy, allocation will prioritise allocating `GameSerers` to nodes that have the least
113127
number of allocated `GameServers` on them.
128+
129+
#### Pod Scheduling Strategy
130+
131+
Under the "Distributed" strategy, `Pod` scheduling is provided by the default Kubernetes scheduler, which will attempt
132+
to distribute the `GameServer` `Pods` across as many nodes as possible.

pkg/apis/stable/v1alpha1/fleet.go

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,6 @@ import (
2222
)
2323

2424
const (
25-
// Packed scheduling strategy will prioritise allocating GameServers
26-
// on Nodes with the most Allocated, and then Ready GameServers
27-
// to bin pack as many Allocated GameServers on a single node.
28-
// This is most useful for dynamic Kubernetes clusters - such as on Cloud Providers.
29-
// In future versions, this will also impact Fleet scale down, and Pod Scheduling.
30-
Packed SchedulingStrategy = "Packed"
31-
32-
// Distributed scheduling strategy will prioritise allocating GameServers
33-
// on Nodes with the least Allocated, and then Ready GameServers
34-
// to distribute Allocated GameServers across many nodes.
35-
// This is most useful for statically sized Kubernetes clusters - such as on physical hardware.
36-
// In future versions, this will also impact Fleet scale down, and Pod Scheduling.
37-
Distributed SchedulingStrategy = "Distributed"
38-
3925
// FleetGameServerSetLabel is the label that the name of the Fleet
4026
// is set to on the GameServerSet the Fleet controls
4127
FleetGameServerSetLabel = stable.GroupName + "/fleet"
@@ -93,7 +79,8 @@ func (f *Fleet) GameServerSet() *GameServerSet {
9379
gsSet := &GameServerSet{
9480
ObjectMeta: *f.Spec.Template.ObjectMeta.DeepCopy(),
9581
Spec: GameServerSetSpec{
96-
Template: f.Spec.Template,
82+
Template: f.Spec.Template,
83+
Scheduling: f.Spec.Scheduling,
9784
},
9885
}
9986

pkg/apis/stable/v1alpha1/fleet_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ func TestFleetGameServerSetGameServer(t *testing.T) {
3131
UID: "1234",
3232
},
3333
Spec: FleetSpec{
34-
Replicas: 10,
34+
Replicas: 10,
35+
Scheduling: Packed,
3536
Template: GameServerTemplateSpec{
3637
Spec: GameServerSpec{
3738
Ports: []GameServerPort{{ContainerPort: 1234}},
@@ -51,6 +52,7 @@ func TestFleetGameServerSetGameServer(t *testing.T) {
5152
assert.Equal(t, f.ObjectMeta.Name+"-", gsSet.ObjectMeta.GenerateName)
5253
assert.Equal(t, f.ObjectMeta.Name, gsSet.ObjectMeta.Labels[FleetGameServerSetLabel])
5354
assert.Equal(t, int32(0), gsSet.Spec.Replicas)
55+
assert.Equal(t, f.Spec.Scheduling, gsSet.Spec.Scheduling)
5456
assert.Equal(t, f.Spec.Template, gsSet.Spec.Template)
5557
assert.True(t, v1.IsControlledBy(gsSet, &f))
5658
}

pkg/apis/stable/v1alpha1/gameserver.go

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ type GameServerSpec struct {
117117
Ports []GameServerPort `json:"ports"`
118118
// Health configures health checking
119119
Health Health `json:"health,omitempty"`
120+
// Scheduling strategy. Defaults to "Packed".
121+
Scheduling SchedulingStrategy `json:"scheduling"`
120122
// Template describes the Pod that will be created for the GameServer
121123
Template corev1.PodTemplateSpec `json:"template"`
122124
}
@@ -182,6 +184,7 @@ func (gs *GameServer) ApplyDefaults() {
182184
gs.applyPortDefaults()
183185
gs.applyStateDefaults()
184186
gs.applyHealthDefaults()
187+
gs.applySchedulingDefaults()
185188
}
186189

187190
// applyContainerDefaults applues the container defaults
@@ -230,6 +233,12 @@ func (gs *GameServer) applyPortDefaults() {
230233
}
231234
}
232235

236+
func (gs *GameServer) applySchedulingDefaults() {
237+
if gs.Spec.Scheduling == "" {
238+
gs.Spec.Scheduling = Packed
239+
}
240+
}
241+
233242
// Validate validates the GameServer configuration.
234243
// If a GameServer is invalid there will be > 0 values in
235244
// the returned array
@@ -289,18 +298,47 @@ func (gs *GameServer) Pod(sidecars ...corev1.Container) (*corev1.Pod, error) {
289298
ObjectMeta: *gs.Spec.Template.ObjectMeta.DeepCopy(),
290299
Spec: *gs.Spec.Template.Spec.DeepCopy(),
291300
}
301+
302+
gs.podObjectMeta(pod)
303+
304+
if pod.Spec.ServiceAccountName == "" {
305+
pod.Spec.ServiceAccountName = SidecarServiceAccountName
306+
}
307+
308+
i, gsContainer, err := gs.FindGameServerContainer()
309+
// this shouldn't happen, but if it does.
310+
if err != nil {
311+
return pod, err
312+
}
313+
314+
for _, p := range gs.Spec.Ports {
315+
cp := corev1.ContainerPort{
316+
ContainerPort: p.ContainerPort,
317+
HostPort: p.HostPort,
318+
Protocol: p.Protocol,
319+
}
320+
gsContainer.Ports = append(gsContainer.Ports, cp)
321+
}
322+
pod.Spec.Containers[i] = gsContainer
323+
324+
pod.Spec.Containers = append(pod.Spec.Containers, sidecars...)
325+
326+
gs.podScheduling(pod)
327+
328+
return pod, nil
329+
}
330+
331+
// podObjectMeta configures the pod ObjectMeta details
332+
func (gs *GameServer) podObjectMeta(pod *corev1.Pod) {
292333
// Switch to GenerateName, so that we always get a Unique name for the Pod, and there
293334
// can be no collisions
294335
pod.ObjectMeta.GenerateName = gs.ObjectMeta.Name + "-"
295336
pod.ObjectMeta.Name = ""
296337
// Pods for GameServers need to stay in the same namespace
297338
pod.ObjectMeta.Namespace = gs.ObjectMeta.Namespace
298339
// Make sure these are blank, just in case
299-
pod.ResourceVersion = ""
300-
if pod.Spec.ServiceAccountName == "" {
301-
pod.Spec.ServiceAccountName = SidecarServiceAccountName
302-
}
303-
pod.UID = ""
340+
pod.ObjectMeta.ResourceVersion = ""
341+
pod.ObjectMeta.UID = ""
304342
if pod.ObjectMeta.Labels == nil {
305343
pod.ObjectMeta.Labels = make(map[string]string, 2)
306344
}
@@ -312,28 +350,34 @@ func (gs *GameServer) Pod(sidecars ...corev1.Container) (*corev1.Pod, error) {
312350
pod.ObjectMeta.Labels[GameServerPodLabel] = gs.ObjectMeta.Name
313351
// store the GameServer container as an annotation, to make lookup at a Pod level easier
314352
pod.ObjectMeta.Annotations[GameServerContainerAnnotation] = gs.Spec.Container
315-
316353
ref := metav1.NewControllerRef(gs, SchemeGroupVersion.WithKind("GameServer"))
317354
pod.ObjectMeta.OwnerReferences = append(pod.ObjectMeta.OwnerReferences, *ref)
355+
}
318356

319-
i, gsContainer, err := gs.FindGameServerContainer()
320-
// this shouldn't happen, but if it does.
321-
if err != nil {
322-
return pod, err
323-
}
357+
// podScheduling applies the Fleet scheduling strategy to the passed in Pod
358+
// this sets the a PreferredDuringSchedulingIgnoredDuringExecution for GameServer
359+
// pods to a host topology. Basically doing a half decent job of packing GameServer
360+
// pods together.
361+
// TODO: update the scheduling doc
362+
func (gs *GameServer) podScheduling(pod *corev1.Pod) {
363+
if gs.Spec.Scheduling == Packed {
364+
if pod.Spec.Affinity == nil {
365+
pod.Spec.Affinity = &corev1.Affinity{}
366+
}
367+
if pod.Spec.Affinity.PodAffinity == nil {
368+
pod.Spec.Affinity.PodAffinity = &corev1.PodAffinity{}
369+
}
324370

325-
for _, p := range gs.Spec.Ports {
326-
cp := corev1.ContainerPort{
327-
ContainerPort: p.ContainerPort,
328-
HostPort: p.HostPort,
329-
Protocol: p.Protocol,
371+
wpat := corev1.WeightedPodAffinityTerm{
372+
Weight: 100,
373+
PodAffinityTerm: corev1.PodAffinityTerm{
374+
TopologyKey: "kubernetes.io/hostname",
375+
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{RoleLabel: GameServerLabelRole}},
376+
},
330377
}
331-
gsContainer.Ports = append(gsContainer.Ports, cp)
332-
}
333-
pod.Spec.Containers[i] = gsContainer
334378

335-
pod.Spec.Containers = append(pod.Spec.Containers, sidecars...)
336-
return pod, nil
379+
pod.Spec.Affinity.PodAffinity.PreferredDuringSchedulingIgnoredDuringExecution = append(pod.Spec.Affinity.PodAffinity.PreferredDuringSchedulingIgnoredDuringExecution, wpat)
380+
}
337381
}
338382

339383
// HasPortPolicy checks if there is a port with a given

pkg/apis/stable/v1alpha1/gameserver_test.go

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ func TestGameServerApplyDefaults(t *testing.T) {
5353
t.Parallel()
5454

5555
type expected struct {
56-
protocol corev1.Protocol
57-
state State
58-
policy PortPolicy
59-
health Health
56+
protocol corev1.Protocol
57+
state State
58+
policy PortPolicy
59+
health Health
60+
scheduling SchedulingStrategy
6061
}
6162
data := map[string]struct {
6263
gameServer GameServer
@@ -74,9 +75,10 @@ func TestGameServerApplyDefaults(t *testing.T) {
7475
},
7576
container: "testing",
7677
expected: expected{
77-
protocol: "UDP",
78-
state: PortAllocation,
79-
policy: Dynamic,
78+
protocol: "UDP",
79+
state: PortAllocation,
80+
policy: Dynamic,
81+
scheduling: Packed,
8082
health: Health{
8183
Disabled: false,
8284
FailureThreshold: 3,
@@ -109,9 +111,10 @@ func TestGameServerApplyDefaults(t *testing.T) {
109111
Status: GameServerStatus{State: "TestState"}},
110112
container: "testing2",
111113
expected: expected{
112-
protocol: "TCP",
113-
state: "TestState",
114-
policy: Static,
114+
protocol: "TCP",
115+
state: "TestState",
116+
policy: Static,
117+
scheduling: Packed,
115118
health: Health{
116119
Disabled: false,
117120
FailureThreshold: 10,
@@ -129,9 +132,10 @@ func TestGameServerApplyDefaults(t *testing.T) {
129132
},
130133
container: "testing",
131134
expected: expected{
132-
protocol: "UDP",
133-
state: Creating,
134-
policy: Static,
135+
protocol: "UDP",
136+
state: Creating,
137+
policy: Static,
138+
scheduling: Packed,
135139
health: Health{
136140
Disabled: false,
137141
FailureThreshold: 3,
@@ -150,9 +154,10 @@ func TestGameServerApplyDefaults(t *testing.T) {
150154
},
151155
container: "testing",
152156
expected: expected{
153-
protocol: "UDP",
154-
state: PortAllocation,
155-
policy: Dynamic,
157+
protocol: "UDP",
158+
state: PortAllocation,
159+
policy: Dynamic,
160+
scheduling: Packed,
156161
health: Health{
157162
Disabled: true,
158163
},
@@ -175,10 +180,11 @@ func TestGameServerApplyDefaults(t *testing.T) {
175180
},
176181
container: "testing",
177182
expected: expected{
178-
protocol: corev1.ProtocolTCP,
179-
state: Creating,
180-
policy: Static,
181-
health: Health{Disabled: true},
183+
protocol: corev1.ProtocolTCP,
184+
state: Creating,
185+
policy: Static,
186+
scheduling: Packed,
187+
health: Health{Disabled: true},
182188
},
183189
},
184190
}
@@ -193,6 +199,7 @@ func TestGameServerApplyDefaults(t *testing.T) {
193199
assert.Equal(t, test.expected.protocol, spec.Ports[0].Protocol)
194200
assert.Equal(t, test.expected.state, test.gameServer.Status.State)
195201
assert.Equal(t, test.expected.health, test.gameServer.Spec.Health)
202+
assert.Equal(t, test.expected.scheduling, test.gameServer.Spec.Scheduling)
196203
})
197204
}
198205
}
@@ -278,6 +285,29 @@ func TestGameServerPod(t *testing.T) {
278285
assert.True(t, metav1.IsControlledBy(pod, fixture))
279286
}
280287

288+
func TestGameServerPodScheduling(t *testing.T) {
289+
fixture := &corev1.Pod{Spec: corev1.PodSpec{}}
290+
291+
t.Run("packed", func(t *testing.T) {
292+
gs := &GameServer{Spec: GameServerSpec{Scheduling: Packed}}
293+
pod := fixture.DeepCopy()
294+
gs.podScheduling(pod)
295+
296+
assert.Len(t, pod.Spec.Affinity.PodAffinity.PreferredDuringSchedulingIgnoredDuringExecution, 1)
297+
wpat := pod.Spec.Affinity.PodAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0]
298+
assert.Equal(t, int32(100), wpat.Weight)
299+
assert.Contains(t, wpat.PodAffinityTerm.LabelSelector.String(), GameServerLabelRole)
300+
assert.Contains(t, wpat.PodAffinityTerm.LabelSelector.String(), RoleLabel)
301+
})
302+
303+
t.Run("distributed", func(t *testing.T) {
304+
gs := &GameServer{Spec: GameServerSpec{Scheduling: Distributed}}
305+
pod := fixture.DeepCopy()
306+
gs.podScheduling(pod)
307+
assert.Empty(t, pod.Spec.Affinity)
308+
})
309+
}
310+
281311
func TestGameServerCountPorts(t *testing.T) {
282312
fixture := &GameServer{Spec: GameServerSpec{Ports: []GameServerPort{
283313
{PortPolicy: Dynamic},

pkg/apis/stable/v1alpha1/gameserverset.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ type GameServerSetList struct {
5656
type GameServerSetSpec struct {
5757
// Replicas are the number of GameServers that should be in this set
5858
Replicas int32 `json:"replicas"`
59+
// Scheduling strategy. Defaults to "Packed".
60+
Scheduling SchedulingStrategy `json:"scheduling"`
5961
// Template the GameServer template to apply for this GameServerSet
6062
Template GameServerTemplateSpec `json:"template"`
6163
}
@@ -93,6 +95,8 @@ func (gsSet *GameServerSet) GameServer() *GameServer {
9395
Spec: *gsSet.Spec.Template.Spec.DeepCopy(),
9496
}
9597

98+
gs.Spec.Scheduling = gsSet.Spec.Scheduling
99+
96100
// Switch to GenerateName, so that we always get a Unique name for the GameServer, and there
97101
// can be no collisions
98102
gs.ObjectMeta.GenerateName = gsSet.ObjectMeta.Name + "-"

0 commit comments

Comments
 (0)