I don’t install languages on my laptop.
Not Python, not Node, not Go. Docker Desktop is running, and that’s enough. Every project gets a container. The machine stays clean, environments stay reproducible, and “works on my machine” stops being a conversation anyone has to have.
So when I decided I needed to learn Go — and I did need to learn it, Go is the lingua franca of Kubernetes, cloud-native tooling, and operators — I reached for the same pattern. No go install. Just:
docker pull golang:1.24
That solved the runtime question. The harder question was: what do I actually build?
Courses Are the Wrong Entry Point
The standard advice: do the Go Tour, write some small programs, then tackle a real project. I’ve done enough language learning to know this path works eventually, but it optimizes for knowing Go rather than doing something with Go.
What I wanted to do with Go was write Kubernetes operators. Operators are the mechanism behind serious K8s automation — they watch custom resources, reconcile state, drive cluster behavior. The entire ecosystem (Kubebuilder, controller-runtime, client-go) assumes Go. There’s no meaningful alternative path into this space.
The fastest route from zero to operators isn’t a course. It’s building the layer just below operators: a CLI that does what kubectl does, built on the exact same libraries kubectl is built on.
So I built vibeopsctl.
The Git Bash Tax
First obstacle: running Go in Docker on Windows with Git Bash.
The obvious command doesn’t work:
docker run --rm -v "$(pwd)":/app -w /app golang:1.24 go run main.go
docker: Error response from daemon: the working directory 'C:/Program Files/Git/app' is invalid
Git Bash converts /app to a Windows path. Docker Desktop doesn’t understand it. The fix requires two things together:
MSYS_NO_PATHCONV=1— tells Git Bash to stop converting paths$(cmd //c cd)— gets the Windows-style path Docker Desktop actually accepts
Working command:
MSYS_NO_PATHCONV=1 docker run --rm -v "$(cmd //c cd)":/app -w /app golang:1.24 go run main.go
Add an alias and forget about it:
alias gorun='MSYS_NO_PATHCONV=1 docker run --rm -v "$(cmd //c cd)":/app -w /app golang:1.24'
From here: gorun go build, gorun go test ./..., everything works.
What vibeopsctl Is
Three commands:
vibeopsctl get pods [-n namespace]vibeopsctl get deployments [-n namespace]vibeopsctl describe pod <name>
Not trying to replace kubectl. The point is to use the same building blocks — and understand what those building blocks actually are.
Two libraries:
| Library | Role |
|---|---|
cobra | CLI framework — same one kubectl, gh, and hugo are built on |
client-go | Official K8s Go client — same one inside every operator |
The structure is intentionally minimal:
vibeopsctl/├── go.mod├── main.go└── cmd/ ├── root.go # kubeconfig flag, k8s client init ├── get.go # vibeopsctl get ├── pods.go # get pods + describe pod <name> └── deployments.go # get deployments
The Core Pattern
The root command reads ~/.kube/config and builds a Kubernetes client that every subcommand shares:
var rootCmd = &cobra.Command{ Use: "vibeopsctl", Short: "A kubectl-like CLI built on cobra + client-go", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) if err != nil { return fmt.Errorf("failed to load kubeconfig: %w", err) } clientset, err = kubernetes.NewForConfig(config) if err != nil { return fmt.Errorf("failed to create k8s client: %w", err) } return nil },}
Then listing pods is exactly what you’d expect:
pods, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{})
That one call. That’s the core of it. The same call that runs inside every Kubebuilder reconciler, every controller-runtime operator, kubectl itself — just wrapped in a loop and printed to a tab-aligned table.
One Dependency Note Worth Saving
The latest k8s.io/client-go (v0.36.x) requires Go 1.26+. Pin to v0.31.x if you’re on Go 1.24:
go get k8s.io/client-go@v0.31.0go get k8s.io/apimachinery@v0.31.0go get k8s.io/api@v0.31.0go mod tidy
I lost 30 minutes to a cryptic compiler error before I found this. Now you don’t have to.
Running Against a Real Cluster
Build inside the container:
MSYS_NO_PATHCONV=1 docker run --rm \ -v "$(cmd //c cd)":/app \ -w /app/vibeopsctl \ golang:1.24 go build -o vibeopsctl .
Run it, mounting the kubeconfig:
MSYS_NO_PATHCONV=1 docker run --rm \ -v "$(cmd //c cd)":/app \ -v "$HOME/.kube:/root/.kube" \ -w /app/vibeopsctl \ golang:1.24 \ ./vibeopsctl get pods -n kube-system
When real pod names scroll across the terminal, something clicks. Not because the output is impressive — kubectl does the same thing in a tenth of the code. But because you know exactly which API call produced that output, which library made the HTTP request, which struct the response was deserialized into. The abstraction is transparent because you built it.
What You Actually Learn
Go is a small language. The syntax isn’t the hard part. What takes time is the idiomatic layer: error handling (explicit error returns, no exceptions), context.Context flowing through every call stack, interfaces as the mechanism Go uses instead of inheritance, go.mod and why go mod tidy is not optional.
Building a real CLI forces all of this in a context that immediately makes sense. You need error handling because a missing kubeconfig is a real failure. You need context.Context because the Kubernetes API requires it. You need to understand interfaces because both cobra and client-go are built on them.
The result is a binary that connects to a Kubernetes cluster. That’s a demonstrable artifact. More importantly, you’ve used client-go — and client-go is the foundation of every operator you’ll write next.
The Deeper Point
Go isn’t a general-purpose language you learn and then apply to DevOps. It is a DevOps language — shaped by the same constraints as the infrastructure it manages. Fast compilation for tight CI loops. Static binaries for trivial distribution. Goroutines for concurrent network services. Garbage collection traded against latency for everything else.
The way to learn it is the same way you learn any infrastructure tool: by operating it, not by reading about it. You don’t learn Terraform by reading the docs. You write a main.tf, run plan, break something, and fix it.
Build the CLI. Run it against a real cluster. When the pods appear, you haven’t just learned some Go syntax — you’ve understood the machinery that kubectl, every operator, and most of the cloud-native tooling ecosystem is built on.
That’s the entry point worth taking.

Leave a comment