feat: 基础服务镜像支持环境变量与多架构迁移脚本
Some checks failed
Test start.ps1 (Windows) / test-windows (push) Has been cancelled

- docker-compose:REDIS_IMAGE、MONGO_IMAGE、RABBITMQ_IMAGE 可配置,默认保持原阿里云地址
- .env.sample:补充三镜像变量、Mongo 4.4 备选地址及用途说明、RabbitMQ 备选示例
- 新增 mirror-image-to-registry.ps1:从 Hub/镜像站同步指定架构到私有仓库,含凭据与并行复制优化

Made-with: Cursor
This commit is contained in:
wangbolhr 2026-04-10 17:35:18 +08:00
parent 5c856749c1
commit f04bb5e4fc
3 changed files with 500 additions and 6 deletions

View File

@ -56,6 +56,9 @@ SANDBOX_SERVICE_BASE_URL=
REDIS_PORT=
# 默认不对外暴露
# Redis 容器镜像(可改为国内镜像站或自建仓库)
REDIS_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
CONFIG_COMFYUI_QUENE_REDIS_USERNAME=
CONFIG_COMFYUI_QUENE_REDIS_PASSWORD=
CONFIG_COMFYUI_QUENE_REDIS_DB=6
@ -63,11 +66,23 @@ CONFIG_COMFYUI_CACHE_REDIS_DB=11
# ========== 6. MongoDB ==========
MONGO_PORT=27017
# MongoDB 容器镜像(可改为国内镜像站或自建仓库)
MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# 固定 MongoDB 4.4 系列(与旧数据/旧客户端兼容、或需锁定 4.x 行为时使用;升级大版本前请备份并查阅迁移说明):
# MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
MONGO_INITDB_ROOT_USERNAME=username
MONGO_INITDB_ROOT_PASSWORD=password
# 初次部署可修改,更新请勿修改
# ========== 7. 消息队列 RabbitMQ ==========
# RabbitMQ 容器镜像(可改为国内镜像站或自建仓库),备选示例:
# RABBITMQ_IMAGE=docker.m.daocloud.io/library/rabbitmq:4-management
# RABBITMQ_IMAGE=docker.dockerproxy.com/library/rabbitmq:4-management
# RABBITMQ_IMAGE=rabbitmq:4-management
RABBITMQ_IMAGE=registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest
CONFIG_MQ_PROTOCOL=amqp
CONFIG_MQ_USER=admin
CONFIG_MQ_PASSWORD=easyai2025

View File

@ -157,8 +157,8 @@ services:
max-size: "100m"
max-file: "10"
mongo:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
# 镜像见 .env 中 MONGO_IMAGE4.4 备选地址与用途见 .env.sample 注释
image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest}
container_name: mongo
restart: unless-stopped
privileged: true
@ -188,7 +188,7 @@ services:
max-size: "100m"
max-file: "10"
redis:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
image: ${REDIS_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest}
container_name: redis
restart: always
volumes:
@ -207,9 +207,8 @@ services:
max-size: "100m"
max-file: "10"
rabbitmq:
# image: rabbitmq:4-management # 官方地址,需可访问外网
image: registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest # 阿里云镜像
# image: docker.m.daocloud.io/library/rabbitmq:4-management # 国内网络可访问
# 镜像地址见 .env 中 RABBITMQ_IMAGE备选示例见 .env.sample
image: ${RABBITMQ_IMAGE:-registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
container_name: rabbitmq

View File

@ -0,0 +1,480 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Mirror a public image (default: Docker Hub via optional China mirror) to your registry,
keeping only selected platforms (default linux/amd64 + linux/arm64), then push one multi-arch tag.
.DESCRIPTION
Uses skopeo copy per digest + docker buildx imagetools create.
Docker Desktop users: credentials are resolved from credential helper into a UTF-8 no-BOM auth file.
.PARAMETER Source
Docker Hub style: redis:latest, nginx:alpine, bitnami/redis:7, library/redis:7, docker.io/prom/prometheus:latest
Or any registry: ghcr.io/org/image:tag (no Docker-Hub mirror rewrite)
.PARAMETER DestRepo
Target repository without tag, e.g. registry.cn-shanghai.aliyuncs.com/myns/redis
.PARAMETER DestTag
Tag on destination (default: tag parsed from -Source)
.PARAMETER MirrorRegistry
Pull-through mirror host for Docker Hub only, e.g. docker.m.daocloud.io. Empty = use docker.io directly.
.PARAMETER Platforms
OS/arch list to keep, default linux/amd64 and linux/arm64.
.PARAMETER SkopeoParallelCopies
skopeo --image-parallel-copies (layers in flight). 0 = auto (higher default for better upload utilization).
If upload still looks underused, try 32 or 40; if CPU/memory spikes, lower to 12.
Other tuning (outside this script): use ACR region closest to you; Docker Desktop -> Resources give more CPUs/RAM;
avoid VPN throttling; wired Ethernet; temporarily pause heavy uploads on same link.
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source nginx:alpine -DestRepo registry.cn-shanghai.aliyuncs.com/myns/nginx -DestTag alpine
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source docker.io/library/redis:latest -DestRepo registry.example.com/prod/redis -MirrorRegistry ""
#>
param(
[Parameter(Mandatory = $true)][string] $Source,
[Parameter(Mandatory = $true)][string] $DestRepo,
[string] $DestTag = "",
[string] $MirrorRegistry = "docker.m.daocloud.io",
[string[]] $Platforms = @("linux/amd64", "linux/arm64"),
[string] $SkopeoImage = "quay.io/skopeo/stable:latest",
[string] $DestUsername = "",
[string] $DestPassword = "",
[int] $SkopeoParallelCopies = 0
)
$ErrorActionPreference = "Stop"
# Parallel layer copies: 0 = auto (scale with CPU cores, capped)
$nCpu = [Environment]::ProcessorCount
if ($SkopeoParallelCopies -le 0) {
$script:EffectiveParallelCopies = [Math]::Min(40, [Math]::Max(16, $nCpu * 4))
Write-Host "SkopeoParallelCopies=auto -> $script:EffectiveParallelCopies (logical CPUs: $nCpu)"
} else {
$script:EffectiveParallelCopies = $SkopeoParallelCopies
Write-Host "SkopeoParallelCopies=$script:EffectiveParallelCopies"
}
function Test-CmdInPath {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Get-RegistryHostFromRepo {
param([string]$Repo)
return ($Repo -split '/')[0]
}
function Get-DockerConfigPath {
return (Join-Path $env:USERPROFILE ".docker\config.json")
}
function Write-JsonFileUtf8NoBom {
param([string]$Path, [string]$JsonText)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $JsonText, $utf8NoBom)
}
function New-SkopeoAuthJsonFile {
param(
[Parameter(Mandatory = $true)][string]$RegistryHost,
[Parameter(Mandatory = $true)][string]$Username,
[Parameter(Mandatory = $true)][string]$Password
)
$pair = "${Username}:${Password}"
$authB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
$payload = @{
auths = @{
$RegistryHost = @{
auth = $authB64
}
}
}
$path = [System.IO.Path]::GetTempFileName() + ".json"
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
function Get-InlineAuthFromDockerConfig {
param([string]$DockerConfigPath, [string]$RegistryHost)
if (-not (Test-Path -LiteralPath $DockerConfigPath)) { return $null }
$txt = Get-Content -LiteralPath $DockerConfigPath -Raw -ErrorAction Stop
$cfg = $txt | ConvertFrom-Json
if (-not $cfg.auths) { return $null }
foreach ($p in $cfg.auths.PSObject.Properties) {
if ($p.Name -eq $RegistryHost -and $p.Value.auth) {
return [string]$p.Value.auth
}
}
return $null
}
function Get-CredentialFromDockerHelper {
param([string]$RegistryHost, [string]$HelperName)
if (-not $HelperName) { return $null }
$exeName = "docker-credential-$HelperName"
$exe = Get-Command $exeName -ErrorAction SilentlyContinue
if (-not $exe) {
$candidates = @(
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$exeName.exe"),
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$HelperName.exe")
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { $exe = Get-Command $c -ErrorAction SilentlyContinue; break }
}
}
if (-not $exe) {
Write-Warning "Credential helper '$exeName' not found on PATH or Docker resources."
return $null
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe.Source
$psi.Arguments = "get"
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$p.StandardInput.WriteLine($RegistryHost)
$p.StandardInput.Close()
$out = $p.StandardOutput.ReadToEnd()
$err = $p.StandardError.ReadToEnd()
$p.WaitForExit()
if ($p.ExitCode -ne 0) {
Write-Warning "docker-credential-$HelperName get failed (exit $($p.ExitCode)): $err"
return $null
}
try {
$j = $out | ConvertFrom-Json
return @{ Username = $j.Username; Secret = $j.Secret }
} catch {
return $null
}
}
function Resolve-DestinationAuthFile {
param([string]$RegistryHost)
if ($DestUsername -and $DestPassword) {
Write-Host "Using -DestUsername / -DestPassword for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $DestUsername -Password $DestPassword)
}
if ($env:ALIYUN_CR_USER -and $env:ALIYUN_CR_PASS) {
Write-Host "Using ALIYUN_CR_USER / ALIYUN_CR_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:ALIYUN_CR_USER -Password $env:ALIYUN_CR_PASS)
}
if ($env:DEST_REGISTRY_USER -and $env:DEST_REGISTRY_PASS) {
Write-Host "Using DEST_REGISTRY_USER / DEST_REGISTRY_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:DEST_REGISTRY_USER -Password $env:DEST_REGISTRY_PASS)
}
$dockerCfg = Get-DockerConfigPath
$inline = Get-InlineAuthFromDockerConfig -DockerConfigPath $dockerCfg -RegistryHost $RegistryHost
if ($inline) {
Write-Host "Using inline auth from Docker config.json for $RegistryHost"
$path = [System.IO.Path]::GetTempFileName() + ".json"
$payload = @{ auths = @{ $RegistryHost = @{ auth = $inline } } }
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
if (Test-Path -LiteralPath $dockerCfg) {
$cfg = (Get-Content -LiteralPath $dockerCfg -Raw) | ConvertFrom-Json
$helperName = $null
if ($cfg.credHelpers) {
foreach ($hp in $cfg.credHelpers.PSObject.Properties) {
if ($hp.Name -eq $RegistryHost) {
$helperName = [string]$hp.Value
break
}
}
}
if (-not $helperName -and $cfg.credsStore) {
$helperName = $cfg.credsStore
}
if ($helperName) {
Write-Host "Resolving credentials via docker-credential-$helperName (Docker Desktop)..."
$c = Get-CredentialFromDockerHelper -RegistryHost $RegistryHost -HelperName $helperName
if ($c -and $c.Username -and $c.Secret) {
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $c.Username -Password $c.Secret)
}
}
}
return $null
}
function Split-ImageRefPathAndTag {
param([string]$Ref)
if ($Ref -match '@sha256:') {
throw "Digest-pinned sources (@sha256:...) are not supported. Use a tag."
}
$tag = "latest"
$path = $Ref
if ($Ref -match '^(.+):([^:/]+)$') {
$path = $matches[1]
$tag = $matches[2]
}
return @{ Path = $path; Tag = $tag }
}
function Test-IsDockerHubOnlyPath {
param([string]$PathPart)
if ($PathPart -match '^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]+/') { return $false }
return $true
}
function Resolve-DockerHubRepoPath {
param([string]$PathPart)
if ($PathPart -notmatch '/') {
return "library/$PathPart"
}
return $PathPart
}
# Returns: IsDockerHub, InspectRef (docker://...), SourceRepoPath for copy by digest (host/repo without tag), Tag
function Resolve-SourceInspectReference {
param(
[string]$Source,
[string]$MirrorRegistry
)
$s = $Source.Trim()
if ($s.StartsWith("docker://")) {
$s = $s.Substring(9)
}
$st = Split-ImageRefPathAndTag $s
$path = $st.Path
$tag = $st.Tag
if (Test-IsDockerHubOnlyPath $path) {
$repo = Resolve-DockerHubRepoPath $path
$hubRef = "docker.io/${repo}:${tag}"
if ($MirrorRegistry) {
$inspect = "docker://${MirrorRegistry}/${repo}:${tag}"
} else {
$inspect = "docker://${hubRef}"
}
return @{
IsDockerHub = $true
InspectRef = $inspect
SourceRepoPath = if ($MirrorRegistry) { "${MirrorRegistry}/${repo}" } else { "docker.io/${repo}" }
Tag = $tag
}
}
# Full registry reference e.g. ghcr.io/org/img:tag
$firstSlash = $path.IndexOf('/')
if ($firstSlash -lt 1) { throw "Invalid source: $Source" }
$hostPart = $path.Substring(0, $firstSlash)
$repoPath = $path.Substring($firstSlash + 1)
if (-not $repoPath) { throw "Invalid source: $Source" }
$inspect = "docker://${hostPart}/${repoPath}:${tag}"
return @{
IsDockerHub = $false
InspectRef = $inspect
SourceRepoPath = "${hostPart}/${repoPath}"
Tag = $tag
}
}
function Get-DockerAuthVolumeArgs {
param([string]$AuthFilePath)
if ($AuthFilePath -and (Test-Path -LiteralPath $AuthFilePath)) {
return @("-v", "${AuthFilePath}:/auth.json:ro")
}
return @()
}
function Invoke-SkopeoCopyWithAuth {
param(
[string]$SkopeoImage,
[string]$SourceRef,
[string]$DestRef,
[string]$AuthFilePath,
[int]$ParallelCopies = 16,
[int]$GoMaxProcs = 0
)
$parallelArgs = @()
if ($ParallelCopies -gt 0) {
$parallelArgs = @("--image-parallel-copies", "$ParallelCopies")
}
if ($GoMaxProcs -le 0) { $GoMaxProcs = [Environment]::ProcessorCount }
$vol = Get-DockerAuthVolumeArgs -AuthFilePath $AuthFilePath
if (-not (Test-CmdInPath "skopeo")) {
if (-not $AuthFilePath) {
throw "No auth file for skopeo copy. Use -DestUsername/-DestPassword, DEST_REGISTRY_*, ALIYUN_CR_*, or docker login with resolvable credentials."
}
# GOMAXPROCS lets the Go runtime use more threads for TLS/registry I/O alongside parallel blob copies
$dockerLine = @("run", "--rm", "-e", "GOMAXPROCS=$GoMaxProcs") + $vol + @($SkopeoImage) + @("copy", "--authfile", "/auth.json") + $parallelArgs + @($SourceRef, $DestRef)
& docker @dockerLine
$script:SkopeoLastExitCode = $LASTEXITCODE
return
}
if ($AuthFilePath) {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy --authfile $AuthFilePath @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
} else {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
}
$script:SkopeoLastExitCode = $LASTEXITCODE
}
function Invoke-SkopeoInspectRaw {
param([string]$SkopeoImage, [string]$InspectRef)
if (Test-CmdInPath "skopeo") {
$rawLines = & skopeo inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
} else {
$rawLines = & docker run --rm $SkopeoImage inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
}
return [PSCustomObject]@{ RawLines = $rawLines; ExitCode = $code }
}
function Test-PlatformMatch {
param($ManifestEntry, [string]$OsWant, [string]$ArchWant)
if (-not $ManifestEntry.platform) { return $false }
$os = $ManifestEntry.platform.os
$arch = $ManifestEntry.platform.architecture
if ($os -ne $OsWant) { return $false }
if ($arch -eq $ArchWant) { return $true }
return $false
}
if (-not (Test-CmdInPath "docker")) {
Write-Error "docker not found."
}
$resolved = Resolve-SourceInspectReference -Source $Source -MirrorRegistry $MirrorRegistry
if (-not $DestTag) {
$DestTag = $resolved.Tag
}
$destHost = Get-RegistryHostFromRepo -Repo $DestRepo
$destImage = "${DestRepo}:${DestTag}"
if (-not (Test-CmdInPath "skopeo")) {
Write-Host "Using skopeo from Docker image: $SkopeoImage"
}
Write-Host "Inspect: $($resolved.InspectRef)"
Write-Host "Destination: $destImage"
Write-Host "Platforms: $($Platforms -join ', ')"
$inspectResult = Invoke-SkopeoInspectRaw -SkopeoImage $SkopeoImage -InspectRef $resolved.InspectRef
if ($inspectResult.ExitCode -ne 0) {
throw "skopeo inspect failed (exit $($inspectResult.ExitCode)): $($inspectResult.RawLines)"
}
$rawLines = $inspectResult.RawLines
$rawJson = if ($rawLines -is [array]) { $rawLines -join "`n" } else { [string]$rawLines }
$rawJson = $rawJson.Trim()
if ($rawJson -notmatch '^\s*\{') {
$idx = $rawJson.LastIndexOf('{')
if ($idx -ge 0) { $rawJson = $rawJson.Substring($idx) }
}
$index = $rawJson | ConvertFrom-Json
# Single manifest (not a list): copy tag-to-tag
if (-not $index.manifests) {
Write-Host "Source is a single manifest (not a multi-arch index). Copying tag as-is..."
$srcTagRef = $resolved.InspectRef
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) { throw "Could not resolve push credentials for $destHost." }
try {
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef $srcTagRef -DestRef "docker://${destImage}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed (exit $($script:SkopeoLastExitCode))" }
Write-Host "Done: $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}
exit 0
}
$digestByPlatform = @{}
foreach ($plat in $Platforms) {
$parts = $plat -split '/', 2
if ($parts.Length -ne 2) { throw "Invalid platform (use os/arch): $plat" }
$osW = $parts[0]
$archW = $parts[1]
$found = $null
foreach ($m in $index.manifests) {
if (Test-PlatformMatch -ManifestEntry $m -OsWant $osW -ArchWant $archW) {
$found = $m.digest
break
}
}
if (-not $found) {
throw "Platform $plat not found in source manifest list. Inspect with: docker buildx imagetools inspect $($resolved.InspectRef -replace '^docker://','')"
}
$digestByPlatform[$plat] = $found
}
foreach ($p in $Platforms) {
Write-Host "$p : $($digestByPlatform[$p])"
}
$srcBase = $resolved.SourceRepoPath
$dstBase = $DestRepo
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) {
throw @"
Could not resolve push credentials for $destHost.
Use: -DestUsername / -DestPassword, or env DEST_REGISTRY_USER + DEST_REGISTRY_PASS, or ALIYUN_CR_USER + ALIYUN_CR_PASS, or docker login.
"@
}
try {
Write-Host ""
Write-Host "Tip: After the 'Copying blob ...' lines, the console may stay quiet for many minutes while large layers upload to the registry (not frozen). Watch Task Manager -> Network (send) if unsure."
$step = 1
$total = $Platforms.Count
foreach ($plat in $Platforms) {
$d = $digestByPlatform[$plat]
Write-Host ""
Write-Host "[$step/$total] skopeo copy $plat ..."
$step++
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef "docker://${srcBase}@${d}" -DestRef "docker://${dstBase}@${d}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed for $plat (exit $($script:SkopeoLastExitCode))" }
}
$refsForBuildx = @()
foreach ($plat in $Platforms) {
$refsForBuildx += "${dstBase}@$($digestByPlatform[$plat])"
}
Write-Host ""
Write-Host "[$($total + 1)/$($total + 1)] docker buildx imagetools create..."
& docker buildx imagetools create -t $destImage @refsForBuildx
if ($LASTEXITCODE -ne 0) { throw "docker buildx imagetools create failed" }
Write-Host ""
Write-Host "Done: $destImage"
Write-Host "Verify: docker buildx imagetools inspect $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}