Speed up scratch image builds with publish-first contexts

This commit is contained in:
master
2026-03-09 07:37:24 +02:00
parent c9686edf07
commit f218ec82ec
8 changed files with 358 additions and 38 deletions

View File

@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.7
# Runtime-only hardened image for publish-first local builds.
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble
ARG APP_BINARY=StellaOps.Service
ARG APP_USER=stella
ARG APP_UID=10001
ARG APP_GID=10001
ARG APP_PORT=8080
FROM ${RUNTIME_IMAGE} AS runtime
ARG APP_BINARY=StellaOps.Service
ARG APP_USER=stella
ARG APP_UID=10001
ARG APP_GID=10001
ARG APP_PORT=8080
RUN groupadd -r -g ${APP_GID} ${APP_USER} && \
useradd -r -u ${APP_UID} -g ${APP_GID} -d /var/lib/${APP_USER} ${APP_USER} && \
mkdir -p /app /var/lib/${APP_USER} /var/run/${APP_USER} /tmp && \
chown -R ${APP_UID}:${APP_GID} /app /var/lib/${APP_USER} /var/run/${APP_USER} /tmp
WORKDIR /app
COPY --chown=${APP_UID}:${APP_GID} app/ ./
COPY --chown=${APP_UID}:${APP_GID} healthcheck.sh /usr/local/bin/healthcheck.sh
ENV ASPNETCORE_URLS=http://+:${APP_PORT} \
DOTNET_EnableDiagnostics=0 \
COMPlus_EnableDiagnostics=0 \
APP_BINARY=${APP_BINARY}
RUN chmod 500 /app && \
chmod +x /usr/local/bin/healthcheck.sh && \
find /app -maxdepth 1 -type f -name '*.dll' -exec chmod 400 {} \; && \
find /app -maxdepth 1 -type f -name '*.json' -exec chmod 400 {} \; && \
find /app -maxdepth 1 -type f -name '*.pdb' -exec chmod 400 {} \; && \
find /app -maxdepth 1 -type d -exec chmod 500 {} \;
USER ${APP_UID}:${APP_GID}
EXPOSE ${APP_PORT}
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
ENTRYPOINT ["sh","-c","exec dotnet ./\"$APP_BINARY\".dll"]

View File

@@ -1,28 +1,43 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Build hardened Docker images for all Stella Ops services using the shared template/matrix.
Build hardened Docker images for Stella Ops services using the shared matrix.
.DESCRIPTION
PowerShell port of build-all.sh. Reads services-matrix.env (pipe-delimited) and builds
each service image using Dockerfile.hardened.template (or Dockerfile.console for Angular).
The default path publishes .NET services locally and builds hardened runtime
images from small temporary contexts so scratch setup does not keep streaming
the full monorepo into Docker for every backend image.
.PARAMETER Registry
Docker image registry prefix. Default: stellaops
.PARAMETER TagSuffix
Tag suffix for built images. Default: dev
.PARAMETER SdkImage
.NET SDK base image. Default: mcr.microsoft.com/dotnet/sdk:10.0-noble
Reserved for legacy repo-context builds. Default: mcr.microsoft.com/dotnet/sdk:10.0-noble
.PARAMETER RuntimeImage
.NET runtime base image. Default: mcr.microsoft.com/dotnet/aspnet:10.0-noble
.PARAMETER Services
Optional service filter. Only listed services are rebuilt.
.PARAMETER PublishNoRestore
Skip restore during local dotnet publish when a prior solution build already ran.
.PARAMETER UseLegacyRepoContext
Fall back to repo-root docker builds for backend services.
#>
[CmdletBinding()]
param(
[string]$Registry,
[string]$TagSuffix,
[string]$SdkImage,
[string]$RuntimeImage
[string]$RuntimeImage,
[string[]]$Services,
[switch]$PublishNoRestore,
[switch]$UseLegacyRepoContext
)
$ErrorActionPreference = 'Continue'
$previousNativeErrorPreference = $null
if ($PSVersionTable.PSVersion.Major -ge 7) {
$previousNativeErrorPreference = $global:PSNativeCommandUseErrorActionPreference
$global:PSNativeCommandUseErrorActionPreference = $false
}
if ([string]::IsNullOrWhiteSpace($Registry)) {
$Registry = if ([string]::IsNullOrWhiteSpace($env:REGISTRY)) { 'stellaops' } else { $env:REGISTRY }
@@ -53,11 +68,105 @@ if (-not (Test-Path $MatrixPath)) {
exit 1
}
$runtimeDockerfile = Join-Path $Root 'devops/docker/Dockerfile.hardened.runtime'
$healthcheckScript = Join-Path $Root 'devops/docker/healthcheck.sh'
$fastContextRoot = Join-Path ([System.IO.Path]::GetTempPath()) 'stellaops-fast-images'
$serviceFilter = @{}
foreach ($serviceName in ($Services | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
$serviceFilter[$serviceName.Trim().ToLowerInvariant()] = $true
}
Write-Host "Building services from $MatrixPath -> ${Registry}/<service>:${TagSuffix}" -ForegroundColor Cyan
if ($serviceFilter.Count -gt 0) {
Write-Host "Service filter: $($serviceFilter.Keys -join ', ')" -ForegroundColor Cyan
}
$succeeded = @()
$failed = @()
function Invoke-DockerBuild([string[]]$Arguments) {
& docker @Arguments 2>&1 | ForEach-Object {
$text = if ($_ -is [System.Management.Automation.ErrorRecord]) {
$_.ToString()
}
else {
"$_"
}
Write-Host $text
}
return $LASTEXITCODE
}
function Invoke-NativeCommand([string]$Command, [string[]]$Arguments) {
& $Command @Arguments 2>&1 | ForEach-Object {
$text = if ($_ -is [System.Management.Automation.ErrorRecord]) {
$_.ToString()
}
else {
"$_"
}
Write-Host $text
}
return $LASTEXITCODE
}
function Should-BuildService([string]$ServiceName) {
if ($serviceFilter.Count -eq 0) {
return $true
}
return $serviceFilter.ContainsKey($ServiceName.ToLowerInvariant())
}
function Remove-BuildContext([string]$ContextPath) {
if (Test-Path $ContextPath) {
Remove-Item -Path $ContextPath -Recurse -Force -ErrorAction SilentlyContinue
}
}
function New-PublishedBuildContext([string]$Service, [string]$Project) {
if (-not (Test-Path $runtimeDockerfile)) {
throw "Runtime Dockerfile not found: $runtimeDockerfile"
}
if (-not (Test-Path $healthcheckScript)) {
throw "Healthcheck script not found: $healthcheckScript"
}
$serviceRoot = Join-Path $fastContextRoot $Service
$appDir = Join-Path $serviceRoot 'app'
Remove-BuildContext $serviceRoot
New-Item -ItemType Directory -Path $appDir -Force | Out-Null
$publishArguments = @(
'publish',
(Join-Path $Root $Project),
'-c', 'Release',
'-o', $appDir,
'/p:UseAppHost=false',
'/p:PublishTrimmed=false',
'--nologo'
)
if ($PublishNoRestore) {
$publishArguments += '--no-restore'
}
$publishExitCode = Invoke-NativeCommand 'dotnet' $publishArguments
if ($publishExitCode -ne 0) {
Remove-BuildContext $serviceRoot
throw "dotnet publish failed for $Service"
}
Copy-Item -Path $runtimeDockerfile -Destination (Join-Path $serviceRoot 'Dockerfile') -Force
Copy-Item -Path $healthcheckScript -Destination (Join-Path $serviceRoot 'healthcheck.sh') -Force
return $serviceRoot
}
foreach ($line in Get-Content $MatrixPath) {
$line = $line.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
@@ -65,11 +174,15 @@ foreach ($line in Get-Content $MatrixPath) {
$parts = $line -split '\|'
if ($parts.Count -lt 5) { continue }
$service = $parts[0]
$service = $parts[0]
$dockerfile = $parts[1]
$project = $parts[2]
$binary = $parts[3]
$port = $parts[4]
$project = $parts[2]
$binary = $parts[3]
$port = $parts[4]
if (-not (Should-BuildService $service)) {
continue
}
$image = "${Registry}/${service}:${TagSuffix}"
$dfPath = Join-Path $Root $dockerfile
@@ -81,25 +194,57 @@ foreach ($line in Get-Content $MatrixPath) {
if ($dockerfile -like '*Dockerfile.console*') {
Write-Host "[console] $service -> $image" -ForegroundColor Yellow
docker build `
-f $dfPath $Root `
--build-arg "APP_DIR=$project" `
--build-arg "APP_PORT=$port" `
-t $image
$buildExitCode = Invoke-DockerBuild @(
'build',
'-f', $dfPath,
$Root,
'--build-arg', "APP_DIR=$project",
'--build-arg', "APP_PORT=$port",
'-t', $image
)
}
elseif (-not $UseLegacyRepoContext -and $dockerfile -like '*Dockerfile.hardened.template*') {
Write-Host "[service fast] $service -> $image" -ForegroundColor Green
$contextPath = $null
try {
$contextPath = New-PublishedBuildContext -Service $service -Project $project
$buildExitCode = Invoke-DockerBuild @(
'build',
'-f', (Join-Path $contextPath 'Dockerfile'),
$contextPath,
'--build-arg', "RUNTIME_IMAGE=$RuntimeImage",
'--build-arg', "APP_BINARY=$binary",
'--build-arg', "APP_PORT=$port",
'-t', $image
)
}
catch {
Write-Host $_.Exception.Message -ForegroundColor Red
$buildExitCode = 1
}
finally {
if ($contextPath) {
Remove-BuildContext $contextPath
}
}
}
else {
Write-Host "[service] $service -> $image" -ForegroundColor Green
docker build `
-f $dfPath $Root `
--build-arg "SDK_IMAGE=$SdkImage" `
--build-arg "RUNTIME_IMAGE=$RuntimeImage" `
--build-arg "APP_PROJECT=$project" `
--build-arg "APP_BINARY=$binary" `
--build-arg "APP_PORT=$port" `
-t $image
$buildExitCode = Invoke-DockerBuild @(
'build',
'-f', $dfPath,
$Root,
'--build-arg', "SDK_IMAGE=$SdkImage",
'--build-arg', "RUNTIME_IMAGE=$RuntimeImage",
'--build-arg', "APP_PROJECT=$project",
'--build-arg', "APP_BINARY=$binary",
'--build-arg', "APP_PORT=$port",
'-t', $image
)
}
if ($LASTEXITCODE -eq 0) {
if ($buildExitCode -eq 0) {
$succeeded += $service
}
else {
@@ -120,3 +265,7 @@ if ($failed.Count -gt 0) {
}
Write-Host 'Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002).' -ForegroundColor Cyan
if ($PSVersionTable.PSVersion.Major -ge 7) {
$global:PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
}

View File

@@ -1,6 +1,10 @@
#!/usr/bin/env bash
# Build hardened images for the core services using the shared template/matrix (DOCKER-44-001)
# Build hardened images for the core services using the shared template/matrix.
# The default path publishes .NET services locally and builds runtime-only
# images from small temporary contexts to avoid repeatedly sending the full
# monorepo into Docker.
set -uo pipefail
FAILED=()
SUCCEEDED=()
@@ -10,6 +14,12 @@ REGISTRY=${REGISTRY:-"stellaops"}
TAG_SUFFIX=${TAG_SUFFIX:-"dev"}
SDK_IMAGE=${SDK_IMAGE:-"mcr.microsoft.com/dotnet/sdk:10.0-noble"}
RUNTIME_IMAGE=${RUNTIME_IMAGE:-"mcr.microsoft.com/dotnet/aspnet:10.0-noble"}
USE_LEGACY_REPO_CONTEXT=${USE_LEGACY_REPO_CONTEXT:-"false"}
PUBLISH_NO_RESTORE=${PUBLISH_NO_RESTORE:-"false"}
SERVICES=${SERVICES:-""}
FAST_CONTEXT_ROOT=${FAST_CONTEXT_ROOT:-"${TMPDIR:-/tmp}/stellaops-fast-images"}
RUNTIME_DOCKERFILE="${ROOT}/devops/docker/Dockerfile.hardened.runtime"
HEALTHCHECK_SCRIPT="${ROOT}/devops/docker/healthcheck.sh"
if [[ ! -f "${MATRIX}" ]]; then
echo "matrix file not found: ${MATRIX}" >&2
@@ -17,9 +27,75 @@ if [[ ! -f "${MATRIX}" ]]; then
fi
echo "Building services from ${MATRIX} -> ${REGISTRY}/<service>:${TAG_SUFFIX}" >&2
if [[ -n "${SERVICES}" ]]; then
echo "Service filter: ${SERVICES}" >&2
fi
cleanup_context() {
local context_dir="${1:-}"
[[ -n "${context_dir}" && -d "${context_dir}" ]] && rm -rf "${context_dir}"
}
should_build_service() {
local service="$1"
[[ -z "${SERVICES}" ]] && return 0
IFS=',' read -r -a requested <<< "${SERVICES}"
for candidate in "${requested[@]}"; do
local trimmed="${candidate// /}"
[[ "${trimmed}" == "${service}" ]] && return 0
done
return 1
}
build_published_service_image() {
local service="$1"
local project="$2"
local binary="$3"
local port="$4"
local image="$5"
local context_dir="${FAST_CONTEXT_ROOT}/${service}"
cleanup_context "${context_dir}"
mkdir -p "${context_dir}/app"
local publish_args=(
publish "${ROOT}/${project}"
-c Release
-o "${context_dir}/app"
/p:UseAppHost=false
/p:PublishTrimmed=false
--nologo
)
if [[ "${PUBLISH_NO_RESTORE}" == "true" ]]; then
publish_args+=(--no-restore)
fi
dotnet "${publish_args[@]}" || {
cleanup_context "${context_dir}"
return 1
}
cp "${RUNTIME_DOCKERFILE}" "${context_dir}/Dockerfile"
cp "${HEALTHCHECK_SCRIPT}" "${context_dir}/healthcheck.sh"
docker build \
-f "${context_dir}/Dockerfile" "${context_dir}" \
--build-arg "RUNTIME_IMAGE=${RUNTIME_IMAGE}" \
--build-arg "APP_BINARY=${binary}" \
--build-arg "APP_PORT=${port}" \
-t "${image}"
local build_status=$?
cleanup_context "${context_dir}"
return ${build_status}
}
while IFS='|' read -r service dockerfile project binary port; do
[[ -z "${service}" || "${service}" =~ ^# ]] && continue
should_build_service "${service}" || continue
image="${REGISTRY}/${service}:${TAG_SUFFIX}"
df_path="${ROOT}/${dockerfile}"
if [[ ! -f "${df_path}" ]]; then
@@ -28,13 +104,15 @@ while IFS='|' read -r service dockerfile project binary port; do
fi
if [[ "${dockerfile}" == *"Dockerfile.console"* ]]; then
# Angular console build uses its dedicated Dockerfile
echo "[console] ${service} -> ${image}" >&2
docker build \
-f "${df_path}" "${ROOT}" \
--build-arg APP_DIR="${project}" \
--build-arg APP_PORT="${port}" \
-t "${image}"
elif [[ "${USE_LEGACY_REPO_CONTEXT}" != "true" && "${dockerfile}" == *"Dockerfile.hardened.template"* ]]; then
echo "[service fast] ${service} -> ${image}" >&2
build_published_service_image "${service}" "${project}" "${binary}" "${port}" "${image}"
else
echo "[service] ${service} -> ${image}" >&2
docker build \
@@ -53,7 +131,6 @@ while IFS='|' read -r service dockerfile project binary port; do
FAILED+=("${service}")
echo "FAILED: ${service}" >&2
fi
done < "${MATRIX}"
echo "" >&2
@@ -65,4 +142,5 @@ if [[ ${#FAILED[@]} -gt 0 ]]; then
echo "Some builds failed. Fix the issues and re-run." >&2
exit 1
fi
echo "Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002)." >&2