feat: initial Go patterns guide from stdlib + Kubernetes source study

9 pattern files covering stdlib (structs, interfaces, API conventions, docs, style),
Kubernetes (controller/reconciler, informer/cache, leader election, code generation),
comparison (stdlib vs K8s approaches), and anti-patterns.

All patterns cite exact source files and line numbers.
This commit is contained in:
Rodin
2026-04-30 06:34:02 +00:00
commit 0f1d7e4c06
9 changed files with 3335 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
# Stdlib vs Kubernetes: Where K8s Does Things Differently
## 1. Concurrency: Channels vs. Condition Variables + Sets
### Stdlib approach
Go stdlib idiom: communicate via channels. A worker pool typically uses a `chan T` for work items.
```go
// Typical stdlib pattern:
jobs := make(chan Job, 100)
for i := 0; i < workers; i++ {
go func() {
for job := range jobs {
process(job)
}
}()
}
```
### Kubernetes approach
The workqueue uses `sync.Cond` + `sets.Set` instead of channels.
**Source:** `staging/src/k8s.io/client-go/util/workqueue/queue.go` (lines 190300)
```go
type Typed[t comparable] struct {
queue Queue[t]
dirty sets.Set[t] // Items needing processing
processing sets.Set[t] // Items currently being processed
cond *sync.Cond // Notification mechanism
}
```
### Why K8s is different
Channels don't deduplicate. If you send the same key twice to a channel, it gets processed twice. K8s needs:
- **Deduplication**: same object modified 5 times → process once with latest state
- **Re-entrant marking**: if modified during processing, re-queue after Done()
- **Inspection**: can query queue length, processing count for metrics
A channel-based design would require a separate dedup layer anyway, losing its simplicity advantage.
---
## 2. Error Handling: Single Errors vs. Error Aggregation
### Stdlib approach
Return a single error. Wrap with `fmt.Errorf("...: %w", err)`.
### Kubernetes approach
**Source:** `staging/src/k8s.io/apimachinery/pkg/util/errors/errors.go` (lines 34100)
```go
type Aggregate interface {
error
Errors() []error
Is(error) bool
}
func NewAggregate(errlist []error) Aggregate {
// Filters nils, deduplicates messages
}
```
### Why K8s is different
A controller sync often does multiple operations (create 3 pods, update 2 services). You want to attempt all of them, not fail fast on the first error. Error aggregation collects all failures so the user sees the full picture.
**Also**: the `Aggregate` interface properly implements `errors.Is()` by checking if *any* contained error matches — which `errors.Join` didn't originally support well.
---
## 3. Retry: stdlib has nothing; K8s has structured retry
### Stdlib approach
There's no retry utility in stdlib. You write your own loop.
### Kubernetes approach
**Source:** `staging/src/k8s.io/client-go/util/retry/util.go` (lines 30100)
```go
var DefaultRetry = wait.Backoff{
Steps: 5,
Duration: 10 * time.Millisecond,
Factor: 1.0,
Jitter: 0.1,
}
func RetryOnConflict(backoff wait.Backoff, fn func() error) error {
// Retries only on HTTP 409 Conflict
return OnError(backoff, errors.IsConflict, fn)
}
```
### Why K8s is different
In a distributed system, retries are *the* primary reliability mechanism. Stdlib doesn't provide them because stdlib targets single-machine programs. Kubernetes needs:
- Configurable backoff (steps, factor, jitter, cap)
- Condition-based retry (retry only on specific error types)
- Context-aware cancellation
---
## 4. Polling: time.Ticker vs. Contextual Loop With Crash Protection
### Stdlib approach
```go
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
doWork()
}
```
### Kubernetes approach
**Source:** `staging/src/k8s.io/apimachinery/pkg/util/wait/backoff.go` (lines 240260), `loop.go` (lines 3880)
```go
func BackoffUntilWithContext(ctx context.Context, f func(ctx context.Context), backoff BackoffManager, sliding bool) {
for {
select {
case <-ctx.Done():
return
default:
}
if !sliding { t = backoff.Backoff() }
func() {
defer runtime.HandleCrashWithContext(ctx)
f(ctx)
}()
if sliding { t = backoff.Backoff() }
// ... wait for timer or context cancellation
}
}
```
### Why K8s is different
- **Crash protection**: a panic in `doWork()` shouldn't kill the whole process
- **Sliding vs non-sliding**: controls whether interval includes execution time
- **Context cancellation**: allows clean shutdown
- **Jitter**: prevents thundering herd when many controllers sync at similar intervals
- **Double-check for cancellation**: Go's select is non-deterministic, so short timers can "win" against a cancelled context
---
## 5. Graceful Shutdown: http.Server.Shutdown vs. Multi-Phase Orchestration
### Stdlib approach (net/http)
**Source:** `/tmp/go-src/src/net/http/server.go` (lines 32213260)
```go
func (s *Server) Shutdown(ctx context.Context) error {
s.inShutdown.Store(true)
s.closeListenersLocked()
// Poll for idle connections
for {
if s.closeIdleConns() { return nil }
select {
case <-ctx.Done(): return ctx.Err()
case <-timer.C: timer.Reset(nextPollInterval())
}
}
}
```
### Kubernetes approach
**Source:** `pkg/controller/deployment/deployment_controller.go` (lines 171196)
```go
func (dc *DeploymentController) Run(ctx context.Context, workers int) {
defer utilruntime.HandleCrash()
dc.eventBroadcaster.StartStructuredLogging(3)
dc.eventBroadcaster.StartRecordingToSink(...)
defer dc.eventBroadcaster.Shutdown()
var wg sync.WaitGroup
defer func() {
dc.queue.ShutDown() // Stop accepting new work
wg.Wait() // Wait for workers to finish
}()
// Gate: don't start until caches are synced
if !cache.WaitForNamedCacheSyncWithContext(ctx, ...) { return }
for i := 0; i < workers; i++ {
wg.Go(func() {
wait.UntilWithContext(ctx, dc.worker, time.Second)
})
}
<-ctx.Done() // Block until context cancelled
}
```
### Why K8s is different
Stdlib's shutdown is **reactive** (wait for connections to drain). Kubernetes' shutdown is **multi-phase orchestrated**:
1. Stop accepting new events (close watch connections)
2. Drain the work queue (process remaining items)
3. Wait for in-flight syncs to complete
4. Shut down event recorders
The queue's `ShutDownWithDrain()` is the K8s-specific innovation: it waits until all in-flight items call `Done()`.
---
## 6. Type Systems: interfaces vs. Runtime Type Registry
### Stdlib approach
Interfaces for polymorphism. If you need to serialize, use `encoding/json` with struct tags.
### Kubernetes approach
A full runtime type registry (Scheme) that maps between GVK strings and Go types.
**Source:** `staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go`
### Why K8s is different
Stdlib's `encoding/json` requires knowing the concrete type at compile time. Kubernetes must:
- Deserialize objects from the network without knowing their type in advance
- Convert between API versions (`v1beta1.Deployment``v1.Deployment`)
- Support third-party types (CRDs) that don't exist at compile time
- Apply defaulting and validation based on type metadata
This forces a **runtime type system layered on top of Go's static types**.
---
## 7. Testing: httptest vs. Fake Clients + Reactors
### Stdlib approach
`net/http/httptest` provides a test server. You make real HTTP calls against it.
### Kubernetes approach
Fake clientsets with reactor chains:
```go
// Generated fake clients intercept API calls
fakeClient := fake.NewSimpleClientset(existingObjects...)
fakeClient.PrependReactor("create", "pods", func(action testing.Action) (bool, runtime.Object, error) {
// Custom test behavior
return true, nil, fmt.Errorf("simulated error")
})
```
### Why K8s is different
Testing a controller doesn't require a running API server. The fake client + informer pattern lets you:
- Inject specific starting states
- Simulate failures at specific operations
- Run synchronously (no network delay)
- Test the controller logic in isolation
---
## 8. Lifecycle: main() returns vs. Infinite Reconciliation
### Stdlib pattern
Programs start, do work, return.
### Kubernetes pattern
Controllers start and run *forever*, continuously reconciling.
```go
// The fundamental difference: stdlib programs terminate, controllers don't
func main() {
// Stdlib: do work, exit
result := compute()
fmt.Println(result)
}
// Kubernetes: infinite loop with eventual consistency
func (c *Controller) Run(ctx context.Context) {
// Run forever until context cancelled
for i := 0; i < workers; i++ {
go wait.UntilWithContext(ctx, c.worker, time.Second)
}
<-ctx.Done()
}
```
### Why K8s is different
The real world is adversarial. Networks fail, nodes die, humans make mistakes. A one-shot program can't handle drift. The reconciliation loop is Kubernetes' answer to the CAP theorem: you can't guarantee consistency in a single call, but you can achieve it *eventually* through repetition.
---
## 9. Shared State: Package-Level vs. Shared Informer Cache
### Stdlib approach
Package-level variables, or pass state through function parameters.
### Kubernetes approach
The SharedInformerFactory creates a single in-memory cache per resource type, shared by all controllers in the process.
```go
// All controllers share ONE watch and ONE cache per resource:
informerFactory := informers.NewSharedInformerFactory(client, resyncPeriod)
deployInformer := informerFactory.Apps().V1().Deployments()
// Controller A and B both get events from the same informer
deployInformer.Informer().AddEventHandler(controllerA)
deployInformer.Informer().AddEventHandler(controllerB)
```
### Why K8s is different
Without sharing:
- 20 controllers × watch for Pods = 20 TCP connections to API server
- 20 copies of all Pods in memory
With SharedInformerFactory:
- 1 TCP connection for Pods
- 1 copy in memory
- Events fanned out to all registered handlers
---
## 10. Configuration: Flags/Env vs. Feature Gates
### Stdlib approach
`flag` package, environment variables, config files.
### Kubernetes approach
Feature gates: a versioned, lifecycle-aware configuration system.
### Why K8s is different
Stdlib's flag package is for a single binary. Kubernetes has:
- Hundreds of features in various stages of maturity
- Features that must be consistent across control plane components
- Features that need to be enabled/disabled without redeployment
- Features with dependencies on other features
- Automated testing that exercises all combinations
Feature gates encode *maturity* (alpha/beta/GA) alongside the boolean value, something `flag.Bool` can never express.