OCI Kubernetes Engine: Production Cluster Hardening and Workload Identity

Getting an OKE cluster running takes 20 minutes. Getting it production-ready takes considerably longer because the defaults that make it easy to start are not the same defaults that make it safe to run. The API server is publicly accessible by default. Node pools share subnets with system workloads. Pods can mount service account tokens with overly broad scope. None of this belongs in production.

This post covers a production OKE cluster with Terraform: private API server endpoint, separated node pool topology by workload type, NSG isolation between tiers, OCI Workload Identity for pod-level IAM access without stored credentials, Pod Security Admission enforcement, and cluster autoscaler configuration.

Step 1: Private Cluster with Separated Node Pools

resource "oci_containerengine_cluster" "production" {
  compartment_id     = var.compartment_id
  kubernetes_version = "v1.30.1"
  name               = "production-cluster"
  vcn_id             = var.vcn_id

  endpoint_config {
    is_public_ip_enabled = false
    subnet_id            = var.api_endpoint_subnet_id
    nsg_ids              = [oci_core_network_security_group.api_endpoint_nsg.id]
  }

  options {
    kubernetes_network_config {
      pods_cidr     = "10.244.0.0/16"
      services_cidr = "10.96.0.0/16"
    }
    service_lb_subnet_ids = [var.load_balancer_subnet_id]
    add_ons {
      is_kubernetes_dashboard_enabled = false
      is_tiller_enabled               = false
    }
  }

  image_policy_config {
    is_policy_enabled = true
    key_details { kms_key_id = var.image_signing_key_id }
  }

  cluster_pod_network_options { cni_type = "OCI_VCN_IP_NATIVE" }
}

# System node pool - kube-system workloads only
resource "oci_containerengine_node_pool" "system" {
  cluster_id         = oci_containerengine_cluster.production.id
  compartment_id     = var.compartment_id
  kubernetes_version = "v1.30.1"
  name               = "system-pool"

  node_config_details {
    size    = 3
    nsg_ids = [oci_core_network_security_group.system_nodes_nsg.id]
    placement_configs {
      availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
      subnet_id           = var.system_node_subnet_id
      fault_domains       = ["FAULT-DOMAIN-1", "FAULT-DOMAIN-2", "FAULT-DOMAIN-3"]
    }
  }

  node_shape = "VM.Standard.E4.Flex"
  node_shape_config { ocpus = 4; memory_in_gbs = 32 }
  node_source_details { image_id = data.oci_core_images.ol8.images[0].id; source_type = "IMAGE"; boot_volume_size_in_gbs = 100 }
  initial_node_labels { key = "node-role"; value = "system" }
}

# Application node pool - production workloads
resource "oci_containerengine_node_pool" "application" {
  cluster_id         = oci_containerengine_cluster.production.id
  compartment_id     = var.compartment_id
  kubernetes_version = "v1.30.1"
  name               = "application-pool"

  node_config_details {
    size    = 3
    nsg_ids = [oci_core_network_security_group.app_nodes_nsg.id]
    placement_configs {
      availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
      subnet_id           = var.app_node_subnet_id
      fault_domains       = ["FAULT-DOMAIN-1", "FAULT-DOMAIN-2", "FAULT-DOMAIN-3"]
    }
  }

  node_shape = "VM.Standard.E4.Flex"
  node_shape_config { ocpus = 8; memory_in_gbs = 64 }
  node_source_details { image_id = data.oci_core_images.ol8.images[0].id; source_type = "IMAGE"; boot_volume_size_in_gbs = 100 }
  initial_node_labels { key = "node-role"; value = "application" }
}

Step 2: OCI Workload Identity

OCI Workload Identity is the OKE equivalent of AWS IRSA. Pods authenticate to OCI services using their Kubernetes service account identity, mapped to OCI IAM dynamic groups. No credentials in secrets, no long-lived API keys mounted into pods.

resource "oci_identity_dynamic_group" "orders_pods" {
  compartment_id = var.tenancy_ocid
  name           = "orders-api-pods"
  description    = "OKE pods running as the orders-api service account"
  matching_rule  = "All {resource.type = 'workloadIdentities', resource.compartment.id = '${var.compartment_id}'}"
}

resource "oci_identity_policy" "orders_pod_policy" {
  compartment_id = var.compartment_id
  name           = "orders-api-pod-policy"
  statements = [
    "Allow dynamic-group orders-api-pods to use secret-family in compartment id ${var.compartment_id} where target.secret.name = 'orders-api-*'",
    "Allow dynamic-group orders-api-pods to use queues in compartment id ${var.compartment_id} where target.queue.name = 'orders-*'"
  ]
}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api
  namespace: orders
  annotations:
    oci.oraclecloud.com/workload-identity-enabled: "true"
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      serviceAccountName: orders-api
      containers:
        - name: orders-api
          image: me-jeddah-1.ocir.io/namespace/orders-api:1.2.3
          env:
            - name: OCI_RESOURCE_PRINCIPAL_VERSION
              value: "2.2"
            - name: OCI_RESOURCE_PRINCIPAL_REGION
              value: me-jeddah-1
import oci, os

# Pod authenticates automatically via Workload Identity - no credentials needed
signer      = oci.auth.signers.EphemeralResourcePrincipalSigner()
vaults      = oci.vault.VaultsClient(config={}, signer=signer)

secret_bundle = vaults.get_secret_bundle_by_name(
    secret_name="orders-api-db-password",
    vault_id=os.environ["VAULT_ID"]
).data

Step 3: Pod Security Admission

apiVersion: v1
kind: Namespace
metadata:
  name: orders
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: orders-api
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]

Step 4: Cluster Autoscaler

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  template:
    spec:
      containers:
        - name: cluster-autoscaler
          image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.0
          command:
            - ./cluster-autoscaler
            - --cloud-provider=oci
            - --nodes=2:20:ocid1.nodepool.oc1..your-app-pool-ocid
            - --scale-down-delay-after-add=5m
            - --scale-down-unneeded-time=10m
            - --scale-down-utilization-threshold=0.5
            - --expander=least-waste
            - --skip-nodes-with-local-storage=false
            - --v=4

Set --scale-down-delay-after-add=5m to prevent the autoscaler from removing nodes immediately after adding them. Without this, a traffic spike triggers a scale-out followed by an immediate scale-in before the new nodes are fully utilized, causing unnecessary churn and wasting provisioning cost.

Use --expander=least-waste for workloads with heterogeneous resource requests. It selects the node group that wastes the least CPU and memory when adding a node, keeping cluster utilization higher compared to the default random expander.

Step 5: NetworkPolicy for Namespace Isolation

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: orders-namespace-isolation
  namespace: orders
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: orders
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 8080
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: orders
    # Allow DNS
    - ports:
        - port: 53
          protocol: UDP
    # Allow OCI API endpoints
    - to:
        - ipBlock:
            cidr: 10.0.0.0/8

Operational Notes

The image policy config with a KMS signing key enforces that only images signed with your key can run in the cluster. Any unsigned image is rejected at admission. Build image signing into your OCI DevOps pipeline after push and before deployment, and distribute the verification public key to the cluster via the image policy configuration.

System and application node pools should be in different subnets with different NSGs. This allows you to apply more restrictive NSG rules to the application pool and control which node pools have egress access to specific OCI services. Mixing system and application workloads on the same nodes makes it harder to apply least-privilege networking at the node level.

Regards,
Osama

#OCI #OracleCloud #OKE #Kubernetes #Terraform #CloudSecurity #DevOps #PlatformEngineering #TechBlog #Oracle #CloudNative #K8s #IaC #WorkloadIdentity #PodSecurity #ClusterAutoscaler #OracleCloudInfrastructure #ContainerSecurity #DevSecOps #ZeroTrust

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.