From b6ef0435ab745d4a8c5dbf0023e8c2c3c078b079 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 31 Jul 2026 23:50:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(controller):=20=E9=9A=94=E7=A6=BB=E6=97=A0?= =?UTF-8?q?=E5=85=B3=E5=AA=92=E4=BD=93=E5=87=AD=E6=8D=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容量控制器只负责读取容量状态和调整 Worker 副本,按最小权限不注入 OSS 或供应商凭据;全局媒体配置校验却导致控制器在生产启动时 CrashLoop。 容量控制器角色现在跳过不会使用的直传 OSS 校验,API/Worker 仍保持原校验;发布先以零副本应用目标状态,再用精确镜像和同构配置运行 Ready 预检,成功后才扩到双副本;同时补齐迁移 Job 的 restricted PodSecurity 配置。 验证:Go 全量测试、go vet、角色回归测试、bash -n、ShellCheck、人工发布测试、生产 server-side dry-run。 --- apps/api/internal/config/config.go | 2 +- apps/api/internal/config/config_test.go | 16 +++ .../easyai-ai-gateway-cluster-release | 130 +++++++++++++++++- 3 files changed, 143 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index b2869f7..7b3ace6 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -260,7 +260,7 @@ func (c Config) Validate() error { if c.MediaImageNormalizationConcurrency != 0 && (c.MediaImageNormalizationConcurrency < 1 || c.MediaImageNormalizationConcurrency > 64) { return errors.New("AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY must be between 1 and 64") } - if c.MediaOSSDirectEnabled { + if c.MediaOSSDirectEnabled && !c.RunsCapacityController() { if strings.TrimSpace(c.MediaOSSAccessKeyID) == "" || strings.TrimSpace(c.MediaOSSAccessKeySecret) == "" { return errors.New("AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_ID and AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_SECRET are required when direct media OSS is enabled") } diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 7eb306c..b10434a 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -333,3 +333,19 @@ func TestLoadAndValidateDirectMediaOSS(t *testing.T) { t.Fatalf("Validate() error = %v, want invalid object prefix", err) } } + +func TestCapacityControllerDoesNotRequireMediaOSSCredentials(t *testing.T) { + cfg := Load() + cfg.ProcessRole = "capacity-controller" + cfg.MediaOSSDirectEnabled = true + cfg.MediaOSSAccessKeyID = "" + cfg.MediaOSSAccessKeySecret = "" + if err := cfg.Validate(); err != nil { + t.Fatalf("capacity controller rejected unused media OSS credentials: %v", err) + } + + cfg.ProcessRole = "worker" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "ACCESS_KEY") { + t.Fatalf("worker Validate() error = %v, want missing direct OSS credentials", err) + } +} diff --git a/deploy/kubernetes/easyai-ai-gateway-cluster-release b/deploy/kubernetes/easyai-ai-gateway-cluster-release index 77dc623..3c9f9eb 100755 --- a/deploy/kubernetes/easyai-ai-gateway-cluster-release +++ b/deploy/kubernetes/easyai-ai-gateway-cluster-release @@ -402,9 +402,117 @@ rollout_capacity_controller() { "${kubectl[@]}" set image deployment/easyai-capacity-controller -n "$NAMESPACE" \ "capacity-controller=$api_image" fi + "${kubectl[@]}" scale deployment/easyai-capacity-controller -n "$NAMESPACE" --replicas=2 "${kubectl[@]}" rollout status deployment/easyai-capacity-controller -n "$NAMESPACE" --timeout=300s } +run_capacity_controller_preflight() { + local source_sha=$1 + local api_image=$2 + local pod=easyai-capacity-controller-preflight-${source_sha:0:12} + "${kubectl[@]}" delete pod "$pod" -n "$NAMESPACE" \ + --ignore-not-found=true --wait=true >/dev/null + cat </dev/null +apiVersion: v1 +kind: Pod +metadata: + name: $pod + namespace: $NAMESPACE + labels: + easyai.io/preflight: capacity-controller + easyai.io/release: $source_sha +spec: + restartPolicy: Never + serviceAccountName: easyai-capacity-controller + nodeSelector: + easyai.io/workload: "true" + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + imagePullSecrets: + - name: easyai-registry + containers: + - name: capacity-controller + image: $api_image + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: easyai-ai-gateway-config + env: + - name: AI_GATEWAY_PROCESS_ROLE + value: capacity-controller + - name: AI_GATEWAY_DATABASE_URL + valueFrom: + secretKeyRef: + name: easyai-ai-gateway-runtime + key: AI_GATEWAY_DATABASE_URL + - name: AI_GATEWAY_DATABASE_MAX_CONNS + value: "4" + - name: AI_GATEWAY_DATABASE_CRITICAL_MAX_CONNS + value: "0" + - name: AI_GATEWAY_DATABASE_RIVER_MAX_CONNS + value: "0" + - name: AI_GATEWAY_DATABASE_MIN_IDLE_CONNS + value: "1" + - name: AI_GATEWAY_WORKER_DATABASE_MAX_CONNS + value: "$AI_GATEWAY_DATABASE_MAX_CONNS" + - name: AI_GATEWAY_POSTGRES_NON_WORKER_CONNECTION_BUDGET + value: "$AI_GATEWAY_POSTGRES_NON_WORKER_CONNECTION_BUDGET" + - name: AI_GATEWAY_WORKER_AUTOSCALING_ENABLED + value: "false" + - name: AI_GATEWAY_REVISION + value: "$source_sha" + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + readinessProbe: + httpGet: + path: /readyz + port: 8088 + periodSeconds: 2 + timeoutSeconds: 2 + failureThreshold: 30 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + volumeMounts: + - name: temporary-files + mountPath: /tmp + volumes: + - name: temporary-files + emptyDir: + sizeLimit: 64Mi +EOF + if ! "${kubectl[@]}" wait --for=condition=Ready "pod/$pod" \ + -n "$NAMESPACE" --timeout=120s; then + "${kubectl[@]}" delete pod "$pod" -n "$NAMESPACE" \ + --ignore-not-found=true --wait=true >/dev/null || true + echo 'capacity controller exact-image preflight failed' >&2 + return 1 + fi + "${kubectl[@]}" delete pod "$pod" -n "$NAMESPACE" \ + --ignore-not-found=true --wait=true >/dev/null + echo "capacity_controller_preflight=PASS release=$source_sha" +} + rollout_api_capacity_site() { local site=$1 "${kubectl[@]}" set env "deployment/easyai-api-$site" -n "$NAMESPACE" \ @@ -519,6 +627,12 @@ spec: template: spec: restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault imagePullSecrets: - name: easyai-registry nodeSelector: @@ -527,6 +641,10 @@ spec: - name: migrator image: $api_image command: ["/app/easyai-ai-gateway-migrate"] + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] envFrom: - secretRef: name: easyai-ai-gateway-runtime @@ -713,16 +831,20 @@ activate_manifest() { if [[ $action == deploy ]]; then verify_migration_version "$migration_version" fi - capacity_controller_replicas=2 - if [[ $action == rollback ]]; then - capacity_controller_replicas=0 - fi + capacity_controller_replicas=0 if ! apply_desired_state "$api_image" "$web_image" "$capacity_controller_replicas"; then echo '[cluster-release] desired state apply failed; restoring exact deployment snapshot' >&2 restore_application_deployments "$deployment_snapshot" || true rm -f -- "$deployment_snapshot" "$current_file" return 1 fi + if [[ $action == deploy && $api_changed == true ]] && + ! run_capacity_controller_preflight "$source_sha" "$api_image"; then + echo '[cluster-release] capacity controller preflight failed; restoring exact deployment snapshot' >&2 + restore_application_deployments "$deployment_snapshot" || true + rm -f -- "$deployment_snapshot" "$current_file" + return 1 + fi if [[ $action == rollback ]]; then # Historical API images may predate the capacity-controller process role. # Rollbacks therefore restore static Worker replicas and leave the