Run standalone fine-tuning on Kubernetes#
This guide deploys a supervised fine-tuning (SFT) job with Kubernetes manifests
It has the following setup:
A model PVC and a dataset PVC are mounted read-only at
/modeland/data.An ephemeral PVC is mounted at
/outputfor checkpoints and rendered runtime files.An in-memory
emptyDiris mounted at/dev/shm.An optional sidecar copies the final adapter to a persistent export PVC.
This workflow assumes that the model and dataset have already been downloaded to PVCs.
Prerequisites#
You need access to a Kubernetes cluster with AMD GPU resources and a storage class that supports the requested volumes. The model and dataset must already be available in persistent volume claims.
Verify the input claims before creating the job:
kubectl get pvc
Record the input claim names and paths. The example below assumes:
Model claim:
aimft-dev-model-google-gemma-3-27b-itDataset claim:
aimft-dev-dataset-sft-demo-dataDataset path:
/data/sft-demo-data.jsonl
Deploy the training job#
Save the following manifest as standalone-sft.yaml. Replace the image,
claim names, model ID, dataset path, storage class, and resource values for your
environment before applying it.
apiVersion: batch/v1
kind: Job
metadata:
name: standalone-sft
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: sft
image: docker.io/silogenai/aimft-aimfttk:YOUR_TAG
imagePullPolicy: Always
resources:
requests:
amd.com/gpu: "1"
cpu: "8"
memory: "64Gi"
limits:
amd.com/gpu: "1"
cpu: "8"
memory: "64Gi"
env:
- name: AIMFT_MODEL_PATH
value: /model/google/gemma-3-27b-it
- name: AIMFT_OUTPUT_PATH
value: /output
- name: AIMFT_TRAIN_DATA_PATH
value: /data/sft-demo-data.jsonl
- name: AIM_MODEL_ID
value: google/gemma-3-27b-it
- name: AIMFT_USE_ADAPTER
value: "true"
volumeMounts:
- name: model
mountPath: /model
readOnly: true
- name: data
mountPath: /data
readOnly: true
- name: output
mountPath: /output
- name: dshm
mountPath: /dev/shm
volumes:
- name: model
persistentVolumeClaim:
claimName: aimft-dev-model-google-gemma-3-27b-it
readOnly: true
- name: data
persistentVolumeClaim:
claimName: aimft-dev-dataset-sft-demo-data
readOnly: true
- name: output
ephemeral:
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
storageClassName: mlstorage
resources:
requests:
storage: 100Gi
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 12Gi
Apply the manifest and follow the job:
kubectl apply -f standalone-sft.yaml
kubectl get pods -l job-name=standalone-sft -w
kubectl logs job/standalone-sft -f
Size the output volume for the selected model and training method. Full-parameter checkpoints can require substantially more space. Omit storageClassName to use the cluster’s default storage class.
The ephemeral output claim is deleted with the Job’s pod. Export the adapter if you need to retain it after the job finishes.
Export the final adapter#
Add the following resources before the Job in standalone-sft.yaml to persist
/output/checkpoint-final-adapter in a shared PVC. The export container needs
permission to inspect and delete its own pod if an export error occurs. The
example uses ReadWriteMany so the export and inference pods can mount the
claim at the same time; choose an access mode supported by your storage class
and workload topology.
apiVersion: v1
kind: ServiceAccount
metadata:
name: standalone-sft
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: standalone-sft-pod-reader
rules:
- apiGroups: [""]
resources: [pods]
verbs: [get, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: standalone-sft-pod-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: standalone-sft-pod-reader
subjects:
- kind: ServiceAccount
name: standalone-sft
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: standalone-sft-adapters
spec:
accessModes: [ReadWriteMany]
storageClassName: mlstorage
resources:
requests:
storage: 128Gi
---
Then add serviceAccountName beside restartPolicy under
spec.template.spec in the Job:
spec:
template:
spec:
restartPolicy: Never
serviceAccountName: standalone-sft
Add the following sidecar under containers, and add the model-export volume
shown below. Replace my-adapter with a unique destination name. Atomic directory creation prevents concurrent jobs from overwriting an existing export.
- name: model-export
image: bitnami/kubectl:latest
command:
- /bin/sh
- -c
- |
set -e
DEST=/model-export/my-adapter
die() {
echo "ERROR: $1" >&2
kubectl delete pod "$POD_NAME"
exit 1
}
if ! mkdir "$DEST" 2>/dev/null; then
die "destination $DEST already exists, refusing to overwrite."
fi
trap 'rmdir "$DEST" 2>/dev/null' EXIT
echo "Waiting for sft container to finish..."
while true; do
STATE=$(kubectl get pod "$POD_NAME" -o jsonpath='{.status.containerStatuses[?(@.name=="sft")].state.terminated.reason}')
if [ "$STATE" = Completed ]; then
echo "sft completed successfully, copying adapter..."
cp -r /output/checkpoint-final-adapter/. "$DEST"
trap - EXIT
exit 0
elif [ -n "$STATE" ]; then
echo "sft terminated with reason: $STATE, skipping export." >&2
exit 1
fi
sleep 10
done
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: output
mountPath: /output
readOnly: true
- name: model-export
mountPath: /model-export
volumes:
- name: model-export
persistentVolumeClaim:
claimName: standalone-sft-adapters
After the Job reports Complete, the adapter is available at
my-adapter/ in the standalone-sft-adapters claim. The directory must use the
Hugging Face PEFT layout expected by the inference image:
my-adapter/
├── adapter_config.json
└── adapter_model.safetensors
Deploy the adapter for inference#
Deploy the trained LoRA adapter as an AIM. Replace the image, model ID, and PVC names for your environment. Use an AIM image built for the same base model as the adapter.
Verify the adapter PVC#
The PVC must contain the adapter directory created by the export step. Verify that it exists before creating the deployment:
export ADAPTER_PVC=standalone-sft-adapters
kubectl get pvc "$ADAPTER_PVC"
Create the Deployment#
Save this manifest as gemma-aim-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: gemma-aim
spec:
replicas: 1
selector:
matchLabels:
app: gemma-aim
template:
metadata:
labels:
app: gemma-aim
spec:
containers:
- name: aim
image: amdenterpriseai/aim-instinct-google-gemma-3-27b-it:0.13.0
imagePullPolicy: Always
ports:
- containerPort: 8000
resources:
requests:
amd.com/gpu: "1"
memory: "64Gi"
cpu: "8"
limits:
amd.com/gpu: "1"
memory: "64Gi"
cpu: "8"
env:
- name: AIM_MODEL_ID
value: google/gemma-3-27b-it
- name: AIM_ADAPTER_SOURCE
value: /adapters
- name: AIM_ADAPTER_MODE
value: dynamic
- name: AIM_ADAPTER_MAX_COUNT
value: "8"
- name: AIM_ADAPTER_MAX_CPU_COUNT
value: "16"
- name: AIM_ADAPTER_MAX_RANK
value: "64"
volumeMounts:
- name: adapter
mountPath: /adapters
readOnly: true
volumes:
- name: adapter
persistentVolumeClaim:
claimName: standalone-sft-adapters
readOnly: true
Apply the Deployment and wait for it to become ready:
kubectl apply -f gemma-aim-deployment.yaml
kubectl rollout status deployment/gemma-aim
kubectl logs deployment/gemma-aim -f
Set AIM_ADAPTER_MAX_RANK to at least the LoRA rank used during training. The
Gemma recipe and the corresponding deploy-aim override use rank 64.
Create the Service#
Save this manifest as gemma-aim-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: gemma-aim
spec:
selector:
app: gemma-aim
ports:
- port: 8000
targetPort: 8000
Apply the Service and inspect the endpoint:
kubectl apply -f gemma-aim-service.yaml
kubectl get deployment gemma-aim
kubectl get pods -l app=gemma-aim
kubectl get service gemma-aim
For an alternative automated deployment approach, use AIM Engine. For more information, see the AIM Engine repository.