fix(railpack): stop the idle BuildKit builder and update Railpack to 0.40.1 (#12097)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
Aditya Tripathi
2026-10-07 13:48:16 +02:00
committed by GitHub
co-authored by Andras Bacsai
parent 41a264a2be
commit 3bc90995da
14 changed files with 155 additions and 22 deletions
@@ -28,6 +28,12 @@ class CleanupCancelledDeployment
if (str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$deploymentUuid}"], $server);
$containerStopped = true;
// A removed helper cannot stop the Railpack builder, which keeps the memory of its builds.
if ($deployment->application?->build_pack === 'railpack') {
$helperImage = coolifyHelperImage().':'.getHelperVersion();
instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false);
}
}
} catch (\Throwable $e) {
$firstError ??= $e;
+2 -4
View File
@@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
$buildxMetadataVolume = isDev() && $server->isLocalhost()
? 'coolify-buildx'
: '$HOME/.docker/buildx';
$cleanupLog = [];
@@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
// The prune fails on a stopped builder, so start it first.
railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'),
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
];
+9 -1
View File
@@ -3350,12 +3350,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $variables;
}
/**
* Without the `...` entry, the list replaces the packages that Railpack generates
* (for example libatomic1, which Node.js 25+ needs at runtime).
*/
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
{
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
->filter()
->values();
if (! $packages->contains('...')) {
$packages->prepend('...');
}
foreach (['curl', 'wget'] as $package) {
if (! $packages->contains($package)) {
$packages->push($package);
@@ -3439,7 +3447,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
." {$this->workdir}";
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')');
}
private function decode_railpack_config(string $config, string $source): array
+34
View File
@@ -2109,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString):
return $modifiedCommand ?? $command;
}
/**
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
*
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
*/
function railpackBuilderLockedScript(string $command): string
{
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
."{$command}; status=\$?; "
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
.'exit $status';
}
/**
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
*/
function railpackBuildxMetadataVolume(Server $server): string
{
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
}
/**
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
* when no build uses it.
*/
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
{
$script = railpackBuilderLockedScript($command);
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
}
+2 -2
View File
@@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600);
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.4.2',
'helper_version' => '1.0.17',
'railpack_version' => '0.23.0',
'helper_version' => '1.0.18',
'railpack_version' => '0.40.1',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
+4 -4
View File
@@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
# https://github.com/railwayapp/railpack/releases
ARG RAILPACK_VERSION=0.23.0
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
ARG MISE_VERSION=2026.3.17
ARG RAILPACK_VERSION=0.40.1
# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt)
ARG MISE_VERSION=2026.9.15
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
FROM ${BASE_IMAGE} AS base
@@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \
RUN mkdir -p ~/.docker/cli-plugins
# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
# Railpack skips its own mise download when this file exists.
RUN mkdir -p /tmp/railpack/mise && \
if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
+1 -1
View File
@@ -7,7 +7,7 @@
"version": "4.5-rc.1"
},
"helper": {
"version": "1.0.17"
"version": "1.0.18"
},
"sentinel": {
"version": "1.0.2"
@@ -1372,7 +1372,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
@@ -1424,7 +1424,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
@@ -179,3 +179,16 @@ test('cancelling after switching to another team still cleans up and starts the
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value);
});
test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) {
$this->application->update(['build_pack' => $buildPack]);
$this->actingAs($this->user);
Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment])
->call('cancel');
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder);
})->with([
'railpack' => ['railpack', true],
'nixpacks' => ['nixpacks', false],
]);
@@ -260,6 +260,23 @@ it('pins docker config while running railpack buildx commands', function () {
->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack');
});
it('stops the railpack builder after the build when no other build uses it', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod($job, $reflection, 'railpack_build_command', ['coollabsio/coolify:test', collect([])]);
$sharedLock = strpos($command, 'flock -s 9');
$build = strpos($command, 'docker buildx build --builder coolify-railpack');
$stop = strpos($command, 'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack');
expect($sharedLock)->not->toBeFalse()
->and($build)->toBeGreaterThan($sharedLock)
->and($stop)->toBeGreaterThan($build)
->and($command)->toEndWith('exit $status');
});
it('filters reserved docker client variables from railpack build secrets', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
@@ -81,13 +81,13 @@ it('generates escaped railpack env args from resolved values and includes instal
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
@@ -136,7 +136,7 @@ it('uses preview railpack environment variables for preview deployments', functi
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
]);
});
@@ -195,7 +195,7 @@ it('merges coolify env variables into railpack build variables', function () {
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
@@ -209,7 +209,7 @@ it('merges coolify env variables into railpack build variables', function () {
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
@@ -257,11 +257,11 @@ it('preserves user railpack deploy apt packages while adding healthcheck tools o
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... ffmpeg curl wget'");
});
@@ -863,3 +863,17 @@ test('parseCommandsByLineForSudo keeps pipes inside quoted strings unchanged', f
expect($result[0])->toBe("sudo docker ps --format '{{.Names}} | {{.Image}}' | sudo grep app");
});
test('railpack builder helper command gets sudo only outside the helper script', function () {
$command = railpackBuilderHelperCommand('$HOME/.docker/buildx', 'coolify-helper:1.0.0', 'docker buildx prune --builder coolify-railpack -af');
$parsed = parseCommandsByLineForSudo(collect([$command]), $this->server)[0];
// The SSH user's shell must expand $HOME, so the line must not be wrapped in `sudo bash -c`.
expect($parsed)->toBe(
'sudo docker run --rm -v $HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock coolify-helper:1.0.0'
." bash -c 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; docker buildx prune --builder coolify-railpack -af; status=$?;"
.' if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi;'
." exit \$status' 2>/dev/null || sudo true"
);
});
+43
View File
@@ -0,0 +1,43 @@
<?php
use Symfony\Component\Process\Process;
require_once __DIR__.'/../../bootstrap/helpers/docker.php';
function runRailpackLockScript(string $command, string $setup = ''): Process
{
$directory = sys_get_temp_dir().'/railpack-lock-'.bin2hex(random_bytes(8));
mkdir($directory);
try {
$script = str_replace('/root/.docker/buildx/coolify-railpack.lock', $directory.'/builder.lock', $setup.railpackBuilderLockedScript($command));
$process = new Process(['bash', '-c', $script]);
$process->run();
return $process;
} finally {
@unlink($directory.'/builder.lock');
rmdir($directory);
}
}
test('a failed shared lock prevents the builder command from running', function () {
$process = runRailpackLockScript('printf build-ran', 'flock() { return 73; }; ');
expect($process->getExitCode())->toBe(73);
expect($process->getOutput())->toBe('');
});
test('the builder stops after a failed command without hiding its exit status', function () {
$process = runRailpackLockScript('(exit 42)', 'exec 3>&1; docker() { printf stopped >&3; return 0; }; ');
expect($process->getExitCode())->toBe(42);
expect($process->getOutput())->toBe('stopped');
});
test('a held shared lock prevents stopping the builder', function () {
$process = runRailpackLockScript('printf built', 'exec 8>/root/.docker/buildx/coolify-railpack.lock; flock -s 8; docker() { exit 99; }; ');
expect($process->getExitCode())->toBe(0);
expect($process->getOutput())->toBe('built');
});
+1 -1
View File
@@ -7,7 +7,7 @@
"version": "4.5-rc.1"
},
"helper": {
"version": "1.0.17"
"version": "1.0.18"
},
"sentinel": {
"version": "1.0.2"