diff --git a/docs/advanced-usage/resource-management.md b/docs/advanced-usage/resource-management.md index e24b56b32..804397489 100644 --- a/docs/advanced-usage/resource-management.md +++ b/docs/advanced-usage/resource-management.md @@ -32,7 +32,7 @@ Valid resource options include: See the [Supported Resource Management Properties](/docs/deployment/schedulers/docker-local.md#supported-resource-management-properties) section of the docker local scheduler documentation for more information on how each resource limit maps to Docker. -Resource limits and reservations are applied only during the `run` and `deploy` phases of an application, and will not impact the `build` phase of an application. +Resource reservations are applied only during the `run` and `deploy` phases of an application. Resource limits also apply during the `build` phase when explicitly configured against the `build` process type - see [Build-time Resource Limits](#build-time-resource-limits) below. ### Resource Limits @@ -120,6 +120,40 @@ dokku resource:limit --process-type web node-js-app nvidia-gpu: ``` +#### Build-time Resource Limits + +> [!IMPORTANT] +> New as of 0.38.0 + +Resource limits may be applied to the build container by using the special `build` process type. This allows constraining memory and CPU usage during the `build` phase, which is otherwise unconstrained. + +```shell +dokku resource:limit --memory 4g --process-type build node-js-app +``` + +``` +=====> Setting resource limits for node-js-app (build) + memory: 4g +``` + +Build-time limits are applied via the `docker-args-process-build` plugin trigger. They do not inherit from default (`_default_`) limits - only limits explicitly set against the `build` process type are applied at build time. This is intentional: build phases often require more memory than the runtime process, and silently inheriting a small runtime limit would cause confusing OOM failures. + +> [!WARNING] +> Do not use `build` as a runtime process type in your `Procfile`. The `build` name is reserved by the resource plugin to scope limits to the build container, and using it as a Procfile entry will cause those limits to be applied to that runtime container as well. + +Supported limits per builder: + +| Builder | `--cpu` | `--memory` | `--memory-swap` | `--nvidia-gpu` | +|------------|---------|------------|-----------------|----------------| +| herokuish | yes | yes | yes | yes | +| dockerfile | no | yes | yes | no | +| pack | no | no | no | no | +| nixpacks | no | no | no | no | +| railpack | no | no | no | no | +| lambda | no | no | no | no | + +Resource keys outside the supported set for a builder are silently ignored. Reservations (`resource:reserve`) are never applied at build time - only limits. + #### Clearing Resource Limits In cases where the values are incorrect - or there is no desire to limit resources - resource limits may be cleared using the `resource:limit-clear` command. diff --git a/plugins/builder-dockerfile/builder-build b/plugins/builder-dockerfile/builder-build index 751a4c2e6..158c55a1c 100755 --- a/plugins/builder-dockerfile/builder-build +++ b/plugins/builder-dockerfile/builder-build @@ -141,6 +141,28 @@ trigger-builder-dockerfile-builder-build() { DOCKERFILE_ARGS+=("--label" "${1#--label=}") shift 1 ;; + --memory | -m) + DOCKERFILE_ARGS+=("--memory") + DOCKERFILE_ARGS+=("$2") + shift 2 + ;; + --memory=* | -m=*) + if [[ "$1" == "--memory=*" ]]; then + DOCKERFILE_ARGS+=("--memory" "${1#--memory=}") + elif [[ "$1" == "-m=*" ]]; then + DOCKERFILE_ARGS+=("--memory" "${1#-m=}") + fi + shift 1 + ;; + --memory-swap) + DOCKERFILE_ARGS+=("--memory-swap") + DOCKERFILE_ARGS+=("$2") + shift 2 + ;; + --memory-swap=*) + DOCKERFILE_ARGS+=("--memory-swap" "${1#--memory-swap=}") + shift 1 + ;; --network) DOCKERFILE_ARGS+=("--network") DOCKERFILE_ARGS+=("$2") @@ -205,7 +227,7 @@ trigger-builder-dockerfile-builder-build() { shift 1 ;; --ssh) - DOCKERFILE_ARGS+=("--platform") + DOCKERFILE_ARGS+=("--ssh") DOCKERFILE_ARGS+=("$2") shift 2 ;; @@ -213,6 +235,15 @@ trigger-builder-dockerfile-builder-build() { DOCKERFILE_ARGS+=("--ssh" "${1#--ssh=}") shift 1 ;; + --tag) + DOCKERFILE_ARGS+=("--tag") + DOCKERFILE_ARGS+=("$2") + shift 2 + ;; + --tag=*) + DOCKERFILE_ARGS+=("--tag" "${1#--tag=}") + shift 1 + ;; --target) DOCKERFILE_ARGS+=("--target") DOCKERFILE_ARGS+=("$2") @@ -223,12 +254,12 @@ trigger-builder-dockerfile-builder-build() { shift 1 ;; --ulimit) - DOCKERFILE_ARGS+=("--tag") + DOCKERFILE_ARGS+=("--ulimit") DOCKERFILE_ARGS+=("$2") shift 2 ;; - --tag=*) - DOCKERFILE_ARGS+=("--tag" "${1#--tag=}") + --ulimit=*) + DOCKERFILE_ARGS+=("--ulimit" "${1#--ulimit=}") shift 1 ;; --check | -D | --debug | --no-cache) diff --git a/plugins/resource/.gitignore b/plugins/resource/.gitignore index 1e1f0ddd2..6c9bef3b9 100644 --- a/plugins/resource/.gitignore +++ b/plugins/resource/.gitignore @@ -5,6 +5,7 @@ /install /post-* /report +/docker-args-process-build /docker-args-process-deploy /docker-args-process-run /resource-get-property diff --git a/plugins/resource/Makefile b/plugins/resource/Makefile index 9357f9cc2..8260561f5 100644 --- a/plugins/resource/Makefile +++ b/plugins/resource/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/limit subcommands/limit-clear subcommands/report subcommands/reserve subcommands/reserve-clear -TRIGGERS = triggers/docker-args-process-deploy triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report triggers/resource-get-property +TRIGGERS = triggers/docker-args-process-build triggers/docker-args-process-deploy triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report triggers/resource-get-property BUILD = commands subcommands triggers PLUGIN_NAME = resource diff --git a/plugins/resource/src/triggers/triggers.go b/plugins/resource/src/triggers/triggers.go index 01e6a9cfe..8dcb03380 100644 --- a/plugins/resource/src/triggers/triggers.go +++ b/plugins/resource/src/triggers/triggers.go @@ -18,6 +18,10 @@ func main() { var err error switch trigger { + case "docker-args-process-build": + appName := flag.Arg(0) + builderType := flag.Arg(1) + err = resource.TriggerDockerArgsProcessBuild(appName, builderType) case "docker-args-process-deploy": appName := flag.Arg(0) processType := flag.Arg(3) diff --git a/plugins/resource/triggers.go b/plugins/resource/triggers.go index 8e3746ae9..6994c69d1 100644 --- a/plugins/resource/triggers.go +++ b/plugins/resource/triggers.go @@ -91,6 +91,84 @@ func TriggerDockerArgsProcessDeploy(appName string, processType string) error { return nil } +// TriggerDockerArgsProcessBuild outputs build-phase docker options for a builder +func TriggerDockerArgsProcessBuild(appName string, builderType string) error { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + if os.Getenv("DOKKU_OMIT_RESOURCE_ARGS") == "1" { + fmt.Print(string(stdin)) + return nil + } + + allowed := validBuildLimitsForBuilder(builderType) + if len(allowed) == 0 { + fmt.Print(string(stdin)) + return nil + } + + resources, err := common.PropertyGetAll("resource", appName) + if err != nil { + fmt.Print(string(stdin)) + return nil + } + + limits := make(map[string]string) + prefix := "build.limit." + for key, value := range resources { + if !strings.HasPrefix(key, prefix) { + continue + } + resourceKey := strings.TrimPrefix(key, prefix) + if !allowed[resourceKey] { + continue + } + + flagName := resourceKey + if resourceKey == "cpu" { + flagName = "cpus" + } + if resourceKey == "nvidia-gpu" { + flagName = "gpus" + } + limits[flagName] = value + } + + for key, value := range limits { + if value == "" { + continue + } + value = addMemorySuffixForDocker(key, value) + fmt.Printf(" --%s=%s ", key, value) + } + + fmt.Print(string(stdin)) + return nil +} + +// validBuildLimitsForBuilder returns the resource keys (in property-key form) +// that the named builder can apply during the build phase. An empty map means +// the builder does not support build-phase resource limits. +func validBuildLimitsForBuilder(builderType string) map[string]bool { + switch builderType { + case "herokuish": + return map[string]bool{ + "cpu": true, + "memory": true, + "memory-swap": true, + "nvidia-gpu": true, + } + case "dockerfile": + return map[string]bool{ + "memory": true, + "memory-swap": true, + } + } + return nil +} + // TriggerInstall runs the install step for the resource plugin func TriggerInstall() error { if err := common.PropertySetup("resource"); err != nil { diff --git a/tests/unit/resource_3.bats b/tests/unit/resource_3.bats new file mode 100644 index 000000000..687c1a512 --- /dev/null +++ b/tests/unit/resource_3.bats @@ -0,0 +1,131 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(resource) resource:limit --process-type build (herokuish)" { + run /bin/bash -c "dokku resource:limit --memory 256m --cpu 1 --memory-swap 512m --nvidia-gpu 1 --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_output "256m" + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--memory=256m" + assert_output_contains "--cpus=1" + assert_output_contains "--memory-swap=512m" + assert_output_contains "--gpus=1" +} + +@test "(resource) resource:limit --process-type build (dockerfile filters cpu and gpu)" { + run /bin/bash -c "dokku resource:limit --memory 512m --cpu 1 --nvidia-gpu 1 --memory-swap 1g --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP dockerfile < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--memory=512m" + assert_output_contains "--memory-swap=1g" + assert_output_not_contains "--cpus=" + assert_output_not_contains "--gpus=" +} + +@test "(resource) resource:limit --process-type build (pack/lambda emit nothing)" { + run /bin/bash -c "dokku resource:limit --memory 512m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP pack < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP lambda < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit defaults do not leak into build trigger" { + run /bin/bash -c "dokku resource:limit --memory 128m $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit reservations do not apply at build time" { + run /bin/bash -c "dokku resource:reserve --memory 128m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory-reservation" + assert_output_not_contains "--memory" +} + +@test "(resource) DOKKU_OMIT_RESOURCE_ARGS suppresses build trigger" { + run /bin/bash -c "dokku resource:limit --memory 256m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT DOKKU_OMIT_RESOURCE_ARGS=1 plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit-clear --process-type build" { + run /bin/bash -c "dokku resource:limit --memory 256m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_output "256m" + + run /bin/bash -c "dokku resource:limit-clear --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure +}