From Kind to K3s: Local Registry and Production Deployment Workflow

DevOps Learning Notes

After learning K8s core concepts with kind, switching to k3s with a local registry to make local practice closer to production deployment workflows. Also clarifying K8s network isolation mechanisms and why production doesn’t use kubectl set image.

Switching to a More Complete On-Machine Runtime

kind is sufficient for learning concepts, but has several areas far from production:

Feature kind k3s Production (GKE/AKS)
Ingress controller None, must install yourself Built-in Traefik Built-in cloud LB
Service LoadBalancer Cannot get external IP Built-in ServiceLB, gets IP Real external IP
Multi-Node Possible but rarely used Easy multi-node Automatically managed
Local storage Must configure yourself Built-in local-path provisioner Automatic (Persistent Disk)
Image loading kind load (non-standard) Pull from registry (standard flow) Pull from Artifact Registry

k3s is a full K8s, just more lightweight. After installation, Ingress and LoadBalancer are ready to use, no port-forward needed.

k3s Bootstrap Steps

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# install k3s
curl -sfL https://get.k3s.io | sh -

# k3s kubeconfig is at /etc/rancher/k3s/k3s.yaml (root-only)
# copy to kubectl's default location
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config

# verify
kubectl get nodes

kind automatically configures kubeconfig after installation, but k3s places kubeconfig in a root-only path, requiring manual copy to ~/.kube/config so a regular user’s kubectl can read it.

Private Artifact Store for On-Machine Distribution

Direct Side-Loading (Non-Standard Path)

1
2
# kind: transfer image from Docker daemon to kind's containerd
kind load docker-image go-api:0.0.2 --name devops-lab

This is a kind-specific command. It doesn’t exist in production environments. In reality, K8s pulls images from a registry.

Repository Container and Canonical Upload/Download

Run a local registry container. It’s the same thing as Docker Hub / GHCR / Artifact Registry, just on your local machine:

1
2
3
4
# start local registry
# --name registry is the container name (can be anything)
# registry:2 is the image name + tag from Docker Hub
docker run -d -p 5050:5000 --restart always --name registry registry:2

Note the port mapping: the registry internally always listens on 5000, and you can expose it on any external port. 5050:5000 means host’s 5050 โ†’ container’s 5000.

Compile, Label, Upload, then Apply

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. build, tag points to local registry
docker buildx build -t 127.0.0.1:5050/go-api:0.0.3 .

# 2. push to registry (same as pushing to GHCR or Artifact Registry)
docker push 127.0.0.1:5050/go-api:0.0.3

# 3. verify image exists in registry
curl http://127.0.0.1:5050/v2/_catalog
# {"repositories":["go-api"]}

curl http://127.0.0.1:5050/v2/go-api/tags/list
# {"name":"go-api","tags":["0.0.3"]}

Compared to production:

1
2
3
Local:  docker push 127.0.0.1:5050/go-api:0.0.3
GHCR:   docker push ghcr.io/ianyu93/pebble-api:latest
GCP:    docker push us-docker.pkg.dev/my-project/repo/api:abc123

Only the destination differs. The workflow is identical. When K8s sees image: 127.0.0.1:5050/go-api:0.0.3 in the Deployment, it pulls from the registry automatically.

Pod Manifest Referencing the Registry

1
2
3
4
5
6
spec:
  containers:
    - name: go-api
      image: 127.0.0.1:5050/go-api:0.0.3   # pull from registry, not local import
      ports:
        - containerPort: 8080

Cluster-Network Segmentation from the Host

Workload Addresses Stay Inside the Mesh

K8s cluster has its own independent network, isolated from the host network:

1
2
3
4
5
6
7
8
9
Host network (your machine)
โ”œโ”€โ”€ can reach: 127.0.0.1, localhost
โ”œโ”€โ”€ can reach: docker containers (via port mapping)
โ””โ”€โ”€ cannot reach: K8s Pod IP, Service IP, Service DNS name

K8s cluster internal network (isolated)
โ”œโ”€โ”€ Pod IP: 10.42.0.x
โ”œโ”€โ”€ Service IP: 10.43.x.x
โ””โ”€โ”€ DNS: go-api โ†’ 10.43.148.136

The DNS name go-api is only recognized by K8s’s internal CoreDNS. Your local machine’s DNS doesn’t know what it is.

Testing DNS Resolution from a Temporary Pod

1
2
3
4
5
6
# create a temporary Pod inside the cluster
kubectl run test --rm -it --image=busybox -- sh

# inside the Pod, call go-api by name
wget -qO- http://go-api:8080/healthz
# {"status":"ok","version":"0.0.3"}

Parameters for kubectl run --rm -it:

  • --rm: remove Pod when done (like docker run –rm)
  • -i: interactive, connect stdin
  • -t: allocate tty
  • -- sh: run shell inside the Pod

Inside the Pod, http://go-api:8080 works because the Pod is in the K8s network and can query CoreDNS.

External Traffic Entry Points

If local machine can’t reach cluster internals, how do external users connect?

Method Use Case
kubectl port-forward Development and testing, not a production approach
Service type: LoadBalancer k3s / cloud environments, gets real external IP
Ingress Production standard, single entry point routing to multiple Services

kind can’t get LoadBalancer IP, so you can only use port-forward. k3s has built-in ServiceLB โ€” change to type: LoadBalancer and you get an external IP.

GitOps Deploy Versus Imperative Shortcuts

Quick-and-Dirty Commands for Local Testing

1
2
# imperative: directly change image, no record
kubectl set image deployment/go-api go-api=go-api:0.0.4

The Standard Git-Driven Pipeline

Modify YAML โ†’ commit โ†’ push โ†’ CI/CD automatically applies:

1
2
3
4
5
# k8s/deployment.yaml
spec:
  containers:
    - name: go-api
      image: 127.0.0.1:5050/go-api:0.0.4   # change this line
1
2
3
4
5
# the standard flow
git add k8s/deployment.yaml
git commit -m "bump go-api to 0.0.4"
git push
# CI/CD pipeline runs kubectl apply automatically

Why Not Use kubectl set image

kubectl set image YAML + git push
Record None, no idea who changed it git history fully traceable
Rollback kubectl rollout undo git revert (more reliable)
Review Cannot review PR review before merge
Consistency Cluster state diverges from YAML git = single source of truth

This is the core of GitOps โ€” the YAML in git always represents the true state of production. Tools like ArgoCD monitor the git repo, automatically syncing to the cluster whenever YAML changes.

Mapping Stack Definitions to Kubernetes Primitives

If you already have services running on EC2 + Docker Compose, here’s the mapping when transitioning to K8s:

Docker Compose K8s
services: each service Deployment + Service YAML
ports: Service (ClusterIP / LoadBalancer)
environment: ConfigMap / Secret
volumes: PersistentVolumeClaim (PVC)
depends_on: K8s doesn’t manage startup order, app must handle retries
logging: awslogs GKE built-in Cloud Logging
restart: always Deployment default behavior (reconciliation loop)
nginx reverse proxy Ingress controller replaces

During migration, application code and Dockerfile need zero changes. What changes is the deployment method โ€” from Compose to K8s YAML.

References