From 74cf0ed5c827e3dac0b2d9737e9d1d8d6233e4c5 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:02:42 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix(deploy):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=85=A5=E5=8F=A3=E7=AB=AF=E5=8F=A3=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将宁波跨节点验收私有入口从被 staging Docker 占用的 18088 调整为 18089,避免 NGINX reload 在切换前失败。同步更新真实文件任务验收脚本默认地址。已通过 node --check、bash -n、ShellCheck 和 git diff --check。 --- deploy/nginx/cluster-acceptance-origin-ningbo.conf | 2 +- scripts/cluster/cross-node-file-e2e.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/nginx/cluster-acceptance-origin-ningbo.conf b/deploy/nginx/cluster-acceptance-origin-ningbo.conf index e14bf28..a730646 100644 --- a/deploy/nginx/cluster-acceptance-origin-ningbo.conf +++ b/deploy/nginx/cluster-acceptance-origin-ningbo.conf @@ -1,6 +1,6 @@ # Private-only deterministic path used by the cross-node acceptance test. server { - listen 10.77.0.1:18088; + listen 10.77.0.1:18089; server_name _; allow 10.77.0.0/24; diff --git a/scripts/cluster/cross-node-file-e2e.mjs b/scripts/cluster/cross-node-file-e2e.mjs index 00f4fbc..8a2b775 100755 --- a/scripts/cluster/cross-node-file-e2e.mjs +++ b/scripts/cluster/cross-node-file-e2e.mjs @@ -51,7 +51,7 @@ function createFixturePNG(width = 256, height = 256) { ]) } -const baseURL = process.env.AI_GATEWAY_E2E_BASE_URL || 'http://10.77.0.1:18088' +const baseURL = process.env.AI_GATEWAY_E2E_BASE_URL || 'http://10.77.0.1:18089' const account = Buffer.from(process.env.E2E_ACCOUNT_B64 || '', 'base64').toString('utf8') const password = Buffer.from(process.env.E2E_PASSWORD_B64 || '', 'base64').toString('utf8') const model = process.env.AI_GATEWAY_E2E_IMAGE_MODEL || 'openai:gpt-image-1' From b7c03d348e72ff5cc6f08c005b180424b2fecb00 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:06:12 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix(deploy):=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=AE=81=E6=B3=A2=20NGINX=20=E9=85=8D=E7=BD=AE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宁波 NGINX 仅加载 conf.d/*.conf,原切换脚本写入 sites-enabled 后不会生效。统一将维护页、集群入口和回滚配置写入实际加载的 conf.d 入口,并保留切换前配置备份。\n\n验证:bash -n、ShellCheck、git diff --check、双节点 nginx -t 与 prepare。 --- scripts/cluster/install-nginx-edges.sh | 7 +++---- scripts/cluster/migrate-production.sh | 14 ++++---------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/scripts/cluster/install-nginx-edges.sh b/scripts/cluster/install-nginx-edges.sh index 16ba041..6b48dc7 100755 --- a/scripts/cluster/install-nginx-edges.sh +++ b/scripts/cluster/install-nginx-edges.sh @@ -81,11 +81,10 @@ if [[ $mode == activate ]]; then for host in "$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST"; do cluster_ssh "$host" 'bash -s' <<'REMOTE' set -euo pipefail -ln -sfn /etc/nginx/sites-available/ai.51easyai.com-cluster \ +install -m 0644 /etc/nginx/sites-available/ai.51easyai.com-cluster \ + /etc/nginx/conf.d/easyai-ai-gateway-ai.51easyai.com.conf +rm -f /etc/nginx/sites-enabled/ai.51easyai.com \ /etc/nginx/sites-enabled/ai.51easyai.com-cluster -if [[ -L /etc/nginx/sites-enabled/ai.51easyai.com ]]; then - rm -f /etc/nginx/sites-enabled/ai.51easyai.com -fi nginx -t systemctl reload nginx REMOTE diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 604d985..7b4040f 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -101,7 +101,7 @@ set -euo pipefail source_sha=$1 api_image=$2 -active_site=${AI_GATEWAY_NGINX_ACTIVE_SITE:-/etc/nginx/sites-enabled/ai.51easyai.com} +active_site=${AI_GATEWAY_NGINX_ACTIVE_SITE:-/etc/nginx/conf.d/easyai-ai-gateway-ai.51easyai.com.conf} timestamp=$(date -u '+%Y%m%dT%H%M%SZ') backup_directory=/var/backups/easyai-ai-gateway/pre-cutover-"$timestamp" old_site_backup=$backup_directory/nginx-site.conf @@ -118,9 +118,7 @@ rollback_before_commit() { --replicas=0 >/dev/null 2>&1 || true docker start easyai-ai-gateway-api-1 easyai-ai-gateway-web-1 >/dev/null 2>&1 || true if [[ -f $old_site_backup ]]; then - install -m 0644 "$old_site_backup" /etc/nginx/sites-available/ai.51easyai.com-pre-ha - rm -f -- "$active_site" - ln -s /etc/nginx/sites-available/ai.51easyai.com-pre-ha "$active_site" + install -m 0644 "$old_site_backup" "$active_site" nginx -t && systemctl reload nginx fi else @@ -137,8 +135,7 @@ install -d -m 0700 "$backup_directory" } cp -L "$active_site" "$old_site_backup" chmod 0600 "$old_site_backup" -rm -f -- "$active_site" -ln -s /etc/nginx/sites-available/ai.51easyai.com-maintenance "$active_site" +install -m 0644 /etc/nginx/sites-available/ai.51easyai.com-maintenance "$active_site" nginx -t systemctl reload nginx curl -ksS --resolve ai.51easyai.com:443:127.0.0.1 \ @@ -310,11 +307,8 @@ k3s kubectl scale deployment -n easyai \ easyai-api-ningbo easyai-api-hongkong easyai-web-ningbo easyai-web-hongkong \ --replicas=0 >/dev/null 2>&1 || true docker start easyai-ai-gateway-api-1 easyai-ai-gateway-web-1 >/dev/null -rm -f -- /etc/nginx/sites-enabled/ai.51easyai.com install -m 0644 "$backup_directory/nginx-site.conf" \ - /etc/nginx/sites-available/ai.51easyai.com-pre-ha -ln -s /etc/nginx/sites-available/ai.51easyai.com-pre-ha \ - /etc/nginx/sites-enabled/ai.51easyai.com + /etc/nginx/conf.d/easyai-ai-gateway-ai.51easyai.com.conf nginx -t systemctl reload nginx REMOTE From 5d2796337be7868d08abff489a9d0de86e78c0c1 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:09:45 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(deploy):=20=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E5=85=A5=E5=8F=A3=E5=AE=8C=E6=88=90=E9=87=8D?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NGINX 优雅重载期间旧 worker 可能短暂继续返回 200,单次状态码断言会误触发安全回滚。改为最多轮询 15 秒等待维护页返回 503。\n\n验证:bash -n、ShellCheck、git diff --check、真实维护页 503 与恢复后公网 200。 --- scripts/cluster/migrate-production.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 7b4040f..68372cc 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -138,9 +138,17 @@ chmod 0600 "$old_site_backup" install -m 0644 /etc/nginx/sites-available/ai.51easyai.com-maintenance "$active_site" nginx -t systemctl reload nginx -curl -ksS --resolve ai.51easyai.com:443:127.0.0.1 \ - -o /dev/null -w '%{http_code}' https://ai.51easyai.com/api/v1/healthz | - grep -qx 503 +maintenance_code= +for _ in $(seq 1 15); do + maintenance_code=$(curl -ksS --resolve ai.51easyai.com:443:127.0.0.1 \ + -o /dev/null -w '%{http_code}' https://ai.51easyai.com/api/v1/healthz || true) + [[ $maintenance_code == 503 ]] && break + sleep 1 +done +[[ $maintenance_code == 503 ]] || { + echo "maintenance NGINX did not become active: HTTP $maintenance_code" >&2 + exit 1 +} docker stop --time 60 easyai-ai-gateway-api-1 >/dev/null postgres_user=$(docker inspect easyai-ai-gateway-postgres-1 \ From 945fc83c60734e8299104b16d4b401cd246f68e6 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:11:48 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix(deploy):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=BF=AB=E7=85=A7=E7=9B=AE=E5=BD=95=E8=A1=A8?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换前验收快照误用了不存在的 gateway_providers,导致在数据库导出前触发安全回滚。改为实际的 model_catalog_providers。\n\n验证:完整快照 SQL 已对线上旧库只读执行,版本、扩展、表、序列和关键计数均成功生成。 --- scripts/cluster/migrate-production.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 68372cc..6988584 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -192,7 +192,7 @@ SELECT 'gateway_wallet_transactions=' || count(*) FROM gateway_wallet_transactio SELECT 'gateway_upload_assets=' || count(*) FROM gateway_upload_assets; SELECT 'gateway_users=' || count(*) FROM gateway_users; SELECT 'gateway_api_keys=' || count(*) FROM gateway_api_keys; -SELECT 'gateway_providers=' || count(*) FROM gateway_providers; +SELECT 'model_catalog_providers=' || count(*) FROM model_catalog_providers; SELECT 'task_sample=' || md5(COALESCE(string_agg(id::text, ',' ORDER BY id), '')) FROM (SELECT id FROM gateway_tasks ORDER BY id LIMIT 100) sample; SQL From f12f352bf05c144427761c81b2f7f3eb6fd52b9a Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:17:13 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(deploy):=20=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E6=95=B0=E6=8D=AE=E5=BA=93=E7=89=88=E6=9C=AC=E5=AF=B9?= =?UTF-8?q?=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL 官方与 CNPG 镜像的 server_version 展示字符串包含不同构建后缀,造成同为 18.4 的数据库被误判不一致。改用 server_version_num 进行精确版本校验。\n\n验证:旧 Docker 与已恢复 CNPG 的完整验收快照 diff 无差异,server_version_num 均为 180004。 --- scripts/cluster/migrate-production.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 6988584..4b11ea1 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -164,7 +164,7 @@ snapshot_sql=$backup_directory/acceptance-snapshot.sql cat >"$snapshot_sql" <<'SQL' \pset tuples_only on \pset format unaligned -SELECT 'server_version=' || current_setting('server_version'); +SELECT 'server_version_num=' || current_setting('server_version_num'); SELECT 'extensions=' || COALESCE(string_agg(extname || ':' || extversion, ',' ORDER BY extname), '') FROM pg_extension WHERE extname <> 'plpgsql'; SELECT 'tables=' || count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace From 39a8fd2ce0555b5df4a2fe494e23232bc5f183b9 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:25:43 +0800 Subject: [PATCH 06/15] =?UTF-8?q?fix(deploy):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E9=98=B6=E6=AE=B5=E5=A4=96=E7=BD=91=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正式激活时不再从 GitHub 下载并重装已健康运行的 cert-manager、CloudNativePG 和 Barman Operator;Operator 安装继续保留在 prepare 阶段。激活阶段只渲染、校验并启动已锁定 digest 的应用清单,避免维护窗口受外网波动影响。\n\n验证:bash -n、ShellCheck、git diff --check。 --- scripts/cluster/bootstrap-platform.sh | 44 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/scripts/cluster/bootstrap-platform.sh b/scripts/cluster/bootstrap-platform.sh index 74542b5..4e8d65f 100755 --- a/scripts/cluster/bootstrap-platform.sh +++ b/scripts/cluster/bootstrap-platform.sh @@ -57,15 +57,17 @@ operator_checksums=( e0c5ff41bf5b01c0775bf225929b8423d543cd23e9742e4274fe7de8499cf93a d2e71e7b06822448f1a421f05781846cfdb9cc621e7ef32eef5e20c5133213b0 ) -for index in 0 1 2; do - operator=${operators[$index]} - curl -fsSL "${operator_urls[$index]}" -o "$working_directory/$operator" - actual_checksum=$(shasum -a 256 "$working_directory/$operator" | awk '{print $1}') - [[ $actual_checksum == "${operator_checksums[$index]}" ]] || { - echo "operator manifest checksum mismatch: $operator" >&2 - exit 1 - } -done +if [[ $mode == prepare ]]; then + for index in 0 1 2; do + operator=${operators[$index]} + curl -fsSL "${operator_urls[$index]}" -o "$working_directory/$operator" + actual_checksum=$(shasum -a 256 "$working_directory/$operator" | awk '{print $1}') + [[ $actual_checksum == "${operator_checksums[$index]}" ]] || { + echo "operator manifest checksum mismatch: $operator" >&2 + exit 1 + } + done +fi cp -R "$cluster_root/deploy/kubernetes/production" "$working_directory/production" api_digest=${api_image##*@} @@ -126,7 +128,11 @@ stringData: EOF chmod 0600 "$working_directory/bootstrap-secrets.yaml" -for file in cert-manager.yaml cnpg.yaml barman.yaml easyai-production.yaml bootstrap-secrets.yaml; do +platform_files=(easyai-production.yaml bootstrap-secrets.yaml) +if [[ $mode == prepare ]]; then + platform_files=(cert-manager.yaml cnpg.yaml barman.yaml "${platform_files[@]}") +fi +for file in "${platform_files[@]}"; do cluster_scp "$working_directory/$file" "$CLUSTER_NINGBO_HOST:/root/$file" done @@ -151,14 +157,16 @@ cleanup_bootstrap_files() { } trap cleanup_bootstrap_files EXIT HUP INT TERM -$kubectl apply --server-side --force-conflicts -f /root/cert-manager.yaml >/dev/null -$kubectl rollout status deployment/cert-manager -n cert-manager --timeout=180s -$kubectl rollout status deployment/cert-manager-webhook -n cert-manager --timeout=180s -$kubectl rollout status deployment/cert-manager-cainjector -n cert-manager --timeout=180s -$kubectl apply --server-side --force-conflicts -f /root/cnpg.yaml >/dev/null -$kubectl rollout status deployment/cnpg-controller-manager -n cnpg-system --timeout=600s -$kubectl apply --server-side --force-conflicts -f /root/barman.yaml >/dev/null -$kubectl rollout status deployment/barman-cloud -n cnpg-system --timeout=600s +if [[ $mode == prepare ]]; then + $kubectl apply --server-side --force-conflicts -f /root/cert-manager.yaml >/dev/null + $kubectl rollout status deployment/cert-manager -n cert-manager --timeout=180s + $kubectl rollout status deployment/cert-manager-webhook -n cert-manager --timeout=180s + $kubectl rollout status deployment/cert-manager-cainjector -n cert-manager --timeout=180s + $kubectl apply --server-side --force-conflicts -f /root/cnpg.yaml >/dev/null + $kubectl rollout status deployment/cnpg-controller-manager -n cnpg-system --timeout=600s + $kubectl apply --server-side --force-conflicts -f /root/barman.yaml >/dev/null + $kubectl rollout status deployment/barman-cloud -n cnpg-system --timeout=600s +fi $kubectl create namespace easyai --dry-run=client -o yaml | $kubectl apply -f - >/dev/null $kubectl apply -f /root/bootstrap-secrets.yaml >/dev/null From 8e387ca55357311814b30161b5a391ad21a78cd2 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:28:19 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix(deploy):=20=E5=9B=BA=E5=AE=9A=20API?= =?UTF-8?q?=20=E5=AE=B9=E5=99=A8=E8=BF=90=E8=A1=8C=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 镜像使用名称用户 appuser,Kubelet 无法仅凭 runAsNonRoot 验证其非 root 身份,导致 CreateContainerConfigError。按照 Dockerfile 中固定 UID 10001 显式设置 runAsUser、runAsGroup 和 fsGroup,确保 EmptyDir 可写且满足 Pod 安全约束。\n\n验证:kubectl kustomize 渲染成功,两个 API Deployment 均包含 UID/GID 10001,git diff --check 通过。 --- deploy/kubernetes/production/application.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deploy/kubernetes/production/application.yaml b/deploy/kubernetes/production/application.yaml index f7481f7..db76632 100644 --- a/deploy/kubernetes/production/application.yaml +++ b/deploy/kubernetes/production/application.yaml @@ -32,6 +32,10 @@ spec: easyai.io/workload: "true" securityContext: runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault containers: @@ -134,6 +138,10 @@ spec: easyai.io/workload: "true" securityContext: runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault containers: From 006609569d4aeaf1e856bebaaa3a75618c728c51 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:36:29 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix(deploy):=20=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E8=BE=B9=E7=BC=98=E5=88=B0=E7=AB=99=E7=82=B9=E7=9A=84=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主机本地访问跨站点 NodePort 时 kube-proxy 会随机选择不可达的对端 Pod,导致约四分之一请求被拒绝。为两地应用绑定仅位于 WireGuard 地址的 HostPort 31088/31178,双 NGINX 和集群验收改用该固定入口;原 NodePort 30088/30178 继续保留。端口处于现有公网 DROP、wg0 私网放行范围。\n\n验证:kubectl kustomize、服务端 dry-run、bash -n、ShellCheck、Secret 与 digest 静态检查、git diff --check。 --- deploy/kubernetes/easyai-ai-gateway-cluster-release | 6 +++--- deploy/kubernetes/production/application.yaml | 8 ++++++++ deploy/nginx/ai.51easyai.com-cluster.conf | 8 ++++---- deploy/nginx/cluster-acceptance-origin-ningbo.conf | 2 +- scripts/cluster/migrate-production.sh | 6 +++--- scripts/cluster/run-cross-node-file-e2e.sh | 4 ++-- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/deploy/kubernetes/easyai-ai-gateway-cluster-release b/deploy/kubernetes/easyai-ai-gateway-cluster-release index 40029e9..d6d398d 100755 --- a/deploy/kubernetes/easyai-ai-gateway-cluster-release +++ b/deploy/kubernetes/easyai-ai-gateway-cluster-release @@ -69,9 +69,9 @@ verify_site() { ningbo) address=10.77.0.1 ;; *) return 1 ;; esac - wait_for_url "$site API health" "http://$address:30088/api/v1/healthz" easyai-ai-gateway - wait_for_url "$site API readiness" "http://$address:30088/api/v1/readyz" '"ok":true' - wait_for_url "$site Web" "http://$address:30178/" 'EasyAI AI Gateway' + wait_for_url "$site API health" "http://$address:31088/api/v1/healthz" easyai-ai-gateway + wait_for_url "$site API readiness" "http://$address:31088/api/v1/readyz" '"ok":true' + wait_for_url "$site Web" "http://$address:31178/" 'EasyAI AI Gateway' } rollout_site() { diff --git a/deploy/kubernetes/production/application.yaml b/deploy/kubernetes/production/application.yaml index db76632..2ef4968 100644 --- a/deploy/kubernetes/production/application.yaml +++ b/deploy/kubernetes/production/application.yaml @@ -59,6 +59,8 @@ spec: ports: - name: http containerPort: 8088 + hostIP: 10.77.0.1 + hostPort: 31088 readinessProbe: httpGet: path: /api/v1/readyz @@ -165,6 +167,8 @@ spec: ports: - name: http containerPort: 8088 + hostIP: 10.77.0.2 + hostPort: 31088 readinessProbe: httpGet: path: /api/v1/readyz @@ -257,6 +261,8 @@ spec: ports: - name: http containerPort: 80 + hostIP: 10.77.0.1 + hostPort: 31178 readinessProbe: httpGet: path: / @@ -346,6 +352,8 @@ spec: ports: - name: http containerPort: 80 + hostIP: 10.77.0.2 + hostPort: 31178 readinessProbe: httpGet: path: / diff --git a/deploy/nginx/ai.51easyai.com-cluster.conf b/deploy/nginx/ai.51easyai.com-cluster.conf index 468a200..b1d9bae 100644 --- a/deploy/nginx/ai.51easyai.com-cluster.conf +++ b/deploy/nginx/ai.51easyai.com-cluster.conf @@ -1,15 +1,15 @@ upstream easyai_gateway_api { least_conn; keepalive 64; - server 10.77.0.1:30088 max_fails=2 fail_timeout=5s; - server 10.77.0.2:30088 max_fails=2 fail_timeout=5s; + server 10.77.0.1:31088 max_fails=2 fail_timeout=5s; + server 10.77.0.2:31088 max_fails=2 fail_timeout=5s; } upstream easyai_gateway_web { least_conn; keepalive 32; - server 10.77.0.1:30178 max_fails=2 fail_timeout=5s; - server 10.77.0.2:30178 max_fails=2 fail_timeout=5s; + server 10.77.0.1:31178 max_fails=2 fail_timeout=5s; + server 10.77.0.2:31178 max_fails=2 fail_timeout=5s; } server { diff --git a/deploy/nginx/cluster-acceptance-origin-ningbo.conf b/deploy/nginx/cluster-acceptance-origin-ningbo.conf index a730646..f3fc8b8 100644 --- a/deploy/nginx/cluster-acceptance-origin-ningbo.conf +++ b/deploy/nginx/cluster-acceptance-origin-ningbo.conf @@ -19,6 +19,6 @@ server { proxy_read_timeout 3600s; proxy_request_buffering off; proxy_buffering off; - proxy_pass http://10.77.0.1:30088; + proxy_pass http://10.77.0.1:31088; } } diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 4b11ea1..2fb492f 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -331,11 +331,11 @@ AI_GATEWAY_ACTIVATE_WORKERS=false \ for site_ip in 10.77.0.1 10.77.0.2; do cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:30088/api/v1/healthz >/dev/null" + "curl -fsS --max-time 10 http://$site_ip:31088/api/v1/healthz >/dev/null" cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:30088/api/v1/readyz | grep -q '\"ok\":true'" + "curl -fsS --max-time 10 http://$site_ip:31088/api/v1/readyz | grep -q '\"ok\":true'" cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:30178/ | grep -q 'EasyAI AI Gateway'" + "curl -fsS --max-time 10 http://$site_ip:31178/ | grep -q 'EasyAI AI Gateway'" done "$script_dir/install-nginx-edges.sh" activate diff --git a/scripts/cluster/run-cross-node-file-e2e.sh b/scripts/cluster/run-cross-node-file-e2e.sh index 312be0c..313b4f8 100755 --- a/scripts/cluster/run-cross-node-file-e2e.sh +++ b/scripts/cluster/run-cross-node-file-e2e.sh @@ -31,8 +31,8 @@ k3s kubectl rollout status deployment/easyai-api-hongkong -n easyai --timeout=30 k3s kubectl set env deployment/easyai-api-ningbo -n easyai \ AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=false >/dev/null k3s kubectl rollout status deployment/easyai-api-ningbo -n easyai --timeout=300s -curl -fsS http://10.77.0.1:30088/api/v1/readyz | grep -q '"ok":true' -curl -fsS http://10.77.0.2:30088/api/v1/readyz | grep -q '"ok":true' +curl -fsS http://10.77.0.1:31088/api/v1/readyz | grep -q '"ok":true' +curl -fsS http://10.77.0.2:31088/api/v1/readyz | grep -q '"ok":true' REMOTE credentials_file=$(mktemp "$cluster_root/.local-secrets/cluster/e2e.XXXXXX") From 6897fb3b66510f0104ece3ce76c0bf0ed84382f6 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:40:57 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix(deploy):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E4=B8=93=E5=B1=9E=20NodePort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostPort 被命名空间 PodSecurity baseline 拒绝,因此恢复无特权 Pod 配置,并为宁波、香港的 API/Web 增加只选择本站 Pod 的专属 NodePort Service。双 NGINX 和验收脚本按站点使用 31088/31089 与 31178/31179,避免主机本地访问共享 NodePort 时随机命中不可达的跨站 Pod。\n\n验证:kubectl kustomize、服务端 dry-run、bash -n、ShellCheck、无 hostPort/hostIP、git diff --check。 --- .../easyai-ai-gateway-cluster-release | 20 +++- deploy/kubernetes/production/application.yaml | 92 +++++++++++++++++-- deploy/nginx/ai.51easyai.com-cluster.conf | 4 +- scripts/cluster/migrate-production.sh | 9 +- scripts/cluster/run-cross-node-file-e2e.sh | 2 +- 5 files changed, 107 insertions(+), 20 deletions(-) diff --git a/deploy/kubernetes/easyai-ai-gateway-cluster-release b/deploy/kubernetes/easyai-ai-gateway-cluster-release index d6d398d..d262e6e 100755 --- a/deploy/kubernetes/easyai-ai-gateway-cluster-release +++ b/deploy/kubernetes/easyai-ai-gateway-cluster-release @@ -64,14 +64,24 @@ wait_for_url() { verify_site() { local site=$1 local address + local api_port + local web_port case $site in - hongkong) address=10.77.0.2 ;; - ningbo) address=10.77.0.1 ;; + hongkong) + address=10.77.0.2 + api_port=31089 + web_port=31179 + ;; + ningbo) + address=10.77.0.1 + api_port=31088 + web_port=31178 + ;; *) return 1 ;; esac - wait_for_url "$site API health" "http://$address:31088/api/v1/healthz" easyai-ai-gateway - wait_for_url "$site API readiness" "http://$address:31088/api/v1/readyz" '"ok":true' - wait_for_url "$site Web" "http://$address:31178/" 'EasyAI AI Gateway' + wait_for_url "$site API health" "http://$address:$api_port/api/v1/healthz" easyai-ai-gateway + wait_for_url "$site API readiness" "http://$address:$api_port/api/v1/readyz" '"ok":true' + wait_for_url "$site Web" "http://$address:$web_port/" 'EasyAI AI Gateway' } rollout_site() { diff --git a/deploy/kubernetes/production/application.yaml b/deploy/kubernetes/production/application.yaml index 2ef4968..6072027 100644 --- a/deploy/kubernetes/production/application.yaml +++ b/deploy/kubernetes/production/application.yaml @@ -59,8 +59,6 @@ spec: ports: - name: http containerPort: 8088 - hostIP: 10.77.0.1 - hostPort: 31088 readinessProbe: httpGet: path: /api/v1/readyz @@ -167,8 +165,6 @@ spec: ports: - name: http containerPort: 8088 - hostIP: 10.77.0.2 - hostPort: 31088 readinessProbe: httpGet: path: /api/v1/readyz @@ -261,8 +257,6 @@ spec: ports: - name: http containerPort: 80 - hostIP: 10.77.0.1 - hostPort: 31178 readinessProbe: httpGet: path: / @@ -352,8 +346,6 @@ spec: ports: - name: http containerPort: 80 - hostIP: 10.77.0.2 - hostPort: 31178 readinessProbe: httpGet: path: / @@ -434,6 +426,90 @@ spec: targetPort: http nodePort: 30178 --- +apiVersion: v1 +kind: Service +metadata: + name: api-ningbo-edge + labels: + app.kubernetes.io/name: easyai-api + app.kubernetes.io/component: api + app.kubernetes.io/part-of: easyai-ai-gateway + easyai.io/site: ningbo +spec: + type: NodePort + externalTrafficPolicy: Local + selector: + app.kubernetes.io/name: easyai-api + easyai.io/site: ningbo + ports: + - name: http + port: 8088 + targetPort: http + nodePort: 31088 +--- +apiVersion: v1 +kind: Service +metadata: + name: api-hongkong-edge + labels: + app.kubernetes.io/name: easyai-api + app.kubernetes.io/component: api + app.kubernetes.io/part-of: easyai-ai-gateway + easyai.io/site: hongkong +spec: + type: NodePort + externalTrafficPolicy: Local + selector: + app.kubernetes.io/name: easyai-api + easyai.io/site: hongkong + ports: + - name: http + port: 8088 + targetPort: http + nodePort: 31089 +--- +apiVersion: v1 +kind: Service +metadata: + name: web-ningbo-edge + labels: + app.kubernetes.io/name: easyai-web + app.kubernetes.io/component: web + app.kubernetes.io/part-of: easyai-ai-gateway + easyai.io/site: ningbo +spec: + type: NodePort + externalTrafficPolicy: Local + selector: + app.kubernetes.io/name: easyai-web + easyai.io/site: ningbo + ports: + - name: http + port: 80 + targetPort: http + nodePort: 31178 +--- +apiVersion: v1 +kind: Service +metadata: + name: web-hongkong-edge + labels: + app.kubernetes.io/name: easyai-web + app.kubernetes.io/component: web + app.kubernetes.io/part-of: easyai-ai-gateway + easyai.io/site: hongkong +spec: + type: NodePort + externalTrafficPolicy: Local + selector: + app.kubernetes.io/name: easyai-web + easyai.io/site: hongkong + ports: + - name: http + port: 80 + targetPort: http + nodePort: 31179 +--- apiVersion: policy/v1 kind: PodDisruptionBudget metadata: diff --git a/deploy/nginx/ai.51easyai.com-cluster.conf b/deploy/nginx/ai.51easyai.com-cluster.conf index b1d9bae..d3f31e1 100644 --- a/deploy/nginx/ai.51easyai.com-cluster.conf +++ b/deploy/nginx/ai.51easyai.com-cluster.conf @@ -2,14 +2,14 @@ upstream easyai_gateway_api { least_conn; keepalive 64; server 10.77.0.1:31088 max_fails=2 fail_timeout=5s; - server 10.77.0.2:31088 max_fails=2 fail_timeout=5s; + server 10.77.0.2:31089 max_fails=2 fail_timeout=5s; } upstream easyai_gateway_web { least_conn; keepalive 32; server 10.77.0.1:31178 max_fails=2 fail_timeout=5s; - server 10.77.0.2:31178 max_fails=2 fail_timeout=5s; + server 10.77.0.2:31179 max_fails=2 fail_timeout=5s; } server { diff --git a/scripts/cluster/migrate-production.sh b/scripts/cluster/migrate-production.sh index 2fb492f..75abab2 100755 --- a/scripts/cluster/migrate-production.sh +++ b/scripts/cluster/migrate-production.sh @@ -329,13 +329,14 @@ echo '[cutover] starting both application sites with River workers frozen' AI_GATEWAY_ACTIVATE_WORKERS=false \ "$script_dir/bootstrap-platform.sh" activate "$release_manifest" -for site_ip in 10.77.0.1 10.77.0.2; do +for site_endpoint in 10.77.0.1:31088:31178 10.77.0.2:31089:31179; do + IFS=: read -r site_ip api_port web_port <<<"$site_endpoint" cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:31088/api/v1/healthz >/dev/null" + "curl -fsS --max-time 10 http://$site_ip:$api_port/api/v1/healthz >/dev/null" cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:31088/api/v1/readyz | grep -q '\"ok\":true'" + "curl -fsS --max-time 10 http://$site_ip:$api_port/api/v1/readyz | grep -q '\"ok\":true'" cluster_ssh "$CLUSTER_NINGBO_HOST" \ - "curl -fsS --max-time 10 http://$site_ip:31178/ | grep -q 'EasyAI AI Gateway'" + "curl -fsS --max-time 10 http://$site_ip:$web_port/ | grep -q 'EasyAI AI Gateway'" done "$script_dir/install-nginx-edges.sh" activate diff --git a/scripts/cluster/run-cross-node-file-e2e.sh b/scripts/cluster/run-cross-node-file-e2e.sh index 313b4f8..bfd1fed 100755 --- a/scripts/cluster/run-cross-node-file-e2e.sh +++ b/scripts/cluster/run-cross-node-file-e2e.sh @@ -32,7 +32,7 @@ k3s kubectl set env deployment/easyai-api-ningbo -n easyai \ AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=false >/dev/null k3s kubectl rollout status deployment/easyai-api-ningbo -n easyai --timeout=300s curl -fsS http://10.77.0.1:31088/api/v1/readyz | grep -q '"ok":true' -curl -fsS http://10.77.0.2:31088/api/v1/readyz | grep -q '"ok":true' +curl -fsS http://10.77.0.2:31089/api/v1/readyz | grep -q '"ok":true' REMOTE credentials_file=$(mktemp "$cluster_root/.local-secrets/cluster/e2e.XXXXXX") From 0bef13fbd732a33a352e45bc19b8c6ff5c7ef948 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 02:56:50 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix(deploy):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E8=BE=B9=E7=BC=98=E9=AA=8C=E6=94=B6=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 响应较大,curl 通过管道交给 grep -q 时会因下游提前退出报写端关闭,造成健康服务被误判失败。改为完整读取各响应后再断言内容。\n\n验证:bash -n、ShellCheck、git diff --check。 --- scripts/cluster/verify-cluster.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/cluster/verify-cluster.sh b/scripts/cluster/verify-cluster.sh index ef327da..3f75306 100755 --- a/scripts/cluster/verify-cluster.sh +++ b/scripts/cluster/verify-cluster.sh @@ -115,14 +115,18 @@ REMOTE echo 'application_acceptance=PASS api=2 web=2 digests=pinned' for edge_ip in "${CLUSTER_NINGBO_HOST#*@}" "${CLUSTER_HONGKONG_HOST#*@}"; do - curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ - https://ai.51easyai.com/api/v1/healthz | grep -q easyai-ai-gateway - curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ - https://ai.51easyai.com/api/v1/readyz | grep -q '"ok":true' - curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ - https://ai.51easyai.com/api/v1/openapi.json | grep -q '"/api/v1/chat/completions"' - curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ - https://ai.51easyai.com/ | grep -q 'EasyAI AI Gateway' + health_body=$(curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ + https://ai.51easyai.com/api/v1/healthz) + grep -q easyai-ai-gateway <<<"$health_body" + ready_body=$(curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ + https://ai.51easyai.com/api/v1/readyz) + grep -q '"ok":true' <<<"$ready_body" + openapi_body=$(curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ + https://ai.51easyai.com/api/v1/openapi.json) + grep -q '"/api/v1/chat/completions"' <<<"$openapi_body" + web_body=$(curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \ + https://ai.51easyai.com/) + grep -q 'EasyAI AI Gateway' <<<"$web_body" done echo 'nginx_edges=PASS ningbo=true hongkong=true dns_changed=false' fi From 9a3cc582ce8a28d3252e8a684a891b7812191555 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 03:03:14 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix(api):=20=E5=88=86=E7=A6=BB=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E9=98=9F=E5=88=97=E5=85=A5=E9=98=9F=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=false 时仍初始化 River schema 与控制客户端,允许 HTTP 请求持久化并入队;仅跳过执行客户端和动态容量 Worker。关闭上下文时同步清理控制客户端引用。\n\n新增数据库集成测试覆盖无执行 Worker 时成功创建 River job 且 attempted_by 为空。验证:gofmt、go test ./... -count=1、go vet ./...。 --- apps/api/internal/httpapi/server.go | 7 +- apps/api/internal/runner/queue_client_test.go | 74 +++++++++++++++++++ apps/api/internal/runner/queue_worker.go | 29 +++++++- 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 apps/api/internal/runner/queue_client_test.go diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index e0682ee..d5ed588 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -122,8 +122,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey if cfg.AsyncQueueWorkerEnabled { server.runner.StartAsyncQueueWorker(ctx) - } else if logger != nil { - logger.Info("asynchronous queue worker disabled for this process") + } else { + server.runner.StartAsyncQueueClient(ctx) + if logger != nil { + logger.Info("asynchronous queue worker disabled for this process") + } } server.runner.StartBillingSettlementWorker(ctx) server.runner.StartTaskHistoryWorkers(ctx) diff --git a/apps/api/internal/runner/queue_client_test.go b/apps/api/internal/runner/queue_client_test.go new file mode 100644 index 0000000..9820f79 --- /dev/null +++ b/apps/api/internal/runner/queue_client_test.go @@ -0,0 +1,74 @@ +package runner + +import ( + "context" + "io" + "log/slog" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestAsyncQueueClientEnqueuesWithoutExecutionWorker(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the queue client integration test") + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db, err := store.Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect store: %v", err) + } + t.Cleanup(db.Close) + + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + task, err := db.CreateTask(ctx, store.CreateTaskInput{ + Kind: "images.edits", + Model: "queue-client-" + suffix, + Request: map[string]any{"prompt": "enqueue without execution worker"}, + Async: true, + RunMode: "simulation", + }, &auth.User{ID: "queue-client-" + suffix, Source: "gateway"}) + if err != nil { + t.Fatalf("create task: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id = $1::uuid`, task.ID) + }) + + service := New(config.Config{AppEnv: "test"}, db, slog.New(slog.NewTextHandler(io.Discard, nil))) + service.StartAsyncQueueClient(ctx) + if err := service.EnqueueAsyncTask(ctx, task); err != nil { + t.Fatalf("enqueue task without execution worker: %v", err) + } + + queued, err := db.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("load queued task: %v", err) + } + if queued.RiverJobID <= 0 { + t.Fatal("queue client did not persist a River job ID") + } + t.Cleanup(func() { + _, _ = db.Pool().Exec(context.Background(), `DELETE FROM river_job WHERE id = $1`, queued.RiverJobID) + }) + + var attemptedByCount int + if err := db.Pool().QueryRow(ctx, ` +SELECT cardinality(attempted_by) +FROM river_job +WHERE id = $1`, queued.RiverJobID).Scan(&attemptedByCount); err != nil { + t.Fatalf("load River job: %v", err) + } + if attemptedByCount != 0 { + t.Fatalf("control-only queue client executed the job: attempted_by=%d", attemptedByCount) + } +} diff --git a/apps/api/internal/runner/queue_worker.go b/apps/api/internal/runner/queue_worker.go index 72e0b9b..3de7bfa 100644 --- a/apps/api/internal/runner/queue_worker.go +++ b/apps/api/internal/runner/queue_worker.go @@ -78,13 +78,20 @@ func (w *asyncTaskWorker) Work(ctx context.Context, job *river.Job[asyncTaskArgs } func (s *Service) StartAsyncQueueWorker(ctx context.Context) { - if err := s.startRiverQueue(ctx); err != nil { + if err := s.startRiverQueue(ctx, true); err != nil { s.logger.Error("start river async queue failed", "error", err) panic(err) } } -func (s *Service) startRiverQueue(ctx context.Context) error { +func (s *Service) StartAsyncQueueClient(ctx context.Context) { + if err := s.startRiverQueue(ctx, false); err != nil { + s.logger.Error("start river async queue client failed", "error", err) + panic(err) + } +} + +func (s *Service) startRiverQueue(ctx context.Context, workerEnabled bool) error { driver := riverpgxv5.New(s.store.Pool()) migrator, err := rivermigrate.New(driver, nil) if err != nil { @@ -102,6 +109,23 @@ func (s *Service) startRiverQueue(ctx context.Context) error { if err != nil { return err } + if !workerEnabled { + s.riverMu.Lock() + s.riverControlClient = controlClient + s.riverExecutionClient = nil + s.riverWorkerCapacity = 0 + s.riverDrainingClients = make(map[asyncExecutionClient]struct{}) + s.riverMu.Unlock() + if err := s.recoverAsyncRiverJobs(ctx); err != nil { + s.riverMu.Lock() + s.riverControlClient = nil + s.riverMu.Unlock() + return err + } + go s.stopAsyncWorkersOnShutdown(ctx) + s.logger.Info("river async queue client initialized without execution workers") + return nil + } snapshot, err := s.loadAsyncWorkerCapacity(ctx) if err != nil { return fmt.Errorf("calculate initial async worker capacity: %w", err) @@ -271,6 +295,7 @@ func (s *Service) drainAsyncWorkerClient(client asyncExecutionClient) { func (s *Service) stopAsyncWorkersOnShutdown(ctx context.Context) { <-ctx.Done() s.riverMu.Lock() + s.riverControlClient = nil clients := make([]asyncExecutionClient, 0, 1+len(s.riverDrainingClients)) if s.riverExecutionClient != nil { clients = append(clients, s.riverExecutionClient) From 5d5284e068691659ce4af8cf4358a71b8792cf7e Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 03:07:13 +0800 Subject: [PATCH 12/15] =?UTF-8?q?test(api):=20=E6=98=BE=E5=BC=8F=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E5=BC=82=E6=AD=A5=E6=89=A7=E8=A1=8C=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动态并发验收直接构造 Config 时需显式开启 AsyncQueueWorkerEnabled,避免零值 false 把该测试误变为仅入队模式。\n\n验证:gofmt、go test ./internal/httpapi -count=1。 --- .../internal/httpapi/async_worker_acceptance_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/internal/httpapi/async_worker_acceptance_integration_test.go b/apps/api/internal/httpapi/async_worker_acceptance_integration_test.go index 3210ab7..38684b1 100644 --- a/apps/api/internal/httpapi/async_worker_acceptance_integration_test.go +++ b/apps/api/internal/httpapi/async_worker_acceptance_integration_test.go @@ -54,6 +54,7 @@ func TestAsyncWorkerDynamicConcurrencyAcceptance(t *testing.T) { JWTSecret: "test-secret", BillingEngineMode: "observe", CORSAllowedOrigin: "*", + AsyncQueueWorkerEnabled: true, AsyncWorkerHardLimit: 256, AsyncWorkerRefreshIntervalSeconds: 1, }, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) From a642f43cf2f1f7b870341c06383053cedcdbe8ad Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 03:13:01 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix(deploy):=20=E6=A0=A1=E5=87=86?= =?UTF-8?q?=E8=B7=A8=E8=8A=82=E7=82=B9=E5=9B=BE=E7=89=87=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实成功样本使用 2048x2048,验收脚本改用相同默认尺寸并允许环境覆盖。修正创建 API Key 响应中的 apiKey.id 读取,每次运行前清理本脚本遗留 Key,结束时删除新 Key。\n\n验证:bash -n、ShellCheck、node --check、git diff --check。 --- scripts/cluster/cross-node-file-e2e.mjs | 14 ++++++++++++-- scripts/cluster/run-cross-node-file-e2e.sh | 16 ++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/cluster/cross-node-file-e2e.mjs b/scripts/cluster/cross-node-file-e2e.mjs index 8a2b775..b6c4de5 100755 --- a/scripts/cluster/cross-node-file-e2e.mjs +++ b/scripts/cluster/cross-node-file-e2e.mjs @@ -55,7 +55,9 @@ const baseURL = process.env.AI_GATEWAY_E2E_BASE_URL || 'http://10.77.0.1:18089' const account = Buffer.from(process.env.E2E_ACCOUNT_B64 || '', 'base64').toString('utf8') const password = Buffer.from(process.env.E2E_PASSWORD_B64 || '', 'base64').toString('utf8') const model = process.env.AI_GATEWAY_E2E_IMAGE_MODEL || 'openai:gpt-image-1' +const imageSize = process.env.AI_GATEWAY_E2E_IMAGE_SIZE || '2048x2048' assert(account && password, 'E2E login credentials are unavailable') +assert(/^\d+x\d+$/.test(imageSize), 'E2E image size is invalid') async function request(path, { method = 'GET', token, body, form } = {}) { const headers = { Host: 'ai.51easyai.com' } @@ -95,6 +97,13 @@ const login = await request('/api/v1/auth/login', { const jwt = login.payload.accessToken assert(typeof jwt === 'string' && jwt.length > 20, 'login did not return an access token') +const existingKeys = await request('/api/v1/api-keys', { token: jwt }) +for (const item of existingKeys.payload.items || []) { + if (typeof item?.id === 'string' && item?.name?.startsWith('cluster-cross-node-')) { + await request(`/api/v1/api-keys/${item.id}`, { method: 'DELETE', token: jwt }) + } +} + const keyName = `cluster-cross-node-${Date.now()}` const keyResult = await request('/api/v1/api-keys', { method: 'POST', @@ -102,8 +111,9 @@ const keyResult = await request('/api/v1/api-keys', { body: { name: keyName }, }) const apiKey = keyResult.payload.secret -const apiKeyId = keyResult.payload.id || keyResult.payload.item?.id +const apiKeyId = keyResult.payload.apiKey?.id assert(typeof apiKey === 'string' && apiKey.startsWith('sk-gw-'), 'API key creation failed') +assert(typeof apiKeyId === 'string' && apiKeyId.length > 0, 'API key ID is unavailable') let result try { @@ -112,7 +122,7 @@ try { const form = new FormData() form.append('model', model) form.append('prompt', `Cross-node HA acceptance ${randomUUID()}: add a thin white border`) - form.append('size', '1024x1024') + form.append('size', imageSize) form.append('image', new Blob([fixture], { type: 'image/png' }), 'cluster-e2e.png') const accepted = await request('/api/v1/images/edits', { method: 'POST', diff --git a/scripts/cluster/run-cross-node-file-e2e.sh b/scripts/cluster/run-cross-node-file-e2e.sh index bfd1fed..e6591da 100755 --- a/scripts/cluster/run-cross-node-file-e2e.sh +++ b/scripts/cluster/run-cross-node-file-e2e.sh @@ -38,12 +38,16 @@ REMOTE credentials_file=$(mktemp "$cluster_root/.local-secrets/cluster/e2e.XXXXXX") trap 'rm -f -- "$credentials_file"; restore_workers' EXIT HUP INT TERM chmod 0600 "$credentials_file" -printf 'E2E_ACCOUNT_B64=%s\n' "$(printf '%s' "$AI_GATEWAY_ONLINE_ACCOUNT" | base64 | tr -d '\n')" \ - >"$credentials_file" -printf 'E2E_PASSWORD_B64=%s\n' "$(printf '%s' "$AI_GATEWAY_ONLINE_PASSWORD" | base64 | tr -d '\n')" \ - >>"$credentials_file" -printf 'AI_GATEWAY_E2E_IMAGE_MODEL=%s\n' "${AI_GATEWAY_E2E_IMAGE_MODEL:-openai:gpt-image-1}" \ - >>"$credentials_file" +{ + printf 'E2E_ACCOUNT_B64=%s\n' \ + "$(printf '%s' "$AI_GATEWAY_ONLINE_ACCOUNT" | base64 | tr -d '\n')" + printf 'E2E_PASSWORD_B64=%s\n' \ + "$(printf '%s' "$AI_GATEWAY_ONLINE_PASSWORD" | base64 | tr -d '\n')" + printf 'AI_GATEWAY_E2E_IMAGE_MODEL=%s\n' \ + "${AI_GATEWAY_E2E_IMAGE_MODEL:-openai:gpt-image-1}" + printf 'AI_GATEWAY_E2E_IMAGE_SIZE=%s\n' \ + "${AI_GATEWAY_E2E_IMAGE_SIZE:-2048x2048}" +} >"$credentials_file" cluster_scp "$credentials_file" "$CLUSTER_NINGBO_HOST:/root/easyai-cross-node-e2e.env" cluster_scp "$script_dir/cross-node-file-e2e.mjs" \ "$CLUSTER_NINGBO_HOST:/root/easyai-cross-node-file-e2e.mjs" From ea33be5d69a72e64a9783cbcdf45cec0f9449060 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 03:24:02 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix(deploy):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E8=B7=A8=E8=8A=82=E7=82=B9=E7=9C=9F=E5=AE=9E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正宁波旧 Worker 在滚动退场期间仍可能领取任务的竞态,提交任务前要求仅保留唯一且明确禁用 Worker 的新 Pod。\n\n为临时验收 Key 分配 gpt-image-2 所需的最小平台访问资源,并让 multipart 参数对齐线上 2K 图像编辑请求。已通过 bash -n、ShellCheck、node --check,并完成真实跨节点任务验收。 --- scripts/cluster/cross-node-file-e2e.mjs | 57 +++++++++++++++++++++- scripts/cluster/run-cross-node-file-e2e.sh | 19 ++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/scripts/cluster/cross-node-file-e2e.mjs b/scripts/cluster/cross-node-file-e2e.mjs index b6c4de5..19930c7 100755 --- a/scripts/cluster/cross-node-file-e2e.mjs +++ b/scripts/cluster/cross-node-file-e2e.mjs @@ -58,6 +58,19 @@ const model = process.env.AI_GATEWAY_E2E_IMAGE_MODEL || 'openai:gpt-image-1' const imageSize = process.env.AI_GATEWAY_E2E_IMAGE_SIZE || '2048x2048' assert(account && password, 'E2E login credentials are unavailable') assert(/^\d+x\d+$/.test(imageSize), 'E2E image size is invalid') +const [imageWidth, imageHeight] = imageSize.split('x').map(Number) +const imageResolution = + process.env.AI_GATEWAY_E2E_IMAGE_RESOLUTION || + (imageWidth === imageHeight && imageWidth % 1024 === 0 ? `${imageWidth / 1024}K` : '') +const imageAspectRatio = + process.env.AI_GATEWAY_E2E_IMAGE_ASPECT_RATIO || + `${imageWidth / greatestCommonDivisor(imageWidth, imageHeight)}:${imageHeight / greatestCommonDivisor(imageWidth, imageHeight)}` +assert(imageResolution, 'E2E image resolution is unavailable') + +function greatestCommonDivisor(left, right) { + while (right !== 0) [left, right] = [right, left % right] + return left +} async function request(path, { method = 'GET', token, body, form } = {}) { const headers = { Host: 'ai.51easyai.com' } @@ -117,13 +130,55 @@ assert(typeof apiKeyId === 'string' && apiKeyId.length > 0, 'API key ID is unava let result try { + const assignableModels = await request('/api/v1/api-keys/assignable-models', { token: jwt }) + const targetModels = (assignableModels.payload.items || []).filter( + (item) => + [item?.modelName, item?.modelAlias].includes(model) && + Array.isArray(item?.modelType) && + item.modelType.includes('image_edit'), + ) + assert(targetModels.length > 0, `no assignable image_edit platform model found for ${model}`) + const resourceMap = new Map() + for (const item of targetModels) { + for (const [resourceType, resourceId] of [ + ['platform', item.platformId], + ['platform_model', item.id], + ['base_model', item.baseModelId], + ]) { + if (typeof resourceId === 'string' && resourceId) { + resourceMap.set(`${resourceType}:${resourceId}`, { + resourceType, + resourceId, + priority: 100, + minPermissionLevel: 0, + status: 'active', + }) + } + } + } + await request('/api/v1/api-keys/access-rules/batch', { + method: 'POST', + token: jwt, + body: { + subjectType: 'api_key', + subjectId: apiKeyId, + effect: 'allow', + upsertResources: [...resourceMap.values()], + deleteResources: [], + }, + }) + const fixture = createFixturePNG() const sourceSHA256 = createHash('sha256').update(fixture).digest('hex') const form = new FormData() form.append('model', model) form.append('prompt', `Cross-node HA acceptance ${randomUUID()}: add a thin white border`) form.append('size', imageSize) - form.append('image', new Blob([fixture], { type: 'image/png' }), 'cluster-e2e.png') + form.append('width', String(imageWidth)) + form.append('height', String(imageHeight)) + form.append('resolution', imageResolution) + form.append('aspect_ratio', imageAspectRatio) + form.append('images', new Blob([fixture], { type: 'image/png' }), 'cluster-e2e.png') const accepted = await request('/api/v1/images/edits', { method: 'POST', token: apiKey, diff --git a/scripts/cluster/run-cross-node-file-e2e.sh b/scripts/cluster/run-cross-node-file-e2e.sh index e6591da..0d7f91d 100755 --- a/scripts/cluster/run-cross-node-file-e2e.sh +++ b/scripts/cluster/run-cross-node-file-e2e.sh @@ -31,6 +31,25 @@ k3s kubectl rollout status deployment/easyai-api-hongkong -n easyai --timeout=30 k3s kubectl set env deployment/easyai-api-ningbo -n easyai \ AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=false >/dev/null k3s kubectl rollout status deployment/easyai-api-ningbo -n easyai --timeout=300s +for _ in $(seq 1 60); do + pods=$(k3s kubectl get pods -n easyai \ + -l app.kubernetes.io/name=easyai-api,easyai.io/site=ningbo \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}') + pod_count=$(wc -w <<<"$pods" | tr -d ' ') + if [[ $pod_count == 1 ]]; then + pod=$pods + worker_enabled=$(k3s kubectl get pod "$pod" -n easyai \ + -o jsonpath='{.spec.containers[0].env[?(@.name=="AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED")].value}') + pod_ready=$(k3s kubectl get pod "$pod" -n easyai \ + -o jsonpath='{.status.containerStatuses[0].ready}') + if [[ $worker_enabled == false && $pod_ready == true ]]; then + break + fi + fi + sleep 1 +done +[[ ${pod_count:-0} == 1 && ${worker_enabled:-} == false && ${pod_ready:-} == true ]] +k3s kubectl logs "$pod" -n easyai | grep -q 'asynchronous queue worker disabled for this process' curl -fsS http://10.77.0.1:31088/api/v1/readyz | grep -q '"ok":true' curl -fsS http://10.77.0.2:31089/api/v1/readyz | grep -q '"ok":true' REMOTE From 135fb3d5b8d63cfc1145cafc4f1870719c9d0a1e Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 29 Jul 2026 03:46:16 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix(deploy):=20=E5=BB=B6=E9=95=BF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=89=AF=E6=9C=AC=E9=87=8D=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=97=A8=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CNPG 主库在 5 秒内晋升,但跨地域 pg_rewind 需要重传约 1.27GB,原 6 分钟等待不足。将同步副本恢复等待扩展到 30 分钟,并增加退出时清理写入探针的保护。\n\n已通过 bash -n、ShellCheck 和 git diff --check。 --- scripts/cluster/failure-drill.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/cluster/failure-drill.sh b/scripts/cluster/failure-drill.sh index 88bf7e5..edb2a27 100755 --- a/scripts/cluster/failure-drill.sh +++ b/scripts/cluster/failure-drill.sh @@ -51,10 +51,16 @@ case $drill in cluster_ssh "$CLUSTER_NINGBO_HOST" bash -s -- "$new_primary" <<'REMOTE' set -euo pipefail primary=$1 +cleanup_probe() { + k3s kubectl exec -n easyai "$primary" -c postgres -- \ + psql -X -U postgres -d easyai_ai_gateway -c \ + "DELETE FROM system_settings WHERE setting_key='ha_acceptance_probe';" >/dev/null 2>&1 || true +} +trap cleanup_probe EXIT HUP INT TERM k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -c \ "INSERT INTO system_settings(setting_key,value) VALUES('ha_acceptance_probe',jsonb_build_object('at',now())) ON CONFLICT(setting_key) DO UPDATE SET value=excluded.value,updated_at=now();" >/dev/null -for _ in $(seq 1 180); do +for _ in $(seq 1 900); do if [[ $(k3s kubectl get cluster easyai-postgres -n easyai -o jsonpath='{.status.readyInstances}') == 2 ]]; then replication=$(k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -U postgres -d easyai_ai_gateway -At -c \ @@ -67,6 +73,7 @@ done k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -c \ "DELETE FROM system_settings WHERE setting_key='ha_acceptance_probe';" >/dev/null +trap - EXIT HUP INT TERM REMOTE echo "database_failure_drill=PASS promotion_seconds=$elapsed old_primary=$old_primary new_primary=$new_primary" ;;