הטמעה לדוגמה של LLM עם משקלים פתוחים ב-GDC עם air gap

סקירה כללית

במאמר הזה מוסבר איך לפרוס מודלי שפה גדולים (LLM) עם משקלים פתוחים כמו Gemma,‏ Llama ו-DeepSeek בסביבות מבודדות (air-gapped) של Google Distributed Cloud‏ (GDC). בשיעור הזה נסביר איך להשתמש ב-vLLM כדי לפרסם מודלים עם תפוקה גבוהה וב-Ollama כדי להקל על השימוש, תוך ניצול היכולות של פלטפורמת GDC, כולל Kubernetes,‏ Harbor ומשאבי GPU.

ארכיטקטורה

הפתרון כולל פריסה של קצה עורפי (backend) להפעלת מודלים גדולים של שפה (LLM) במאגרי קונטיינרים (vLLM,‏ Ollama) כפריסות בתוך אשכול משתמשים. משקלי המודלים מאוחסנים ב-Persistent Volumes, שאוכלסו מתמונות במאגר Harbor. שירותי Kubernetes מסוג LoadBalancer חושפים את ממשקי ה-API של ה-backends. מדיניות הרשת של הפרויקט מאבטחת את הגישה לשירותים האלה.

דיאגרמת ארכיטקטורה של הטמעה לדוגמה של LLM עם משקלים פתוחים.

לפני שמתחילים

צריך לוודא שהתנאים המוקדמים הבאים מתקיימים:

  • ‫GDC עם air gap מגרסה 1.15.1 ואילך.
  • נוצר אשכול משתמשים עם מספיק משאבים (CPU, זיכרון, GPU).
  • נדרש לפחות GPU אחד מסוג NVIDIA A100.
  • מופע Harbor זמין ונגיש.
  • ממשקי ה-CLI‏ kubectl ו-gdcloud מוגדרים לגישה לאשכול המשתמשים.
  • לקוח Docker מותקן ומוגדר להעברה בדחיפה אל Harbor.
  • ההרשאות הנדרשות ב-IAM הוענקו (לדוגמה, Namespace Admin, ‏ Cluster Developer**).
  • אם משתמשים במודלים עם גישה מוגבלת, צריך להגדיר חשבון ואימות ב-Hugging Face.

חלק 1: הגדרה נפוצה

‫1.1 יצירת סוד למשיכת תמונה

כדי להגדיר סוד למשיכת תמונה עבור עומס עבודה של קונטיינר ב-GDC עם air gap, צריך ליצור סוד של Kubernetes docker-registry שמכיל פרטי כניסה לגישה לפרויקט הפרטי של Harbor. הסוד הזה מוזכר במפרט הפריסה.

מומלץ להשתמש בחשבון רובוט של Harbor לגישה פרוגרמטית לתמונות בפרויקטים פרטיים של Harbor.

כדי להגדיר את סוד משיכת התמונה:

יצירת חשבון רובוט של Harbor:

  • עוברים לממשק המשתמש של מופע Harbor.
  • עוברים לפרויקט Harbor.
  • לוחצים על הכרטיסייה חשבונות רובוט.
  • לוחצים על New Robot Account (חשבון רובוט חדש).
  • נותנים לו שם (לדוגמה, oss-llm-puller) ומעניקים לו את ההרשאות הנדרשות (לפחות גישת pull) עד למועד התפוגה.
  • שומרים בצורה מאובטחת את שם חשבון הרובוט (לדוגמה, robot$oss-llm-puller) ואת האסימון הסודי שסופק.

אימות Docker ב-Harbor:

במחשב שבו מותקן Docker ויש גישה לרשת לרישום של Harbor, נכנסים באמצעות פרטי הכניסה של חשבון הרובוט:

export INSTANCE_URL="HARBOR_INSTANCE_URL"
# for example, harbor1-project1.org1.zone1.google.gdc.com

export ROBOT_NAME="ROBOT_ACCOUNT_NAME"
# for example, robot\$oss-llm-puller (note how we escape the $ character)

export ROBOT_SECRET="ROBOT_ACCOUNT_SECRET"

docker login ${INSTANCE_URL} --username ${ROBOT_NAME} --password ${ROBOT_SECRET}

יוצרים את סוד המשיכה של תמונת Kubernetes:

משתמשים ב-kubectl כדי ליצור סוד מסוג docker-registry במרחב השמות של הפרויקט, באמצעות קובץ ההגדרות של Docker שעודכן בשלב הקודם:

# Log in into GDC environment using the next commands
gdcloud auth login --login-config-cert WEB_TLS_CERT_PATH
gdcloud clusters get-credentials KUBERNETES_CLUSTER
kubectl config set-context --current --namespace=NAMESPACE

export SECRET_NAME="OSS_LLM_PULL_SECRET"
export NAMESPACE="PROJECT_NAMESPACE"
# Assuming default Docker config path. Adjust if necessary.
export DOCKER_CONFIG_PATH="$HOME/.docker/config.json"

kubectl create secret docker-registry ${SECRET_NAME} \
      --from-file=.dockerconfigjson=${DOCKER_CONFIG_PATH} \
      -n ${NAMESPACE}

חלק 2: פריסה באמצעות vLLM

‫2.1 מקבלים את קובץ האימג' של Docker של vLLM

במחשב עם גישה לאינטרנט, מושכים את קובץ האימג' של vLLM Docker ומעבירים אותו לפרויקט Harbor:

# Pull and Tag vLLM (v0.13.0 recommended for stability)
docker pull vllm/vllm-openai:v0.13.0
docker tag vllm/vllm-openai:v0.13.0 HARBOR_URL/PROJECT/vllm-openai:v0.13.0
docker push HARBOR_URL/PROJECT/vllm-openai:v0.13.0

מחליפים את HARBOR_URL ואת PROJECT בכתובת ה-URL של מופע Harbor ובשם הפרויקט.

‫2.2 הכנת משקלי המודל ב-PVC

יוצרים קובץ YAML (לדוגמה, model-pvc.yaml):

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: model-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 500Gi
  storageClassName: standard-rwo
  volumeMode: Filesystem

החלת ה-PVC: kubectl apply -f model-pvc.yaml

הורדת משקלים מ-Hugging Face:

hf auth login
hf download google/gemma-3-4b-it

משתמשים ב-Pod עזר (לדוגמה, helper-pod.yaml) כדי להעביר משקלים ל-PVC. מוודאים שיש לכם תמונת busybox ב-Harbor.

# Push busybox if not present
docker pull busybox:latest
docker tag busybox HARBOR_URL/PROJECT/busybox:latest
docker push HARBOR_URL/PROJECT/busybox:latest

# Contents of helper-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: model-uploader
spec:
  containers:
  - name: uploader
    image: HARBOR_URL/PROJECT/busybox:latest
    command: ["sleep", "3600"]
    volumeMounts:
    - name: model-data
      mountPath: /data
  imagePullSecrets:
  - name: oss-llm-pull-secret
  volumes:
  - name: model-data
    persistentVolumeClaim:
      claimName: model-pvc

החלת ה-POD והעתקת קבצים:

kubectl apply -f helper-pod.yaml
# Wait for pod to be Running
kubectl cp ~/.cache/huggingface/hub/ NAMESPACE/model-uploader:/data/
kubectl delete pod model-uploader

‫2.3 פריסת קצה עורפי של vLLM

יוצרים את קובץ הפריסה vllm-gemma-3-4b-it-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gemma-3-4b-it
  labels:
    app: gemma-3-4b-it
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gemma-3-4b-it
  template:
    metadata:
      labels:
        app: gemma-3-4b-it
    spec:
      volumes:
      - name: cache-volume
        persistentVolumeClaim:
          claimName: model-pvc
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: "16Gi"
      containers:
      - name: gemma-3-4b-it
        image: HARBOR_URL/PROJECT/vllm-openai:v0.13.0
        command: ["python3"]
        args: [
          "-m",
          "vllm.entrypoints.openai.api_server",
          "--model",
          "google/gemma-3-4b-it",
          "--max-model-len",
          "32768",
          "--enforce-eager"
        ]
        env:
        - name: HF_HUB_OFFLINE
          value: "1"
        - name: HF_HOME
          value: "/model"
        - name: NCCL_P2P_DISABLE
          value: "1"
        - name: NCCL_IB_DISABLE
          value: "1"
        - name: BORINGSSL_FIPS
          value: "0"
        - name: OPENSSL_FIPS
          value: "0"
        - name: OPENSSL_CONF
          value: "/dev/null"
        - name: FIPS_SIG
          value: "off"
        ports:
        - containerPort: 8000
        securityContext:
          privileged: true
          runAsUser: 0
        resources:
          limits:
            nvidia.com/gpu-pod-NVIDIA_A100_80GB_PCIE: 1
            cpu: "8"
            memory: "64Gi"
          requests:
            nvidia.com/gpu-pod-NVIDIA_A100_80GB_PCIE: 1
            cpu: "8"
            memory: "32Gi"
        volumeMounts:
        - name: cache-volume
          mountPath: /model
        - name: shm
          mountPath: /dev/shm
      imagePullSecrets:
      - name: oss-llm-pull-secret

יוצרים את קובץ השירות vllm-gemma-3-4b-it-service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: gemma-3-4b-it
  namespace: NAMESPACE
spec:
  ports:
  - name: http-gemma-3-4b-it
    port: 80
    protocol: TCP
    targetPort: 8000
  selector:
    app: gemma-3-4b-it
  sessionAffinity: None
  type: LoadBalancer

מחילים את ההגדרות:

kubectl apply -f vllm-gemma-3-4b-it-deployment.yaml
kubectl apply -f vllm-gemma-3-4b-it-service.yaml

‫2.4 הגדרת מדיניות רשת ב-Kubernetes

מחילים משאב ProjectNetworkPolicy כדי לאפשר תעבורת נתונים נכנסת (ingress) ליציאת השירות (8000) של vLLM. יצירת vllm-netpol.yaml:

apiVersion: networking.gdc.goog/v1
kind: ProjectNetworkPolicy
metadata:
  name: allow-vllm-ingress
  namespace: NAMESPACE
spec:
  subject:
    subjectType: UserWorkload
  policyType: Ingress
  ingress:
  - from:
    - ipBlock:
        cidr: 0.0.0.0/0 # Restrict this in production
    ports:
    - protocol: TCP
      port: 8000

החלת המדיניות: kubectl apply -f vllm-netpol.yaml

חלק 3: פריסה באמצעות Ollama

‫3.1 הכנת Dockerfile

יוצרים Dockerfile כדי ליצור את קובץ האימג' של Ollama עם המודלים שלכם שנטענו מראש:

FROM ubuntu

RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates zstd
RUN curl -fsSL https://ollama.com/install.sh -o install.sh
RUN chmod +x install.sh
RUN ./install.sh && \
    rm -rf /var/lib/apt/lists/*

# Pre-pull gemma3 model
RUN ollama serve & \
    sleep 5 && \
    curl --retry 10 --retry-connrefused -s http://localhost:11434 || true && \
    ollama pull gemma3:latest && \
    pkill ollama || true

EXPOSE 11434
CMD ["ollama", "serve"]

‫3.2 יצירה ודחיפה של תמונה

יוצרים את האימג' ומעבירים אותו:

docker build -t ollama-gemma3 .
docker tag ollama-gemma3 HARBOR_URL/PROJECT/ollama-gemma3:latest
docker push HARBOR_URL/PROJECT/ollama-gemma3:latest

‫3.3 פריסת קצה עורפי של Ollama

יצירת ollama-gemma3.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ollama-gemma3
  namespace: NAMESPACE
  labels:
    app: ollama-gemma3
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ollama-gemma3
  template:
    metadata:
      labels:
        app: ollama-gemma3
    spec:
      containers:
      - name: ollama-gemma3
        image: HARBOR_URL/PROJECT/ollama-gemma3:latest
        env:
        - name: OLLAMA_HOST
          value: "0.0.0.0"
        imagePullPolicy: Always
        ports:
        - containerPort: 11434
        securityContext:
          privileged: true
          runAsUser: 0
        resources:
          limits:
            nvidia.com/gpu-pod-NVIDIA_A100_80GB_PCIE: 1
          requests:
            nvidia.com/gpu-pod-NVIDIA_A100_80GB_PCIE: 1
      imagePullSecrets:
      - name: oss-llm-pull-secret
---
apiVersion: v1
kind: Service
metadata:
  name: ollama-gemma3
  namespace: NAMESPACE
spec:
  type: LoadBalancer
  selector:
    app: ollama-gemma3
  ports:
  - name: ollama-gemma3-port
    port: 11434
    protocol: TCP
    targetPort: 11434

החלת המניפסט: kubectl apply -f ollama-gemma3.yaml

‫3.4 הגדרת מדיניות רשת ב-Kubernetes

יצירת ollama-netpol.yaml:

apiVersion: networking.gdc.goog/v1
kind: ProjectNetworkPolicy
metadata:
  name: allow-ollama-ingress
  namespace: NAMESPACE
spec:
  subject:
    subjectType: UserWorkload
  policyType: Ingress
  ingress:
  - from:
    - ipBlock:
        cidr: 0.0.0.0/0 # Restrict this for production.
    ports:
    - protocol: TCP
      port: 11434

החלת המדיניות: kubectl apply -f ollama-netpol.yaml

סעיף 4: אימות

כדי לאמת את הפריסות, בודקים את הסטטוסים של ה-pods, את כתובות ה-IP של השירות ושולחים בקשות בדיקה להסקת מסקנות באמצעות curl לכתובות ה-IP של LoadBalancer גם עבור vLLM וגם עבור Ollama.

בודקים שכל המאגרים והשירותים הם Running:

# Login into GDC air-gapped using the next commands
gdcloud auth login --login-config-cert WEB_TLS_CERT_PATH
gdcloud clusters get-credentials KUBERNETES_CLUSTER
kubectl config set-context --current --namespace=NAMESPACE

# Pods
kubectl get pods

# Services
kubectl get services

בדיקת vLLM:

export VLLM_IP=$(kubectl get service gemma-3-4b-it -n NAMESPACE -o jsonpath='{.status.loadBalancer.ingress[*].ip}')
curl http://${VLLM_IP}/v1/chat/completion \
  -H "Content-Type: application/json" \
  -d '{
    "model": "google/gemma-3-4b-it",
    "messages": [
      {"role": "user", "content": "What is Google Distributed Cloud air-gapped?"}
    ],
    "max_tokens": 100
  }'

בדיקת Ollama:

export OLLAMA_IP=$(kubectl get service ollama-gemma3 -n NAMESPACE -o jsonpath='{.status.loadBalancer.ingress[*].ip}')
# Check if Ollama is running
curl http://${OLLAMA_IP}:11434
# Send a completion request
curl -X POST http://${OLLAMA_IP}:11434/v1/completions \
-H "Content-Type: application/json" \
-d '{
  "model": "gemma3:latest",
  "prompt": "Google Distributed Cloud air-gapped is a",
  "max_tokens": 128,
  "temperature": 0.90,
  "stream": false
}'

סעיף 5: פעולות ופתרון בעיות

‫5.1 פעולות vLLM

בדיקת היומנים: kubectl logs -f -n NAMESPACE

שאילתה לגבי סטטוס פנימי (מ-pod לניפוי באגים):

wget -qO- http://gemma-3-4b-it/v1/models
wget -qO- http://gemma-3-4b-it/health

‫5.2 פעולות Ollama

גישה ל-CLI: kubectl exec -it -n NAMESPACE -- sh

בתוך הפוד: ollama list, ollama ps

‫5.3 התאמה לעומס (scaling)

התאמת הפתרון באמצעות קצה עורפי של Ollama

אנכית

  • כדאי להקצות פרוסה גדולה יותר של GPU למודל שפה גדול שהפך לצוואר בקבוק, עד שתשתמשו ב-GPU מלא.
  • אם רוצים להשתמש ב-LLM גדול יותר כדי לקבל תשובות מדויקות יותר, למשל LLM עם 405 מיליארד פרמטרים במקום 7 מיליארד, יכול להיות שתצטרכו יותר מ-GPU אחד כדי להריץ אותו בצורה חלקה.
  • מודלים שמתאימים ליותר מיחידת GPU אחת חווים השהיה שקשורה לתקשורת בין יחידות ה-GPU.

אופקית

  • פורסים כמה פודים של Ollama שצריך כדי להשיג את קצב העברת הנתונים הרצוי במודל שפה גדול (LLM) נתון.
    • כדי לעשות את זה, מגדילים את מספר העותקים בקובץ ה-YAML של פריסת Ollama המתאים.
  • שירות Kubernetes, מסוג LoadBalancer, יפיץ בקשות לעזרה בכתיבת קוד בין נקודות קצה (כלומר, pods) ויחזיר את התגובות המתאימות דרך כתובת ה-IP החיצונית שנחשפת.
  • חשוב לזכור שהתוסף Continue מצביע רק על כתובת IP אחת לכל פונקציונליות.

דיאגרמת ארכיטקטורה של סקייל של LLM עם משקלים פתוחים.

כדי לשנות את קנה המידה של קצה העורפי (backend) של vLLM בסביבת GDC עם בידוד פיזי, אפשר לפעול לפי אסטרטגיה דומה לזו שמשמשת ל-Ollama, ולהתמקד בהקצאת משאבי חומרה ובשכפול של פודים.

הרחבת הפתרון באמצעות קצה עורפי של vLLM

אנכית

  • שדרוג הקצאת ה-GPU: אם קצב העברת הנתונים של ההסקה (טוקנים/שנייה) הופך לצוואר בקבוק, צריך להקצות פרוסת GPU גדולה יותר עד לשימוש ב-GPU מלא של NVIDIA A100.
  • תצורות של כמה מעבדי GPU: כדי להשתמש במודלים גדולים מאוד (לדוגמה, פרמטרים של 70B עד 405B) שלא נכנסים לזיכרון של A100 יחיד בנפח 80GB, צריך להגדיל את מספר מעבדי ה-GPU באמצעות מקביליות טנסורית.
  • שיקולים לגבי זמן האחזור: שימו לב שבדגמים שמשתרעים על יותר מ-GPU אחד יכול להיות שתהיה תקורה קלה שקשורה לתקשורת בין ה-GPU (לדוגמה, סנכרון NCCL).

אופקית

  • הגדלת קצב העברת הנתונים באמצעות רפליקות: כדי לטפל בנפח גבוה יותר של בקשות משתמשים בו-זמניות עבור אותו מודל, מגדילים את מספר הרפליקות ב-YAML של פריסת vLLM.
  • מופעים ייעודיים של מודלים: מכיוון ש-vLLM מתוכנן כמנוע להצגת מודל יחיד ומצמיד את זיכרון מטמון KV שלו במהלך האתחול, צריך לפרוס קבוצה נפרדת של פודים לכל LLM שרוצים לארח.
  • איזון עומסים: שירות GDC Kubernetes (סוג LoadBalancer) יפיץ באופן אוטומטי בקשות הסקה נכנסות בין כל נקודות הקצה (endpoints) של vLLM pod שמשויכות לשירות הזה.

‫5.4 פתרון בעיות

שגיאות נפוצות ופתרונות.

שגיאה צמצום הפגיעה
FIPS SELFTEST FAILURE השגיאה הזו מתרחשת כשחסרים חתימות שלמות בספריות כמו BoringSSL. תיקון באמצעות הגדרת BORINGSSL_FIPS=0 ושימוש בתמונות vLLM רשמיות
טעינת משקל מושהית בודקים אם יש הגבלת IOPS ב-PVC קטנים. נדרש נפח אחסון של 500GiB כדי לאתחל את מודל הביצועים.
החיבור נדחה מוודאים ש-PNP מאפשר באופן מפורש את targetPort ‏ (8000/8080). חומות האש של GDC לא מעניקות אוטומטית גישה ליציאות של השרתים העורפיים עבור כתובות ה-VIP של מאזן העומסים.