Skip to main content

Pods Should Be Managed By A Controller

More Info:

Verifies pods are owned by a controller (Deployment, StatefulSet, DaemonSet, Job). A naked pod is not rescheduled if its node dies.

Risk Level

Low

Address

Security

Compliance Standards

  • Cloudanix Best Practice

Triage and Remediation

Remediation

Manual Steps
  1. Identify naked pods (run on any machine with kubectl access):

    kubectl get pods --all-namespaces -o json | jq -r '
    [ .items[]
    | select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
    | .metadata as $m
    | ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
    | select($own == null)
    | "\($m.namespace) \($m.name)"
    ][]'
  2. For each naked pod, export its manifest (run on any machine with kubectl access):

    NAMESPACE="<namespace-of-pod>"
    POD_NAME="<pod-name>"

    kubectl get pod "$POD_NAME" -n "$NAMESPACE" -o yaml > "/tmp/${NAMESPACE}-${POD_NAME}-pod.yaml"
  3. Convert the pod manifest into a controller manifest (run on any machine with kubectl access; edit with your editor of choice):

    • Open the exported file:
      vi "/tmp/${NAMESPACE}-${POD_NAME}-pod.yaml"
    • Change the top-level kind: Pod to an appropriate controller, e.g.:
      • For a stateless app: kind: Deployment, apiVersion: apps/v1
      • For a singleton: kind: Deployment with spec.replicas: 1
      • For node-wide: kind: DaemonSet, apiVersion: apps/v1
    • Wrap the existing pod spec under the controller spec, for example for a Deployment:
      apiVersion: apps/v1
      kind: Deployment
      metadata:
      name: <pod-name>
      namespace: <namespace-of-pod>
      labels:
      app: <app-label>
      spec:
      replicas: 1
      selector:
      matchLabels:
      app: <app-label>
      template:
      metadata:
      labels:
      app: <app-label>
      spec:
      # paste the original pod.spec content here
    • Remove fields that are not valid under a template (e.g. status:, metadata.resourceVersion, metadata.uid, metadata.selfLink, metadata.creationTimestamp).
  4. Apply the new controller and delete the naked pod (run on any machine with kubectl access):

    kubectl apply -f "/tmp/${NAMESPACE}-${POD_NAME}-pod.yaml"

    kubectl delete pod "$POD_NAME" -n "$NAMESPACE"
  5. Confirm the controller created replacement pods and workloads are healthy (run on any machine with kubectl access):

    kubectl get deploy,ds,sts,job -n "$NAMESPACE" -o wide
    kubectl get pods -n "$NAMESPACE" -o wide
  6. Re-run the audit to verify no remaining naked pods (run on any machine with kubectl access):

    kubectl get pods --all-namespaces -o json | jq -r '
    [ .items[]
    | select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
    | .metadata as $m
    | (.spec.nodeName // "") as $node
    | (($m.labels // {}) | to_entries | map("\(.key):\(.value)") | join(",")) as $labels
    | ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
    | "kind=Pod ns=\($m.namespace) name=\($m.name) uid=\($m.uid) apiVersion=v1"
    + (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
    + (if $node == "" then "" else " node=\($node)" end)
    + (if $labels == "" then "" else " labels=\($labels)" end)
    + (if $own == null then "" else " owner=\($own.kind)/\($m.namespace)/\($own.name)/\($own.uid)" end)
    + " is_compliant=\(if $own == null then "false" else "true" end)"
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Using kubectl

On any machine with kubectl access:

  1. Identify naked pods (no controller ownerReference, excluding core namespaces):
kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
| select($own == null)
| "\($m.namespace) \($m.name)"
][]'
  1. For each reported pod, export its spec and remove fields that must not be in a controller template, then wrap it in a Deployment (example for default myapp-pod):
kubectl get pod myapp-pod -n default -o json \
| jq '{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name: "myapp",
namespace: .metadata.namespace,
labels: (.metadata.labels // {})
},
spec: {
replicas: 1,
selector: { matchLabels: (.metadata.labels // { "app": "myapp" }) },
template: {
metadata: {
labels: (.metadata.labels // { "app": "myapp" })
},
spec: .spec
| del(.nodeName, .hostname, .subdomain, .affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[].matchFields)
}
}
}' > myapp-deployment.yaml

Adjust the generated myapp-deployment.yaml as needed (labels, replicas, affinity, etc.), then create the Deployment:

kubectl apply -f myapp-deployment.yaml
  1. Once the new controller-created pod is Running and Ready, delete the original naked pod:
kubectl delete pod myapp-pod -n default
  1. Repeat steps 2–3 for every naked pod, choosing the appropriate controller type (Deployment/StatefulSet/DaemonSet/Job) and adapting the manifest kind and spec accordingly.

  2. Verification (same logic as the audit):

kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
| "kind=Pod ns=\($m.namespace) name=\($m.name) is_compliant=\(if $own == null then "false" else "true" end)"
] as $rows
| if ($rows | map(select(. | contains("is_compliant=false"))) | length) == 0
then "is_compliant=true"
else $rows[]
end'
Automation
#!/usr/bin/env bash
#
# Remediate naked Pods by exporting them as manifests and wrapping them
# in a Deployment per namespace. Skips control-plane/system namespaces.
#
# Requirements:
# - Run on any machine with kubectl, jq, and yq installed and configured.
# - You must have permissions to list/get/create/delete Pods and Deployments.
#
# Operational notes:
# - Each naked Pod will be replaced by a Deployment with 1 replica.
# - The original Pod will be deleted after the Deployment is created.
# - This is NOT fully lossless (e.g., ephemeral container state, IPs),
# so review before running in production.

set -euo pipefail

# Namespaces to exclude from processing (system namespaces)
EXCLUDED_NAMESPACES=("kube-system" "kube-public" "kube-node-lease")

# Utility: check if namespace is excluded
is_excluded_ns() {
local ns="$1"
for e in "${EXCLUDED_NAMESPACES[@]}"; do
if [[ "$ns" == "$e" ]]; then
return 0
fi
done
return 1
}

# Ensure required tools are present
for bin in kubectl jq yq; do
if ! command -v "$bin" >/dev/null 2>&1; then
echo "ERROR: $bin is required but not installed or not in PATH" >&2
exit 1
fi
done

# Get all naked Pods (no controller ownerReference) outside excluded namespaces
mapfile -t NAKED_PODS < <(
kubectl get pods --all-namespaces -o json \
| jq -r '
.items[]
| select(.metadata.namespace as $n
| ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| . as $pod
| (($pod.metadata.ownerReferences // []) | map(select(.controller == true)) | length) as $owns
| select($owns == 0)
| "\($pod.metadata.namespace) \($pod.metadata.name)"
'
)

if [[ "${#NAKED_PODS[@]}" -eq 0 ]]; then
echo "No naked Pods found outside excluded namespaces. Nothing to do."
else
echo "Found ${#NAKED_PODS[@]} naked Pod(s) to process:"
printf ' %s\n' "${NAKED_PODS[@]}"
fi

for line in "${NAKED_PODS[@]}"; do
ns="$(awk '{print $1}' <<<"$line")"
pod="$(awk '{print $2}' <<<"$line")"

if is_excluded_ns "$ns"; then
echo "Skipping Pod $ns/$pod (excluded namespace)"
continue
fi

echo "Processing naked Pod $ns/$pod"

# Export Pod manifest
tmp_pod_yaml="$(mktemp)"
kubectl get pod "$pod" -n "$ns" -o yaml > "$tmp_pod_yaml"

# Basic safety: ensure this Pod still has no controller owner
if kubectl get pod "$pod" -n "$ns" -o json \
| jq -e '((.metadata.ownerReferences // []) | map(select(.controller == true)) | length) != 0' \
>/dev/null 2>&1; then
echo " Pod $ns/$pod now has a controller. Skipping."
rm -f "$tmp_pod_yaml"
continue
fi

# Construct Deployment name (same as Pod name by default)
deploy_name="$pod"

# Build Deployment manifest using yq
tmp_deploy_yaml="$(mktemp)"
yq eval '
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": .metadata.name,
"namespace": .metadata.namespace,
"labels": (.metadata.labels // {})
},
"spec": {
"replicas": 1,
"selector": {
"matchLabels": (
if (.metadata.labels // {} | length) > 0 then
.metadata.labels
else
{"app": .metadata.name}
end
)
},
"template": {
"metadata": {
"labels": (
if (.metadata.labels // {} | length) > 0 then
.metadata.labels
else
{"app": .metadata.name}
end
)
},
"spec": .spec
}
}
}
# Clean up pod-specific fields from the template spec
| del(.spec.template.spec["nodeName","hostIP","phase","podIP","podIPs","status"])
' "$tmp_pod_yaml" > "$tmp_deploy_yaml"

echo " Applying Deployment $ns/$deploy_name"
kubectl apply -n "$ns" -f "$tmp_deploy_yaml"

# Wait briefly for Deployment to create a Pod
echo " Waiting for a Pod from Deployment $ns/$deploy_name"
kubectl rollout status deployment/"$deploy_name" -n "$ns" --timeout=120s || {
echo " WARNING: Deployment $ns/$deploy_name did not become ready in time. Skipping Pod deletion."
rm -f "$tmp_pod_yaml" "$tmp_deploy_yaml"
continue
}

# Delete original naked Pod
echo " Deleting original naked Pod $ns/$pod"
kubectl delete pod "$pod" -n "$ns" --ignore-not-found=true

rm -f "$tmp_pod_yaml" "$tmp_deploy_yaml"
done

echo
echo "Verification: re-running compliance check for naked Pods"
kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
| select($own == null)
| "kind=Pod ns=\($m.namespace) name=\($m.name) is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end
'