How To Create Health Check For RESTful Microservice In Golang

Imagine you’ve recently released and deployed to production a cool RESTful microservice you worked on for a while. You heaved a sigh of relief just to hear from Ops team that your service is unstable. You are damn sure that the service should be fine, but you get a feeling that there could be something wrong with services it depends on. What should you do?

Health check will come to your rescue. It is an endpoint in your service returning status of your application including statuses of connections to all external services your service directly depends on. In this post I’ll show how to create a health check for a microservice running on multiple nodes, storing its state in MongoDB and calling Elasticsearch.

If you raised an eyebrow, surprised by why your service should monitor external services… You are right, external services must be monitored independently. In practice, however, some checks may be temporarily down. Nothing is more permanent than the temporary. So it’s a good practice to include your direct dependencies in service status, so you (and Ops) always know what’s broken.

Design

As I alluded earlier, imagine you have a microservice running on multiple nodes, keeping state in MongoDB and calling Elasticsearch. What health check should look like for such a service?

Let’s address the question from different aspects.

Endpoint

An easy one. Let’s follow industry naming convention and call the endpoint /health.

Format

For RESTful service, you should always return HTTP status code 200 and the state as content in JSON format.

Content

This is an interesting one. Response content must reflect health of all critical parts of the service. In our case they are nodes, connection to MongoDB and connection to Elasticsearch. Represented as Golang struct, health status may look like below.

type HealthStatus struct {
	Nodes   map[string]string `json:"nodes"`
	Mongo   string `json:"mongo"`
	Elastic string `json:"elastic"`
}

Implementation

A descriptive way to demonstrate how health check fits in a microservice is to show it together with other modules it collaborates with. A skeleton of my example will have the following modules:

  • main
  • mongo
  • elastic
  • health
main module

main module just sets up the service:

package main

import (
	"encoding/json"
	"github.com/ypitsishin/code-with-yury-examples/healthcheck/elastic"
	"github.com/ypitsishin/code-with-yury-examples/healthcheck/health"
	"github.com/ypitsishin/code-with-yury-examples/healthcheck/mongo"
	"net/http"
)

func main() {
	healthService := health.New([]string{"node1", "node2", "node3"}, mongo.New(), elastic.New())
	http.HandleFunc("/health", statusHandler(healthService))
	http.ListenAndServe("localhost:8080", nil)
}

func statusHandler(healthService health.Service) func(http.ResponseWriter, *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		bytes, err := json.MarshalIndent(healthService.Health(), "", "\t")
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Write(bytes)
	}
}

Note that health service needs access to both mongo and elastic modules.

mongo and elastic modules

I’ll use rand package to simulate random errors occurring in MongoDB, Elasticsearch and nodes. A simple simulated mongo module is below. elastic module is similar.

package mongo

import (
	"math/rand"
	"errors"
)

type Service interface {
	Health() error
	// Business methods go here
}

func New() Service {
	return &service{}
}

type service struct {
	// Some fields
}

func (s *service) Health() error {
	if rand.Intn(2) > 0 {
		return errors.New("Service unavailable")
	}
	return nil
}
health module

And finally health module itself:

package health

import (
	"github.com/ypitsishin/code-with-yury-examples/healthcheck/mongo"
	"github.com/ypitsishin/code-with-yury-examples/healthcheck/elastic"
	"math/rand"
	"fmt"
)

type HealthStatus struct {
	Nodes   map[string]string `json:"nodes"`
	Mongo   string `json:"mongo"`
	Elastic string `json:"elastic"`
}

type Service interface {
	Health() HealthStatus
}

type service struct {
	nodes   []string
	mongo   mongo.Service
	elastic elastic.Service
}

func New(nodes []string, mongo mongo.Service, elastic elastic.Service) Service {
	return &service{
		nodes: nodes,
		mongo: mongo,
		elastic: elastic,
	}
}

func (s *service) Health() HealthStatus {
	nodesStatus := make(map[string]string)
	for _, n := range s.nodes {
		if rand.Intn(10) > 7 {
			nodesStatus[n] = "Node ERROR: Node not responding"
		} else {
			nodesStatus[n] = "OK"
		}
	}

	mongoStatus := "OK"
	if err := s.mongo.Health(); err != nil {
		mongoStatus = fmt.Sprintf("Mongo ERROR: %s", err)
	}

	elasticStatus := "OK"
	if err := s.elastic.Health(); err != nil {
		elasticStatus = fmt.Sprintf("Elastic ERROR: %s", err)
	}

	return HealthStatus{
		Nodes: nodesStatus,
		Mongo: mongoStatus,
		Elastic: elasticStatus,
	}
}

Note that error messages follow pattern <service> ERROR: <detail>. This is important as health status messages are intended to be consumed by monitoring systems, e.g. Sensu, and should be easy to parse.

Testing

Calling health check via curl

curl localhost:8080/health

outputs

{
	"nodes": {
		"node1": "OK",
		"node2": "OK",
		"node3": "OK"
	},
	"mongo": "Mongo ERROR: Service unavailable",
	"elastic": "OK"
}

Every time you run curl command may result in different output, because errors are randomised.