We closed the previous lesson with an uncomfortable observation: we have given the Rutas Norte components their configuration, their credentials and their resources, but we have not given them an identity. Right now, the pods of web-store, bookings-api, bookings-postgres, redis-cache and notifications-worker are all using the same default ServiceAccount of their namespace, with a Kubernetes API access token mounted inside that none of them needs and that is the first thing an attacker who managed to run code in one of those containers would go looking for. This lesson fixes it: you will understand what a ServiceAccount is and how it differs from a user, how modern projected tokens work, why automountServiceAccountToken: false should be your default choice, and you will talk to the API from inside a pod with curl to see with your own eyes both an authorised response and a well-deserved 403 Forbidden.
Contents
- The identity of a workload
- Users versus ServiceAccounts
- The
defaultServiceAccount and why it must not be used - Creating dedicated ServiceAccounts for Rutas Norte
- The projected token: how it has changed
- What lives in
/var/run/secrets/kubernetes.io/serviceaccount/ automountServiceAccountToken: false- Talking to the API from inside a pod
- The
403 Forbiddenand what it means imagePullSecretson the ServiceAccount- The ServiceAccount says who you are, RBAC says what you can do
- Identity federation with the clouds
- The identity of a workload
Every request reaching the kube-apiserver goes through three phases you already know from module 1:
flowchart LR
A["Request"] --> B["AUTHENTICATION<br/>Who are you?"]
B --> C["AUTHORIZATION (RBAC)<br/>Can you do this?"]
C --> D["ADMISSION<br/>LimitRanger, ResourceQuota..."]
D --> E["etcd"]
B -.->|"not identified"| F["401 Unauthorized"]
C -.->|"identified but not allowed"| G["403 Forbidden"]
When you run kubectl get pods, the first phase is resolved with the certificate or the token in your kubeconfig. But what about when the one asking is a pod?
The cases where a pod needs to talk to the API are more common than they seem:
| Who | What for |
|---|---|
| An Ingress controller | Reading Ingress and Service objects to configure its routing |
| Prometheus | Discovering which pods to scrape (07-03) |
| cert-manager | Creating and updating TLS Secrets (04-05) |
| An operator | Reconciling its custom resources (06-07) |
| Argo CD | Applying manifests from Git (10-05) |
| An application of yours | Reading a ConfigMap live, querying its own replicas, coordinating a leader election |
And in Rutas Norte there is a specific case coming in module 6: occupancy-reports, the nightly task, will have to query the API to find out how many bookings-api replicas were active each hour and correlate that with occupancy. It needs an identity and it needs permissions.
But the main reason for studying this is not the pods that do talk to the API: it is the five that do not and yet carry a valid credential around without needing it. That is free attack surface.
- Users versus ServiceAccounts
Kubernetes distinguishes two kinds of identity, and the difference is fundamental:
| Users | ServiceAccounts | |
|---|---|---|
| For whom | People: administrators, developers | Processes: pods, controllers |
| Is it a Kubernetes object? | No. There is no kind: User |
Yes: kind: ServiceAccount |
| Who manages them | An external system: certificates, OIDC, LDAP, cloud IAM | Kubernetes, with kubectl create sa |
kubectl get |
Impossible | kubectl get serviceaccounts |
| Scope | The whole cluster | Namespaced |
| Name in RBAC | [email protected] |
system:serviceaccount:<ns>:<name> |
| How they authenticate | Client certificate, OIDC token | JWT signed by the cluster |
| Life cycle | External | Tied to the namespace |
The first point always comes as a surprise: Kubernetes has no users. There is no user database, and no command to create one. When your kubeconfig uses a client certificate, Kubernetes reads the CN (Common Name) field of the certificate and takes it as the user name, trusting that the cluster's certificate authority only signs legitimate certificates. With OIDC, it delegates to the identity provider. Managing people is, deliberately, somebody else's problem.
ServiceAccounts, by contrast, are first-class objects:
There is only one, the default, and all our pods are using it. Notice the SECRETS 0: it is the sign that we are on modern Kubernetes, where ServiceAccounts no longer carry an associated Secret with a permanent token. We will come back to that in section 5.
The full name of a ServiceAccount in the authorization system has this shape:
That is the exact identifier you will use in the RoleBindings of RBAC, and also the one that appears in the audit logs. In addition, every ServiceAccount automatically belongs to the group system:serviceaccounts and to its namespace's group, system:serviceaccounts:rutas-norte-pro, which lets you grant permissions to all of an environment's accounts at once (something that is almost never a good idea, but worth knowing exists).
- The
default ServiceAccount and why it must not be used
default ServiceAccount and why it must not be usedEvery namespace gets a ServiceAccount called default the moment it is created. If a pod specifies none, it is assigned that one.
kubectl get pod bookings-api-7f4b8c9d6-2xkqp -n rutas-norte-pro \
-o jsonpath='{.spec.serviceAccountName}'; echoFive reasons not to use it:
Reason 1: it is shared. Every pod in the namespace has the same identity. If tomorrow occupancy-reports needs permission to read Deployments and you grant it to default, you are also granting it to web-store, to redis-cache and to bookings-postgres. It is the opposite of least privilege.
Reason 2: it destroys traceability. In the audit log every request shows up as system:serviceaccount:rutas-norte-pro:default. Faced with a suspicious access, there is no way of knowing which component made it.
Reason 3: it prevents selective revocation. If notifications-worker is compromised, you want to withdraw its permissions immediately. With the default, withdrawing them means withdrawing them from the whole namespace.
Reason 4: it mounts a token almost nobody needs. By default, automountServiceAccountToken is true, so all our pods carry a valid API token inside. Check it:
kubectl exec -n rutas-norte-pro deploy/web-store -- ls /var/run/secrets/kubernetes.io/serviceaccount/An nginx server that only serves static files has a cluster API access credential sitting there. If someone finds an arbitrary file read vulnerability in that application (hardly exotic), the first thing they will do is read that token.
Reason 5: the temptation to grant it permissions. It is so convenient that, in a rush, somebody ends up binding the default to a powerful ClusterRole "just to make it work". And that never gets reverted.
The rule is simple:
One ServiceAccount per component, and
automountServiceAccountToken: falseon all of them except those that genuinely talk to the API.
- Creating dedicated ServiceAccounts for Rutas Norte
Let us start with the object. It is one of the simplest in Kubernetes:
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
automountServiceAccountToken: false # bookings-api does NOT talk to the APIOr imperatively, to generate the YAML:
apiVersion: v1
kind: ServiceAccount
metadata:
creationTimestamp: null
name: bookings-api
namespace: rutas-norte-proThe complete plan for Rutas Norte, with the reasoned decision for each component:
| Component | ServiceAccount | Talks to the API? | automountServiceAccountToken |
|---|---|---|---|
web-store |
web-store |
No | false |
bookings-api |
bookings-api |
No | false |
bookings-postgres |
bookings-postgres |
No | false |
redis-cache |
redis-cache |
No | false |
notifications-worker |
notifications-worker |
No | false |
occupancy-reports |
occupancy-reports |
Yes (module 6) | true |
Five out of six need no token. That is the normal outcome: the vast majority of business applications do not talk to the Kubernetes API. And even so, the default configuration mounts one for all of them.
So why create a ServiceAccount for those that will not use it, if they are not going to talk to the API? For three reasons:
- Traceability: if one day one of them makes a request, you will know which one.
imagePullSecrets: the ServiceAccount is the right place to declare them (section 10).- Preparation: when tomorrow
bookings-apineeds a specific permission, the identity already exists and there is no need to touch the Deployment.
The six objects, in k8s/base/serviceaccounts.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-store
namespace: rutas-norte-pro
labels: {app: web-store, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
- name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels: {app: bookings-api, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
- name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
labels: {app: bookings-postgres, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: redis-cache
namespace: rutas-norte-pro
labels: {app: redis-cache, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: notifications-worker
namespace: rutas-norte-pro
labels: {app: notifications-worker, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
- name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: occupancy-reports
namespace: rutas-norte-pro
labels: {app: occupancy-reports, app.kubernetes.io/part-of: rutas-norte, environment: pro}
# This one DOES need the token: it will query the API in module 6.
automountServiceAccountToken: true
imagePullSecrets:
- name: registry-rutasnorteAnd the assignment on the pod, with the serviceAccountName field:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
replicas: 4
selector:
matchLabels:
app: bookings-api
environment: pro
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
serviceAccountName: bookings-api # <-- the pod's identity
automountServiceAccountToken: false # belt and braces
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0A historical detail you will see in old manifests: there is also a serviceAccount field (without Name). It has been deprecated for many versions and is kept only for compatibility. Always use serviceAccountName.
We apply and verify:
kubectl apply -f k8s/base/serviceaccounts.yaml
kubectl rollout restart deploy -n rutas-norte-pro
kubectl get pods -n rutas-norte-pro \
-o custom-columns='POD:.metadata.name,SA:.spec.serviceAccountName'serviceaccount/web-store created
serviceaccount/bookings-api created
serviceaccount/bookings-postgres created
serviceaccount/redis-cache created
serviceaccount/notifications-worker created
serviceaccount/occupancy-reports created
POD SA
bookings-api-8b5c7d9e2-4mkqp bookings-api
bookings-api-8b5c7d9e2-9wnzr bookings-api
bookings-postgres-6e9g7c0d5-l3nqx bookings-postgres
redis-cache-7d0e9g8c6-wo5ry redis-cache
web-store-6c8d0g5e9-i8qol web-store
notifications-worker-7g8e0d9c5-mn4qu notifications-workerEach component with its own identity. And the token has disappeared from where it was not needed:
kubectl exec -n rutas-norte-pro deploy/web-store -- ls /var/run/secrets/kubernetes.io/serviceaccount/ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory
command terminated with exit code 1That error is exactly the desired outcome. There is no credential to steal.
- The projected token: how it has changed
This part has a history and it is important to know it, because you will come across manifests and tutorials from the earlier era.
Before Kubernetes 1.24: permanent tokens in Secrets
When a ServiceAccount was created, a controller automatically generated a Secret of type kubernetes.io/service-account-token with a JWT inside. That token:
- Never expired.
- Was not tied to any pod: anyone who copied it could use it from anywhere, even from outside the cluster.
- Was stored in etcd, so it was in the backups.
- Could not be revoked without deleting the Secret and the ServiceAccount.
It was, in practice, an eternal password in every namespace. If a token leaked into a log, a dump or a repository, it was still valid months later.
From 1.24, and mandatory since 1.25: the TokenRequest API
The current mechanism is completely different and much better:
sequenceDiagram
participant K as kubelet
participant A as apiserver (TokenRequest)
participant P as Pod
K->>A: TokenRequest for the SA bookings-api,<br/>limited audience and lifetime,<br/>bound to THIS pod
A-->>K: signed JWT (expires in 1 hour)
K->>P: writes it to tmpfs<br/>/var/run/secrets/.../token
Note over K,P: At 48 minutes (80% of the lifetime)
K->>A: renewal
A-->>K: new JWT
K->>P: replaces the file
Note over P: The application must RE-READ the file
The properties of the modern token:
| Property | Old token (Secret) | Projected token (TokenRequest) |
|---|---|---|
| Expiry | Never | 1 hour by default |
| Rotation | Manual | Automatic, by the kubelet at 80% of the lifetime |
| Bound to the pod | No | Yes: it contains the pod UID |
| When the pod is deleted | Still valid | Invalidated |
Audience (aud) |
Generic | Restrictable to one recipient |
| Stored in etcd | Yes | No |
| Where it lives | Secret + volume | Only in the pod's tmpfs |
Let us look at the real contents of a token. Inside a pod that does have one mounted:
kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
'cut -d. -f2 /var/run/secrets/kubernetes.io/serviceaccount/token | base64 -d 2>/dev/null'{
"aud": ["https://kubernetes.default.svc.cluster.local"],
"exp": 1785703921,
"iat": 1785700321,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "b41e9c02-7f3a-4d18-9a6e-2c85f0d31447",
"kubernetes.io": {
"namespace": "rutas-norte-pro",
"node": {"name": "rutas-norte-m02", "uid": "d3a1..."},
"pod": {"name": "occupancy-reports-28912440-x7mzq", "uid": "7f2c..."},
"serviceaccount": {"name": "occupancy-reports", "uid": "1e8b..."}
},
"nbf": 1785700321,
"sub": "system:serviceaccount:rutas-norte-pro:occupancy-reports"
}It is a standard JWT. What matters:
subis the identifier RBAC will use.exp-iat= 3600 seconds: a one-hour lifetime.kubernetes.io.podties the token to a specific pod. If that pod is deleted, the apiserver rejects the token even if it has not expired.audlimits who it is valid for.
An important practical warning: if your application reads the token once at start-up and keeps it in memory, it will stop working in an hour. The official client libraries (client-go, the Python kubernetes client, and so on) re-read the file automatically. If you talk to the API with curl or with a generic HTTP library, you have to re-read the file on every request. It is a source of baffling 401 errors that appear exactly one hour after the deployment.
The projection can be customised with an explicit volume:
volumes:
- name: api-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # minimum 600
audience: api.rutasnorte.example # for one specific recipientThe audience field is especially useful for an advanced pattern: issuing a token that is only valid for one specific external service, so that if it leaks it cannot be used to talk to the Kubernetes API.
And if you ever need a permanent token (integrating an external tool that cannot rotate), it has to be created explicitly:
apiVersion: v1
kind: Secret
metadata:
name: occupancy-reports-permanent-token
namespace: rutas-norte-pro
annotations:
kubernetes.io/service-account.name: occupancy-reports
type: kubernetes.io/service-account-tokenAvoid it whenever you can. It reintroduces every problem of the old model. For one-off cases, the correct alternative is to request a short-lived token:
- What lives in
/var/run/secrets/kubernetes.io/serviceaccount/
/var/run/secrets/kubernetes.io/serviceaccount/When the token is mounted, the directory contains three files:
kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
'ls -la /var/run/secrets/kubernetes.io/serviceaccount/ && mount | grep serviceaccount'total 0
drwxrwxrwt 3 root root 140 Aug 5 22:03 .
drwxr-xr-x 3 root root 60 Aug 5 22:03 ..
lrwxrwxrwx 1 root root 13 Aug 5 22:03 ca.crt -> ..data/ca.crt
lrwxrwxrwx 1 root root 16 Aug 5 22:03 namespace -> ..data/namespace
lrwxrwxrwx 1 root root 12 Aug 5 22:03 token -> ..data/token
tmpfs on /var/run/secrets/kubernetes.io/serviceaccount type tmpfs (ro,relatime)You recognise two things from earlier lessons: the chain of links to ..data that makes atomic updates possible (here it is what enables token rotation) and the tmpfs, that is, RAM.
| File | Contents | What it is for |
|---|---|---|
token |
The signed JWT | The Authorization: Bearer <token> header |
ca.crt |
The cluster CA certificate | Verifying the apiserver's TLS certificate |
namespace |
The namespace name, in plain text | So the application knows where it lives without the Downward API |
kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
'cat /var/run/secrets/kubernetes.io/serviceaccount/namespace; echo; \
head -1 /var/run/secrets/kubernetes.io/serviceaccount/ca.crt'The ca.crt is the piece people usually forget. Without it, a client talking to https://kubernetes.default.svc cannot validate the apiserver's certificate and has to fall back on --insecure, which is exactly what you must not do: with no validation, an attacker with access to the pod's network could impersonate the apiserver and capture the token.
Together with the KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT variables that Kubernetes injects into every pod (you saw them in the environment variables lesson), these three files are everything needed to talk to the API. It is precisely what the client libraries call in-cluster configuration (InClusterConfig).
automountServiceAccountToken: false
automountServiceAccountToken: falseThis field can be set in two places, and the precedence matters:
# On the ServiceAccount: it affects ALL the pods that use it
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-api
namespace: rutas-norte-pro
automountServiceAccountToken: false# On the pod: it affects only THAT pod and it BEATS the ServiceAccount
spec:
serviceAccountName: bookings-api
automountServiceAccountToken: false| ServiceAccount | Pod | Result |
|---|---|---|
| (unspecified) | (unspecified) | Mounted (the default) |
false |
(unspecified) | Not mounted |
true |
(unspecified) | Mounted |
false |
true |
Mounted: the pod wins |
true |
false |
Not mounted: the pod wins |
The pod always has the last word. That enables the recommended pattern: set false on the ServiceAccount as a safety net, and an explicit true only on the specific pod that genuinely needs it.
Why this matters so much, in security terms: if an attacker manages to run code in a container — through an application vulnerability, a compromised dependency or an injection — the first thing an automated exploitation script does is read that token and probe what permissions it has. It is a standard step in any Kubernetes privilege escalation tool.
Let us compare the attack surface:
| With the token mounted | Without the token mounted | |
|---|---|---|
| Can read the token | Yes | It does not exist |
| Can query the API | Yes, with the SA's permissions | No, it cannot even identify itself |
| Can enumerate the cluster | Depends on RBAC | No |
| If RBAC is misconfigured | Privilege escalation | Nothing to escalate |
With no token, even a careless RBAC configuration is neutralised for that pod: there is no credential with which to exercise it.
Checking the state across the whole cluster, which is an audit worth doing:
kubectl get pods -A -o json | jq -r '
.items[] |
select(.spec.automountServiceAccountToken != false) |
"\(.metadata.namespace)/\(.metadata.name) -> \(.spec.serviceAccountName)"' | head -10kube-system/coredns-7db6d8ff4d-2vqxn -> coredns
kube-system/kube-proxy-8xvmk -> kube-proxy
rutas-norte-pro/occupancy-reports-28912440-x7mzq -> occupancy-reportsThe system ones legitimately need it. From Rutas Norte, only occupancy-reports. That is the goal.
An operational note: if you add automountServiceAccountToken: false to a ServiceAccount, the pods already running keep their token. The mount is decided when the pod is created. They have to be recreated:
- Talking to the API from inside a pod
We are going to do it by hand to understand what the client libraries do internally. We will use occupancy-reports, the only Rutas Norte component that genuinely needs to talk to the API.
Step 1: a pod with the ServiceAccount and the token mounted.
apiVersion: v1
kind: Pod
metadata:
name: api-client
namespace: rutas-norte-pro
labels:
app: occupancy-reports
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
serviceAccountName: occupancy-reports
automountServiceAccountToken: true
restartPolicy: Never
containers:
- name: client
image: curlimages/curl:8.10.1
command: ["sleep", "3600"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128MiStep 2: prepare the variables inside the pod.
# Already inside the container
SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token)
NS=$(cat $SA/namespace)
APISERVER=https://kubernetes.default.svc
echo "Namespace: $NS"
echo "API: $APISERVER"
echo "Token: ${TOKEN:0:40}..."Namespace: rutas-norte-pro
API: https://kubernetes.default.svc
Token: eyJhbGciOiJSUzI1NiIsImtpZCI6Ilp3RTFvSm...kubernetes.default.svc is the apiserver's Service, which exists in the default namespace of every cluster. Any pod can resolve it via DNS, as you saw in module 2.
Step 3: the first request, to the version endpoint (which requires no permissions).
{
"major": "1",
"minor": "30",
"gitVersion": "v1.30.4",
"goVersion": "go1.22.5",
"platform": "linux/amd64"
}It worked. The three elements of the request:
--cacert $SA/ca.crt: verifies that the server really is the cluster's apiserver. Never use-kor--insecure.Authorization: Bearer $TOKEN: the authentication. It is the standard JWT mechanism.- The internal Service URL, resolved by DNS.
Step 4: ask who I am. This endpoint is extremely useful for debugging:
curl -s --cacert $SA/ca.crt \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-X POST $APISERVER/apis/authentication.k8s.io/v1/selfsubjectreviews \
-d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}'{
"kind": "SelfSubjectReview",
"apiVersion": "authentication.k8s.io/v1",
"status": {
"userInfo": {
"username": "system:serviceaccount:rutas-norte-pro:occupancy-reports",
"uid": "1e8b4c73-9d02-4a5f-8e31-7b6c0f294a15",
"groups": [
"system:serviceaccounts",
"system:serviceaccounts:rutas-norte-pro",
"system:authenticated"
]
}
}
}There is the complete identity: the username in the format we anticipated and the three automatic groups. From outside, the equivalent is:
Step 5: try something that requires permissions.
curl -s -o /dev/null -w "%{http_code}\n" --cacert $SA/ca.crt \
-H "Authorization: Bearer $TOKEN" \
$APISERVER/api/v1/namespaces/$NS/podsAnd here comes the instructive part.
- The
403 Forbidden and what it means
403 Forbidden and what it meansLet us look at the full body of that response:
curl -s --cacert $SA/ca.crt \
-H "Authorization: Bearer $TOKEN" \
$APISERVER/api/v1/namespaces/$NS/pods | head -20{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:occupancy-reports\"
cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
"reason": "Forbidden",
"details": {"kind": "pods"},
"code": 403
}This message is a small lesson in itself. Let us break it down:
| Part | Meaning |
|---|---|
User "system:serviceaccount:..." |
Authentication worked: the apiserver knows who you are |
cannot list |
The denied verb |
resource "pods" |
The resource |
in API group "" |
The API group: "" is the core group |
in the namespace "..." |
The scope |
"code": 403 |
Forbidden, not "unauthenticated" |
The distinction between 401 and 403 is fundamental and must be clear:
| Code | Meaning | Typical cause |
|---|---|---|
| 401 Unauthorized | "I do not know who you are" | Token missing, expired, malformed or signed by another cluster |
| 403 Forbidden | "I know who you are, but you cannot" | A Role or a RoleBinding is missing |
If you get a 401, the problem is the token: check you are reading it correctly, that it has not expired (remember the one-hour lifetime) and that the pod has the volume mounted. If you get a 403, the token is fine and what is missing is permissions.
And that 403 is exactly what should happen right now. Our occupancy-reports ServiceAccount has no permissions granted at all, because granting permissions is RBAC's job, and RBAC is the subject of lesson 08-01. A freshly created ServiceAccount, with no RoleBindings, can do absolutely nothing beyond the few public endpoints such as /version.
This is worth underlining because it is the correct design:
Kubernetes denies by default. Creating an identity grants no permissions. Everything a ServiceAccount can do has to be granted explicitly.
We can check permissions without making the request, with kubectl auth can-i:
kubectl auth can-i list pods -n rutas-norte-pro \
--as=system:serviceaccount:rutas-norte-pro:occupancy-reports
kubectl auth can-i get deployments -n rutas-norte-pro \
--as=system:serviceaccount:rutas-norte-pro:occupancy-reportsAnd the complete list of what it can do:
kubectl auth can-i --list -n rutas-norte-pro \
--as=system:serviceaccount:rutas-norte-pro:occupancy-reportsResources Non-Resource URLs Verbs
selfsubjectreviews.authentication.k8s.io [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [create]
[/healthz] [get]
[/version] [get]Only the bare minimum: ask who it is, ask what it can do, and check health and version. No reading pods, no deployments, and certainly not the Secrets holding the bookings-postgres password.
As a preview of module 8, this is what the minimal grant occupancy-reports will need looks like, so you know where we are heading:
# PREVIEW of 08-01: do not apply it yet, it is explained there
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployment-reader
namespace: rutas-norte-pro
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list"] # read only, deployments only
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: occupancy-reports-reader
namespace: rutas-norte-pro
subjects:
- kind: ServiceAccount
name: occupancy-reports
namespace: rutas-norte-pro
roleRef:
kind: Role
name: deployment-reader
apiGroup: rbac.authorization.k8s.ioLook at the subjects: that is where the ServiceAccount we created today connects with the permissions. The two pieces fit together, but they are different objects with different responsibilities.
We clean up the test pod:
imagePullSecrets on the ServiceAccount
imagePullSecrets on the ServiceAccountWe pick up a loose end from the Secrets lesson. There we declared imagePullSecrets on each pod so we could pull images from registry.rutasnorte.example:
Repeating it in every Deployment, every Job and every CronJob is tedious and, above all, easy to forget: the failure shows up weeks later, when somebody adds a new component and runs into an incomprehensible ImagePullBackOff.
The ServiceAccount is the right place:
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-api
namespace: rutas-norte-pro
automountServiceAccountToken: false
imagePullSecrets:
- name: registry-rutasnorteAnd the Deployment stays clean:
spec:
serviceAccountName: bookings-api # inherits the imagePullSecrets
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0How it works: when a pod is created, an admission controller copies the ServiceAccount's imagePullSecrets into the pod's spec. You can see it:
kubectl get pod bookings-api-8b5c7d9e2-4mkqp -n rutas-norte-pro \
-o jsonpath='{.spec.imagePullSecrets}'; echoThe manifest did not have it and the object does. It is the same phenomenon as with the LimitRanger from the previous lesson: an admission controller has mutated the object.
Three details:
- They are added, not replaced. If the pod declares its own, they are combined with those of the ServiceAccount.
- The Secret must exist in the same namespace. It has to be created in each of the three environments.
- It is not retroactive. Pods already created do not inherit it; they have to be recreated.
And a very common trick to avoid repeating it per component: add it to each namespace's default ServiceAccount, so that any pod not specifying another inherits it.
kubectl patch serviceaccount default -n rutas-norte-pro \
-p '{"imagePullSecrets":[{"name":"registry-rutasnorte"}]}'It is useful, but remember that it does not exempt you from creating dedicated ServiceAccounts: imagePullSecrets is the only reasonable thing the default should carry.
- The ServiceAccount says who you are, RBAC says what you can do
This is the sentence that sums up the lesson and it is worth engraving:
The ServiceAccount is the identity; RBAC is the permissions. They are two independent things and both are needed.
flowchart LR
SA["ServiceAccount<br/>occupancy-reports<br/>WHO you are"] --> T["JWT token<br/>mounted in the pod"]
T --> AU["Authentication<br/>the apiserver identifies you"]
AU --> RB["RoleBinding<br/>connects identity and permissions"]
RB --> R["Role<br/>WHAT you can do"]
R --> OK["Request allowed"]
AU -.->|"no RoleBinding"| NO["403 Forbidden"]
The possible combinations and what they mean:
| ServiceAccount | RBAC | Result |
|---|---|---|
| Dedicated | No permissions | It identifies itself, it can do nothing. The current state of Rutas Norte |
| Dedicated | Minimal permissions | The goal: each component does exactly its own job |
Shared default |
Broad permissions | The anti-pattern: the whole namespace inherits everything |
| Dedicated | cluster-admin |
Perfect identity, catastrophic permissions |
| No token mounted | Any | It cannot talk to the API. The right thing for 5 of 6 components |
Look at the last row, because it is the most important conclusion in practical terms: the best way to manage the permissions of a pod that does not need the API is for it to have no token. No misconfigured RBAC can hurt it if there is no credential to use.
What is left for 08-01: the Role and ClusterRole objects (which verbs on which resources), the RoleBinding and ClusterRoleBinding objects (to whom), role aggregation, the cluster's predefined roles (view, edit, admin, cluster-admin), and the privilege escalation to be avoided. All of that rests on the identity you created today.
- Identity federation with the clouds
One last note so you know it exists, because it is what you will use if Rutas Norte moves to a managed cluster.
The problem: occupancy-reports has to write its reports to a cloud storage bucket (s3://reports-rutasnorte/). The old way would be to create a permanent access key at the provider and store it in a Secret. That reintroduces every problem we saw in the Secrets lesson: a static credential that has to be rotated by hand and never expires.
The modern solution is called workload identity federation: the cloud provider trusts the Kubernetes cluster's token issuer and accepts the ServiceAccount token as proof of identity, handing back temporary credentials in exchange.
flowchart LR
P["Pod with SA<br/>occupancy-reports"] --> T["Projected token<br/>audience: sts.amazonaws.com"]
T --> S["Cloud STS<br/>verifies the signature of the<br/>cluster's OIDC issuer"]
S --> C["TEMPORARY credentials<br/>expire in 1 hour"]
C --> B["Reports bucket"]
What it is called at each provider:
| Provider | Name | How it is associated |
|---|---|---|
| AWS (EKS) | IRSA (IAM Roles for Service Accounts) or Pod Identity | The eks.amazonaws.com/role-arn annotation on the ServiceAccount |
| Google (GKE) | Workload Identity | The iam.gke.io/gcp-service-account annotation |
| Azure (AKS) | Workload Identity | The azure.workload.identity/client-id annotation + a label on the pod |
An example, just so you recognise the pattern:
apiVersion: v1
kind: ServiceAccount
metadata:
name: occupancy-reports
namespace: rutas-norte-pro
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/rutasnorte-reportsWith that annotation, the AWS SDK inside the pod obtains temporary credentials automatically, with no static key in any Secret. The Kubernetes ServiceAccount becomes an identity recognised outside the cluster too.
The advantages are the same ones you already know from the projected token: nothing permanent, automatic rotation, limited scope, and immediate revocation when the association is deleted. The detail of configuring it at each provider is the subject of Managed Kubernetes.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
Using the default ServiceAccount |
No traceability, shared permissions | One ServiceAccount per component |
| Leaving the token mounted on every pod | Unnecessary attack surface | automountServiceAccountToken: false |
| Changing the ServiceAccount without recreating pods | Old pods keep the previous one | kubectl rollout restart |
Using serviceAccount instead of serviceAccountName |
It works but it is deprecated | serviceAccountName |
| Reading the token only once at start-up | 401 exactly one hour later |
Re-read it on every request, or use a client library |
Using curl -k against the apiserver |
Vulnerable to impersonation | --cacert /var/run/secrets/.../ca.crt |
| Confusing 401 and 403 | You look for the problem in the wrong place | 401 = token; 403 = permissions |
| Creating a permanent token Secret | An eternal, irrevocable credential | kubectl create token --duration |
| Expecting that creating an SA grants permissions | Everything returns 403 |
Kubernetes denies by default: RBAC is needed |
| A ServiceAccount from another namespace | The pod does not start | They must be in the same namespace |
imagePullSecrets repeated on every pod |
It gets forgotten on the new component | Declare it on the ServiceAccount |
| Storing static cloud keys in Secrets | Credentials that never rotate | Identity federation |
Tips:
- Create the ServiceAccount next to the Deployment, in the same file. So there is never a component without its own identity.
automountServiceAccountToken: falseby default in the base template. Make mounting the token a conscious decision, not inherited behaviour.- Audit periodically which pods carry a token. The
jqcommand from section 7 should return only system components and those that genuinely need it. - Use
kubectl auth can-i --list --as=...in reviews. It is the quick way to see an identity's effective permissions before approving a change. - Never grant permissions to the
default. It is the fastest route to a privilege escalation across the whole namespace.
Exercises
Exercise 1: Give the platform an identity
- Create the six Rutas Norte ServiceAccounts in
rutas-norte-prowith the module 2 labelling scheme, settingautomountServiceAccountToken: falseon all of them exceptoccupancy-reports. - Modify the five existing Deployments so that they use their ServiceAccount and recreate the pods.
- Show with a single command that each pod uses its own.
- Show that
web-storeno longer has the token directory and thatoccupancy-reportsdoes. - Write a command that lists every pod in the cluster that does have the token mounted and explain which of them are legitimate.
Exercise 2: Talk to the API and understand the 403
- Create a pod called
api-clientwith theoccupancy-reportsServiceAccount and the token mounted. - From inside, query
/versionand/apis/authentication.k8s.io/v1/selfsubjectreviewsand show the full identity. - Try to list the namespace's pods, capture the HTTP code and the full message, and identify the verb, the resource, the API group and the scope in it.
- Decode the token payload and find
sub,exp, the pod it is bound to and its lifetime in minutes. - Try the same request without the
Authorizationheader and with a tampered token (change one character). Explain the codes you get in each case and why they differ.
Exercise 3: Identity and attack surface audit
You are asked to run an identity audit of the platform ahead of a security review.
- Draw up a table with the six components showing: ServiceAccount, whether it mounts a token, whether it has
imagePullSecretsand what effective permissions it has. - Identify which pods in the cluster (including
kube-system) mount a token and classify them as "legitimate" or "to review". - Simulate an incident: an attacker manages to run code inside the
web-storecontainer. List what they can do with the current configuration and what they could have done before this lesson. - Explain why
imagePullSecretson thedefaultServiceAccount is acceptable but granting RBAC permissions to thedefaultis not. - Propose three further hardening measures, saying in which lesson of the course each one is covered.
Solutions
Solution 1
The k8s/base/serviceaccounts.yaml file is the one from section 4. Applying it and modifying the Deployments:
kubectl apply -f k8s/base/serviceaccounts.yaml
for C in web-store bookings-api bookings-postgres redis-cache notifications-worker; do
kubectl patch deployment $C -n rutas-norte-pro -p \
"{\"spec\":{\"template\":{\"spec\":{\"serviceAccountName\":\"$C\",
\"automountServiceAccountToken\":false}}}}"
done
kubectl rollout status deploy/bookings-api -n rutas-norte-proserviceaccount/web-store created
serviceaccount/bookings-api created
serviceaccount/bookings-postgres created
serviceaccount/redis-cache created
serviceaccount/notifications-worker created
serviceaccount/occupancy-reports created
deployment.apps/web-store patched
deployment.apps/bookings-api patched
deployment.apps/bookings-postgres patched
deployment.apps/redis-cache patched
deployment.apps/notifications-worker patched
deployment "bookings-api" successfully rolled outkubectl get pods -n rutas-norte-pro -o custom-columns=\
'POD:.metadata.name,SA:.spec.serviceAccountName,TOKEN:.spec.automountServiceAccountToken'POD SA TOKEN
bookings-api-8b5c7d9e2-4mkqp bookings-api false
bookings-api-8b5c7d9e2-9wnzr bookings-api false
bookings-postgres-6e9g7c0d5-l3nqx bookings-postgres false
redis-cache-7d0e9g8c6-wo5ry redis-cache false
web-store-6c8d0g5e9-i8qol web-store false
notifications-worker-7g8e0d9c5-mn4qu notifications-worker falsekubectl exec -n rutas-norte-pro deploy/web-store -- ls /var/run/secrets/kubernetes.io/ 2>&1
kubectl run reports-test --image=curlimages/curl:8.10.1 -n rutas-norte-pro \
--overrides='{"spec":{"serviceAccountName":"occupancy-reports"}}' \
--restart=Never -- sleep 60
sleep 5
kubectl exec -n rutas-norte-pro reports-test -- ls /var/run/secrets/kubernetes.io/serviceaccount/ls: /var/run/secrets/kubernetes.io/: No such file or directory
command terminated with exit code 1
pod/reports-test created
ca.crt
namespace
tokenThe audit of mounted tokens:
kubectl get pods -A -o json | jq -r '
.items[] |
select(.spec.automountServiceAccountToken != false) |
"\(.metadata.namespace)\t\(.metadata.name)\t\(.spec.serviceAccountName)"' | column -tkube-system coredns-7db6d8ff4d-2vqxn coredns
kube-system kube-proxy-8xvmk kube-proxy
kube-system storage-provisioner storage-provisioner
kube-system metrics-server-7d9f8c6b4-x2mkl metrics-server
ingress-nginx ingress-nginx-controller-9wnzr ingress-nginx
rutas-norte-pro reports-test occupancy-reportsAll legitimate: CoreDNS watches Services and Endpoints, kube-proxy does too, metrics-server publishes an aggregated API, the Ingress controller reads Ingress objects, and occupancy-reports is ours. From Rutas Norte, only one of six.
Solution 2
Inside the pod:
SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token); NS=$(cat $SA/namespace); API=https://kubernetes.default.svc
curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" $API/version | head -4
curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-X POST $API/apis/authentication.k8s.io/v1/selfsubjectreviews \
-d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}' \
| grep username{
"major": "1",
"minor": "30",
"gitVersion": "v1.30.4",
"username": "system:serviceaccount:rutas-norte-pro:occupancy-reports",curl -s -w "\nHTTP: %{http_code}\n" --cacert $SA/ca.crt \
-H "Authorization: Bearer $TOKEN" $API/api/v1/namespaces/$NS/pods | grep -E "message|HTTP" "message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:occupancy-reports\"
cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
HTTP: 403Breaking the message down:
| Element | Value |
|---|---|
| Verb | list |
| Resource | pods |
| API group | "" (core) |
| Scope | namespace rutas-norte-pro |
| Identity | system:serviceaccount:rutas-norte-pro:occupancy-reports |
The token payload:
"sub":"system:serviceaccount:rutas-norte-pro:occupancy-reports"
"exp":1785703921
"iat":1785700321
"pod":{"name":"api-client"With no header and with a tampered token:
curl -s -o /dev/null -w "no token: %{http_code}\n" --cacert $SA/ca.crt \
$API/api/v1/namespaces/$NS/pods
curl -s -o /dev/null -w "bad token: %{http_code}\n" --cacert $SA/ca.crt \
-H "Authorization: Bearer ${TOKEN}X" $API/api/v1/namespaces/$NS/podsThe difference is subtle and instructive:
- No header → 403. It is not that the apiserver does not know who you are: it classifies you as the anonymous user
system:anonymous, which is a valid identity with no permissions. It is a case of successful authentication as anonymous and denied authorization. (If the cluster had anonymous access disabled, you would indeed get a 401.) - Tampered token → 401. The JWT signature does not validate, so the apiserver cannot establish any identity. It is a pure authentication failure.
That distinction is what will tell you, in a real incident, whether your problem is the credential or the permissions.
Solution 3
- The identity table:
| Component | ServiceAccount | Token? | imagePullSecrets |
Effective permissions |
|---|---|---|---|---|
web-store |
web-store |
No | registry-rutasnorte |
None (no credential) |
bookings-api |
bookings-api |
No | registry-rutasnorte |
None (no credential) |
bookings-postgres |
bookings-postgres |
No | — (public image) | None (no credential) |
redis-cache |
redis-cache |
No | — (public image) | None (no credential) |
notifications-worker |
notifications-worker |
No | registry-rutasnorte |
None (no credential) |
occupancy-reports |
occupancy-reports |
Yes | registry-rutasnorte |
Only selfsubject*, /healthz, /version |
-
Classifying the pods with a token, using the command from solution 1: everything in
kube-systemand the Ingress controller are legitimate (they need to watch API objects in order to work).occupancy-reportsis legitimate and expected. If any otherrutas-norte-*pod showed up, it would be an immediate "to review". -
The simulated incident. An attacker with code execution in
web-store:
| Before this lesson | Now | |
|---|---|---|
| Read the SA token | Yes: /var/run/secrets/.../token |
The file does not exist |
| Identify itself to the API | Yes, as ...:default |
No: it would be system:anonymous |
| Enumerate pods, services, deployments | Depends on RBAC on default |
No |
| Read the namespace's Secrets | If default had get secrets, yes: including the bookings-postgres password with the customers' personal data |
No |
| Create pods to escalate privileges | Depends on RBAC | No |
Reach bookings-postgres over the network |
Yes (the namespace does not isolate the network) | Yes, still |
The fourth row is what gives the measure of the risk: in a cluster where somebody had granted permissions to the default "just to test", a vulnerability in a static file server would have allowed reading the database credentials holding the personal data of every Rutas Norte customer. Removing the token eliminates that route entirely.
The last row points at what this lesson does not solve: the network is still flat. Any pod in the namespace can open a TCP connection to bookings-postgres:5432. That is closed by the network policies.
imagePullSecretson thedefaultis acceptable because it grants pods no capability at all: it only lets the kubelet pull images from the private registry, which is an operation that happens before start-up and not an action the container process can exercise. The contents of that Secret are not even visible from inside the pod.
Granting RBAC permissions to the default is not acceptable because the default is, by definition, used by every pod that does not specify otherwise: any new pod, any debugging pod, any manifest pasted from the internet. A permission granted there is given to an open and unpredictable set of workloads, present and future. It is the opposite of an explicit decision.
- Three further hardening measures:
| Measure | What it brings | Lesson |
|---|---|---|
| RBAC with minimal permissions | So occupancy-reports can read only what it needs, and nobody else anything |
08-01 |
| NetworkPolicies | So only bookings-api and notifications-worker can open connections to bookings-postgres:5432 |
04-06 |
| SecurityContext and Pod Security Standards | A container with no root, a read-only file system, no capabilities and no privilege escalation: it makes code execution far less useful | 08-02 and 08-03 |
All three, together with today's work, form the layers of a defence in depth: minimal identity, minimal permissions, minimal network and minimal container.
Conclusion
You have closed module 3 by giving the platform an identity. You know that Kubernetes distinguishes between users, which are not cluster objects and are managed by an external system, and ServiceAccounts, which are, are namespaced and identify themselves as system:serviceaccount:<ns>:<name>. You know the five reasons not to use the default — it is shared, it destroys traceability, it prevents selective revocation, it mounts a token almost nobody needs and it tempts people to grant it permissions — and you have created a dedicated ServiceAccount for each of the six Rutas Norte components, assigning it with serviceAccountName.
You understand how the token has changed: from those Secrets with eternal JWTs, tied to no pod and stored in etcd, to the projected tokens of the TokenRequest API, which expire in an hour, are rotated by the kubelet at 80% of their lifetime, are bound to a specific pod UID, live only in tmpfs and are invalidated when the pod is deleted. You know what lives in /var/run/secrets/kubernetes.io/serviceaccount/ — token, ca.crt and namespace — and what each file is for, including the ca.crt that saves you from using --insecure. And you are clear about the precedence of automountServiceAccountToken, with the recommended pattern: false on the ServiceAccount as a safety net and an explicit true only where it is genuinely needed. In Rutas Norte, five of six components no longer carry any credential inside, which eliminates at the root the first move of any privilege escalation script.
You have talked to the API from inside a pod with curl, assembling the request by hand with the CA, the token and the kubernetes.default.svc Service; you have asked who you are with SelfSubjectReview; and you have received a 403 Forbidden that you can break down into verb, resource, API group and scope, distinguishing it from the 401 that signals a credential problem rather than a permissions one. That 403 was not a failure: it was the demonstration that Kubernetes denies by default and that creating an identity grants absolutely nothing. You have moved imagePullSecrets onto the ServiceAccount so it is neither repeated nor forgotten, and you know that identity federation with the clouds exists so that even external credentials need not be static.
With this you close module 3 and the Rutas Norte platform is in far better health than it was six lessons ago. The configuration lives outside the image, in per-environment ConfigMaps; the bookings-postgres password is no longer in Git but in a Secret mounted in memory with only the keys each component needs; the environment variables are catalogued component by component and environment by environment, with the Downward API giving traceability to every request; the three environments have a quota and a LimitRange, so a mistake in development cannot touch production; every pod has a QoS class assigned on business grounds, with bookings-postgres as Guaranteed and last survivor; and each component has its own identity before the API, almost always with no credential mounted.
But the platform still has a very visible hole: the network is completely flat. Any pod in rutas-norte-pro can open a connection to bookings-postgres:5432, and no client on the internet can yet reach www.rutasnorte.example or api.rutasnorte.example, because our Services are all ClusterIP and exist only inside the cluster. Module 4, Networking in Kubernetes, tackles that from end to end: how the cluster network really works and the "every pod with its own IP" model, the Service types beyond ClusterIP, the internal DNS we have been using without fully explaining it, the Ingress controllers that will finally publish the store and the API on their domains, the TLS certificates managed automatically with cert-manager, and the network policies that will stop anyone who is not bookings-api or notifications-worker from even attempting to connect to the database holding the customers' personal data.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
