Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ COPY go.mod ./

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o lb cmd/lb/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -o lb ./cmd/lb

FROM alpine:latest

Expand Down
19 changes: 19 additions & 0 deletions Dockerfile.backend
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM golang:1.25.4-alpine AS builder

WORKDIR /app

COPY go.mod ./

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o server cmd/server/main.go

FROM alpine:latest

WORKDIR /root/

COPY --from=builder /app/server .

EXPOSE 80

ENTRYPOINT ["./server"]
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ style LB fill:#d4edfc,stroke:#0052cc,stroke-width:2px
- 🔒 **Thread-Safe Design**: Uses `sync.RWMutex` to manage concurrent reads/writes to the server pool status.
- 🚀 **Atomic Operations**: Uses `sync/atomic` for the request counter to avoid locking bottlenecks in the hot path.
- 🐳 **Docker Native**: Fully containerized with a Multi-Stage Build (Alpine based) for a lightweight production image.
- 📊 **Real-time Stats**: Exposes a `/stats` endpoint providing live metrics (uptime, memory usage) for each backend.

## 🚀 Getting Started

Expand Down Expand Up @@ -84,6 +85,27 @@ Status change: http://app2:80 [down]

Now, run `curl` again. You will notice that traffic is never routed to the stopped server.

### 3. Check Statistics

You can monitor the health and resource usage of your backends in real-time:

```bash
curl http://localhost:3030/stats | jq
```

Output:
```json
[
{
"url": "http://app1:80",
"alive": true,
"uptime": "00h:05m:23s",
"memory_usage": "1.2 MB"
},
...
]
```

## 🧠 Technical Highlights

### Concurrency & Safety
Expand All @@ -99,11 +121,18 @@ For the Round-Robin index, I chose `atomic.AddUint64` instead of a standard Mute

**Why?** Mutexes are expensive. In a high-load scenario (10k req/sec), locking the counter for every request creates a bottleneck. Atomic CPU instructions are non-blocking and significantly faster.

### Worker Pool for Stats

To prevent goroutine leaks when fetching statistics from potentially slow backends, I implemented a **Worker Pool** pattern.

- A fixed number of workers (3) consume update tasks from a buffered channel.
- If the channel is full (backpressure), new updates are skipped until workers are available.
- This ensures the main health check loop is never blocked by slow network calls.

## 🔮 Future Improvements

- [ ] Implement Weighted Round-Robin for servers with different capacities.
- [ ] Add Least Connections algorithm.
- [ ] Expose a `/stats` endpoint for monitoring (Prometheus metrics).

---

Expand Down
63 changes: 60 additions & 3 deletions cmd/lb/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"log"
Expand Down Expand Up @@ -46,6 +47,17 @@ var serverPool core.ServerPool
// waiting 20s before checking again
func healthCheck() {
t := time.NewTicker(20 * time.Second)

// Worker pool for stats updates
jobs := make(chan *core.Backend, len(serverPool.Backends))
for i := 0; i < 3; i++ { // 3 workers
go func() {
for b := range jobs {
updateBackendStats(b)
}
}()
}

for range t.C {
for _, b := range serverPool.Backends {
alive := isBackendAlive(b.URL)
Expand All @@ -59,10 +71,48 @@ func healthCheck() {

b.SetAlive(alive)
}

if alive {
// Non-blocking send to avoid blocking the health check loop
select {
case jobs <- b:
default:
log.Printf("Worker pool full, skipping stats update for %s", b.URL)
}
}
Comment thread
P4ST4S marked this conversation as resolved.
}
}
}

type HealthResponse struct {
MemoryUsage uint64 `json:"memory_usage"`
}
Comment thread
P4ST4S marked this conversation as resolved.

func updateBackendStats(b *core.Backend) {
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(b.URL.String() + "/health")
if err != nil {
log.Printf("Error fetching stats from %s: %s", b.URL, err)
return
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
log.Printf("Error fetching stats from %s: status %d", b.URL, resp.StatusCode)
return
}

var health HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
log.Printf("Error decoding stats from %s: %s", b.URL, err)
return
}

b.SetMemoryUsage(health.MemoryUsage)
}

// isBackendAlive checks whether a backend is alive by establishing a TCP connection
func isBackendAlive(u *url.URL) bool {
timeout := 2 * time.Second
Expand All @@ -89,9 +139,15 @@ func main() {
log.Fatal("Please provide one or more backends using -backends")
}

// Register handlers
http.HandleFunc("/", lbHandler)
Comment thread
P4ST4S marked this conversation as resolved.
http.HandleFunc("/stats", statsHandler)
// The /stats endpoint is intentionally public in production environments.
// If you need to restrict access, add authentication here.

// Parse servers
tokens := strings.Split(serverList, ",")
for _, tok := range tokens {
tokens := strings.SplitSeq(serverList, ",")
for tok := range tokens {
serverUrl, err := url.Parse(tok)
if err != nil {
log.Fatal(err)
Expand All @@ -112,14 +168,15 @@ func main() {
URL: serverUrl,
Alive: true, // We assume they are alive at startup
ReverseProxy: proxy,
StartTime: time.Now(),
})
log.Printf("Configured server: %s\n", serverUrl)
}

// Create HTTP server
server := http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: http.HandlerFunc(lbHandler),
Handler: nil, // Use DefaultServeMux
}

// Start health checking in a separate goroutine
Expand Down
20 changes: 20 additions & 0 deletions cmd/lb/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"encoding/json"
"net/http"
)

// statsHandler returns the current status of the server pool
func statsHandler(w http.ResponseWriter, r *http.Request) {
stats := serverPool.GetStats()

data, err := json.Marshal(stats)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
40 changes: 40 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"runtime"
)

type HealthResponse struct {
MemoryUsage uint64 `json:"memory_usage"`
}

func main() {
port := os.Getenv("PORT")
if port == "" {
port = "80"
}

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from backend! I am running on %s\n", os.Getenv("HOSTNAME"))
})

http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
var m runtime.MemStats
runtime.ReadMemStats(&m)

resp := HealthResponse{
MemoryUsage: m.Alloc,
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})

log.Printf("Backend server starting on port %s...", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
60 changes: 59 additions & 1 deletion core/backend.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package core

import (
"fmt"
"net/http/httputil"
"net/url"
"sync"
"time"
)

// Backend is our unique server instance
Expand All @@ -13,13 +15,20 @@ type Backend struct {
Alive bool
Mux sync.RWMutex
ReverseProxy *httputil.ReverseProxy
StartTime time.Time
MemoryUsage uint64
}

// SetAlive is a thread-safe way to set the alive status of the backend
func (b *Backend) SetAlive(alive bool) {
b.Mux.Lock()
defer b.Mux.Unlock()

// If we are transitioning to alive, reset the timer
if alive && !b.Alive {
b.StartTime = time.Now()
}
b.Alive = alive
b.Mux.Unlock()
}

// IsAlive is a thread-safe way to read the alive status of the backend
Expand All @@ -29,3 +38,52 @@ func (b *Backend) IsAlive() (alive bool) {
b.Mux.RUnlock()
return
}

// GetUpTime returns the uptime in a human-readable format
func (b *Backend) GetUpTime() string {
return formatSecondsToDuration(b.GetUpTimeInSeconds())
}

// GetUpTimeInSeconds returns the uptime in seconds
func (b *Backend) GetUpTimeInSeconds() uint64 {
b.Mux.RLock()
defer b.Mux.RUnlock()
if !b.Alive {
return 0
}
return uint64(time.Since(b.StartTime).Seconds())
}

// SetMemoryUsage sets the memory usage of the backend
func (b *Backend) SetMemoryUsage(mem uint64) {
b.Mux.Lock()
defer b.Mux.Unlock()
b.MemoryUsage = mem
}

// GetMemoryUsage returns the current memory usage
func (b *Backend) GetMemoryUsage() uint64 {
b.Mux.RLock()
defer b.Mux.RUnlock()
return b.MemoryUsage
}

// GetMemoryUsageString returns the memory usage in a human-readable format
func (b *Backend) GetMemoryUsageString() string {
mem := b.GetMemoryUsage()
if mem < 1024 {
return fmt.Sprintf("%d B", mem)
} else if mem < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(mem)/1024)
} else {
return fmt.Sprintf("%.2f MB", float64(mem)/(1024*1024))
}
}

// BackendStats represents the statistics of a backend server
type BackendStats struct {
URL string `json:"url"`
Alive bool `json:"alive"`
UpTime string `json:"uptime"`
MemoryUsage string `json:"memory_usage"`
}
Loading
Loading