在 GDC 网闸隔离配置上使用 vLLM 参考实现部署开放权重模型

本文档提供了在 Google Distributed Cloud (GDC) 网闸隔离环境中部署开放权重模型的分步说明。其中涵盖了如何使用 vLLM,利用 GDC 平台的功能(包括 Kubernetes、Harbor 和加速器 [GPU] 资源)实现高吞吐量服务。

架构

该解决方案涉及在标准集群中将 vLLM 部署为容器化推理服务后端 (Deployment)。模型权重存储在永久性卷上,这些卷的大小根据下载的模型目录动态调整,并预留 25% 的额外空间。这些卷配置了 system-performance-<org>-admin 存储 QoS 政策(每 GiB 30 IOPS,最低 6,148 IOPS,或在启用高性能块存储时使用 performance-rwo),并通过模型加载器工作流进行填充。LoadBalancer 类型的 Kubernetes Service 会公开后端 API,而 NetworkPolicy/ProjectNetworkPolicy 对象会保护对该 API 的访问。

GDC 网闸隔离配置上采用 vLLM 参考架构的开放权重模型。

准备工作

在继续部署之前,请确保您的环境满足所有必要的前提条件,并且所需的命令行实用程序已正确配置。在工作站上设置这些工具对于管理 Container Registry、与集群交互以及自动执行部署流程至关重要。

  • 创建了具有足够资源(CPU、内存、加速器)的 Standard 集群。最低加速器要求因模型而异。
  • Harbor 实例可用且可访问。
  • 已应用必要的 IAM。
  • 与环境和互联网具有必要连接的工作站
  • 如果使用门控模型,则需要 Hugging Face 令牌并接受条款。

环境配置

Identity and Access Management

确保已正确配置必要的 IAM 账号、角色和权限。

组织的 GDC 用户角色:

  • 可选:Standard Cluster Admin(通过控制台查看集群)

项目上的 GDC 用户角色(项目命名空间中的 RoleBinding,由 Project IAM Admin 授予):

  • Harbor Instance Viewer (harbor-instance-viewer)
  • Harbor Project Creator (harbor-project-creator,仅当 Harbor 项目尚不存在时)
  • 标准集群管理员(standard-cluster-admin,gdcloud clusters get-credentials 必需)

标准集群上的 GDC 用户角色:上述项目角色不会授予集群内的任何权限。项目 IAM Admin 必须在管理 API 服务器上的项目命名空间中,使用 StandardClusterRoleBinding 将用户额外绑定到 StandardClusterRole cluster-admin;该绑定会在几秒钟内传播到项目的标准集群(status.clusters[].conditions 显示 Propagated=True)。

cat <<EOF | kubectl apply --kubeconfig MANAGEMENT_API_SERVER -f -
apiVersion: iam.gdc.goog/v1
kind: StandardClusterRoleBinding
metadata:
  name: BINDING_NAME
  namespace: PROJECT
spec:
  roleRef:
    apiGroup: iam.gdc.goog
    kind: StandardClusterRole
    name: cluster-admin
  subjects:
    - apiGroup: rbac.authorization.k8s.io
      kind: User
      name: USER
EOF

替换以下内容:

  • MANAGEMENT_API_SERVER:管理 API 服务器 kubeconfig 文件的路径。
  • BINDING_NAME:绑定的名称,例如 alex-cluster-admin(小写字母、数字和连字符)。
  • PROJECT:项目的名称。
  • USER:要绑定的用户名,与身份提供方报告的用户名完全一致(例如,电子邮件地址)。

基础架构运维人员协助(仅当标准集群上没有 performance-rwo 时):用户指南使用默认 standard-rwo StorageClass 创建模型 PersistentVolumeClaim,这会将大小合适的模型卷限制为每 GiB 3 个 IOPS。请让您的基础架构运维人员将 system-performance-<ORG>-admin QoS 政策应用于支持模型 PersistentVolumeClaim 的卷,以便该卷获得 6,148 IOPS 的下限。用户指南会输出基础架构运维人员所需的值。

crane 的 Harbor 机器人账号的权限:

  • 列出代码库
  • 拉取代码库
  • 推送代码库
  • 读取制品
  • 列出制品
  • 创建标记
  • 列表代码

用于 Kubernetes 映像拉取的 Harbor 机器人账号的权限:

  • 列出代码库
  • 拉取代码库
  • 读取制品
  • 列出制品
  • 列表代码

工作站

除非某个步骤另有说明,否则请在具有所需工具且可连接到环境和互联网的工作站上运行本指南中的所有命令。

要求

工作站上需要安装以下工具:

  • crane:在注册表之间管理和复制容器映像(文档)。
  • docker:构建并推送已修补的 vLLM 容器映像。
  • gdcloud:用于管理 GDC 资源的命令行界面 (CLI)(文档)。
  • hf:用于访问 Hugging Face 的命令行界面 (CLI)。
  • kubectl:用于与 Kubernetes 集群通信和管理 Kubernetes 集群的命令行界面 (CLI)。
  • jq:轻量级且灵活的命令行 JSON 处理器。
  • yq:可移植的命令行 YAML 处理器。

工作站配置

工作站配置需要以下有关环境的信息:

  • GDC_STANDARD_CLUSTER_NAME:GDC 标准集群的名称。
  • GDC_DOMAIN_SUFFIX:GDC 环境的域名后缀(例如 gdc.example.com)。
  • GDC_ORG:GDC 组织的名称。
  • GDC_PROJECT:GDC 项目的名称。
  • GDC_ZONE:GDC 部署可用区的名称。
  • GDC_HARBOR_INSTANCE_NAME:项目中 Harbor 实例的名称。
  • GDCS_HARBOR_PROJECT_NAME:要用于映像的 Harbor 项目的名称(默认:solutions)。
  • GDCS_HARBOR_CRANE_ROBOT_NAME:Harbor crane 机器人账号的名称。
  • GDCS_HARBOR_CRANE_ROBOT_TOKEN:Harbor crane 机器人账号的身份验证令牌。
  • GDCS_HARBOR_K8S_ROBOT_NAME:Harbor Kubernetes 映像拉取机器人账号的名称。
  • GDCS_HARBOR_K8S_ROBOT_TOKEN:Harbor Kubernetes 映像拉取机器人账号的身份验证令牌。

收集完所有必需变量的值后,继续生成环境变量文件。创建后,您可以随时手动修改该文件。

  1. 创建根解决方案目录和密钥文件夹:

    mkdir -p ${HOME}/gdcag-solutions/env.d
    mkdir -p ${HOME}/gdcag-solutions/secrets
    
    touch ${HOME}/gdcag-solutions/secrets/harbor_crane_robot_token
    touch ${HOME}/gdcag-solutions/secrets/harbor_k8s_robot_token
    
    chmod u=rwx,go= ${HOME}/gdcag-solutions/secrets
    chmod -R u=rw,go= ${HOME}/gdcag-solutions/secrets/*
    
  2. 创建平台环境配置文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/env.d/platform.sh && echo "Successfully created." || echo "Failed to create!"
    # Infrastructure (Platform Native)
    export GDC_STANDARD_CLUSTER_NAME="STANDARD_CLUSTER_NAME"
    export GDC_DOMAIN_SUFFIX="DOMAIN_SUFFIX"
    export GDC_ORG="ORG"
    export GDC_PROJECT="PROJECT"
    export GDC_ZONE="ZONE"
    export GDC_HARBOR_INSTANCE_NAME="HARBOR_INSTANCE_NAME"
    
    # Derived platform values
    export GDC_ZONAL_HOSTNAME="${GDC_ORG}.${GDC_ZONE}.${GDC_DOMAIN_SUFFIX}"
    export GDC_ZONAL_CONSOLE_URL="https://console.${GDC_ZONAL_HOSTNAME}"
    export GDC_HARBOR_HOST="${GDC_HARBOR_INSTANCE_NAME}-${GDC_PROJECT}.${GDC_ORG}.${GDC_ZONE}.${GDC_DOMAIN_SUFFIX}"
    EOF
    

    替换以下内容:

    • STANDARD_CLUSTER_NAME:GDC 标准集群名称。
    • DOMAIN_SUFFIX:GDC 域名后缀。
    • ORG:GDC 组织。
    • PROJECT:GDC 项目。
    • ZONE:GDC 可用区。
    • HARBOR_INSTANCE_NAME:GDC Harbor 实例名称。
  3. 创建注册表环境配置文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/env.d/registry.sh && echo "Successfully created." || echo "Failed to create!"
    # GDC Solutions Registry & Secrets
    export GDCS_HARBOR_PROJECT_NAME="solutions"
    export GDCS_HARBOR_CRANE_ROBOT_NAME='HARBOR_CRANE_ROBOT_NAME'
    export GDCS_HARBOR_CRANE_ROBOT_TOKEN="$(cat ${GDCS_ROOT_HOME}/secrets/harbor_crane_robot_token)"
    export GDCS_HARBOR_K8S_ROBOT_NAME='HARBOR_K8S_ROBOT_NAME'
    export GDCS_HARBOR_K8S_ROBOT_TOKEN="$(cat ${GDCS_ROOT_HOME}/secrets/harbor_k8s_robot_token)"
    export GDCS_HARBOR_K8S_PULL_SECRET="gdcs-image-pull-secret"
    
    # Derived registry values
    export GDCS_HARBOR_PROJECT_URI="${GDC_HARBOR_HOST}/${GDCS_HARBOR_PROJECT_NAME}"
    export GDCS_HARBOR_CHART_OCI_URI="oci://${GDCS_HARBOR_PROJECT_URI}"
    EOF
    

    替换以下内容:

    • HARBOR_CRANE_ROBOT_NAME:crane 的 Harbor 机器人账号的全名(如 Harbor 所示),包括 robot$ 前缀。保留单引号,以便 shell 不会展开 $。
    • HARBOR_K8S_ROBOT_NAME:用于 Kubernetes 映像拉取的 Harbor 机器人账号的全名(如 Harbor 所示),包括 robot$ 前缀。保留单引号,以便 shell 不会展开 $。
  4. 将令牌添加到 Secret 文件中:

    set +o history
    
    echo "CRANE_ROBOT_TOKEN" > ${HOME}/gdcag-solutions/secrets/harbor_crane_robot_token
    echo "KUBERNETES_ROBOT_TOKEN" > ${HOME}/gdcag-solutions/secrets/harbor_k8s_robot_token
    
    set -o history
    

    替换以下内容:

    • CRANE_ROBOT_TOKEN:crane 机器人账号的 Secret。
    • KUBERNETES_ROBOT_TOKEN:Kubernetes 映像拉取机器人账号的 Secret。
  5. 创建根环境加载器文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/env.sh && echo "Successfully created." || echo "Failed to create!"
    export GDCS_ROOT_HOME="${HOME}/gdcag-solutions"
    echo "GDCS_ROOT_HOME=${GDCS_ROOT_HOME}"
    
    # Sourced in dependency order
    source "${GDCS_ROOT_HOME}/env.d/platform.sh"
    source "${GDCS_ROOT_HOME}/env.d/registry.sh"
    EOF
    

配置解决方案变量

  1. 创建解决方案实现目录和 Secret 文件夹:

    mkdir -p ${HOME}/gdcag-solutions/open-weight-models/vllm/env.d
    mkdir -p ${HOME}/gdcag-solutions/open-weight-models/vllm/secrets
    
    touch ${HOME}/gdcag-solutions/open-weight-models/vllm/secrets/huggingface_token
    chmod u=rwx,go= ${HOME}/gdcag-solutions/open-weight-models/vllm/secrets
    chmod -R u=rw,go= ${HOME}/gdcag-solutions/open-weight-models/vllm/secrets/*
    
  2. 将 Hugging Face 令牌添加到 Secret 文件中:

    set +o history
    
    echo "HUGGINGFACE_TOKEN" > ${HOME}/gdcag-solutions/open-weight-models/vllm/secrets/huggingface_token
    
    set -o history
    

    替换以下内容:

    • HUGGINGFACE_TOKEN:您的 Hugging Face 访问令牌。
  3. 创建加速器配置文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/open-weight-models/vllm/env.d/accelerator.sh && echo "Successfully created." || echo "Failed to create!"
    # Workload Accelerator (NVIDIA GPU)
    export GDCS_WORKLOAD_ACCELERATOR_TYPE="WORKLOAD_ACCELERATOR_TYPE"
    export GDCS_WORKLOAD_ACCELERATOR_COUNT="1"
    EOF
    

    替换以下内容:

    • WORKLOAD_ACCELERATOR_TYPE:GPU 节点公开的 GPU 资源名称,例如 nvidia.com/gpu-pod-NVIDIA_A100_80GB_PCIE。用户指南列出了 GDC 网闸隔离配置提供的每个 GPU 的资源名称。检索集群凭据后,您可以使用 kubectl get nodes --output=json | jq -r '[.items[].status.allocatable | keys[] | select(startswith("nvidia.com/"))] | unique[]' 列出集群中的名称。
  4. 创建解决方案环境配置文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/open-weight-models/vllm/env.d/vllm.sh && echo "Successfully created." || echo "Failed to create!"
    # Hugging Face
    export HF_TOKEN="$(cat ${GDCS_IMPLEMENTATION_HOME}/secrets/huggingface_token 2>/dev/null || echo '')"
    
    # Images
    export GDCS_BUSYBOX_IMAGE_TAG="1.38.0"
    export GDCS_VLLM_IMAGE_TAG="v0.26.0"
    
    # Docker configuration directories for crane and Kubernetes
    export GDCS_HARBOR_CRANE_DOCKER_CONFIG="${GDCS_IMPLEMENTATION_HOME}/docker/crane"
    export GDCS_HARBOR_K8S_DOCKER_CONFIG="${GDCS_IMPLEMENTATION_HOME}/docker/k8s"
    EOF
    
  5. 创建实现环境加载器文件:

    cat << 'EOF' > ${HOME}/gdcag-solutions/open-weight-models/vllm/env.sh && echo "Successfully created." || echo "Failed to create!"
    source "${HOME}/gdcag-solutions/env.sh"
    
    export GDCS_IMPLEMENTATION_HOME="${HOME}/gdcag-solutions/open-weight-models/vllm"
    echo "GDCS_IMPLEMENTATION_HOME=${GDCS_IMPLEMENTATION_HOME}"
    
    # Sourced in dependency order
    source "${GDCS_IMPLEMENTATION_HOME}/env.d/accelerator.sh"
    source "${GDCS_IMPLEMENTATION_HOME}/env.d/vllm.sh"
    EOF
    
  6. 使用您首选的编辑器修改和查看环境文件:

    ${EDITOR:-vi} ${HOME}/gdcag-solutions/env.d/platform.sh
    ${EDITOR:-vi} ${HOME}/gdcag-solutions/env.d/registry.sh
    ${EDITOR:-vi} ${HOME}/gdcag-solutions/open-weight-models/vllm/env.d/accelerator.sh
    ${EDITOR:-vi} ${HOME}/gdcag-solutions/open-weight-models/vllm/env.d/vllm.sh
    
  7. 获取环境文件:

    source ${HOME}/gdcag-solutions/open-weight-models/vllm/env.sh
    

    输出类似于以下内容:

    GDCS_ROOT_HOME=HOME_DIRECTORY_PATH/gdcag-solutions
    GDCS_IMPLEMENTATION_HOME=HOME_DIRECTORY_PATH/gdcag-solutions/open-weight-models/vllm
    

GDC

本指南假定您的工作站已配置为信任 GDC 环境和 Harbor 实例的 TLS 证书。

  1. 配置 gdcloud:

    gdcloud config set core/account "default-user"
    gdcloud config set core/organization_console_url "${GDC_ZONAL_CONSOLE_URL}"
    gdcloud config set core/project "${GDC_PROJECT}"
    gdcloud config set core/zone "${GDC_ZONE}"
    
  2. 向 GDC 环境进行身份验证:

    gdcloud auth login
    

集群

  1. 检索集群凭证:

    gdcloud clusters get-credentials "${GDC_STANDARD_CLUSTER_NAME}" \
    --project="${GDC_PROJECT}" \
    --standard \
    --zone="${GDC_ZONE}"
    
  2. 验证与集群的连接:

    kubectl get nodes -L node.cluster.private.gdc.goog/machine-class
    

制品迁移准备工作

  1. 为 crane 创建 Docker 配置文件。机器人账号用于推送大型映像层,以避免在使用托管 Harbor 服务 (MHS) 凭证辅助程序 (docker-credential-mhs) 和用户账号时出现身份验证令牌超时:

    export DOCKER_CONFIG="${GDCS_HARBOR_CRANE_DOCKER_CONFIG}"
    
    crane auth login "${GDC_HARBOR_HOST}" \
    --password="${GDCS_HARBOR_CRANE_ROBOT_TOKEN}" \
    --username="${GDCS_HARBOR_CRANE_ROBOT_NAME}"
    
  2. 为 Kubernetes 创建 Docker 配置文件:

    export DOCKER_CONFIG="${GDCS_HARBOR_K8S_DOCKER_CONFIG}"
    
    crane auth login "${GDC_HARBOR_HOST}" \
    --password="${GDCS_HARBOR_K8S_ROBOT_TOKEN}" \
    --username="${GDCS_HARBOR_K8S_ROBOT_NAME}"
    
  3. 创建 seed_registry.sh 脚本:

    cat << 'EOF' > ${GDCS_IMPLEMENTATION_HOME}/seed_registry.sh && echo "Successfully created." || echo "Failed to create!"
    #!/bin/bash
    
    # seed_registry.sh: Modular artifact migration for GDC Solutions
    
    # Requires the env.sh file to be sourced first.
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    
    set -o nounset
    
    source "${SCRIPT_DIR}/env.sh"
    
    # Set the Docker config
    export DOCKER_CONFIG="${GDCS_HARBOR_CRANE_DOCKER_CONFIG}"
    
    eval "${SERIALIZED_IMAGES}"
    
    # Ensure GDCS_REGISTRY_IMAGES is set
    if [[ ${#GDCS_REGISTRY_IMAGES[@]} -eq 0 ]]; then
      echo "GDCS_REGISTRY_IMAGES must be set, exiting..."
      exit 1
    fi
    
    # Migrate the images
    for source_image in "${GDCS_REGISTRY_IMAGES[@]}"; do
      # Strip the registry host only when the first path segment is a host (contains a dot or a port)
      first_segment="${source_image%%/*}"
      if [[ "${first_segment}" == *.* || "${first_segment}" == *:* ]]; then
        image_path="${source_image#*/}"
      else
        image_path="${source_image}"
      fi
      destination_image="${GDCS_HARBOR_PROJECT_URI}/${image_path}"
    
      # Ensure the folder structure is created
      crane append \
      --new_layer=<(tar czf - -T /dev/null) \
      --new_tag="${destination_image%:*}:create" \
      --oci-empty-base 2> /dev/null || true
    
      # Copy the linux/amd64 platform only to avoid transferring multi-arch layers over air-gapped links
      crane copy --platform linux/amd64 "${source_image}" "${destination_image}" || { echo "Failed to copy ${source_image} to ${destination_image}"; exit 1; }
    done
    
    echo "Migration complete: Images are available at ${GDCS_HARBOR_PROJECT_URI}"
    EOF
    chmod u+x "${GDCS_IMPLEMENTATION_HOME}/seed_registry.sh"
    
  4. 定义解决方案所需的容器映像列表:

    declare -a GDCS_REGISTRY_IMAGES=(
      "docker.io/library/busybox:${GDCS_BUSYBOX_IMAGE_TAG}"
      "docker.io/vllm/vllm-openai:${GDCS_VLLM_IMAGE_TAG}"
    )
    export SERIALIZED_IMAGES=$(declare -p GDCS_REGISTRY_IMAGES)
    
  5. 将所需的容器映像植入到制品库:

    ${GDCS_IMPLEMENTATION_HOME}/seed_registry.sh
    
  1. 创建一个 Dockerfile 来修补 vLLM 容器,直到上游更改实现:

    cat <<EOF > ${GDCS_IMPLEMENTATION_HOME}/docker/vllm-${GDCS_VLLM_IMAGE_TAG}-patch.dockerfile && echo "Successfully created." || echo "Failed to create!"
    FROM docker.io/vllm/vllm-openai:${GDCS_VLLM_IMAGE_TAG}
    
    RUN pip install --upgrade "opencv-python-headless<=4.12.0.88"
    EOF
    
  2. 构建并推送已修补的映像:

    context_dir="${GDCS_IMPLEMENTATION_HOME}/docker/empty-context"
    image="${GDCS_HARBOR_PROJECT_URI}/vllm/vllm-openai:${GDCS_VLLM_IMAGE_TAG}-fips"
    
    mkdir -p "${context_dir}"
    rm -rf "${context_dir}"/*
    
    export DOCKER_CONFIG="${GDCS_HARBOR_CRANE_DOCKER_CONFIG}"
    
    docker build \
    --file="${GDCS_IMPLEMENTATION_HOME}/docker/vllm-${GDCS_VLLM_IMAGE_TAG}-patch.dockerfile" \
    --tag="${image}" \
    "${context_dir}"
    
    docker push "${image}"
    
  3. 创建 model_weight_upload.sh 脚本。它通过模型加载器 Pod(GNU tar 流式传输,包括大于 8 GiB 的文件)将本地模型目录复制到 PersistentVolume 中,并跳过已存在且大小相同的文件:

    cat << 'EOF' > ${GDCS_IMPLEMENTATION_HOME}/model_weight_upload.sh && echo "Successfully created." || echo "Failed to create!"
    #!/bin/bash
    
    # model_weight_upload.sh: copy LOCAL_MODEL_DIR into REMOTE_MODEL_DIR on the PersistentVolume mounted in the loader Pod.
    
    # Requires: GDCS_WORKLOAD_NAMESPACE, GDCS_MODEL_LOADER_POD_NAME, LOCAL_MODEL_DIR, REMOTE_MODEL_DIR
    
    # Optional: REMOTE_MOUNT_DIR (PersistentVolume mount path, defaults to REMOTE_MODEL_DIR) - its lost+found directory is removed.
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    
    set -o errexit
    set -o nounset
    set -o pipefail
    
    # Ensure LOCAL_MODEL_DIR is set
    if [[ -z "${LOCAL_MODEL_DIR:-}" ]]; then
      echo "LOCAL_MODEL_DIR must be set, exiting..."
      exit 1
    fi
    
    # Ensure REMOTE_MODEL_DIR is set
    if [[ -z "${REMOTE_MODEL_DIR:-}" ]]; then
      echo "REMOTE_MODEL_DIR must be set, exiting..."
      exit 1
    fi
    
    REMOTE_MOUNT_DIR="${REMOTE_MOUNT_DIR:-${REMOTE_MODEL_DIR}}"
    
    KEEPALIVE_INTERVAL_SEC=300
    start_session_keepalive() {
      echo "[KeepAlive] Started background heartbeat (every ${KEEPALIVE_INTERVAL_SEC}s)."
      while true; do
        sleep "${KEEPALIVE_INTERVAL_SEC}"
        # Lightweight API request to prevent session inactivity logout
        if kubectl get namespace "${GDCS_WORKLOAD_NAMESPACE}" >/dev/null 2>&1; then
          echo "[KeepAlive] Session heartbeat sent at $(date)"
        else
          echo "[KeepAlive] Heartbeat failed or context expired at $(date)"
        fi
      done
    }
    
    start_session_keepalive &
    MY_KEEPALIVE_PID=$!
    
    cleanup() {
      echo "[Cleanup] Stopping keep-alive process (PID: ${MY_KEEPALIVE_PID})..."
      kill "${MY_KEEPALIVE_PID}" 2>/dev/null || true
      wait "${MY_KEEPALIVE_PID}" 2>/dev/null || true
      echo "[Cleanup] Session keep-alive terminated."
    }
    
    trap cleanup EXIT INT TERM
    
    # Remove the lost+found directory
    kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
    --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
    -- rm -rf "${REMOTE_MOUNT_DIR}/lost+found"
    
    # Create the model directory
    kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
    --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
    -- mkdir -p "${REMOTE_MODEL_DIR}"
    
    # Loop through all files in the local directory
    FAILED_FILES=()
    MAX_UPLOAD_ATTEMPTS="${MAX_UPLOAD_ATTEMPTS:-3}"
    remote_size_of() {
        # Prints the remote file size (0 if the file does not exist)
        kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" --namespace="${GDCS_WORKLOAD_NAMESPACE}" -- stat -c%s "$1" 2>/dev/null || echo 0
    }
    
    # Loop through all files in the local directory (process substitution keeps FAILED_FILES in this shell)
    while read -r local_file; do
        # Get relative path
        rel_path="${local_file#"${LOCAL_MODEL_DIR}"/}"
        remote_file="${REMOTE_MODEL_DIR}/${rel_path}"
    
        # Get local file size
        local_size=$(stat -L -c%s "${local_file}")
    
        # Get remote file size (returns 0 if file does not exist)
        remote_size=$(remote_size_of "${remote_file}")
    
        if [ "${local_size}" -eq "${remote_size}" ]; then
            echo "[Loader] Skipping: ${rel_path} (Size matches)"
            continue
        fi
        if [ "${remote_size}" -ne "0" ]; then
            echo "[Loader] Mismatch: ${rel_path} (${remote_size} remote vs ${local_size} local)"
        fi
    
        # Ensure remote parent subdirectories exist if your model has folders
        remote_parent=$(dirname "${remote_file}")
        kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
        --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
        -- mkdir -p "${remote_parent}"
    
        attempt=1
        while [ "${attempt}" -le "${MAX_UPLOAD_ATTEMPTS}" ]; do
            echo "[Loader] Uploading: ${rel_path} (${local_size} bytes, attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS})..."
            # Copy the individual file using GNU tar streaming.
            # Note: 'kubectl cp' uses Go's archive/tar, which defaults to PAX format for files > 8 GiB.
            # BusyBox's tar in the loader pod does not parse PAX size headers and fails with 'tar: invalid tar magic'.
            # Streaming via GNU tar (--format=gnu) uses base-256 binary encoding for files > 8 GiB, which BusyBox tar supports.
            tar --format=gnu -C "$(dirname "${local_file}")" -cf - "$(basename "${local_file}")" | \
            kubectl exec -i "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
            --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
            -- tar -xf - -C "${remote_parent}" || true
    
            # Verify: an interrupted exec stream leaves a truncated file behind without an error
            remote_size=$(remote_size_of "${remote_file}")
            if [ "${local_size}" -eq "${remote_size}" ]; then
                echo "[Loader] Verified: ${rel_path} (${remote_size} bytes)"
                break
            fi
            echo "[Loader] Incomplete upload: ${rel_path} (${remote_size} remote vs ${local_size} local), retrying..."
            attempt=$((attempt + 1))
        done
        if [ "${local_size}" -ne "${remote_size}" ]; then
            FAILED_FILES+=("${rel_path}")
        fi
    done < <(find -L "${LOCAL_MODEL_DIR}" -type f)
    
    if [ "${#FAILED_FILES[@]}" -ne 0 ]; then
        echo "[Loader] FAILED: ${#FAILED_FILES[@]} file(s) could not be uploaded completely after ${MAX_UPLOAD_ATTEMPTS} attempts:"
        printf '[Loader]   %s\n' "${FAILED_FILES[@]}"
        exit 1
    fi
    echo "[Loader] Completed"
    
    # Fix file permissions
    kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
    --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
    -- chown 0:0 -R "${REMOTE_MODEL_DIR}"
    
    # Flush the volume before the loader Pod is deleted (a detached volume with unwritten page cache loses data)
    kubectl exec "pod/${GDCS_MODEL_LOADER_POD_NAME}" \
    --namespace="${GDCS_WORKLOAD_NAMESPACE}" \
    -- sync
    EOF
    chmod u+x "${GDCS_IMPLEMENTATION_HOME}/model_weight_upload.sh"
    

部署模型