mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Andras Bacsai
parent
41a264a2be
commit
3bc90995da
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@
|
||||
"version": "4.5-rc.1"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.17"
|
||||
"version": "1.0.18"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "1.0.2"
|
||||
|
||||
Reference in New Issue
Block a user