diff --git a/.gitignore b/.gitignore index c36b8fab..7e845a69 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ node_modules/ # VS Code .vscode +.devcontainer/ diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 8f2437e1..72948761 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -33,6 +33,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" + "github.com/docker/go-units" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -617,6 +618,7 @@ func (s *Server) CreateServiceContainer( Memory: spec.Container.Resources.Memory, MemoryReservation: spec.Container.Resources.MemoryReservation, DeviceRequests: spec.Container.Resources.DeviceReservations, + Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits), }, // Restart service containers if they exit or a machine restarts unless they are explicitly stopped. // For one-off containers and batch jobs we plan to use a different service type/mode. @@ -877,6 +879,23 @@ func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions { return dockerOpts } +func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit { + if len(ulimits) == 0 { + return nil + } + + dockerUlimits := make([]*units.Ulimit, 0, len(ulimits)) + for name, u := range ulimits { + dockerUlimits = append(dockerUlimits, &units.Ulimit{ + Name: name, + Soft: u.Soft, + Hard: u.Hard, + }) + } + + return dockerUlimits +} + // verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine. func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error { for _, m := range mounts { diff --git a/pkg/api/resources.go b/pkg/api/resources.go index 8aece7d3..3efe1ce8 100644 --- a/pkg/api/resources.go +++ b/pkg/api/resources.go @@ -19,4 +19,11 @@ type ContainerResources struct { MemoryReservation int64 // Device reservations/requests for access to things like GPUs DeviceReservations []container.DeviceRequest + // Ulimits defines the resource limits for the container. + Ulimits map[string]Ulimit +} + +type Ulimit struct { + Soft int64 + Hard int64 } diff --git a/pkg/api/service.go b/pkg/api/service.go index 84a772ef..235e4369 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -363,6 +363,9 @@ func (s *ContainerSpec) Clone() ContainerSpec { spec.Sysctls[k] = v } } + if s.Resources.Ulimits != nil { + spec.Resources.Ulimits = maps.Clone(s.Resources.Ulimits) + } return spec } diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 86f85f35..487b5666 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -125,6 +125,7 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources { CPU: int64(service.CPUS * 1e9), Memory: int64(service.MemLimit), MemoryReservation: int64(service.MemReservation), + Ulimits: ulimitsFromCompose(service.Ulimits), } // Convert GPU device requests from compose format, appending "gpu" capability. @@ -293,6 +294,33 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol return spec } +func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit { + if len(ulimits) == 0 { + return nil + } + + res := make(map[string]api.Ulimit, len(ulimits)) + for name, u := range ulimits { + soft := u.Soft + hard := u.Hard + if u.Single != 0 { + if soft == 0 { + soft = u.Single + } + if hard == 0 { + hard = u.Single + } + } + + res[name] = api.Ulimit{ + Soft: int64(soft), + Hard: int64(hard), + } + } + + return res +} + // validateServicesExtensions validates extension combinations across all services in the project. func validateServicesExtensions(project *types.Project) error { for _, service := range project.Services { diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index e74dfc4e..3e230c45 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -699,6 +699,94 @@ volumes: } } +func TestServiceSpecFromCompose_Ulimits(t *testing.T) { + tests := []struct { + name string + composeYAML string + expected map[string]api.Ulimit + }{ + { + name: "single ulimit with soft and hard limits", + composeYAML: ` +services: + db: + image: postgres + ulimits: + nofile: + soft: 20000 + hard: 40000 +`, + expected: map[string]api.Ulimit{ + "nofile": { + Soft: 20000, + Hard: 40000, + }, + }, + }, + { + name: "single ulimit with single value (soft=hard)", + composeYAML: ` +services: + db: + image: postgres + ulimits: + nproc: 65535 +`, + expected: map[string]api.Ulimit{ + "nproc": { + Soft: 65535, + Hard: 65535, + }, + }, + }, + { + name: "multiple ulimits", + composeYAML: ` +services: + db: + image: postgres + ulimits: + nofile: + soft: 20000 + hard: 40000 + nproc: 65535 +`, + expected: map[string]api.Ulimit{ + "nofile": { + Soft: 20000, + Hard: 40000, + }, + "nproc": { + Soft: 65535, + Hard: 65535, + }, + }, + }, + { + name: "empty ulimits", + composeYAML: ` +services: + db: + image: postgres + ulimits: {} +`, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, err := LoadProjectFromContent(context.Background(), tt.composeYAML) + require.NoError(t, err) + + spec, err := ServiceSpecFromCompose(project, "db") + require.NoError(t, err) + + assert.Equal(t, tt.expected, spec.Container.Resources.Ulimits) + }) + } +} + func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) { tests := []struct { name string