From b4b80f2eab00156e39fe1a4bd85b52787524625e Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 21 Jul 2026 17:29:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=95=9C=E5=83=8F=E5=B1=82?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- app/Utilities/Console.php | 5 +- deploy/activate-incremental-release.sh | 122 +++++++++++++++++ deploy/registry-config.yml | 24 ++++ deploy/setup-incremental-registry.sh | 56 ++++++++ docs/开发与部署SOP.md | 23 ++-- scripts/deploy-from-workstation.ps1 | 177 ++++++++++++++++++++++--- 7 files changed, 381 insertions(+), 32 deletions(-) create mode 100644 deploy/activate-incremental-release.sh create mode 100644 deploy/registry-config.yml create mode 100644 deploy/setup-incremental-registry.sh diff --git a/AGENTS.md b/AGENTS.md index 4e3cb09a..b334e8cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,8 @@ - 当前服务器只有 2GB 内存,只负责运行容器、健康检查和镜像切换。 - 严禁在服务器执行 `docker build`、`docker compose build`、`npm install`、`npm run production`、`composer install` 或其他源码构建命令。 - `compose.jiangling.yml` 不得出现 `build:`;服务器只能运行开发机已经构建并上传的 `caiwu_akaunting:` 镜像。 -- `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 虚拟磁盘文件不会因为普通清理立即缩小;日常目标是控制已用空间不再增长,不得让新人自行删除或重建虚拟磁盘。 diff --git a/app/Utilities/Console.php b/app/Utilities/Console.php index b98392f7..5dabeaf3 100644 --- a/app/Utilities/Console.php +++ b/app/Utilities/Console.php @@ -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); diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh new file mode 100644 index 00000000..1dad4453 --- /dev/null +++ b/deploy/activate-incremental-release.sh @@ -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" diff --git a/deploy/registry-config.yml b/deploy/registry-config.yml new file mode 100644 index 00000000..a785efde --- /dev/null +++ b/deploy/registry-config.yml @@ -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 diff --git a/deploy/setup-incremental-registry.sh b/deploy/setup-incremental-registry.sh new file mode 100644 index 00000000..09887b70 --- /dev/null +++ b/deploy/setup-incremental-registry.sh @@ -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" diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index 90536b5e..1fc189f2 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -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 缓存当前占用。 diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index 264229c6..272c7b55 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -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 }