The language Kubernetes, Docker, Terraform, and Prometheus are written in — and the go-to for fast, single-binary services and CLIs. You'll learn the syntax, Go's famous concurrency, error handling, building a real HTTP service, and shipping a tiny static container. Practical and hands-on. Work top to bottom, or jump to a part.
The journey:
| You need | Why |
|---|---|
| Go 1.22+ | Modern toolchain and generics |
| A terminal + editor | go CLI drives everything |
| Basic programming familiarity | Functions, loops, types |
Install from go.dev (or brew install go) and confirm: go version. Go bundles its formatter, tester, and build tool — no extra setup.
Go dominates cloud-native tooling for concrete reasons: it compiles to a single static binary (no runtime to install — copy it and run), has built-in concurrency that suits network servers, is fast (compiled, garbage-collected), and enforces one formatting style so codebases look identical. That's why Kubernetes, Docker, Terraform, Helm, Prometheus, and most CLIs you use are Go.
For DevOps you'll reach for Go when you need a distributable binary, a performant service or controller, or a tool that must run anywhere without dependencies.
A Go project is a module. Initialize one and write hello world:
mkdir hello && cd hello
go mod init example.com/hello # creates go.mod (your module + deps)
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello from Go")
}
go run . # compile + run
go build -o hello # produce a binary
./hello
go.mod tracks your module path and dependencies; go build produces a self-contained binary. go run . is the quick edit-run loop.
Go is statically typed but terse. Structs group data; interfaces define behavior:
type Server struct {
Name string
Port int
Healthy bool
}
// A method on Server
func (s Server) Addr() string {
return fmt.Sprintf("%s:%d", s.Name, s.Port)
}
// An interface: anything with a Check() method satisfies it, implicitly
type Checker interface {
Check() error
}
servers := []Server{{Name: "web", Port: 8080, Healthy: true}}
for _, s := range servers { // range over a slice
fmt.Println(s.Addr())
}
Key points: := declares and infers type; slices ([]Server) are Go's dynamic arrays; interfaces are satisfied implicitly — a type just needs the methods, no implements keyword. for ... range is the universal loop.
Go has no exceptions. Functions return an error value you check explicitly — verbose but impossible to ignore silently:
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err) // wrap with context
}
return data, nil
}
data, err := readConfig("app.yaml")
if err != nil {
log.Fatal(err) // handle it right here — no try/catch to jump away
}
The if err != nil pattern is everywhere. Wrap errors with %w and context (fmt.Errorf) so the final message reads like a stack trace of what you were doing. Reserve panic for truly unrecoverable programmer errors, not normal failures.
Go's headline feature. A goroutine is a function running concurrently (cheap — thousands are fine); channels pass data between them safely:
func check(url string, results chan<- string) {
resp, err := http.Get(url)
if err != nil {
results <- url + " DOWN"
return
}
resp.Body.Close()
results <- fmt.Sprintf("%s %d", url, resp.StatusCode)
}
func main() {
urls := []string{"https://a.com", "https://b.com"}
results := make(chan string)
for _, u := range urls {
go check(u, results) // launch concurrently
}
for range urls {
fmt.Println(<-results) // receive each result
}
}
go func() starts a goroutine; chan moves values between them without locks. For coordinating many, use sync.WaitGroup; for shared state, prefer channels over mutexes ("share memory by communicating"). This model makes concurrent network code — exactly what infra tools do — natural.
The standard library ships a production-grade HTTP server — no framework needed:
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
go run . &
curl localhost:8080/healthz # ok
curl localhost:8080/api/status # {"status":"healthy"}
net/http handles concurrency for you — each request runs in its own goroutine. The method-prefixed patterns (GET /healthz) are the modern routing style. This is exactly how a Kubernetes controller or a metrics exporter serves endpoints.
Testing is built in — no framework, no config:
// math.go
func MilliCores(s string) int {
if strings.HasSuffix(s, "m") {
n, _ := strconv.Atoi(strings.TrimSuffix(s, "m"))
return n
}
n, _ := strconv.Atoi(s)
return n * 1000
}
// math_test.go
func TestMilliCores(t *testing.T) {
if got := MilliCores("250m"); got != 250 {
t.Errorf("got %d, want 250", got)
}
}
go test ./... # run every test
go vet ./... # catch suspicious code
gofmt -w . # format (one canonical style)
The toolchain is the selling point: go test runs tests, go vet finds bugs, gofmt ends all formatting debates, and go build cross-compiles. Wire go test ./... and go vet ./... into CI.
Go's superpower for containers: a statically linked binary in a scratch/distroless image, often under 20MB.
# ---- build stage ----
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./...
# ---- runtime stage (tiny, no OS) ----
FROM gcr.io/distroless/static
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
CGO_ENABLED=0 produces a fully static binary with no libc dependency, so it runs in an empty image. Cross-compile for any platform with GOOS/GOARCH (GOOS=linux GOARCH=arm64 go build). This is why Go services have such small, secure container images.
error — check if err != nil, wrap with context (%w), never ignore with _.context.Context for cancellation/timeouts on requests and goroutines — pass it as the first arg.CGO_ENABLED=0) into distroless/scratch; run as non-root.go test ./..., go vet ./..., gofmt in CI — non-negotiable.net/http, encoding/json are production-grade.server.Shutdown(ctx) so in-flight requests finish.ReadTimeout, client Timeout).go.mod/go.sum (committed)._. Go makes errors explicit for a reason — handle or wrap them.context and WaitGroup.sync.Mutex; run tests with -race.scratch/distroless; use CGO_ENABLED=0.net/http is enough for most services.panic is for unrecoverable bugs, not a failed API call.go mod init, write hello world, go build, and confirm the binary runs with no Go installed elsewhere (it's static)./healthz and a JSON /api/status, then curl both._test.go for it, and run go test ./... and go vet ./....CGO_ENABLED=0 into distroless; check the final image size with docker images.Self-check:
CGO_ENABLED=0 for container builds?You now have the loop: module → types → errors → concurrency → HTTP service → test → static image. That's Go the way infrastructure is built.