GDC 에어 갭에서 vLLM 참조 구현을 사용하는 개방형 가중치 모델

이 문서에서는 Google Distributed Cloud (GDC) 에어 갭 환경에 가중치 공개 모델을 배포하는 방법을 단계별로 안내합니다. Kubernetes, Harbor, 가속기 (GPU) 리소스를 비롯한 GDC 플랫폼의 기능을 사용하여 높은 처리량의 서빙을 위해 vLLM을 사용하는 방법을 다룹니다.

아키텍처

이 솔루션에는 vLLM을 표준 클러스터 내에서 컨테이너화된 추론 서빙 백엔드 (Deployment)로 배포하는 작업이 포함됩니다. 모델 가중치는 다운로드된 모델 디렉터리에서 25% 오버헤드로 동적으로 크기가 조정되고, system-performance-<org>-admin 스토리지 QoS 정책 (GiB당 30 IOPS,최소 6, 148 IOPS 또는 고성능 블록 스토리지가 사용 설정된 경우 performance-rwo)으로 구성되며, 모델 로더 워크플로를 사용하여 채워지는 영구 볼륨에 저장됩니다. LoadBalancer 유형의 Kubernetes Service는 백엔드 API를 노출하고 NetworkPolicy/ProjectNetworkPolicy 객체는 이에 대한 액세스를 보호합니다.

GDC 에어 갭에서 vLLM 참조 아키텍처를 사용하는 개방형 가중치 모델

시작하기 전에

배포를 진행하기 전에 환경이 필요한 모든 기본 요건을 충족하고 필요한 명령줄 유틸리티가 올바르게 구성되었는지 확인하세요. 워크스테이션에 이러한 도구를 설정하는 것은 Container Registry를 관리하고, 클러스터와 상호작용하고, 배포 프로세스를 자동화하는 데 필수적입니다.

  • 충분한 리소스 (CPU, 메모리, 가속기)로 생성된 Standard 클러스터 최소 가속기 요구사항은 모델에 따라 다릅니다.
  • Harbor 인스턴스를 사용할 수 있고 액세스할 수 있습니다.
  • 필요한 IAM이 적용되었습니다.
  • 환경 및 인터넷에 필요한 연결이 있는 워크스테이션
  • 제한된 모델을 사용하는 경우 Hugging Face 토큰 및 약관에 동의해야 합니다.

환경 구성

Identity and Access Management

필요한 IAM 계정, 역할, 권한이 올바르게 구성되었는지 확인합니다.

조직의 GDC 사용자 역할:

  • 선택사항: 표준 클러스터 관리자 (콘솔에서 클러스터 보기)

프로젝트의 GDC 사용자 역할 (프로젝트 네임스페이스의 RoleBinding, 프로젝트 IAM 관리자가 부여):

  • Harbor 인스턴스 뷰어 (harbor-instance-viewer)
  • Harbor 프로젝트 생성자 (harbor-project-creator, Harbor 프로젝트가 아직 없는 경우에만 해당)
  • 표준 클러스터 관리자 (standard-cluster-admin, gdcloud clusters get-credentials에 필요)

표준 클러스터의 GDC 사용자 역할: 이전 프로젝트 역할은 클러스터 내에서 권한을 부여하지 않습니다. 프로젝트 IAM 관리자는 관리 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: ID 공급업체에서 보고하는 것과 정확히 일치하는 바인딩할 사용자 이름입니다 (예: 이메일 주소).

인프라 운영자 지원 (표준 클러스터에서 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 클러스터와 통신하고 이를 관리하는 데 사용되는 명령줄 인터페이스 (CLI)입니다.
  • jq: 가볍고 유연한 명령줄 JSON 프로세서입니다.
  • yq: 이식 가능한 명령줄 YAML 프로세서입니다.

워크스테이션 구성

워크스테이션 구성에는 환경에 대한 다음 정보가 필요합니다.

  • GDC_STANDARD_CLUSTER_NAME: GDC Standard 클러스터의 이름입니다.
  • 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: Harbor에 표시된 crane의 Harbor 로봇 계정의 전체 이름이며 robot$ 프리픽스를 포함합니다. 셸이 $를 확장하지 않도록 작은따옴표를 유지합니다.
    • HARBOR_K8S_ROBOT_NAME: Harbor에 표시된 Kubernetes 이미지 가져오기를 위한 Harbor 로봇 계정의 전체 이름입니다(robot$ 프리픽스 포함). 셸이 $를 확장하지 않도록 작은따옴표를 유지합니다.
  4. 보안 비밀 파일에 토큰을 추가합니다.

    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 로봇 계정의 보안 비밀입니다.
    • KUBERNETES_ROBOT_TOKEN: Kubernetes 이미지 가져오기 로봇 계정의 보안 비밀입니다.
  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. 솔루션 구현 디렉터리와 보안 비밀 폴더를 만듭니다.

    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 토큰을 추가합니다.

    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. 필수 컨테이너 이미지를 Artifact Registry에 시드합니다.

    ${GDCS_IMPLEMENTATION_HOME}/seed_registry.sh
    
  1. 업스트림 변경사항이 구현될 때까지 vLLM 컨테이너를 패치하는 Dockerfile을 만듭니다.

    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 스트리밍, 8GiB보다 큰 파일 포함)를 통해 로컬 모델 디렉터리를 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"
    

모델 배포