Modules/businessdev.ALbuild.Containers/Public/New-BcImage.ps1
|
function New-BcImage { <# .SYNOPSIS Builds a version-specific Business Central image with the artifact already installed. .DESCRIPTION A container started from the generic image installs the Business Central artifact into its writable layer on every start. Measured on build 27197 that install is the dominant cost of a runtime-package run - 178 s on BC18 rising to 468 s on BC27, against ~80 s of actual work per product. Baking the install into an image layer moves it out of the per-container path: the install is paid once per platform version and every later container only starts the service tier. The build uses the generic image's own install-only entry point, 'start.ps1 -installOnly', which performs the installation and stops short of creating the instance, generating certificates and starting the service tier. That split matters: 'docker commit' of a fully started container would bake in instance state, self-signed certificates and the container hostname, all of which must be produced fresh at run time. WHY IT IS WORTH BUILDING AT ALL The image only pays for itself on a repeat visit to the same platform version - building it costs an install, same as starting a container once. It wins because platform versions are revisited constantly: every product release walks the whole matrix again, and the weekly sweep revisits the newest versions for every product. It is therefore a cache with a budget, trimmed by Optimize-BcImageCache, not something to build for all ~200 versions. Concurrency: two workers asking for the same image must not both build it. The build is serialised on the same global lock used for the artifact cache, and the presence check is repeated under that lock. Note the BUILD downloads the artifact itself. 'docker build' on Windows cannot bind-mount, so the host's artifact cache is not visible to the build container - unlike a normal container start, which mounts it at C:\dl. That is a one-off cost per platform version, and it is why the image is built once and then reused rather than rebuilt. .PARAMETER ArtifactUrl The artifact to install into the image. .PARAMETER ImageName Image tag. Defaults to Get-BcImageName. .PARAMETER BaseImage The generic image to build on. .PARAMETER MemoryLimit Memory for the BUILD container. Not cosmetic: 'docker build' gives a Windows build container 1 GB by default and Business Central's start script refuses to install below 3 GB, so without this the build fails with "At least 3Gb memory needs to be available to the Container". .PARAMETER Force Rebuild even when the image already exists. .PARAMETER MinFreeDiskGb Refuse to build below this much free disk. An image build that fills the drive mid-layer leaves a large dangling layer behind and makes the next build worse. .PARAMETER TimeoutSeconds Build timeout. .PARAMETER DockerExecutable Docker executable. .EXAMPLE New-BcImage -ArtifactUrl (Find-BcArtifactUrl -Type OnPrem -Country de -Version '26.3' -Select Closest) .OUTPUTS PSCustomObject: ImageName, ArtifactUrl, Built, BuildSeconds, SizeBytes. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ArtifactUrl, [string] $ImageName, [string] $BaseImage = 'mcr.microsoft.com/businesscentral:ltsc2022', [string] $MemoryLimit = '8G', [switch] $Force, [ValidateRange(0, [int]::MaxValue)] [int] $MinFreeDiskGb = 60, [ValidateRange(1, [int]::MaxValue)] [int] $TimeoutSeconds = 3600, [string] $DockerExecutable = 'docker' ) Test-BcPlatform -Require Test-BcDocker -Require -DockerExecutable $DockerExecutable if (-not $ImageName) { $ImageName = Get-BcImageName -ArtifactUrl $ArtifactUrl -BaseImage $BaseImage } $exists = { (Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -PassThru -SuccessExitCodes @(0, 1) ` -Arguments @('image', 'inspect', '--format', '{{.Id}}', $ImageName)).ExitCode -eq 0 } if ((& $exists) -and -not $Force) { Write-ALbuildLog "Image '$ImageName' already exists; reusing it." return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = (Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable) } } if (-not $PSCmdlet.ShouldProcess($ImageName, "Build a Business Central image for $ArtifactUrl")) { return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = 0 } } # Serialise with the artifact cache lock family so two workers cannot build the same image twice. $mutex = New-Object System.Threading.Mutex($false, (Get-ALbuildCacheLockName -Path "bcimage:$ImageName")) $held = $false $started = Get-Date try { try { $held = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) } catch [System.Threading.AbandonedMutexException] { $held = $true } # prior holder crashed; we own it # Re-check under the lock: another worker may have built it while we waited. if ((& $exists) -and -not $Force) { Write-ALbuildLog "Image '$ImageName' was built by a concurrent build; reusing it." return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = (Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable) } } if ($MinFreeDiskGb -gt 0) { $disk = Get-BcHostFreeDiskGb -DockerExecutable $DockerExecutable if ($disk -and $disk.FreeGb -lt $MinFreeDiskGb) { throw ("Only $($disk.FreeGb) GB free on $($disk.Drive); building '$ImageName' needs at least $MinFreeDiskGb GB. " + 'Run Optimize-BcImageCache to trim the image cache, or lower -MinFreeDiskGb if you know the drive can take it.') } } $context = Join-Path ([System.IO.Path]::GetTempPath()) ('albuild-img-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Force -Path $context | Out-Null try { # Labels make the cache self-describing and, crucially, prune-protectable: Clear-ALbuildCache # excludes 'albuild.image' so the emergency prune cannot delete this cache. $dockerfile = @" FROM $BaseImage ENV artifactUrl=$ArtifactUrl ENV accept_eula=Y ENV accept_outdated=Y RUN \Run\start.ps1 -installOnly LABEL albuild.image=bc LABEL albuild.artifacturl=$ArtifactUrl LABEL albuild.basetag=$BaseImage LABEL albuild.created=$((Get-Date).ToString('o')) "@ Set-Content -LiteralPath (Join-Path $context 'Dockerfile') -Value $dockerfile -Encoding ASCII Write-ALbuildLog "Building image '$ImageName' from $ArtifactUrl (installs Business Central once so later containers only start the service tier)..." Invoke-BcDocker -DockerExecutable $DockerExecutable -StreamOutput ` -Arguments @('build', '--memory', $MemoryLimit, '-t', $ImageName, $context) | Out-Null } finally { Remove-Item -LiteralPath $context -Recurse -Force -ErrorAction SilentlyContinue } $seconds = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1) $size = Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable Write-ALbuildLog -Level Success "Image '$ImageName' built in $seconds s ($([Math]::Round($size / 1GB, 2)) GB total)." return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $true; BuildSeconds = $seconds; SizeBytes = $size } } finally { if ($held) { $mutex.ReleaseMutex() } $mutex.Dispose() } } |