Browse code

Merge pull request #51114 from dperny/memory_flags_for_swarm

Add support for memory swap settings for services

Paweł Gronowski authored on 2025/10/24 17:04:17
Showing 11 changed files
... ...
@@ -57,6 +57,18 @@ keywords: "API, Docker, rcli, REST, documentation"
57 57
 * `POST /containers/create` no longer supports configuring a container-wide MAC address
58 58
   via the container's `Config.MacAddress` field. A container's MAC address can now only 
59 59
   be configured via endpoint settings when connecting to a network.
60
+* `GET /services` now returns `SwapBytes` and `MemorySwappiness` fields as part
61
+  of the `Resource` requirements.
62
+* `GET /services/{id}` now returns `SwapBytes` and `MemorySwappiness` fields as
63
+  part of the `Resource` requirements.
64
+* `POST /services/create` now accepts `SwapBytes` and `MemorySwappiness` fields
65
+  as part of the `Resource` requirements.
66
+* `POST /services/{id}/update` now accepts `SwapBytes` and `MemorySwappiness`
67
+  fields as part of the `Resource` requirements.
68
+* `GET /tasks` now  returns `SwapBytes` and `MemorySwappiness` fields as part
69
+  of the `Resource` requirements.
70
+* `GET /tasks/{id}` now  returns `SwapBytes` and `MemorySwappiness` fields as
71
+  part of the `Resource` requirements.
60 72
 
61 73
 ## v1.51 API changes
62 74
 
... ...
@@ -4287,6 +4287,29 @@ definitions:
4287 4287
           Reservations:
4288 4288
             description: "Define resources reservation."
4289 4289
             $ref: "#/definitions/ResourceObject"
4290
+          SwapBytes:
4291
+            description: |
4292
+              Amount of swap in bytes - can only be used together with a memory limit.
4293
+              If not specified, the default behaviour is to grant a swap space twice
4294
+              as big as the memory limit.
4295
+              Set to -1 to enable unlimited swap.
4296
+            type: "integer"
4297
+            format: "int64"
4298
+            minimum: -1
4299
+            x-nullable: true
4300
+            x-omitempty: true
4301
+          MemorySwappiness:
4302
+            description: |
4303
+              Tune the service's containers' memory swappiness (0 to 100).
4304
+              If not specified, defaults to the containers' OS' default, generally 60,
4305
+              or whatever value was predefined in the image.
4306
+              Set to -1 to unset a previously set value.
4307
+            type: "integer"
4308
+            format: "int64"
4309
+            minimum: -1
4310
+            maximum: 100
4311
+            x-nullable: true
4312
+            x-omitempty: true
4290 4313
       RestartPolicy:
4291 4314
         description: |
4292 4315
           Specification for the restart policy which applies to containers
... ...
@@ -4287,6 +4287,29 @@ definitions:
4287 4287
           Reservations:
4288 4288
             description: "Define resources reservation."
4289 4289
             $ref: "#/definitions/ResourceObject"
4290
+          SwapBytes:
4291
+            description: |
4292
+              Amount of swap in bytes - can only be used together with a memory limit.
4293
+              If not specified, the default behaviour is to grant a swap space twice
4294
+              as big as the memory limit.
4295
+              Set to -1 to enable unlimited swap.
4296
+            type: "integer"
4297
+            format: "int64"
4298
+            minimum: -1
4299
+            x-nullable: true
4300
+            x-omitempty: true
4301
+          MemorySwappiness:
4302
+            description: |
4303
+              Tune the service's containers' memory swappiness (0 to 100).
4304
+              If not specified, defaults to the containers' OS' default, generally 60,
4305
+              or whatever value was predefined in the image.
4306
+              Set to -1 to unset a previously set value.
4307
+            type: "integer"
4308
+            format: "int64"
4309
+            minimum: -1
4310
+            maximum: 100
4311
+            x-nullable: true
4312
+            x-omitempty: true
4290 4313
       RestartPolicy:
4291 4314
         description: |
4292 4315
           Specification for the restart policy which applies to containers
... ...
@@ -138,6 +138,17 @@ type DiscreteGenericResource struct {
138 138
 type ResourceRequirements struct {
139 139
 	Limits       *Limit     `json:",omitempty"`
140 140
 	Reservations *Resources `json:",omitempty"`
141
+
142
+	// Amount of swap in bytes - can only be used together with a memory limit
143
+	// -1 means unlimited
144
+	// a null pointer keeps the default behaviour of granting twice the memory
145
+	// amount in swap
146
+	SwapBytes *int64 `json:"SwapBytes,omitzero"`
147
+
148
+	// Tune container memory swappiness (0 to 100) - if not specified, defaults
149
+	// to the container OS's default - generally 60, or the value predefined in
150
+	// the image; set to -1 to unset a previously set value
151
+	MemorySwappiness *int64 `json:MemorySwappiness,omitzero"`
141 152
 }
142 153
 
143 154
 // Placement represents orchestration parameters.
... ...
@@ -178,13 +178,18 @@ func ServiceSpecToGRPC(s types.ServiceSpec) (swarmapi.ServiceSpec, error) {
178 178
 		taskNetworks = append(taskNetworks, netConfig)
179 179
 	}
180 180
 
181
+	resources, err := resourcesToGRPC(s.TaskTemplate.Resources)
182
+	if err != nil {
183
+		return swarmapi.ServiceSpec{}, err
184
+	}
185
+
181 186
 	spec := swarmapi.ServiceSpec{
182 187
 		Annotations: swarmapi.Annotations{
183 188
 			Name:   name,
184 189
 			Labels: s.Labels,
185 190
 		},
186 191
 		Task: swarmapi.TaskSpec{
187
-			Resources:   resourcesToGRPC(s.TaskTemplate.Resources),
192
+			Resources:   resources,
188 193
 			LogDriver:   driverToGRPC(s.TaskTemplate.LogDriver),
189 194
 			Networks:    taskNetworks,
190 195
 			ForceUpdate: s.TaskTemplate.ForceUpdate,
... ...
@@ -439,11 +444,20 @@ func resourcesFromGRPC(ts *swarmapi.TaskSpec) *types.ResourceRequirements {
439 439
 				GenericResources: GenericResourcesFromGRPC(res.Reservations.Generic),
440 440
 			}
441 441
 		}
442
+		resources.SwapBytes = int64PointerFromGRPC(res.SwapBytes)
443
+		resources.MemorySwappiness = int64PointerFromGRPC(res.MemorySwappiness)
442 444
 	}
443 445
 
444 446
 	return resources
445 447
 }
446 448
 
449
+func int64PointerFromGRPC(v *gogotypes.Int64Value) *int64 {
450
+	if v == nil {
451
+		return nil
452
+	}
453
+	return &v.Value
454
+}
455
+
447 456
 // GenericResourcesToGRPC converts a GenericResource to a GRPC GenericResource
448 457
 func GenericResourcesToGRPC(genericRes []types.GenericResource) []*swarmapi.GenericResource {
449 458
 	var generic []*swarmapi.GenericResource
... ...
@@ -462,7 +476,7 @@ func GenericResourcesToGRPC(genericRes []types.GenericResource) []*swarmapi.Gene
462 462
 	return generic
463 463
 }
464 464
 
465
-func resourcesToGRPC(res *types.ResourceRequirements) *swarmapi.ResourceRequirements {
465
+func resourcesToGRPC(res *types.ResourceRequirements) (*swarmapi.ResourceRequirements, error) {
466 466
 	var reqs *swarmapi.ResourceRequirements
467 467
 	if res != nil {
468 468
 		reqs = &swarmapi.ResourceRequirements{}
... ...
@@ -480,8 +494,21 @@ func resourcesToGRPC(res *types.ResourceRequirements) *swarmapi.ResourceRequirem
480 480
 				Generic:     GenericResourcesToGRPC(res.Reservations.GenericResources),
481 481
 			}
482 482
 		}
483
+		reqs.SwapBytes = int64PointerToGRPC(res.SwapBytes)
484
+		reqs.MemorySwappiness = int64PointerToGRPC(res.MemorySwappiness)
485
+
486
+		if reqs.SwapBytes != nil && (reqs.Limits == nil || reqs.Limits.MemoryBytes == 0) {
487
+			return nil, errors.New("memory swap provided, but no memory-limit was set")
488
+		}
489
+	}
490
+	return reqs, nil
491
+}
492
+
493
+func int64PointerToGRPC(v *int64) *gogotypes.Int64Value {
494
+	if v == nil {
495
+		return nil
483 496
 	}
484
-	return reqs
497
+	return &gogotypes.Int64Value{Value: *v}
485 498
 }
486 499
 
487 500
 func restartPolicyFromGRPC(p *swarmapi.RestartPolicy) *types.RestartPolicy {
... ...
@@ -527,6 +527,20 @@ func (c *containerConfig) resources() container.Resources {
527 527
 
528 528
 	if r.Limits.MemoryBytes > 0 {
529 529
 		resources.Memory = r.Limits.MemoryBytes
530
+
531
+		if r.SwapBytes != nil {
532
+			if swapBytes := r.SwapBytes.Value; swapBytes == -1 {
533
+				// means unlimited
534
+				resources.MemorySwap = -1
535
+			} else if swapBytes >= 0 {
536
+				// resources.MemorySwap is actually the sum of the memory + the swap
537
+				resources.MemorySwap = resources.Memory + swapBytes
538
+			}
539
+		}
540
+	}
541
+
542
+	if r.MemorySwappiness != nil {
543
+		resources.MemorySwappiness = &r.MemorySwappiness.Value
530 544
 	}
531 545
 
532 546
 	if r.Limits.NanoCPUs > 0 {
... ...
@@ -77,6 +77,14 @@ func adjustForAPIVersion(cliVersion string, service *serviceWithLegacy) {
77 77
 	if cliVersion == "" {
78 78
 		return
79 79
 	}
80
+
81
+	if versions.LessThan(cliVersion, "1.52") {
82
+		if service.TaskTemplate.Resources != nil {
83
+			service.TaskTemplate.Resources.SwapBytes = nil
84
+			service.TaskTemplate.Resources.MemorySwappiness = nil
85
+		}
86
+	}
87
+
80 88
 	if versions.LessThan(cliVersion, "1.46") {
81 89
 		if service.TaskTemplate.ContainerSpec != nil {
82 90
 			for i, mount := range service.TaskTemplate.ContainerSpec.Mounts {
... ...
@@ -11,6 +11,9 @@ import (
11 11
 
12 12
 func TestAdjustForAPIVersion(t *testing.T) {
13 13
 	expectedSysctls := map[string]string{"foo": "bar"}
14
+	swapBytes := int64(12)
15
+	memorySwappiness := int64(28)
16
+
14 17
 	// testing the negative -- does this leave everything else alone? -- is
15 18
 	// prohibitively time-consuming to write, because it would need an object
16 19
 	// with literally every field filled in.
... ...
@@ -66,6 +69,8 @@ func TestAdjustForAPIVersion(t *testing.T) {
66 66
 				Limits: &swarm.Limit{
67 67
 					Pids: 300,
68 68
 				},
69
+				SwapBytes:        &swapBytes,
70
+				MemorySwappiness: &memorySwappiness,
69 71
 			},
70 72
 		},
71 73
 	}
... ...
@@ -73,6 +78,16 @@ func TestAdjustForAPIVersion(t *testing.T) {
73 73
 	spec := &serviceWithLegacy{
74 74
 		ServiceSpec: serviceSpec,
75 75
 	}
76
+
77
+	adjustForAPIVersion("1.52", spec)
78
+	if spec.TaskTemplate.Resources.MemorySwappiness == nil {
79
+		t.Error("SwapBytes was stripped from spec")
80
+	}
81
+
82
+	if spec.TaskTemplate.Resources.SwapBytes == nil {
83
+		t.Error("MemorySwappiness was stripped from spec")
84
+	}
85
+
76 86
 	adjustForAPIVersion("1.46", spec)
77 87
 	if !reflect.DeepEqual(
78 88
 		spec.TaskTemplate.ContainerSpec.Mounts[0].TmpfsOptions.Options,
... ...
@@ -142,4 +157,11 @@ func TestAdjustForAPIVersion(t *testing.T) {
142 142
 	if len(spec.TaskTemplate.ContainerSpec.Ulimits) != 0 {
143 143
 		t.Error("Ulimits were not stripped from spec")
144 144
 	}
145
+	if spec.TaskTemplate.Resources.MemorySwappiness != nil {
146
+		t.Error("SwapBytes was not stripped from spec")
147
+	}
148
+
149
+	if spec.TaskTemplate.Resources.SwapBytes != nil {
150
+		t.Error("MemorySwappiness was not stripped from spec")
151
+	}
145 152
 }
... ...
@@ -202,6 +202,21 @@ func ServiceWithPidsLimit(limit int64) ServiceSpecOpt {
202 202
 	}
203 203
 }
204 204
 
205
+func ServiceWithMemorySwap(swap *int64, memoryLimit int64) ServiceSpecOpt {
206
+	return func(spec *swarmtypes.ServiceSpec) {
207
+		ensureResources(spec)
208
+		spec.TaskTemplate.Resources.SwapBytes = swap
209
+		spec.TaskTemplate.Resources.Limits.MemoryBytes = memoryLimit
210
+	}
211
+}
212
+
213
+func ServiceWithMemorySwappiness(swappiness *int64) ServiceSpecOpt {
214
+	return func(spec *swarmtypes.ServiceSpec) {
215
+		ensureResources(spec)
216
+		spec.TaskTemplate.Resources.MemorySwappiness = swappiness
217
+	}
218
+}
219
+
205 220
 // GetRunningTasks gets the list of running tasks for a service
206 221
 func GetRunningTasks(ctx context.Context, t *testing.T, c client.ServiceAPIClient, serviceID string) []swarmtypes.Task {
207 222
 	t.Helper()
... ...
@@ -8,6 +8,7 @@ import (
8 8
 	"time"
9 9
 
10 10
 	cerrdefs "github.com/containerd/errdefs"
11
+	"github.com/docker/go-units"
11 12
 	"github.com/moby/moby/api/types/container"
12 13
 	swarmtypes "github.com/moby/moby/api/types/swarm"
13 14
 	"github.com/moby/moby/client"
... ...
@@ -464,3 +465,162 @@ func TestCreateServiceCapabilities(t *testing.T) {
464 464
 	assert.DeepEqual(t, result.Service.Spec.TaskTemplate.ContainerSpec.CapabilityAdd, capAdd)
465 465
 	assert.DeepEqual(t, result.Service.Spec.TaskTemplate.ContainerSpec.CapabilityDrop, capDrop)
466 466
 }
467
+
468
+func TestCreateServiceMemorySwap(t *testing.T) {
469
+	ctx := setupTest(t)
470
+	d := swarm.NewSwarm(ctx, t, testEnv)
471
+	defer d.Stop(t)
472
+	apiClient := d.NewClientT(t)
473
+	defer apiClient.Close()
474
+
475
+	toPtr := func(v int64) *int64 { return &v }
476
+	tests := []struct {
477
+		testName string
478
+
479
+		swapSpec  *int64
480
+		limitSpec int64
481
+
482
+		// as reported by Docker
483
+		expectedDockerSwap int64
484
+	}{
485
+		{
486
+			testName: "default",
487
+		},
488
+		{
489
+			testName:           "memory-limit and memory-swap",
490
+			swapSpec:           toPtr(1 * units.MiB),
491
+			limitSpec:          20 * units.MiB,
492
+			expectedDockerSwap: 21 * units.MiB,
493
+		},
494
+		{
495
+			testName:           "memory-limit alone - should default to twice as much swap",
496
+			limitSpec:          20 * units.MiB,
497
+			expectedDockerSwap: 40 * units.MiB,
498
+		},
499
+		{
500
+			testName:           "memory-limit and zero memory-swap",
501
+			swapSpec:           toPtr(0),
502
+			limitSpec:          20 * units.MiB,
503
+			expectedDockerSwap: 20 * units.MiB,
504
+		},
505
+		{
506
+			testName:           "memory-limit and unlimited memory-swap",
507
+			swapSpec:           toPtr(-1),
508
+			limitSpec:          20 * units.MiB,
509
+			expectedDockerSwap: -1,
510
+		},
511
+	}
512
+
513
+	for _, testCase := range tests {
514
+		t.Run("service create with "+testCase.testName, func(t *testing.T) {
515
+			serviceID := swarm.CreateService(
516
+				ctx, t, d,
517
+				swarm.ServiceWithMemorySwap(testCase.swapSpec, testCase.limitSpec),
518
+			)
519
+			poll.WaitOn(t, swarm.RunningTasksCount(ctx, apiClient, serviceID, 1))
520
+
521
+			service, _, err := apiClient.ServiceInspectWithRaw(ctx, serviceID, client.ServiceInspectOptions{})
522
+			assert.NilError(t, err)
523
+
524
+			filter := make(client.Filters)
525
+			filter.Add("service", serviceID)
526
+			tasks, err := apiClient.TaskList(ctx, client.TaskListOptions{
527
+				Filters: filter,
528
+			})
529
+			assert.NilError(t, err)
530
+			assert.Check(t, is.Equal(len(tasks), 1))
531
+			task := tasks[0]
532
+
533
+			if testCase.swapSpec == nil {
534
+				assert.Check(t, is.Nil(task.Spec.Resources.SwapBytes))
535
+				assert.Check(t, is.Nil(service.Spec.TaskTemplate.Resources.SwapBytes))
536
+			} else {
537
+				assert.Equal(t, *testCase.swapSpec, *task.Spec.Resources.SwapBytes)
538
+				assert.Equal(t, *testCase.swapSpec, *service.Spec.TaskTemplate.Resources.SwapBytes)
539
+			}
540
+
541
+			// if the host supports it (see https://github.com/moby/moby/blob/v17.03.2-ce/daemon/daemon_unix.go#L290-L294)
542
+			// then check that the swap option is set on the container, and properly reported by the group FS as well
543
+			if testEnv.DaemonInfo.SwapLimit {
544
+				ctnr, err := apiClient.ContainerInspect(ctx, task.Status.ContainerStatus.ContainerID)
545
+				assert.NilError(t, err)
546
+				assert.Equal(t, testCase.expectedDockerSwap, ctnr.HostConfig.Resources.MemorySwap)
547
+			}
548
+		})
549
+	}
550
+
551
+	t.Run("cannot create a service with a memory swap option without setting a memory limit", func(t *testing.T) {
552
+		serviceOpts := func(spec *swarmtypes.ServiceSpec) {
553
+			if spec.TaskTemplate.Resources == nil {
554
+				spec.TaskTemplate.Resources = &swarmtypes.ResourceRequirements{}
555
+			}
556
+			spec.TaskTemplate.Resources.SwapBytes = toPtr(10 * units.MiB)
557
+		}
558
+
559
+		spec := swarm.CreateServiceSpec(t, serviceOpts)
560
+		_, err := apiClient.ServiceCreate(context.Background(), spec, client.ServiceCreateOptions{})
561
+
562
+		assert.ErrorContains(t, err, "memory swap provided, but no memory-limit was set")
563
+	})
564
+}
565
+
566
+func TestCreateServiceMemorySwappiness(t *testing.T) {
567
+	ctx := setupTest(t)
568
+	d := swarm.NewSwarm(ctx, t, testEnv)
569
+	defer d.Stop(t)
570
+	apiClient := d.NewClientT(t)
571
+	defer apiClient.Close()
572
+
573
+	toPtr := func(v int64) *int64 { return &v }
574
+
575
+	tests := []struct {
576
+		testName       string
577
+		swappinessSpec *int64
578
+	}{
579
+		{testName: "default"},
580
+		{testName: "zero memory-swappiness", swappinessSpec: toPtr(0)},
581
+		{testName: "memory-swappiness", swappinessSpec: toPtr(28)},
582
+	}
583
+
584
+	for _, testCase := range tests {
585
+		t.Run("service create with "+testCase.testName, func(t *testing.T) {
586
+			serviceID := swarm.CreateService(
587
+				ctx, t, d,
588
+				swarm.ServiceWithMemorySwappiness(testCase.swappinessSpec),
589
+			)
590
+			poll.WaitOn(t, swarm.RunningTasksCount(ctx, apiClient, serviceID, 1))
591
+
592
+			filter := make(client.Filters)
593
+			filter.Add("service", serviceID)
594
+			tasks, err := apiClient.TaskList(ctx, client.TaskListOptions{
595
+				Filters: filter,
596
+			})
597
+			assert.NilError(t, err)
598
+			assert.Check(t, is.Equal(len(tasks), 1))
599
+			task := tasks[0]
600
+
601
+			service, _, err := apiClient.ServiceInspectWithRaw(ctx, serviceID, client.ServiceInspectOptions{})
602
+			assert.NilError(t, err)
603
+
604
+			// An earlier version of this test also inspected the container
605
+			// created by Swarm to ensure that MemorySwappiness was set on its
606
+			// HostConfig. However, on systems that do not support
607
+			// MemorySwappiness (the Github Actions platform is one, in late
608
+			// 2025), that field in the HostConfig is nilled out, and a warning
609
+			// is returned. Swarm doesn't do anything with the warning, so the
610
+			// setting is silently ignored. Getting the raw SysInfo can show if
611
+			// MemorySwappiness is supported, but that field is not present in
612
+			// a regular Info API call, and so is not part of
613
+			// testEnv.DaemonInfo and cannot be checked (easily) here. So,
614
+			// ultimately, we'll skip that check in the integration test.
615
+
616
+			if testCase.swappinessSpec == nil {
617
+				assert.Check(t, is.Nil(task.Spec.Resources.MemorySwappiness))
618
+				assert.Check(t, is.Nil(service.Spec.TaskTemplate.Resources.MemorySwappiness))
619
+			} else {
620
+				assert.Equal(t, *testCase.swappinessSpec, *task.Spec.Resources.MemorySwappiness)
621
+				assert.Equal(t, *testCase.swappinessSpec, *service.Spec.TaskTemplate.Resources.MemorySwappiness)
622
+			}
623
+		})
624
+	}
625
+}
... ...
@@ -138,6 +138,17 @@ type DiscreteGenericResource struct {
138 138
 type ResourceRequirements struct {
139 139
 	Limits       *Limit     `json:",omitempty"`
140 140
 	Reservations *Resources `json:",omitempty"`
141
+
142
+	// Amount of swap in bytes - can only be used together with a memory limit
143
+	// -1 means unlimited
144
+	// a null pointer keeps the default behaviour of granting twice the memory
145
+	// amount in swap
146
+	SwapBytes *int64 `json:"SwapBytes,omitzero"`
147
+
148
+	// Tune container memory swappiness (0 to 100) - if not specified, defaults
149
+	// to the container OS's default - generally 60, or the value predefined in
150
+	// the image; set to -1 to unset a previously set value
151
+	MemorySwappiness *int64 `json:MemorySwappiness,omitzero"`
141 152
 }
142 153
 
143 154
 // Placement represents orchestration parameters.