Microservices in Go: From Idea to Deployment
Step-by-step guide to building a production-ready microservices architecture in Go. We'll cover gRPC, Docker, Kubernetes, and everything you need for real-world deployment.
Why Go for Microservices?
Go is practically built for microservices:
- Small binaries — typically 10-20MB, perfect for containers
- Fast startup — milliseconds, not seconds
- Built-in concurrency — goroutines handle thousands of connections
- Simple deployment — single binary, no runtime dependencies
- Excellent tooling — gRPC, Docker, K8s all have first-class Go support
Architecture Overview
We'll build a simple e-commerce system with three services:
- Product Service — manages product catalog
- Order Service — handles orders and payments
- User Service — authentication and user management
Step 1: Define Service Contracts with Protobuf
Start with clear API contracts using Protocol Buffers:
// proto/product/product.proto
syntax = "proto3";
package product;
option go_package = "github.com/yourcompany/services/product";
service ProductService {
rpc GetProduct(GetProductRequest) returns (Product);
rpc ListProducts(ListProductsRequest) returns (ProductList);
rpc CreateProduct(CreateProductRequest) returns (Product);
}
message Product {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 stock = 5;
}
message GetProductRequest {
string id = 1;
}
message ListProductsRequest {
int32 page = 1;
int32 limit = 2;
}
Step 2: Implement the Service
Generate Go code and implement the service:
# Generate Go code from protobuf
protoc --go_out=. --go-grpc_out=. proto/product/*.proto
// services/product/server.go
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "github.com/yourcompany/services/product"
)
type server struct {
pb.UnimplementedProductServiceServer
db *sql.DB
}
func (s *server) GetProduct(ctx context.Context, req *pb.GetProductRequest) (*pb.Product, error) {
// Add tracing
span, ctx := opentracing.StartSpanFromContext(ctx, "GetProduct")
defer span.Finish()
var product pb.Product
err := s.db.QueryRowContext(ctx,
"SELECT id, name, description, price, stock FROM products WHERE id = $1",
req.Id,
).Scan(&product.Id, &product.Name, &product.Description, &product.Price, &product.Stock)
if err != nil {
return nil, status.Errorf(codes.NotFound, "product not found: %v", err)
}
return &product, nil
}
func main() {
// Setup database
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Setup gRPC server
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
// Add interceptors for logging, auth, metrics
s := grpc.NewServer(
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
grpc_prometheus.UnaryServerInterceptor,
grpc_zap.UnaryServerInterceptor(logger),
grpc_recovery.UnaryServerInterceptor(),
)),
)
pb.RegisterProductServiceServer(s, &server{db: db})
log.Printf("Server starting on port 50051")
if err := s.Serve(lis); err != nil {
log.Fatal(err)
}
}
Step 3: Add Service Discovery
Use Consul or etcd for service discovery:
// pkg/discovery/consul.go
type ServiceDiscovery struct {
client *consul.Client
}
func (sd *ServiceDiscovery) Register(name, address string, port int) error {
return sd.client.Agent().ServiceRegister(&consul.AgentServiceRegistration{
ID: fmt.Sprintf("%s-%s", name, uuid.New()),
Name: name,
Port: port,
Address: address,
Check: &consul.AgentServiceCheck{
GRPC: fmt.Sprintf("%s:%d", address, port),
Interval: "10s",
DeregisterCriticalServiceAfter: "30s",
},
})
}
func (sd *ServiceDiscovery) Discover(name string) ([]string, error) {
services, _, err := sd.client.Health().Service(name, "", true, nil)
if err != nil {
return nil, err
}
var addresses []string
for _, service := range services {
addr := fmt.Sprintf("%s:%d", service.Service.Address, service.Service.Port)
addresses = append(addresses, addr)
}
return addresses, nil
}
Step 4: Inter-Service Communication
Implement resilient service-to-service communication:
// pkg/client/grpc_client.go
type GRPCClient struct {
conn *grpc.ClientConn
breaker *gobreaker.CircuitBreaker
}
func NewGRPCClient(serviceName string, discovery ServiceDiscovery) (*GRPCClient, error) {
// Circuit breaker settings
settings := gobreaker.Settings{
Name: serviceName,
MaxRequests: 3,
Interval: time.Second * 10,
Timeout: time.Second * 30,
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
}
cb := gobreaker.NewCircuitBreaker(settings)
// Load balancing with service discovery
addresses, err := discovery.Discover(serviceName)
if err != nil {
return nil, err
}
// gRPC with retries and timeout
conn, err := grpc.Dial(
addresses[0], // In production, use proper load balancer
grpc.WithInsecure(),
grpc.WithUnaryInterceptor(grpc_retry.UnaryClientInterceptor(
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffExponential(100*time.Millisecond)),
)),
grpc.WithTimeout(5*time.Second),
)
return &GRPCClient{conn: conn, breaker: cb}, nil
}
Step 5: Containerize with Docker
Multi-stage build for minimal image size:
# Dockerfile
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
COPY --from=builder /app/configs ./configs
EXPOSE 50051
CMD ["./main"]
Step 6: Deploy to Kubernetes
Kubernetes manifests for production deployment:
# k8s/product-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: product-service
labels:
app: product-service
spec:
replicas: 3
selector:
matchLabels:
app: product-service
template:
metadata:
labels:
app: product-service
spec:
containers:
- name: product-service
image: yourcompany/product-service:v1.0.0
ports:
- containerPort: 50051
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
livenessProbe:
grpc:
port: 50051
initialDelaySeconds: 10
readinessProbe:
grpc:
port: 50051
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: product-service
spec:
selector:
app: product-service
ports:
- protocol: TCP
port: 50051
targetPort: 50051
type: ClusterIP
Step 7: Add Observability
Monitoring, logging, and tracing are crucial:
// pkg/observability/setup.go
func SetupObservability() {
// Prometheus metrics
prometheus.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
// Jaeger tracing
cfg := config.Configuration{
ServiceName: os.Getenv("SERVICE_NAME"),
Sampler: &config.SamplerConfig{
Type: "const",
Param: 1,
},
Reporter: &config.ReporterConfig{
LogSpans: true,
LocalAgentHostPort: "jaeger:6831",
},
}
tracer, closer, _ := cfg.NewTracer()
opentracing.SetGlobalTracer(tracer)
defer closer.Close()
// Structured logging with Zap
logger, _ := zap.NewProduction()
defer logger.Sync()
zap.ReplaceGlobals(logger)
}
Step 8: API Gateway
Expose services through a unified API gateway:
// gateway/main.go
func main() {
r := gin.New()
// Middleware
r.Use(gin.Recovery())
r.Use(cors.Default())
r.Use(RateLimitMiddleware())
r.Use(AuthMiddleware())
// Product endpoints
productClient := pb.NewProductServiceClient(getGRPCConn("product-service"))
r.GET("/api/v1/products/:id", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
product, err := productClient.GetProduct(ctx, &pb.GetProductRequest{
Id: c.Param("id"),
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, product)
})
r.Run(":8080")
}
Production Checklist
- ✅ Health checks and readiness probes
- ✅ Graceful shutdown
- ✅ Circuit breakers for external calls
- ✅ Distributed tracing
- ✅ Structured logging
- ✅ Metrics and monitoring
- ✅ Rate limiting
- ✅ Authentication & authorization
- ✅ Database migrations
- ✅ Configuration management
- ✅ Secrets management
- ✅ Horizontal pod autoscaling
Common Pitfalls to Avoid
- Not handling context properly — always propagate context for cancellation
- Ignoring idempotency — make operations retry-safe
- No circuit breakers — cascading failures will kill your system
- Synchronous everything — use message queues for long operations
- No distributed tracing — debugging becomes impossible
Conclusion
Building microservices in Go is straightforward when you follow best practices. Start simple, add complexity as needed, and always prioritize observability and resilience. The combination of Go's simplicity and the ecosystem's maturity makes it an excellent choice for microservices architecture.
Next Steps
- Add event-driven communication with NATS or Kafka
- Implement CQRS and Event Sourcing patterns
- Set up GitOps with ArgoCD
- Add service mesh with Istio
- Implement distributed caching with Redis