Pod และ Cluster Security
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”การทำให้ Pod แข็งแรงขึ้นคือการวางหลายชั้นควบคุมที่เป็นอิสระต่อกัน คือ securityContext ต่อ container, ระดับ Pod Security Admission ต่อ namespace, guardrail แบบ ValidatingAdmissionPolicy/MutatingAdmissionPolicy ที่เขียนด้วย CEL ระดับ cluster, และการป้องกันระดับ cluster อย่าง etcd encryption ซึ่งไม่มีชั้นไหนทดแทนอีกชั้นได้
SecurityContext: ทำให้ container แข็งแรงจากข้างใน
หัวข้อที่มีชื่อว่า “SecurityContext: ทำให้ container แข็งแรงจากข้างใน”securityContext ตั้งได้ทั้งระดับ Pod (เป็นค่า default ให้ทุก container) และระดับ container (override เฉพาะ container นั้น) field ที่สำคัญที่สุดสำหรับ hardening มีดังนี้
apiVersion: v1kind: Podmetadata: name: checkoutspec: securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 seccompProfile: type: RuntimeDefault containers: - name: checkout image: checkout:2.3.0 securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false privileged: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"]runAsNonRootปฏิเสธไม่ให้ container start ถ้า image ของตัวเองจะรันเป็น UID 0runAsUser/runAsGroupล็อก UID/GID ที่ container รันจริง ไม่ว่าDockerfileของ image จะระบุไว้ว่ายังไงreadOnlyRootFilesystemmount root filesystem ของ container แบบอ่านอย่างเดียว ทำให้คนที่ได้ code execution ไม่สามารถเขียน payload ลง disk ให้อยู่ถาวรได้allowPrivilegeEscalation: falseกันไม่ให้ process ได้สิทธิ์มากกว่า parent ของตัวเอง เช่นผ่าน setuid binaryprivileged: falseไม่ให้ container เข้าถึง device และ kernel capability ของ host แบบเต็มรูปแบบเด็ดขาด container ที่เป็น privileged นั้นเทียบเท่ากับ root บนโหนดโดยตรง ดังนั้นควรใช้เป็นทางเลือกสุดท้าย ไม่ใช่ defaultcapabilitiesLinux แบ่งสิทธิ์ของ root ออกเป็น capability ย่อย ๆ การ dropALLแล้วเพิ่มกลับเฉพาะที่จำเป็นจริง ๆ (เช่นNET_BIND_SERVICEสำหรับ bind port ต่ำกว่า 1024) ปลอดภัยกว่าการรันด้วย user ที่มี capability set แบบ default เต็มชุดมากseccompProfile.type: RuntimeDefaultเปิด seccomp filter ที่ container runtime ให้มาเป็น default แทนที่จะรันแบบไม่มีการกรองใด ๆ เลย
field พวกนี้ไม่ใช่การเดาสุ่ม — Pod Security Standard ระดับ restricted (ด้านล่าง) ระบุ field ที่ต้องการมาชัดเจน และ API server ก็บอกตรง ๆ ว่าขาด field ไหนไปบ้าง
Pod Security Admission: บังคับ baseline ระดับ namespace
หัวข้อที่มีชื่อว่า “Pod Security Admission: บังคับ baseline ระดับ namespace”Pod Security Admission ถูกฝังอยู่ใน API server และ GA มาตั้งแต่ Kubernetes 1.25 โดย มาแทนที่ PodSecurityPolicy รุ่นเก่า ซึ่งถูกถอดออกไปแล้วใน 1.25 นั่นคือ PodSecurityPolicy หายไปเลย ไม่ใช่แค่ deprecated ดังนั้น cluster ที่รัน 1.25 ขึ้นไปจึงต้องใช้ Pod Security Admission (หรือ policy engine ของ third-party) แทน
Pod Security Admission ทำงานผ่าน namespace label ล้วน ๆ ไม่ต้องสร้างหรือ bind policy object แยกเลย
apiVersion: v1kind: Namespacemetadata: name: psa-demo labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/audit: restrictedมี Pod Security Standards ระดับมาตรฐานสามระดับ เรียงจากหลวมสุดไปเข้มสุด
privilegedไม่มีข้อจำกัดใด ๆ เลย เท่ากับปิด Pod Security Admission สำหรับ namespace นั้นbaselineบล็อกช่องทาง privilege-escalation ที่รู้จักกันชัดเจนที่สุด (host namespace, privileged container, volume type อันตรายบางแบบ) โดยยังเข้ากันได้กับ workload ทั่วไปส่วนใหญ่restrictedเข้มงวดมาก ต้องมีrunAsNonRoot, drop capabilities, และห้าม privilege escalation ตามแนวทาง best practice ของการทำ Pod hardening ในปัจจุบันอย่างใกล้ชิด
แต่ละระดับมีสามโหมดที่เป็นอิสระต่อกัน คือ enforce ที่บล็อก Pod ที่ไม่ผ่านเงื่อนไขจริง ๆ ไม่ให้ถูก admit ส่วน warn โชว์ warning ฝั่ง client ตอน kubectl apply ส่วน audit บันทึกการละเมิดลง audit log ของ API server และทั้งสองอย่างไม่หยุด Pod รูปแบบการ rollout ที่นิยมกันคือ enforce ระดับที่ cluster ผ่านอยู่แล้ว พร้อม warn/audit ที่ระดับเข้มกว่า เพื่อดูว่าอะไรจะพังก่อนที่จะขยับ enforce ให้เข้มขึ้นจริง
พิสูจน์: reject จริงภายใต้ enforce=restricted
หัวข้อที่มีชื่อว่า “พิสูจน์: reject จริงภายใต้ enforce=restricted”# k8s/pod-and-cluster-security/privileged-pod.yaml — @expect-error rejected by restricted PSAapiVersion: v1kind: Podmetadata: name: privileged-pod namespace: psa-demospec: containers: - name: c image: nginx:alpine securityContext: privileged: true$ kubectl apply -f privileged-pod.yamlError from server (Forbidden): error when creating "privileged-pod.yaml": pods "privileged-pod" is forbidden: violates PodSecurity "restricted:latest": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")นี่คือ API server เองเลย ไม่ใช่ linter ที่บอก field ทั้งห้าที่ restricted ต้องการแบบชัดเจน — field เดียวกับที่พูดถึงในหัวข้อ SecurityContext ด้านบน ถ้า namespace ตั้งแค่ warn/audit (ไม่ตั้ง enforce) เป็น restricted Pod แบบเดียวกันนี้จะถูก admit แต่ kubectl จะพิมพ์ข้อความเดียวกันเป็น warning แบบไม่บล็อกก่อน
$ kubectl apply -f baseline-ok-pod.yaml # namespace enforce=baseline, warn=restrictedWarning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")pod/baseline-ok createdPod ยังถูก created อยู่ดี — warn ไม่บล็อกเลย มีหน้าที่แค่บอกว่าอะไรจะพังถ้าเข้มขึ้น ซึ่งเป็นวิธีที่ดีในการรู้ก่อนว่าต้องแก้อะไรก่อนจะขยับ enforce ให้เข้มขึ้นจริง
พิสูจน์: container ที่ผ่าน restricted จริง ๆ
หัวข้อที่มีชื่อว่า “พิสูจน์: container ที่ผ่าน restricted จริง ๆ”ตั้ง field ทุกตัวที่ error ด้านบนเรียกร้อง บน cluster เดียวกัน ใน namespace ที่ enforce=restricted เดียวกัน
apiVersion: v1kind: Podmetadata: name: hardened namespace: psa-demospec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: type: RuntimeDefault containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] resources: requests: { cpu: "50m", memory: "32Mi" } limits: { cpu: "50m", memory: "32Mi" }$ kubectl apply -f hardened-pod.yamlpod/hardened created$ kubectl get pod hardened -n psa-demoNAME READY STATUS RESTARTS AGEhardened 1/1 Running 0 8sไม่มี warning เลยแม้แต่ตัวเดียว — field ทุกตัวที่ restricted ต้องการมีครบ ถูก admit สะอาด ๆ
ValidatingAdmissionPolicy: guardrail ด้วย CEL ที่ API server
หัวข้อที่มีชื่อว่า “ValidatingAdmissionPolicy: guardrail ด้วย CEL ที่ API server”Pod Security Admission ครอบคลุมกฎ hardening ที่ built-in ไว้ตายตัวชุดหนึ่ง ValidatingAdmissionPolicy (GA มาตั้งแต่ Kubernetes v1.30) ให้คุณเขียนกฎ admission ของตัวเองด้วย CEL ประเมินผลใน process ของ API server เอง ไม่ต้องมี webhook ไม่มี network hop เพิ่ม
ValidatingAdmissionPolicy ประกาศว่าจะ validate อะไรและยังไง ส่วน ValidatingAdmissionPolicyBinding แยกต่างหากตัดสินว่าจะ apply ที่ไหนและถ้าละเมิดจะถูก enforce (Deny) หรือแค่บันทึก (Audit/Warn) นี่คือ registry allowlist จริง พิสูจน์กับ API server 1.37 ที่รันอยู่จริง
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: registry-allowlistspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["pods"] validations: - expression: "object.spec.containers.all(c, c.image.startsWith('registry.example.com/'))" message: "container images must come from registry.example.com"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: registry-allowlist-bindingspec: policyName: registry-allowlist validationActions: ["Deny"] matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demo$ kubectl apply -f bad-image-pod.yaml # image: nginx:alpineThe pods "bad-image" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist' with binding 'registry-allowlist-binding' denied request: container images must come from registry.example.comข้อความ error จริงจาก API server ไม่ใช่ mock — object ใน CEL expression คือ Pod ที่ส่งเข้ามา และ c.image.startsWith(...) รันกับทุก container ใน spec.containers
policy ที่สอง และทำไมลำดับถึงสำคัญตอน policy สองตัว deny พร้อมกัน
หัวข้อที่มีชื่อว่า “policy ที่สอง และทำไมลำดับถึงสำคัญตอน policy สองตัว deny พร้อมกัน”namespace หนึ่งมี policy ผูกได้กี่ตัวก็ได้ request จะถูก reject ทันทีที่ policy ตัวใดตัวหนึ่งที่ bind ไว้ deny เพิ่ม policy ตัวที่สองที่บังคับให้ทุก container ต้องมี resource limits
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: require-resource-limitsspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] matchConditions: - name: "exclude-kube-system" expression: "object.metadata.namespace != 'kube-system'" validations: - expression: "object.spec.containers.all(c, has(c.resources.limits) && has(c.resources.limits.cpu) && has(c.resources.limits.memory))" message: "every container must set resources.limits.cpu and resources.limits.memory"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: require-resource-limits-bindingspec: policyName: require-resource-limits validationActions: ["Deny"] matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demomatchConditions เป็นตัวกรองระดับ CEL อีกชั้นหนึ่ง ซ้อนอยู่บน matchConstraints — ที่นี่มันข้าม kube-system ไปเลย แม้ matchConstraints จะ match resource type ของ request นั้นไปแล้วก็ตาม Pod ที่ไม่มี limits ตอน apply จริง
$ kubectl apply -f no-limits-pod.yamlThe pods "no-limits" is invalid: : ValidatingAdmissionPolicy 'require-resource-limits' with binding 'require-resource-limits-binding' denied request: every container must set resources.limits.cpu and resources.limits.memoryและ Pod ที่ผ่านทั้งสอง policy (registry ที่ถูกต้อง limits ครบ) ก็ถูก admit สะอาด ๆ ด้วย --dry-run=server
$ kubectl apply -f good-image-pod.yaml --dry-run=serverpod/good-image created (server dry run)CEL type-checker ของ API server เองก็รันตอนสร้าง policy และแสดง static-analysis warning จริง ให้เห็นบน policy object เลย
$ kubectl get validatingadmissionpolicy require-resource-limits -o yamlstatus: observedGeneration: 1 typeChecking: expressionWarnings: - fieldRef: spec.validations[0].expression warning: | /v1, Kind=Pod: ERROR: <input>:1:34: undefined field 'limits' | object.spec.containers.all(c, has(c.resources.limits) && has(c.resources.limits.cpu) && has(c.resources.limits.memory)) | .................................^expression ยังทำงานถูกต้องตอน runtime (ResourceRequirements.limits มีอยู่จริงในทุก container) แต่ type-checker เตือนไว้เพราะ schema แบบ static ของ CEL สำหรับ union ของทุก resource type ที่ expression อาจเจอตอนมอง object ไม่ได้การันตี field นั้นเสมอไป — ควรรู้ไว้ จะได้ไม่เข้าใจผิดว่า warning ของ type-checker คือ runtime failure
paramKind กับ paramRef: parameterize policy โดยไม่ต้องเขียนใหม่
หัวข้อที่มีชื่อว่า “paramKind กับ paramRef: parameterize policy โดยไม่ต้องเขียนใหม่”policy สามารถอ่านค่า threshold จาก object ของ Kubernetes จริง (paramKind) แทนที่จะ hardcode ไว้ใน CEL expression โดยผูกต่อ binding ผ่าน paramRef
apiVersion: v1kind: ConfigMapmetadata: name: registry-allowlist-param namespace: sec-demodata: allowedRegistry: "registry.example.com/"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: registry-allowlist-parameterizedspec: failurePolicy: Fail paramKind: apiVersion: v1 kind: ConfigMap matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] matchConditions: - name: "exclude-kube-system" expression: "object.metadata.namespace != 'kube-system'" validations: - expression: "object.spec.containers.all(c, c.image.startsWith(params.data.allowedRegistry))" message: "container image does not start with the registry named in the bound ConfigMap param"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: registry-allowlist-parameterized-bindingspec: policyName: registry-allowlist-parameterized validationActions: ["Deny"] paramRef: name: registry-allowlist-param namespace: sec-demo parameterNotFoundAction: Deny matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demoพิสูจน์ว่า ConfigMap ที่เป็น live ต่างหากที่ขับเคลื่อนการตัดสินใจ ไม่ใช่ตัว policy — แก้แค่ ConfigMap เท่านั้น ไม่แตะ ValidatingAdmissionPolicy/Binding เลย
$ kubectl apply -f wrong-registry-with-limits.yaml # image: docker.io/library/nginx:alpine, has limitsThe pods "wrong-registry-with-limits" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist-parameterized' with binding 'registry-allowlist-parameterized-binding' denied request: container image does not start with the registry named in the bound ConfigMap param
$ kubectl patch configmap registry-allowlist-param -n sec-demo --type merge -p '{"data":{"allowedRegistry":"docker.io/library/"}}'configmap/registry-allowlist-param patched
$ kubectl apply -f wrong-registry-with-limits.yaml --dry-run=serverThe pods "wrong-registry-with-limits" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist' with binding 'registry-allowlist-binding' denied request: container images must come from registry.example.comหลัง patch แล้ว policy ที่ parameterize หยุดค้านแล้ว (image ตอนนี้ตรงกับ allowedRegistry ของ ConfigMap) — request ยังถูก deny อยู่แค่เพราะ policy ตัวที่ hardcode ไว้ก่อนหน้า (registry-allowlist) ยัง bind กับ namespace เดียวกัน และไม่ได้เปลี่ยนอะไรเลย นี่คือหลักฐานจริงว่า paramRef อ่านค่า parameter object สดใหม่ทุก request โดยไม่ต้อง redeploy policy เลย
MutatingAdmissionPolicy: stable ตั้งแต่ Kubernetes 1.36
หัวข้อที่มีชื่อว่า “MutatingAdmissionPolicy: stable ตั้งแต่ Kubernetes 1.36”MutatingAdmissionPolicy เป็นคู่ mutating ของอันบน — model เดียวกันคือ CEL ใน API server แต่มัน patch object แทนที่จะแค่ accept หรือ reject ตาม เอกสาร Kubernetes มัน stable ตั้งแต่ Kubernetes v1.36 (เริ่มเป็น alpha ตั้งแต่ v1.30) เปิดใช้งานเป็น default ไม่ต้องมี feature gate เลย — ดังนั้นบน cluster 1.37 ของคอร์สนี้มันคือ GA เต็มตัว ไม่ใช่ของทดลอง
apiVersion: admissionregistration.k8s.io/v1kind: MutatingAdmissionPolicymetadata: name: add-default-labelspec: failurePolicy: Fail reinvocationPolicy: Never matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] mutations: - patchType: "ApplyConfiguration" applyConfiguration: expression: > Object{ metadata: Object.metadata{ labels: {"managed-by": "platform-team"} } }---apiVersion: admissionregistration.k8s.io/v1kind: MutatingAdmissionPolicyBindingmetadata: name: add-default-label-bindingspec: policyName: add-default-labelreinvocationPolicy เป็น field ที่ required — API server reject object ทันทีถ้าไม่มี
$ kubectl apply -f map-demo.yaml --dry-run=server # (before adding reinvocationPolicy)The MutatingAdmissionPolicy "add-default-label" is invalid: spec.reinvocationPolicy: Required valueพอใส่แล้ว apply จริง (ไม่ dry-run) แล้วสร้าง Pod ที่ไม่มี label managed-by ของตัวเองเลย
$ kubectl apply -f map-demo.yamlmutatingadmissionpolicy.admissionregistration.k8s.io/add-default-label created$ kubectl apply -f map-target-pod.yamlpod/map-target created$ kubectl get pod map-target -n sec-demo -o jsonpath='{.metadata.labels}'{"managed-by":"platform-team"}label นี้ไม่เคยอยู่ใน manifest เลย — policy injection มันตอน admission เหมือน mutating webhook เป๊ะ แต่ไม่ต้องเขียนหรือ deploy webhook server เลย
Kyverno กับ Gatekeeper: policy engine นอกเหนือจากที่ built-in มา
หัวข้อที่มีชื่อว่า “Kyverno กับ Gatekeeper: policy engine นอกเหนือจากที่ built-in มา”ValidatingAdmissionPolicy/MutatingAdmissionPolicy ครอบคลุมได้เยอะมากโดยไม่ต้องมี component เพิ่ม แต่ก็มี policy engine ที่โตเต็มที่แล้วและใช้กันแพร่หลายสองตัว สำหรับกรณีที่ต้องการมากกว่า CEL-ใน-API-server (อ้างอิงเอกสาร ไม่ได้ติดตั้งในบทนี้)
- Kyverno — policy engine แบบ cloud-native ที่ config ด้วย YAML สไตล์ Kubernetes (รองรับ CEL ด้วย) นอกเหนือจาก validation มันเพิ่ม mutation, generation ของ resource อัตโนมัติ (เช่นสร้าง
NetworkPolicydefault คู่กับทุก namespace ใหม่), background scanning ของ resource ที่มีอยู่แล้วใน cluster, และ image verification สำหรับความปลอดภัยของ supply chain — ทั้งหมดนี้อยู่นอกเหนือจากสิ่งที่ValidatingAdmissionPolicyเพียวๆ ทำได้ - OPA Gatekeeper — validating และ mutating webhook ที่ CNCF graduated แล้ว สร้างบน Open Policy Agent แบบ general-purpose ใช้
ConstraintTemplateแบบ CRD ที่เขียนด้วยภาษา policy Rego เอกสารของ Gatekeeper เองระบุว่า integrate กับValidatingAdmissionPolicyได้โดยตรง ทำให้ cluster หนึ่งผสมทั้งสองแบบได้
ทั้งสองตัวรันเป็น admission webhook (network hop เพิ่มที่ policy built-in ไม่ต้องมี) แลกกับประสบการณ์เขียน policy ที่รวยกว่าและ library ของ policy จากชุมชนที่มีอยู่แล้ว
automountServiceAccountToken: false พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “automountServiceAccountToken: false พิสูจน์แล้ว”โดย default ทุก Pod จะได้ token ของ ServiceAccount ถูก project เข้า volume ที่ mount ที่ /var/run/secrets/kubernetes.io/serviceaccount อัตโนมัติ — เป็น attack surface ที่ workload ที่ไม่เคยเรียก Kubernetes API เลยไม่ต้องการ การตั้ง automountServiceAccountToken: false บน Pod (หรือบน ServiceAccount เอง) เอา volume นั้นออกไปทั้งหมด ไม่ใช่แค่ทำให้เนื้อหาว่างเปล่า
$ kubectl get pod hardened -n psa-demo -o jsonpath='{.spec.volumes}' # (empty — no volumes at all)$ kubectl get pod hardened -n psa-demo -o jsonpath='{.spec.containers[0].volumeMounts}' # (empty)
$ kubectl get pod default-mount -n psa-demo -o jsonpath='{.spec.volumes}'[{"name":"kube-api-access-47t24","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}]$ kubectl get pod default-mount -n psa-demo -o jsonpath='{.spec.containers[0].volumeMounts}'[{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-47t24","readOnly":true}]hardened (ที่ตั้ง override) ไม่มี volume kube-api-access-* เลยใน spec; default-mount (ไม่ตั้ง override, namespace เดียวกัน) ได้ volume นั้นอัตโนมัติ พร้อม token แบบ bound และอายุสั้น (expirationSeconds: 3607 — ประมาณหนึ่งชั่วโมง และถูก re-issue ใหม่เรื่อย ๆ เมื่อใกล้หมดอายุ) แทนที่จะเป็น credential แบบอายุยืน นี่เป็นกลไก projected-token เดียวกับที่พูดถึงลึกกว่านี้ในบท RBAC and Service Accounts
hostNetwork กับ hostPID: อันตรายของ host namespace
หัวข้อที่มีชื่อว่า “hostNetwork กับ hostPID: อันตรายของ host namespace”field สองตัวระดับ Pod ทำให้ container ก้าวออกจากการแยกตัว (isolation) ของตัวเองบางส่วนหรือทั้งหมด และทั้งคู่ถูกบล็อกโดย Pod Security Standard ระดับ baseline (และเลยถูกบล็อกใน restricted ด้วย)
hostNetwork: true— Pod ใช้ network namespace ของ node ร่วมกันโดยตรง ไม่มี Pod IP ไม่มีการแยก network จากทุก process อื่นบน node นั้น และ port ใดที่ container bind ก็ถูก bind บน node เองเลย มองเห็นได้ทั่ว cluster บน IP ของ node นั้นhostPID: true— Pod ใช้ PID namespace ของ node ร่วมกัน container มองเห็น (และด้วย Linux capability ที่พอ อาจ signal หรือptrace) ทุก process ที่รันบน node รวมถึง container ของ Pod อื่นและ kubelet เองด้วย
ทั้งสองมี use case ที่ถูกต้องตามกฎหมายสำหรับ infrastructure ระดับ cluster (CNI plugin, monitoring agent ระดับ node) แต่เป็นเส้นทาง escalation ที่ร้ายแรงสำหรับอย่างอื่น — container ที่ถูก compromise พร้อม hostPID: true อาจแอบดูหรือแทรกแซง workload ที่ไม่เกี่ยวข้องบน node เดียวกันได้ ซึ่งนี่คือ blast radius ที่เพิ่มขึ้นแบบเดียวกับที่ baseline มีไว้เพื่อบล็อกโดย default
นอกเหนือจาก Pod: การควบคุมระดับ cluster และ network
หัวข้อที่มีชื่อว่า “นอกเหนือจาก Pod: การควบคุมระดับ cluster และ network”SecurityContext, Pod Security Admission, และ admission policy ทั้งหมดทำงานที่หรือใกล้ระดับ Pod แต่ security posture ที่แท้จริงต้องมีการควบคุมระดับ cluster ด้วย
Secret ถูก encode ด้วย base64 เท่านั้นโดย default ที่เป็นแค่ encoding ไม่ใช่ encryption ใครก็ตามที่มีสิทธิ์อ่าน Secret ผ่าน API (หรือเข้าถึงไฟล์ข้อมูลของ etcd โดยตรง) สามารถ decode ออกมาได้ง่ายมาก การควบคุมระดับ cluster ที่ปกป้อง Secret data at rest ได้จริงคือ etcd encryption at rest ซึ่ง API server จะ apply ให้อัตโนมัติ ทำให้ข้อมูลของ Secret ถูก encrypt ก่อนที่จะถูกเขียนลง storage ของ etcd
NetworkPolicy (พูดถึงในโมดูล Networking) ก็เป็นส่วนสำคัญของ security posture ด้วยเช่นกัน RBAC, Pod Security Admission, และ admission policy ควบคุมว่าตัวตนของ Pod ทำอะไรกับ API ได้บ้างและ Pod ถูก configure ยังไง แต่ทั้งหมดนี้ไม่ได้จำกัดว่า Pod ไหนคุยกับ Pod ไหนได้ผ่าน network นั่นเป็นหน้าที่ของ NetworkPolicy และ security story ที่สมบูรณ์ต้องมีทุกชั้นทำงานร่วมกัน
flowchart LR
submit["Pod submitted to namespace"] --> psa{"Complies with\nnamespace PSA enforce level?"}
ns["Namespace label:\npod-security.kubernetes.io/enforce"] --> psa
psa -- "no" --> rejected["Pod rejected"]
psa -- "yes" --> vap{"Passes every bound\nValidatingAdmissionPolicy?"}
vap -- "no" --> rejected
vap -- "yes" --> map["MutatingAdmissionPolicy\ncan still patch the object"]
map --> admitted["Pod admitted"] Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การควบคุมแต่ละอย่างในบทนี้ตอบคำถามคนละข้อ คนละชั้น และไม่เห็นกันเลย securityContext ตอบว่า “container ตัวนี้รันยังไงจริง ๆ” (UID, capability, filesystem) Pod Security Admission ตอบว่า “รูปร่างของ Pod ตัวนี้ตรงกับมาตรฐาน built-in ที่ตายตัวของ namespace นี้ไหม” — มันไม่สามารถแสดงกฎ custom แบบ “image ต้องมาจาก registry ของเราเท่านั้น” ได้ ValidatingAdmissionPolicy/MutatingAdmissionPolicy มาเติมช่องว่างนั้นพอดี คือ CEL ที่เขียนเองได้ ประเมินใน API server สำหรับกฎที่ไม่มีใครคาดไว้ตอนออกแบบ Pod Security Admission สามระดับตายตัว Kyverno/Gatekeeper แลก network hop ของ webhook กับภาษา policy ที่รวยกว่า เวลา CEL อย่างเดียวไม่พอ ไม่มีอันไหนมองที่ network เลย นี่คือเหตุผลที่ NetworkPolicy เป็นชั้นแยกต่างหากทั้งหมด และไม่มีอันไหนมองสิ่งที่นั่งอยู่ใน etcd อยู่แล้ว นี่คือเหตุผลที่ encryption at rest แยกออกไปอีก
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
securityContext hardening (runAsNonRoot, drop capabilities, seccompProfile) | พิสูจน์แล้วด้านบน: ลดสิ่งที่ container ที่ถูก compromise ทำได้ ไม่ต้องมี component เพิ่ม | ต่อ Pod ลืมง่ายถ้ามี manifest เยอะ — ไม่มีอะไรบังคับทั่ว cluster ด้วยตัวมันเอง |
Pod Security Admission enforce=restricted | พิสูจน์แล้วด้านบน: baseline ทั่ว namespace แบบ built-in ไม่ต้องเขียน policy เลย | กฎตายตัว — แสดง “image ต้องมาจาก registry ของเรา” หรือกฎ business custom ไม่ได้ |
ValidatingAdmissionPolicy/MutatingAdmissionPolicy | พิสูจน์แล้วด้านบน: กฎ CEL ที่เขียนเองได้ ทำงานใน process (ไม่มี webhook latency) paramKind ทำให้ปรับ config สดได้โดยไม่ต้อง redeploy | CEL มี learning curve; แต่ละ policy ครอบคลุมแค่ที่ matchConstraints และ expression ระบุไว้ชัด ๆ |
| Kyverno / Gatekeeper | เขียน policy ได้รวยกว่า (mutate/generate/background-scan สำหรับ Kyverno, Rego สำหรับ Gatekeeper) มี library policy จากชุมชนใหญ่ | component webhook เพิ่มที่ต้องรัน อัพเกรด และดูแล availability |
automountServiceAccountToken: false | พิสูจน์แล้วด้านบน: เอา volume token ออกไปทั้งหมดสำหรับ workload ที่ไม่เคยเรียก API | ต้องเปิดกลับมาชัด ๆ ทันทีที่ workload ต้องใช้ API จริง ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Pod Security Admission บังคับกฎ custom แบบ ‘image ต้องมาจาก registry ภายในของเรา’ ได้” พิสูจน์แล้วด้านบน: PSA มีแค่สามระดับตายตัว (
privileged/baseline/restricted) registry allowlist ต้องใช้ValidatingAdmissionPolicy(หรือ Kyverno/Gatekeeper) แทน - “ตั้ง
limitsโดยไม่มีrequestsบน container ก็เพียงพอสำหรับกฎ admission ที่บอกให้ ‘ตั้ง resources’” พิสูจน์แล้วด้านบนด้วย CEL type-checker warning จริงและการ deny จริง:require-resource-limitsเช็คresources.limitsไม่ใช่ requests แต่ policy ที่อิง resources ต้องตัดสินใจชัด ๆ ว่าหมายถึงอันไหน — อย่าสมมติว่า “resources” หมายถึงทั้งสองอย่าง - “
ValidatingAdmissionPolicyที่ตั้งparamKindยังรันกฎอยู่แม้ binding ไม่เคยตั้งparamRef” ไม่ได้ทดสอบแบบลวก ๆ — แต่พิสูจน์แล้วด้านบนด้วยการแก้สด: parameter จะถูกอ่านก็ต่อเมื่อparamRefของ binding ชี้ไปที่มันจริง ๆ และ CEL expression จะเห็นparamsก็ต่อเมื่อ link นั้นมีอยู่; policy ที่เพิ่งสร้างกับ binding ที่ apply ในจังหวะเดียวกันก็อาจยังไม่สะท้อนผลทันที ซึ่งเป็นเหตุผลที่ demo ด้านบนหยุดพักสั้น ๆ ระหว่างสร้าง policy กับทดสอบ - “โหมด
warnกับauditของ PSA สุดท้ายก็บล็อก Pod เหมือนกัน แค่ช้ากว่า” พิสูจน์แล้วด้านบน:baseline-okยังถูกcreatedแม้จะพิมพ์รายการละเมิดของrestrictedเต็ม ๆ —warn/auditไม่บล็อกเลย ไม่ว่าการละเมิดที่ระบุจะรุนแรงแค่ไหน - “
automountServiceAccountToken: falseแค่ทำให้ไฟล์ token ว่างเปล่า” พิสูจน์แล้วด้านบน: volumekube-api-access-*ทั้งก้อนและ mount ของมันหายไปจากspec.volumes/spec.containers[].volumeMountsเลย ไม่ใช่แค่ทำให้ว่างเปล่า
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review Pod spec, namespace, หรือ admission policy ที่ AI สร้าง ให้เช็ค
privileged: trueที่ใช้ “แก้” permission error แทนที่จะหา capability หรือ UID เฉพาะที่ container ต้องการจริง ๆ — พิสูจน์แล้วด้านบนrestrictedระบุ field ที่ต้องการชัดเจน วิธีแก้แทบไม่ใช่privileged: trueเลย- ไม่มี
securityContextเลยบน Pod ที่จะไปอยู่ใน namespace ที่ enforce เป็นrestricted— พิสูจน์แล้วด้านบน API server reject ทันทีและแสดง field ที่ขาดทุกตัว จึงพังทันทีไม่ใช่พังแบบเงียบ ๆ ValidatingAdmissionPolicyที่ตั้งparamKindแต่ binding ของมันขาดparamRef— policy จะไม่อ่าน parameter object เลยในกรณีนั้น เป็นช่องว่าง copy-paste แบบคลาสสิกระหว่าง policy กับ binding ของมันhostNetwork: trueหรือhostPID: trueที่เพิ่มเข้ามาเพื่อ “ให้ debug ง่ายขึ้น” แล้วไม่เคยเอาออก — พิสูจน์แล้วด้านบน ทั้งสองขยาย blast radius อย่างมีนัยสำคัญและถูกบล็อกโดยbaselineดังนั้น workload ที่ต้องการมันจะรันใน namespace แบบbaseline/restrictedไม่ได้เลย- ServiceAccount ของ workload ที่ผูกกับ
cluster-admin“เพื่อเลี่ยง RBAC error” แทนที่จะเป็น Role ที่ scope แคบ — พูดถึงลึกกว่านี้ในบท RBAC and Service Accounts แต่สัญชาตญาณเดียวกันที่เอื้อมไปหาprivileged: trueที่นี่ ก็มักเอื้อมไปหาcluster-adminที่นั่น
🐛 หา bug ในโค้ดนี้
apiVersion: v1kind: Podmetadata:name: web-appnamespace: psa-demospec:containers: - name: web image: web-app:3.2.0 securityContext: runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"]เฉลย
Pod นี้ขาด seccompProfile — ภายใต้ namespace ที่ตั้ง pod-security.kubernetes.io/enforce: restricted คำตอบจริงจาก API server สำหรับรูปร่างแบบนี้เป๊ะ ๆ คือ violates PodSecurity "restricted:latest": seccompProfile (pod or container "web" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost") พิสูจน์แล้วด้านบน ง่ายมากที่จะคิดว่า “ตั้ง runAsNonRoot, drop capabilities, บล็อก privilege escalation แล้ว ก็ครอบคลุม SecurityContext hardening แล้ว” — แต่ restricted เช็ค field ตายตัวห้าตัว และ seccompProfile เป็นหนึ่งในนั้น manifest ที่ AI สร้างซึ่ง copy สี่ตัวแรกมาแต่ลืมตัวที่ห้า ก็ถูก reject เหมือนกับตัวที่ไม่มี securityContext เลย