改用镜像层增量发布

This commit is contained in:
WuYongJie 2026-07-21 17:29:51 +08:00
parent 4a201f8480
commit b4b80f2eab
7 changed files with 381 additions and 32 deletions

View File

@ -84,7 +84,8 @@
- 当前服务器只有 2GB 内存,只负责运行容器、健康检查和镜像切换。
- 严禁在服务器执行 `docker build`、`docker compose build`、`npm install`、`npm run production`、`composer install` 或其他源码构建命令。
- `compose.jiangling.yml` 不得出现 `build:`;服务器只能运行开发机已经构建并上传的 `caiwu_akaunting:<commit>` 镜像。
- `mariadb:11.4` 运行镜像也由开发机缓存并随发布包上传,服务器不得在发布时临时访问 Docker Hub。
- `mariadb:11.4` 运行镜像由开发机缓存并仅随首次初始化全量包上传,服务器不得在发布时临时访问 Docker Hub。
- 日常发布必须通过只监听 `127.0.0.1` 的服务器本机 Registry 和 SSH 隧道传输,仅上传 Registry 中不存在的镜像层;禁止每次上传完整 `docker save` 压缩包。
- 服务器保留当前镜像和上一个可回滚镜像,其他本项目旧镜像在发布成功后清理。
- 服务器发布必须使用互斥锁;同一时间只允许一个人切换版本,禁止并发覆盖。
@ -96,12 +97,13 @@
- Dockerfile 必须先复制锁文件再安装依赖:只有 `composer.lock`、`package-lock.json` 或基础镜像定义变化时,才允许对应依赖层失效。
- 前端资源必须由开发机宿主系统运行 `scripts/build-frontend.ps1` 构建;脚本固定并复用项目专用 Node 20,不得在 Docker 虚拟机或服务器内执行前端打包。
- 基础镜像、Composer 下载缓存、npm 下载缓存和未变化的构建层应直接复用,不得每次全量下载。
- `-BootstrapServer` 仅用于新服务器首次全量初始化;已有服务器的日常发布自动走增量镜像层传输。
### 8.4 磁盘和缓存上限
- Docker Desktop 虚拟磁盘上限建议设为 60GB;宿主机至少保留 15GB 可用空间。前端打包使用宿主机内存,不要求为了本项目调高 Docker 虚拟机内存。
- 发布脚本使用独立缓存并将 BuildKit 缓存控制在 8GB 内,只清理 7 天前的本项目缓存。
- 开发机和服务器默认各保留 2 个本项目镜像;上传用临时 `image.tar` 无论成功失败都必须删除。
- 开发机、服务器 Docker 和服务器本机 Registry 默认各保留 2 个本项目版本;上传用临时 `image.tar` 无论成功失败都必须删除。
- 禁止脚本自动执行全局 `docker system prune -a`、删除其他项目镜像、删除卷或重置 Docker Desktop。
- Docker 虚拟磁盘文件不会因为普通清理立即缩小;日常目标是控制已用空间不再增长,不得让新人自行删除或重建虚拟磁盘。

View File

@ -11,7 +11,10 @@ class Console
{
public static function run($string, $timeout = 0)
{
$command = sprintf('%s %s %s', escapeshellarg(PHP_BINARY), Application::artisanBinary(), $string);
// PHP_BINARY is empty under some web SAPIs (including Apache mod_php).
// PHP_BINDIR still points to the directory containing the active PHP binary.
$phpBinary = PHP_BINARY ?: PHP_BINDIR . DIRECTORY_SEPARATOR . 'php';
$command = sprintf('%s %s %s', escapeshellarg($phpBinary), Application::artisanBinary(), $string);
logger('Console command:: ' . $command);

View File

@ -0,0 +1,122 @@
#!/bin/sh
set -eu
REGISTRY_IMAGE="${1:?REGISTRY_IMAGE is required}"
IMAGE_TAG="${2:?IMAGE_TAG is required}"
REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
TAG_HISTORY="$REMOTE_DIR/.registry-tags"
if [ "$(id -u)" -ne 0 ]; then
echo "activate-incremental-release.sh must run as root" >&2
exit 1
fi
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
exit 1
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another CaiWuXiTong deployment is already running" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
docker pull "$REGISTRY_IMAGE"
docker tag "$REGISTRY_IMAGE" "$IMAGE_NAME:$IMAGE_TAG"
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
exit 1
fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
attempt=1
while [ "$attempt" -le 30 ]; do
if curl -fsS -o /dev/null http://127.0.0.1/; then
healthy=1
break
fi
sleep 5
attempt=$((attempt + 1))
done
if [ "$healthy" -ne 1 ]; then
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
case "$PREVIOUS_IMAGE" in
"$IMAGE_NAME":*)
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d app || true
;;
esac
echo "Release failed health check; previous image was restored when available" >&2
exit 1
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
| awk '!seen[$0]++' \
| tail -n "+$((KEEP_IMAGES + 1))" \
| while IFS= read -r old_image; do
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
done
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
# Track tags by successful deployment order, retain only current + previous,
# delete older manifests, and reclaim their unreferenced blobs.
touch "$TAG_HISTORY"
awk -v tag="$IMAGE_TAG" '$0 != tag && NF { print }' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
printf '%s\n' "$IMAGE_TAG" >> "$TAG_HISTORY.tmp"
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
deleted_registry_tag=0
while [ "$(wc -l < "$TAG_HISTORY")" -gt "$KEEP_IMAGES" ]; do
OLD_TAG="$(sed -n '1p' "$TAG_HISTORY")"
DIGEST="$(curl -fsSI \
-H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"$REGISTRY_API/manifests/$OLD_TAG" \
| awk -F ': ' 'tolower($1) == "docker-content-digest" { gsub("\r", "", $2); print $2 }')"
if [ -n "$DIGEST" ]; then
curl -fsS -X DELETE "$REGISTRY_API/manifests/$DIGEST" >/dev/null
deleted_registry_tag=1
fi
docker image rm "127.0.0.1:5000/caiwuxitong/app:$OLD_TAG" >/dev/null 2>&1 || true
sed '1d' "$TAG_HISTORY" > "$TAG_HISTORY.tmp"
mv "$TAG_HISTORY.tmp" "$TAG_HISTORY"
done
if [ "$deleted_registry_tag" -eq 1 ]; then
systemctl stop docker-registry
if ! docker-registry garbage-collect --delete-untagged /etc/docker/registry/config.yml; then
echo "Registry garbage collection failed; the service will still be restarted" >&2
fi
systemctl start docker-registry
fi
docker compose -f compose.jiangling.yml ps
echo "DEPLOYED_SHA=$DEPLOY_SHA"
: "$PREVIOUS_SHA"

View File

@ -0,0 +1,24 @@
version: 0.1
log:
fields:
service: caiwuxitong-registry
storage:
cache:
blobdescriptor: inmemory
filesystem:
rootdirectory: /var/lib/docker-registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000
headers:
X-Content-Type-Options: [nosniff]
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3

View File

@ -0,0 +1,56 @@
#!/bin/sh
set -eu
REMOTE_DIR="${1:-/opt/caiwuxitong}"
CONFIG_SOURCE="${2:-$REMOTE_DIR/registry-config.yml}"
REGISTRY_CONFIG="/etc/docker/registry/config.yml"
REGISTRY_REPOSITORY="127.0.0.1:5000/caiwuxitong/app"
if [ "$(id -u)" -ne 0 ]; then
echo "setup-incremental-registry.sh must run as root" >&2
exit 1
fi
if [ ! -f "$CONFIG_SOURCE" ]; then
echo "Missing registry config: $CONFIG_SOURCE" >&2
exit 1
fi
if ! command -v docker-registry >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends docker-registry
fi
install -d -m 755 "$(dirname "$REGISTRY_CONFIG")"
install -m 644 "$CONFIG_SOURCE" "$REGISTRY_CONFIG"
systemctl enable docker-registry >/dev/null
systemctl restart docker-registry
attempt=1
while [ "$attempt" -le 20 ]; do
if curl -fsS -o /dev/null http://127.0.0.1:5000/v2/; then
break
fi
sleep 1
attempt=$((attempt + 1))
done
curl -fsS -o /dev/null http://127.0.0.1:5000/v2/
# Seed the registry from the image already on this server. This makes the first
# incremental release reuse existing layers instead of uploading them again.
CURRENT_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
case "$CURRENT_IMAGE" in
caiwu_akaunting:*)
CURRENT_TAG="${CURRENT_IMAGE#*:}"
docker tag "$CURRENT_IMAGE" "$REGISTRY_REPOSITORY:$CURRENT_TAG"
docker push "$REGISTRY_REPOSITORY:$CURRENT_TAG"
touch "$REMOTE_DIR/.registry-tags"
if ! grep -Fxq "$CURRENT_TAG" "$REMOTE_DIR/.registry-tags"; then
printf '%s\n' "$CURRENT_TAG" >> "$REMOTE_DIR/.registry-tags"
fi
;;
esac
echo "REGISTRY_READY=127.0.0.1:5000"

View File

@ -9,8 +9,8 @@
```text
任务分支开发 -> 本机验证 -> PR -> 合并 main
-> 开发机复用缓存构建镜像
-> 临时导出并上传镜像
-> 2GB 服务器只加载和切换镜像
-> 经 SSH 隧道只上传变化的镜像层
-> 2GB 服务器只拉取本机 Registry 并切换镜像
-> 真实页面健康检查
-> 保留当前与上一个镜像,清理其余本项目产物
```
@ -28,7 +28,7 @@
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
## 3. 为什么后续不会反复全量下载
## 3. 为什么后续不会反复全量下载和上传
项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开:
@ -39,8 +39,12 @@
- npm 优先使用本机下载缓存,`node_modules` 原位刷新,不会逐版本堆积;
- 业务代码变化只重新执行前端资源构建和后面的代码复制;
- 日常命令不带 `--pull` 和 `--no-cache`。
- 首次初始化才上传应用和 MariaDB 的全量包;日常发布通过 SSH 隧道连接服务器本机 Registry。
- Registry 按内容哈希判断镜像层是否已经存在,基础环境、PHP 依赖和未变化的前端层不会重复上传。
- Registry 只监听服务器 `127.0.0.1:5000`,公网无法直接访问;发布脚本退出时会关闭 SSH 隧道。
- 每次发布后只保留当前和上一个 Registry 版本,并回收旧版本不再引用的镜像层。
首次构建必然较慢;后续构建应大量显示 `CACHED`。
首次构建和首次上传必然较慢;后续构建应大量显示 `CACHED`,上传量由本次实际变化的镜像层决定。
## 4. 首次初始化服务器
@ -60,6 +64,7 @@
- 创建 `/opt/caiwuxitong/runtime/.env`;
- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来;
- 创建持久化数据库卷和上传文件卷。
- 上传应用与 MariaDB 首次全量包,并初始化仅限本机访问的增量 Registry。
首次打开 `http://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。
@ -81,13 +86,13 @@ git pull --ff-only
1. 检查分支和未提交改动;
2. 使用项目专用 Node 20 和本机 npm 缓存构建前端资源;
3. 使用提交 SHA 构建镜像并复用项目专属缓存;
4. 将应用镜像和本机缓存的 MariaDB 运行镜像导出为 gzip 临时包并上传;
5. 服务器加载镜像并启动;
4. 将应用镜像导出到临时文件,通过 SSH 隧道只上传 Registry 缺少的镜像层;
5. 服务器从本机 Registry 拉取新版本并启动;
6. 检查真实 HTTP 页面;
7. 失败时恢复上一个镜像;
8. 使用服务器互斥锁阻止两个人同时切换版本;
9. 删除本地和服务器临时镜像包;
10. 两端仅保留当前和上一个本项目镜像;
9. 删除本地临时镜像包并关闭 SSH 隧道;
10. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。
## 6. 数据库迁移
@ -120,6 +125,7 @@ git pull --ff-only
- 禁止登录服务器运行 `docker build` 或 `docker compose build`;
- 禁止在服务器运行 Composer、npm 或前端打包;
- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git;
- 禁止把服务器 Registry 的 `5000` 端口开放到公网;
- 禁止为了省空间删除 Docker 卷;
- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目;
- 禁止跳过健康检查后口头说“部署完成”。
@ -133,4 +139,5 @@ git pull --ff-only
- 是否执行数据库迁移;
- 本地临时包是否删除;
- 本地/服务器保留了几个本项目镜像;
- 本次增量新增镜像层大小和传输耗时;
- BuildKit 缓存当前占用。

View File

@ -29,7 +29,10 @@ $localTar = Join-Path $tempRoot 'image.tar'
$localArchive = Join-Path $tempRoot 'image.tar.gz'
$remoteArchive = $null
$bootstrapRemote = $null
$sshTunnel = $null
$buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
$regctlVersion = 'v0.11.5'
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
function Invoke-Native {
param(
@ -94,6 +97,91 @@ function Compress-Gzip {
}
}
function Get-RegctlPath {
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
$regctlPath = Join-Path $toolRoot "regctl-$regctlVersion.exe"
if (Test-Path -LiteralPath $regctlPath) {
$actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -eq $regctlSha256) {
return $regctlPath
}
Remove-Item -LiteralPath $regctlPath -Force
}
New-Item -ItemType Directory -Path $toolRoot -Force | Out-Null
$downloadPath = "$regctlPath.download"
try {
Invoke-WebRequest `
-Uri "https://github.com/regclient/regclient/releases/download/$regctlVersion/regctl-windows-amd64.exe" `
-OutFile $downloadPath
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $regctlSha256) {
throw "regctl 校验失败:预期 $regctlSha256,实际 $actualHash。"
}
Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force
}
finally {
if (Test-Path -LiteralPath $downloadPath) {
Remove-Item -LiteralPath $downloadPath -Force
}
}
return $regctlPath
}
function Get-AvailableLocalPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Start-RegistryTunnel {
param([int]$LocalPort)
$sshPath = (Get-Command ssh -ErrorAction Stop).Source
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $sshPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
foreach ($argument in @(
'-N',
'-i', $keyFullPath,
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=15',
'-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000",
$sshTarget
)) {
$startInfo.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::Start($startInfo)
$attempt = 1
while ($attempt -le 20) {
if ($process.HasExited) {
throw 'SSH 增量传输隧道启动失败。'
}
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$LocalPort/v2/" -TimeoutSec 1 | Out-Null
return $process
}
catch {
Start-Sleep -Milliseconds 500
$attempt++
}
}
$process.Kill()
$process.Dispose()
throw 'SSH 隧道已建立,但无法访问服务器本机 Registry。'
}
Push-Location $repoRoot
try {
$branch = (git branch --show-current).Trim()
@ -186,29 +274,69 @@ try {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
docker image inspect $DatabaseImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
docker image inspect $DatabaseMirrorImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。'
}
Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。'
}
Invoke-Native {
docker save --output $localTar $fullImage $DatabaseImage
} '导出应用与数据库运行镜像失败。'
Compress-Gzip -Source $localTar -Destination $localArchive
Remove-Item -LiteralPath $localTar -Force
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
Write-Host "压缩传输包:$archiveSizeMB MiB"
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。'
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
} '服务器激活或健康检查失败。'
scp -i $keyFullPath -o BatchMode=yes `
compose.jiangling.yml `
deploy/activate-release.sh `
deploy/activate-incremental-release.sh `
deploy/setup-incremental-registry.sh `
deploy/registry-config.yml `
"${sshTarget}:$RemotePath/"
} '上传运行配置失败。'
if ($BootstrapServer) {
docker image inspect $DatabaseImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
docker image inspect $DatabaseMirrorImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。'
}
Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。'
}
Invoke-Native {
docker save --output $localTar $fullImage $DatabaseImage
} '导出应用与数据库运行镜像失败。'
Compress-Gzip -Source $localTar -Destination $localArchive
Remove-Item -LiteralPath $localTar -Force
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
} '服务器激活、健康检查或增量仓库初始化失败。'
}
else {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
} '服务器增量镜像仓库初始化失败。'
Invoke-Native { docker save --output $localTar $fullImage } '导出本次应用镜像失败。'
$regctlPath = Get-RegctlPath
$localRegistryPort = Get-AvailableLocalPort
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
$serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag"
$beforeBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
$beforeBytes = [long](($beforeBytesOutput -join '').Trim())
$uploadTimer = [Diagnostics.Stopwatch]::StartNew()
Invoke-Native {
& $regctlPath `
--host "reg=127.0.0.1:${localRegistryPort},tls=disabled" `
image import $registryImage $localTar
} '增量镜像层上传失败。'
$uploadTimer.Stop()
$afterBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
$afterBytes = [long](($afterBytesOutput -join '').Trim())
$incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2)
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
} '服务器增量激活或健康检查失败。'
}
Remove-OldProjectImages -CurrentImage $fullImage
Write-Host "发布完成:$fullImage -> $AppUrl"
@ -220,6 +348,13 @@ finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
if ($sshTunnel) {
if (-not $sshTunnel.HasExited) {
$sshTunnel.Kill()
}
$sshTunnel.Dispose()
}
if ($remoteArchive) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
}