002

Local Kubernetes on a Mac: Minikube profiles, kubectl contexts and k9s

Minikube runs several Kubernetes clusters on one Mac, one profile per project. This note covers installation, profiles with their own CPU and memory, switching between clusters with kubectl contexts, and k9s for daily work.

problem

Testing a change on a remote Kubernetes cluster means building an image, pushing it to a registry, deploying it and waiting, which takes minutes for every change. A local cluster removes the remote deploy from that loop.

On a remote cluster, every change means building an image, pushing it to a registry, deploying it and waiting for the pod. When I work on network policies, resource limits or a pod that does not start, I repeat that cycle many times, and each cycle takes minutes.

Minikube runs a Kubernetes cluster on the local machine. It has the same API server, scheduler and RBAC model as a real cluster. You can run several clusters at the same time as named profiles, each with its own CPU and memory limits.

This note covers the installation on a Mac, profiles, kubectl contexts, a separate kubeconfig per cluster, and k9s.


Installing Minikube

Install it with Homebrew:

brew install minikube

Check the installation:

minikube version

Choosing a driver

Minikube needs a driver to run the cluster in a VM or a container. On a Mac, two drivers work well:

Docker: if Docker Desktop is running, Minikube finds it and needs no extra setup.

QEMU: it uses fewer resources than Docker Desktop and runs natively on Intel and Apple Silicon:

brew install qemu

If Docker Desktop is already running, use it. On my Apple Silicon machines QEMU uses less memory and starts faster, so I use QEMU there. On Intel I did not see a real difference. Set the driver in the configuration, so Minikube does not choose it again at every start:

minikube config set driver docker
# or
minikube config set driver qemu2

Starting a cluster

minikube start

This creates a cluster named minikube, the default profile, with 2 CPUs and 2 GB of RAM. For workloads closer to production, give it more resources:

minikube start --cpus 4 --memory 8192

Memory is in MB, so 8192 is 8 GB. When the cluster is running, check it:

minikube status
kubectl cluster-info
kubectl get nodes

The output shows one node in the Ready state.


Multiple clusters with profiles

A profile is a separate Minikube cluster. Each profile has its own VM or container, its own Kubernetes state and its own network. Profiles can run at the same time, so I keep one cluster per project and switch between them without deleting anything.

Creating named clusters

# A resource-intensive microservices project
minikube start -p project-a --cpus 4 --memory 8192

# A lighter stack, a database and a couple of APIs
minikube start -p project-b --cpus 2 --memory 4096

List all clusters:

minikube profile list
|-----------|--------|---------|--------------|------|---------|---------|-------|--------|
|  Profile  |   VM   | Runtime |      IP      | Port | Version | Status  | Nodes | Active |
|-----------|--------|---------|--------------|------|---------|---------|-------|--------|
| project-a | docker | docker  | 192.168.49.2 | 8443 | v1.31.0 | Running |     1 | *      |
| project-b | docker | docker  | 192.168.49.3 | 8443 | v1.31.0 | Running |     1 |        |
|-----------|--------|---------|--------------|------|---------|---------|-------|--------|

The * marks the active profile.

Cluster lifecycle

# Stop without losing state
minikube stop -p project-a

# Restart (picks up exactly where it left off)
minikube start -p project-a

# Pause: frees CPU without stopping the cluster
minikube pause -p project-b

# Unpause
minikube unpause -p project-b

# Delete completely
minikube delete -p project-b

stop keeps the cluster state, including workloads, configmaps and secrets. delete removes everything.


Switching between clusters

When Minikube creates a profile, it adds a kubectl context with the same name to ~/.kube/config. A context is a named entry with a cluster endpoint, credentials and a namespace. kubectl sends its commands to the cluster of the active context.

List and switch

# Show all available contexts
kubectl config get-contexts

# Switch globally
kubectl config use-context project-a
kubectl config use-context project-b

# Confirm the active context
kubectl config current-context

Per-command targeting

use-context changes the context for every terminal window. When I work on two projects at the same time, I use the --context flag instead. It sends one command to one cluster and does not change the active context:

kubectl get pods --context=project-a
kubectl get services --context=project-b

Per-session kubeconfig

For a long session, or for a script that must behave like the CI pipeline, export a kubeconfig file with only one cluster in it and use it in that terminal:

# Extract the kubeconfig for project-a
kubectl config view --minify --context=project-a --raw > ~/.kube/project-a.yaml

# In that terminal session only
export KUBECONFIG=~/.kube/project-a.yaml
kubectl get pods  # always hits project-a, regardless of global context

kubectl in that terminal can only reach project-a, so a script cannot deploy to the wrong cluster by mistake.


k9s

k9s is a terminal UI for Kubernetes. It shows pods, deployments, services, events and logs, and it updates them live. One key streams the logs of a pod, another key opens a shell in a container, and you can filter any list while it updates. I use k9s for most daily work and kubectl mainly in scripts.

Install

brew install k9s

Connect to a cluster

k9s uses the active kubectl context by default. To open another cluster without changing the active context:

# Use the active context
k9s

# Target a specific context
k9s --context project-a

# Use a standalone kubeconfig file
k9s --kubeconfig ~/.kube/project-a.yaml

Press : and type a resource name to open the list of that resource:

  • :pods, all pods in the current namespace
  • :deploy, deployments
  • :svc, services
  • :ns, namespaces (select to switch)
  • :cm, configmaps
  • :secret, secrets

In any list, with the cursor on a row:

  • l, stream logs from the selected pod
  • s, exec a shell into the selected pod
  • d, describe (equivalent to kubectl describe)
  • e, edit in $EDITOR
  • ctrl+d, delete the selected resource
  • ctrl+k, force-kill the selected pod
  • /, filter the current list
  • esc, go back / close panel
  • q, quit k9s

For debugging I mostly use three commands: :ns to change namespace, l for logs and s for a shell.


Quick reference

minikube Cluster manager
profile flag -p / --profile name
resource flags --cpus N --memory N
driver docker · qemu2
kubectl Context client
config file ~/.kube/config
per-command --context=name
per-session export KUBECONFIG=path
k9s Terminal UI
target context --context name
target kubeconfig --kubeconfig path
navigate :pods · :deploy · :svc

Cluster lifecycle

operation
minikube command
create (defaults)
minikube start
create with profile
minikube start -p name --cpus N --memory N
list all clusters
minikube profile list
stop (keep state)
minikube stop -p name
restart
minikube start -p name
pause (free CPU)
minikube pause -p name
delete completely
minikube delete -p name

Context management

operation
kubectl command
list contexts
kubectl config get-contexts
switch context
kubectl config use-context name
active context
kubectl config current-context
per-command
kubectl get pods --context=name
export kubeconfig
kubectl config view --minify --context=name --raw > name.yaml
per-session
export KUBECONFIG=~/.kube/name.yaml

k9s shortcuts

k9s
:podspod list
:deploydeployments
:svcservices
:nsnamespaces
:cmconfigmaps
:secretsecrets
lstream logs
sexec shell
ddescribe resource
eedit in $EDITOR
ctrl+ddelete resource
ctrl+kforce kill pod
/filter list
escgo back
qquit k9s

With a local cluster you still build an image for every change. To skip the registry push, build the image directly in the Docker daemon of the cluster: run eval $(minikube -p project-a docker-env) in the terminal, then docker build. The pods of that cluster can use the image directly. Set imagePullPolicy: IfNotPresent in the deployment, so Kubernetes does not try to pull the image from a registry.