English
Implement a web service with health checks using Golang + Gin

In the previous two chapters, we discussed the architecture of the high-availability entry point and the principles of the VRRP protocol. Starting from this chapter, we move into the real hands-on phase.
We will use the Go language and the Gin framework to implement a Can report one's own identity Web service. This service has two core interfaces:
/health: Health check endpoint that tells Nginx whether it is alive/api/v1/task: Business interface, simulate task dispatch
In subsequent chapters, we will deploy this service across three machines, and work with Nginx + Keepalived to build a complete high-availability entry point.
1. Why do we need “talking” Web services?
When building a high-availability entry point, we need a Can verify whether the architecture is working properly the backend service.
This service needs to have the following capabilities:
| Ability | Use |
|---|---|
| Report identity | Tell us which machine handled the request (to verify VIP drift) |
| Health check | Provide a /health interface for Nginx/Keepalived probing |
| Simulated business | Provide a business interface to verify whether the reverse proxy is working properly. |
| Simulated fault | Provide slow endpoints and error endpoints to test circuit breaking and removal logic. |
This is what we mean by a “conversational” service—it can not only handle requests, but also tell the client “who I am.”
II. Project Initialization
2.1 Create Project Directory
bash
mkdir -p $GOPATH/src/github.com/fishfinal/go-gateway-demo
cd $GOPATH/src/github.com/fishfinal/go-gateway-demo
go mod init github.com/fishfinal/go-gateway-demo2.2 Install Gin Dependencies
bash
go get -u github.com/gin-gonic/gin2.3 Project Structure
go-gateway-demo/
├── go.mod
├── go.sum
├── main.go # Main program
├── Dockerfile # Containerized deployment (optional)
└── docker-compose.yml # Three-node simulation (optional)III. Complete Code Implementation
3.1 Main Program main.go
go
package main
import (
"fmt"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
// Server encapsulates service information
type Server struct {
hostname string
localIP string
port string
}
// NewServer creates a new server instance
func NewServer(port string) *Server {
hostname, _ := os.Hostname()
localIP := getLocalIP()
return &Server{
hostname: hostname,
localIP: localIP,
port: port,
}
}
// getLocalIP retrieves the local non-loopback IPv4 address
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "unknown"
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
return "unknown"
}
// setupRouter configures the routing
func (s *Server) setupRouter() *gin.Engine {
r := gin.Default()
// Health check endpoint (for Nginx/Keepalived)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"hostname": s.hostname,
"ip": s.localIP,
"timestamp": time.Now().Unix(),
})
})
// Business endpoint: simulate task dispatching
r.POST("/api/v1/task", func(c *gin.Context) {
var req map[string]interface{}
if err := c.BindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
c.JSON(200, gin.H{
"code": 0,
"message": "task accepted",
"processed_by": gin.H{
"hostname": s.hostname,
"ip": s.localIP,
},
"task": req,
})
})
// Simulated slow endpoint: for timeout testing
r.GET("/api/slow", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.JSON(200, gin.H{
"message": "slow response",
"processed_by": s.hostname,
})
})
// Simulated error endpoint: for failure detection testing
r.GET("/api/error", func(c *gin.Context) {
c.JSON(500, gin.H{
"error": "internal server error",
"processed_by": s.hostname,
})
})
// Debug endpoint: view service information
r.GET("/info", func(c *gin.Context) {
c.JSON(200, gin.H{
"hostname": s.hostname,
"ip": s.localIP,
"port": s.port,
})
})
return r
}
// Run starts the server
func (s *Server) Run() error {
r := s.setupRouter()
addr := ":" + s.port
fmt.Printf("🚀 Server starting on %s\n", addr)
fmt.Printf("📡 Hostname: %s\n", s.hostname)
fmt.Printf("🌐 IP: %s\n", s.localIP)
fmt.Printf("✅ Health check: http://localhost%s/health\n", addr)
fmt.Printf("📋 Task API: POST http://localhost%s/api/v1/task\n", addr)
return r.Run(addr)
}
func main() {
// Read port from environment variable, default to 8080
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := NewServer(port)
// Graceful shutdown handling
go func() {
if err := server.Run(); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\n🛑 Shutting down server...")
}3.2 Code Structure Description
3.3 Core Function Verification
Start service:
bash
go run main.go
# Output:
# 🚀 Server starting on :8080
# 📡 Hostname: ubuntu-node1
# 🌐 IP: 192.168.0.201
# ✅ Health check: http://localhost:8080/health
# 📋 Task API: POST http://localhost:8080/api/v1/taskVerify health check:
bash
curl http://localhost:8080/health | jq .
# Response:
{
"status": "ok",
"hostname": "ubuntu-node1",
"ip": "192.168.0.201",
"timestamp": 1749876543
}Verify business interface:
bash
curl -X POST http://localhost:8080/api/v1/task \
-H "Content-Type: application/json" \
-d '{"task_id": "task-001", "target": "192.168.0.0/24"}' | jq .
# Response:
{
"code": 0,
"message": "task accepted",
"processed_by": {
"hostname": "ubuntu-node1",
"ip": "192.168.0.201"
},
"task": {
"task_id": "task-001",
"target": "192.168.0.0/24"
}
}Verify slow API:
bash
time curl http://localhost:8080/api/slow
# real 0m5.012sIV. Three-Node Simulation Environment (Docker Compose)
To simulate a three-machine cluster on a single computer, we use Docker Compose + a custom bridge network.
4.1 docker-compose.yml
yaml
services:
node1:
build: .
container_name: gateway-node1
environment:
- PORT=8080
networks:
ha_net:
ipv4_address: 192.168.0.201
ports:
- "8081:8080" # Map to the host machine for convenient standalone debugging.
restart: unless-stopped
node2:
build: .
container_name: gateway-node2
environment:
- PORT=8080
networks:
ha_net:
ipv4_address: 192.168.0.202
ports:
- "8082:8080"
restart: unless-stopped
node3:
build: .
container_name: gateway-node3
environment:
- PORT=8080
networks:
ha_net:
ipv4_address: 192.168.0.203
ports:
- "8083:8080"
restart: unless-stopped
networks:
ha_net:
driver: bridge
ipam:
config:
- subnet: 192.168.0.0/24
gateway: 192.168.0.14.2 Dockerfile
dockerfile
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o gateway main.go
FROM alpine:3.24
RUN apk --no-cache add ca-certificates curl
WORKDIR /root/
COPY --from=builder /app/gateway .
EXPOSE 8080
CMD ["./gateway"]4.3 Start the three-node cluster
4.3.1 Build and Start
bash
docker-compose up -d --buildThe specific build process and startup logs are similar to the following:
Compose can now delegate builds to bake for better performance.
To do so, set COMPOSE_BAKE=true.
[+] Building 81.1s (27/37) docker:desktop-linux
=> [node2 internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 314B 0.0s
=> [node3 internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 314B 0.0s
=> [node1 internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 314B 0.0s
=> [node3 internal] load metadata for docker.io/library/alpine:3.24 0.1s
=> [node3 internal] load metadata for docker.io/library/golang:1.21-alpine 0.1s
=> [node2 internal] load .dockerignore 0.1s
...
[+] Running 4/4
✔ Network go-gateway-demo_ha_net Created 0.1s
✔ Container gateway-node1 Started 1.3s
✔ Container gateway-node2 Started 1.5s
✔ Container gateway-node3 Started 1.2s4.3.2 View Running Status
bash
docker-compose psWill output information similar to the following:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
gateway-node1 go-gateway-demo-node1 "./gateway" node1 6 seconds ago Up 4 seconds 0.0.0.0:8081->8080/tcp
gateway-node2 go-gateway-demo-node2 "./gateway" node2 6 seconds ago Up 4 seconds 0.0.0.0:8082->8080/tcp
gateway-node3 go-gateway-demo-node3 "./gateway" node3 6 seconds ago Up 4 seconds 0.0.0.0:8083->8080/tcp4.3.3 Test the three nodes separately
bash
# Test the three nodes separately.
curl -s http://localhost:8081/health | jq .hostname
# d3a2b1c4e5f6 (The actual output is the first 12 characters of the container ID, which differs each time it is created)
curl -s http://localhost:8082/health | jq .hostname
# "a7b8c9d0e1f2"
curl -s http://localhost:8083/health | jq .hostname
# "f6e5d4c3b2a1"💡Why isn't it "gateway-node1"? The default hostname of a Docker container is the first 12 characters of the container ID (e.g.c1d70aac6b5f), rather than container_name. If you need to customize the hostname, you can docker-compose.yml add to hostname Configuration:
yaml
services:
node1:
hostname: gateway-node1
# ... Other configurationAfter adding, restart the container,/health The returned hostname will become a custom value. However, for functional verification, as long as it can distinguish that the three nodes are different containers, a random ID is completely sufficient.
V. Compile and Deploy to Physical Machines
5.1 Cross-compiling Linux binaries
bash
# Compile the Linux amd64 version
GOOS=linux GOARCH=amd64 go build -o gateway main.go
# View file information
file gateway
# gateway: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked5.2 Deploy to Server
bash
# Upload to the server (using node1 as an example)
scp gateway [email protected]:/usr/local/bin/
# Run on the server
ssh [email protected]
chmod +x /usr/local/bin/gateway
./gateway &
# Verify
curl http://localhost:8080/health💡 Note
It needs to be deployed separately on the three servers 192.168.0.201/202/203. It is recommended to use the following systemd method for deployment!
5.3 Configure as a Systemd Service (Recommended for Production)
5.3.1 Create a Service Definition Configuration File
bash
# /etc/systemd/system/gateway.service
cat > /etc/systemd/system/gateway.service << 'EOF'
[Unit]
Description=Gateway Health Check Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/lib/gateway/
ExecStart=/usr/local/bin/gateway
Restart=always
RestartSec=5
Environment="PORT=8080"
[Install]
WantedBy=multi-user.target
EOF5.3.2 Create necessary directories
bash
mkdir -p /var/lib/gateway5.3.3 Start and set auto-start on boot
bash
systemctl daemon-reload
systemctl enable gateway
systemctl start gateway5.3.4 View Status
bash
systemctl status gatewayVI. Description of the Practice Environment
The hands-on environment for this series uses the following home network configuration:
| Configuration item | value | Explanation |
|---|---|---|
| network segment | 192.168.0.0/24 | Standard home local area network |
| gateway | 192.168.0.1 | Router address |
| VIP | 192.168.0.200 | Virtual IP, used in subsequent chapters |
| node1 | 192.168.0.201 | Node 1, priority 100 |
| node2 | 192.168.0.202 | Node 2, priority 90 |
| node3 | 192.168.0.203 | Node 3, priority 80 |
💡 If your home network subnet is different (e.g.192.168.1.0/24), just need to take all 192.168.0.x Just replace it with your own network segment.
VII. Integration with Master Election (Preview)
In the subsequent chapter on distributed scanning engine integration, we will link this service with etcd election:
go
// Pseudocode example
func (m *Master) Start() {
if m.election.IsLeader() {
// Become Master, create identification file
os.Create("/tmp/is_master")
// Start scheduling logic
go m.schedule()
} else {
os.Remove("/tmp/is_master")
}
}Then the Nginx health check script can determine it like this:
bash
#!/bin/bash
# Return 200 only when the /tmp/is_master file exists.
if [ -f /tmp/is_master ]; then
exit 0
else
exit 1
fiThis way, Nginx will only forward requests to nodes that are truly the Master, achieving "product-transparent" high-availability scheduling.
VIII. Summary
In this chapter, we completed a complete Golang + Gin health check service with the following capabilities:
| Function | interface | Purpose |
|---|---|---|
| Health check | GET /health | Nginx/Keepalived probing |
| Business interface | POST /api/v1/task | Simulate task dispatch and verify identity |
| slow endpoint | GET /api/slow | Test timeout and circuit breaking |
| Error interface | GET /api/error | Test fault removal |
| Debug information | GET /info | View service identity |
and provides two deployment methods:
Docker Compose: Local three-node simulation, suitable for development and debugging
Systemd service: Production environment deployment, supports startup on boot and automatic recovery
In the next chapter, we will configure Nginx health checks and active circuit breaking so that Nginx can dynamically remove faulty nodes.
💡 This article is the 3rd installment of the "Distributed High-Availability Ingress Architecture in Practice Series"
📦Example code repository:https://github.com/fishfinal/go-gateway-demo
Previous post:VRRP Protocol and Keepalived: How Does the Virtual IP "Drift"?
Next article:Nginx upstream health check and passive circuit breaker configuration
