Kubernetes GPU-node resource isolation¶
Scope: how a Kubernetes GPU node keeps system daemons, the kubelet, and neighboring pods from starving a GPU pod's CPU and memory (and vice versa), via Node Allocatable carve-outs (kubeReserved, systemReserved, reservedSystemCPUs), Guaranteed QoS plus the CPU/Memory Manager, node-pressure eviction thresholds, taints, cgroup v2 controls, and kubelet swap behavior.
What it is¶
Resource isolation on a GPU node is the set of kubelet- and scheduler-level mechanisms that partition a node's CPU, memory, and (partially) I/O between three tenants that must not interfere with each other: the OS and its daemons (sshd, udev, journald), Kubernetes' own agents (kubelet, container runtime), and the pods a user schedules. Without this partitioning, a single noisy pod, or the normal creep of system-daemon memory, can starve the kubelet itself or push a GPU training pod's CPU-bound data loader off its cores.
The primary mechanisms, in the order the kubelet applies them:
- Node Allocatable: capacity minus reservations minus the hard eviction buffer, the number the scheduler actually bin-packs pods against.1
kubeReserved/systemReserved/reservedSystemCPUs: kubelet config fields that carve out CPU, memory, and PID headroom for Kubernetes daemons and the OS respectively.1- Guaranteed QoS + CPU Manager
static+ Memory ManagerStatic: the pod-level opt-in that turns a soft CFS-quota share into exclusive, pinned cores and protected memory, covered in depth in Topology-Aware GPU Scheduling in Kubernetes.45 - Node-pressure eviction (
evictionHard/evictionSoft): the kubelet's last line of defense, killing pods before the node itself runs out of memory or disk.3 - Taints and tolerations: keep non-GPU workloads off expensive GPU nodes so they cannot compete for that node's CPU/memory at the scheduling level at all.8
- cgroup v2
io.max/io.weight: kernel primitives for I/O isolation that Kubernetes does not yet expose as a pod-spec field, only as requests/limits for CPU and memory.910 memorySwap.swapBehavior(NoSwap/LimitedSwap): whether Burstable pods may spill to swap under memory pressure instead of being killed outright.1112
Why it matters¶
A GPU node's economics are dominated by the GPU, not the CPU or memory: an idle H100 because the kubelet OOM-killed the data-loader pod's neighbor, or throttled the kubelet itself out of CPU time and lost the node from the cluster, is a far more expensive failure than the equivalent event on a plain CPU node. Reservation and eviction settings exist precisely to keep the two "invisible" tenants (OS daemons, kubelet/container-runtime) alive under load so the visible tenant (the GPU pod) keeps running. Guaranteed QoS and the CPU/Memory Manager static policies close the other direction: they stop a different pod on the same node from stealing the cycles or memory bandwidth a latency-sensitive GPU pipeline depends on. This page is the isolation and reservation half of that story; Topology-Aware GPU Scheduling in Kubernetes is the alignment half (making sure the reserved/pinned resources are also on the same NUMA node as the GPU).
When it is needed (and when not)¶
Needed:
- Any production GPU node, whether single-GPU inference or multi-GPU training: unreserved system daemons on a memory-dense GPU box are exactly as vulnerable to eviction storms as any other node, and the failure mode (losing the node, not just a pod) is worse when it is carrying an expensive accelerator.
- Mixed clusters with GPU and non-GPU node pools, where taints are the only thing stopping a CPU-only batch job from landing on, and hogging CPU/memory on, a GPU node.
- Guaranteed-QoS GPU pods that also want CPU Manager
staticpinning (per Topology-Aware GPU Scheduling in Kubernetes): the pod'srequests/limitsdesign and the node'skubeReserved/systemReserved/reservedSystemCPUssizing have to agree, or the static policy either fails to start or leaves too small a shared pool for everything else.
Not needed / low value:
LimitedSwapon a GPU training node: it only helps Burstable pods, and a Guaranteed-QoS GPU pod (the common case) gets no swap allocation under the KEP's design, so enabling swap buys nothing for the pod that matters and adds tail-latency risk for the rest.12- cgroup v2
io.max/io.weightat the pod-spec level: do not design an architecture around per-pod Kubernetes-native I/O limits, that field does not exist yet; solve pod-level I/O contention at the container-runtime layer (containerd NRI plugin, CRI-OBlockIOClass) or with node-levelnodefs/imagefsseparation instead.910 enforceNodeAllocatable: [pods, kube-reserved, system-reserved]without first watching actual daemon usage: enforcing reservations as hard cgroup ceilings before you know real consumption can OOM-kill the kubelet or container runtime instead of protecting it.1
Architecture¶
flowchart TB
CAP["Node Capacity<br/>(all CPU / memory / PIDs)"]
KR["kubeReserved<br/>(kubelet, container runtime)"]
SR["systemReserved<br/>(sshd, udev, journald, kernel)"]
RSC["reservedSystemCPUs<br/>(explicit CPU list, takes<br/>precedence over kubeReserved/<br/>systemReserved cpu component)"]
EV["evictionHard buffer<br/>(memory.available, nodefs, imagefs)"]
ALLOC["Node Allocatable<br/>= Capacity - kubeReserved<br/>- systemReserved - evictionHard"]
SCHED["Scheduler bin-packs<br/>against Allocatable"]
TAINT["GPU-node taint<br/>nvidia.com/gpu=present:NoSchedule"]
TOL["GPU pod toleration"]
QOS["Guaranteed QoS<br/>(requests == limits, CPU+mem)"]
CPUM["CPU Manager static<br/>exclusive core pinning"]
MEMM["Memory Manager Static<br/>reservedMemory per NUMA node"]
PODCG["Pod cgroup<br/>(cpuset + memory.min/max)"]
GPUPOD["GPU pod runs isolated"]
NPE["Node-pressure eviction<br/>kills BestEffort, then Burstable<br/>over-request, before Guaranteed"]
CAP --> ALLOC
KR --> ALLOC
SR --> ALLOC
RSC -.->|substitutes cpu portion| KR
EV --> ALLOC
ALLOC --> SCHED
TAINT --> TOL
TOL --> SCHED
SCHED --> QOS
QOS --> CPUM
QOS --> MEMM
CPUM --> PODCG
MEMM --> PODCG
PODCG --> GPUPOD
EV -.->|breach triggers| NPE
NPE -.->|protects| GPUPOD
Node Allocatable is the gate the scheduler sees; taints are the gate that keeps the wrong pods off the node entirely; Guaranteed QoS plus the CPU/Memory Manager is what turns a scheduled pod's requests into an actual pinned, protected cgroup; and eviction is the backstop that fires if all of that under-provisions and the node still runs out of resources.
How: implement, integrate, maintain¶
1. Size Node Allocatable: kubeReserved, systemReserved, evictionHard¶
Node Allocatable is computed by the kubelet as:
kubeReserved covers Kubernetes system daemons (kubelet, container runtime); systemReserved covers non-Kubernetes OS daemons; evictionHard is held back, not consumed, as the buffer the kubelet must still have free before it starts evicting pods.1
Worked example, a 128-vCPU / 1024Gi GPU node:
# /var/lib/kubelet/config.yaml (KubeletConfiguration)
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
kubeReserved:
cpu: "2"
memory: 8Gi
systemReserved:
cpu: "1"
memory: 4Gi
evictionHard:
memory.available: "100Mi" # kubelet default; shown explicitly here
nodefs.available: "10%" # kubelet default
nodefs.inodesFree: "5%" # kubelet default
imagefs.available: "15%" # kubelet default
enforceNodeAllocatable:
- pods # start here; add kube-reserved/system-reserved after watching usage
By hand: Capacity is 128 CPU and 1024Gi = 1,048,576Mi memory.
- CPU Allocatable = 128 - 2 (kubeReserved) - 1 (systemReserved) = 125 CPU.
- Memory Allocatable = 1,048,576Mi - 8,192Mi (kubeReserved, 8Gi) - 4,096Mi (systemReserved, 4Gi) - 100Mi (evictionHard) = 1,036,188Mi ≈ 1,011.91Gi.
evictionHard.memory.available: 100Mi, nodefs.available: 10%, nodefs.inodesFree: 5%, and imagefs.available: 15% are the kubelet's built-in defaults; only memory and ephemeral-storage are supported eviction resources.13 Soft eviction (evictionSoft) has no built-in default; it is opt-in and must be paired with evictionSoftGracePeriod (per-signal grace period before eviction fires) and typically evictionMaxPodGracePeriod.3
enforceNodeAllocatable (default [pods]) controls whether the kubelet also creates and enforces the kube-reserved/system-reserved cgroup ceilings themselves; adding kube-reserved or system-reserved requires setting kubeReservedCgroup/systemReservedCgroup to an existing cgroup, and the kubelet will fail to start if that cgroup does not exist.1
2. Pin CPUs to system daemons with reservedSystemCPUs¶
Instead of (not in addition to) the cpu field inside kubeReserved/systemReserved, the kubelet accepts an explicit CPU list. It takes precedence: "the explicit CPU list specified by --reserved-cpus takes precedence over the CPU reservation specified by --kube-reserved and --system-reserved."2 This is the option to use whenever CPU Manager static is also enabled, since the static policy requires a non-zero CPU reservation (via one path or the other) or its shared pool would be emptied by exclusive allocations.2
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
reservedSystemCPUs: "0-3" # 4 physical cores reserved for OS + Kubernetes daemons
cpuManagerPolicy: static
On the same 128-CPU node, reservedSystemCPUs: "0-3" reserves 4 cores, leaving 124 CPUs in the CPU Manager's exclusively-allocatable shared pool (128 - 4), separate from the memory-side Node Allocatable arithmetic above. Reserved CPUs are taken, in integer quantity, from the shared pool in ascending physical-core-ID order.2
3. Make the GPU pod Guaranteed QoS, and compose CPU Manager + Memory Manager¶
Guaranteed QoS, CPU Manager static, and Memory Manager Static are already specified in detail in Topology-Aware GPU Scheduling in Kubernetes; this page only adds the reservation angle: reservedMemory (required once memoryManagerPolicy: Static is set) must sum, across NUMA nodes, to kubeReserved + systemReserved + evictionHard[memory.available], or the kubelet fails to start.6 That is the same reservation arithmetic from step 1, now also fenced off per-NUMA-node for the Memory Manager. A pod only gets exclusive cores and protected memory once it is Guaranteed (every container's CPU and memory requests equal limits); Burstable and BestEffort pods draw from whatever the static/exclusive allocations left in the shared pool.45
apiVersion: v1
kind: Pod
metadata:
name: gpu-trainer
spec:
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
containers:
- name: train
image: nvcr.io/nvidia/pytorch:25.04-py3
resources:
requests:
cpu: "16"
memory: 64Gi
nvidia.com/gpu: "4"
limits: # requests == limits on both CPU and memory -> Guaranteed
cpu: "16"
memory: 64Gi
nvidia.com/gpu: "4"
An optional, still-immature layer under this: the kubelet's cgroup v2 Memory QoS feature (FEATURE STATE: Kubernetes v1.22 [alpha], disabled by default) sets memory.high on Burstable containers to throttle before they hit memory.max, computed as memory.high = requests + memoryThrottlingFactor * (limits - requests) with memoryThrottlingFactor defaulting to 0.9; Guaranteed containers get no memory.high because requests already equal limits.7 Because it is still alpha years after introduction, do not depend on it for hard isolation guarantees; use Guaranteed QoS and the Memory Manager for that.
4. Keep other workloads off the GPU node with taints¶
Taints stop the scheduler from placing non-tolerating pods on a node at all (NoSchedule), or evict already-running ones (NoExecute, with an optional tolerationSeconds grace window), or merely discourage placement (PreferNoSchedule, best-effort only).8
nvidia.com/gpu=present:NoSchedule is a widely used convention, not something the NVIDIA GPU Operator itself applies. Google Kubernetes Engine, for example, auto-applies exactly this taint (and an auto-toleration via the ExtendedResourceToleration admission controller) to GPU node pools, but only when the cluster also has at least one non-GPU node pool; it is not retroactively applied if a non-GPU pool is added later.13 On self-managed clusters, apply the taint yourself and give every GPU-requesting pod spec an explicit toleration; do not assume the toleration is automatic unless your platform documents that admission-controller behavior.
5. cgroup v2 I/O: what Kubernetes does and does not isolate¶
cgroup v2 exposes a unified io controller (io.max for hard IOPS/bandwidth caps, io.weight for proportional share), succeeding cgroup v1's blkio.9 Kubernetes uses cgroup v2 for CPU and memory requests/limits and for the alpha Memory QoS feature above, but it does not expose a pod-spec field for io.max or io.weight; a 2018 upstream issue tracking per-pod blkio/IO limiting was closed as stale without a fix landing, and no such field has shipped since.10 Do not architect around a Kubernetes-native I/O guarantee. The available levers are: separate nodefs/imagefs filesystems so image churn cannot starve workload I/O (both have dedicated eviction signals, see step 1), container-runtime-specific mechanisms (containerd NRI plugins, CRI-O BlockIOClass), or dedicated NVMe/local-SSD node pools per workload class.
6. NoSwap versus LimitedSwap¶
memorySwap.swapBehavior in the KubeletConfiguration chooses whether Kubernetes workloads may use swap at all; failSwapOn: false must also be set, or the kubelet refuses to start on a node with swap enabled.11 NoSwap is the default: "workloads running as Pods on this node do not and cannot use swap," though non-Kubernetes processes, including the kubelet itself, still can.11 LimitedSwap lets pods use swap, but only Burstable-QoS pods that are not high-priority, static, or mirrored; Guaranteed pods get none (their memory is already fully reserved) and BestEffort pods get none (they are the first evicted under pressure anyway).12 The per-container swap ceiling is proportional to its memory request's share of node memory: container swap limit = (container memory request / node memory) * available swap.12
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
failSwapOn: false
memorySwap:
swapBehavior: LimitedSwap # or NoSwap (default)
Swap support progressed alpha (v1.22) to beta1 (v1.28) to beta2 (v1.30, which also removed the earlier UnlimitedSwap option in favor of just NoSwap/LimitedSwap) per the upstream KEP.12 Verify the feature's current stage and default against the exact minor version you run; it is one of the faster-moving kubelet features and this page does not assert it has reached GA. For a Guaranteed-QoS GPU pod specifically, swap changes nothing: it is not eligible either way, so NoSwap is the right default for GPU training/inference nodes and LimitedSwap only matters for co-located Burstable sidecars or batch jobs on the same node.
Maintain¶
- Re-derive the Node Allocatable arithmetic (step 1) whenever node instance types change;
kubeReserved/systemReservedsized for one SKU's daemon footprint does not transfer to a bigger or smaller node. - Watch
enforceNodeAllocatablerollout carefully: addingkube-reserved/system-reservedenforcement after onlypodswas enforced can newly OOM-kill a daemon that was previously allowed to burst past its reservation.1 - Audit taints after every managed-Kubernetes node-pool change (adding a first non-GPU pool, migrating GPU pools) since some platforms only apply the GPU taint at pool-creation time, not retroactively.13
- Track the swap feature gate's stage on upgrade; a cluster that relied on the removed
UnlimitedSwapbehavior needs its kubelet config migrated toLimitedSwaporNoSwapexplicitly.12
Failure modes¶
- Under-reserved
kubeReserved/systemReserved: the kubelet or container runtime gets starved under load, the node goesNotReady, and every GPU pod on it is rescheduled, an outage far more expensive than a plain-CPU-node equivalent. reservedSystemCPUssized smaller than actual daemon usage: system daemons spill into the CPU Manager's shared pool, jittering the "shared" CPUs that Burstable/BestEffort pods (and any un-pinned sidecars) rely on.- Guaranteed QoS misconfigured as Burstable (limits set but requests omitted, or requests != limits): the pod silently loses CPU pinning and Memory Manager protection; nothing rejects the pod, it just does not get the isolation the operator assumed.
- Missing GPU-node toleration on a non-GPU-requesting pod, combined with a missing or removed taint: cheap CPU-only pods land on the GPU node and compete for the same CPU/memory reservation the GPU pod needs.
evictionHardset too tight (or left at conservative defaults) on a memory-dense GPU node: the kubelet evicts BestEffort/Burstable neighbors well before the node is actually in danger, wasting headroom; too loose, and the kubelet has no runway to react before real memory exhaustion.- Assuming pod-level I/O isolation exists: without a runtime-specific mechanism, one pod's checkpoint-write burst or image pull can starve another pod's I/O on the same
nodefs/imagefs, since Kubernetes has no nativeio.max/io.weightpod field.10 - Enabling
LimitedSwapexpecting it to help a Guaranteed GPU pod: it structurally cannot, only Burstable pods are eligible, so it adds swap-thrash risk elsewhere on the node for zero benefit to the workload that matters.12
References¶
- Kubernetes: Reserve Compute Resources for System Daemons: https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/
- Kubernetes: Control CPU Management Policies on the Node: https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/
- Kubernetes: Node-pressure Eviction: https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/
- Kubernetes: Pod Quality of Service Classes (including Memory QoS with cgroup v2): https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/
- Kubernetes: Taints and Tolerations: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/
- Kubernetes: Memory Manager (
reservedMemory, per-NUMA-node sum requirement): https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/ - Kubernetes: About cgroup v2 (kernel/runtime requirements, MemoryQoS): https://kubernetes.io/docs/concepts/architecture/cgroups/
- Kubernetes: Swap Memory Management: https://kubernetes.io/docs/concepts/cluster-administration/swap-memory-management/
- Kubernetes Enhancement Proposal, sig-node/2400 (Node System Swap Support): https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/2400-node-swap/README.md
- Kubernetes blog: Fresh Swap Features for Linux Users in Kubernetes 1.32: https://kubernetes.io/blog/2025/03/25/swap-linux-improvements/
- kubernetes/kubernetes issue #70364, "Limiting blkio with cgroups by annotation" (closed as stale, unresolved; no native pod-level blkio/IO isolation has shipped since): https://github.com/kubernetes/kubernetes/issues/70364
- Google Cloud: Run GPUs in GKE Standard node pools (auto-taint/auto-toleration behavior): https://cloud.google.com/kubernetes-engine/docs/how-to/gpus
Related: Topology-Aware GPU Scheduling in Kubernetes · NUMA Affinity and CPU Pinning for GPU Pipelines · GPU Containerization Performance · Glossary
-
Kubernetes docs, "Reserve Compute Resources for System Daemons":
Allocatable = Capacity - kubeReserved - systemReserved - evictionHard;evictionHarddefaultsmemory.available: 100Mi,nodefs.available: 10%,nodefs.inodesFree: 5%,imagefs.available: 15%; onlymemoryandephemeral-storageare supported eviction resources;enforceNodeAllocatabledefaults to[pods], valid values[pods]/[pods, system-reserved, kube-reserved];kubeReservedCgroup/systemReservedCgroupmust reference an existing cgroup or the kubelet fails to start. https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/ ↩↩↩↩↩↩↩ -
Kubernetes docs, "Control CPU Management Policies on the Node": CPU Manager
staticpolicy assigns exclusive CPUs only to Guaranteed-QoS containers with integer CPU requests;--reserved-cpus(kubelet configreservedSystemCPUs) takes precedence over--kube-reserved/--system-reservedfor the CPU reservation; a non-zero reservation via one of these paths is required whenstaticis enabled; reserved CPUs are taken from the shared pool in ascending physical-core-ID order. https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/ ↩↩↩↩ -
Kubernetes docs, "Node-pressure Eviction": hard eviction thresholds trigger immediate eviction (no
PodDisruptionBudgetrespect, 0s grace period); soft thresholds (evictionSoft) requireevictionSoftGracePeriod;evictionPressureTransitionPerioddefault 5m;memory.availableis computed from cgroupfs, notfree -m, and respects Node Allocatable. https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/ ↩↩↩ -
Kubernetes docs, "Pod Quality of Service Classes": Guaranteed requires every container's CPU and memory
requeststo equallimits, both greater than zero; Burstable is any pod with at least one request/limit that does not qualify as Guaranteed; BestEffort has none at all; BestEffort pods are preferentially evicted under node pressure. https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/ ↩↩ -
See 2 and Topology-Aware GPU Scheduling in Kubernetes for how CPU Manager
staticand Memory ManagerStaticcompose with Guaranteed QoS and the Topology Manager. ↩↩ -
Kubernetes docs, "Memory Manager":
memoryManagerPolicy: StaticrequiresreservedMemoryper NUMA node, whose sum across all nodes must equalkubeReserved + systemReserved + evictionHard[memory.available]; only affects Guaranteed-QoS pods. https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/ ↩ -
Kubernetes docs, "Pod Quality of Service Classes," Memory QoS with cgroup v2 section:
FEATURE STATE: Kubernetes v1.22 [alpha](disabled by default); Burstable containers getmemory.high = requests + memoryThrottlingFactor * (limits - requests),memoryThrottlingFactordefaults to 0.9; Guaranteed containers get nomemory.highsince requests equal limits; requires cgroup v2, kernel 5.9+ recommended. https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/ ↩↩ -
Kubernetes docs, "Taints and Tolerations":
NoScheduleblocks new non-tolerating pods but does not evict running ones;NoExecuteevicts non-tolerating pods immediately (or aftertolerationSeconds);PreferNoScheduleis a soft, non-guaranteed preference; default tolerationoperatorisEqual. https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ ↩↩ -
Kubernetes docs, "About cgroup v2": cgroup v2 requires Linux kernel 5.8+ (containerd 1.4+ / CRI-O 1.20+, systemd cgroup driver recommended); the page notes the
MemoryQoSfeature relies on cgroup v2 memory primitives (see 7 for its actualv1.22 [alpha]stage, the authoritative source); the page does not describe a pod-spec-levelio.max/io.weightfield. https://kubernetes.io/docs/concepts/architecture/cgroups/ ↩↩↩ -
kubernetes/kubernetes issue #70364, "Limiting blkio with cgroups by annotation": tracks the long-standing absence of a native, portable Kubernetes field for per-pod blkio/IO (cgroup v1
blkio/ cgroup v2io) weight or IOPS limits; opened 2018-10-29, closed 2019-03-28 by the stale-issue bot without a fix landing, and no such field has shipped in Kubernetes since. https://github.com/kubernetes/kubernetes/issues/70364 ↩↩↩↩ -
Kubernetes docs, "Swap Memory Management":
memorySwap.swapBehaviorfield;NoSwap(default): "workloads running as Pods on this node do not and cannot use swap," though non-Kubernetes processes and the kubelet itself still can;LimitedSwap: "Kubernetes workloads can utilize swap memory";failSwapOn: falserequired for the kubelet to start on a swap-enabled node. https://kubernetes.io/docs/concepts/cluster-administration/swap-memory-management/ ↩↩↩ -
Kubernetes Enhancement Proposal sig-node/2400: swap access under
LimitedSwapis granted only to Burstable-QoS pods, excluding high-priority, static, and mirrored pods; Guaranteed pods are excluded because their memory is already fully reserved, BestEffort pods are excluded since they are first evicted under pressure; per-container swap limit is proportional to(container memory request / node memory) * available swap; feature progressed alpha (v1.22) to beta1 (v1.28) to beta2 (v1.30, which also removed the earlierUnlimitedSwapoption). https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/2400-node-swap/README.md ↩↩↩↩↩↩↩ -
Google Cloud, "Run GPUs in GKE Standard node pools": GKE automatically applies the
nvidia.com/gpu=present:NoScheduletaint (and a matching auto-toleration via theExtendedResourceTolerationadmission controller) to GPU node pools, but only when the cluster already has at least one non-GPU node pool, and not retroactively if a non-GPU pool is added afterward. https://cloud.google.com/kubernetes-engine/docs/how-to/gpus ↩↩