Introduction to Microservices in Go, part 1

This is a very simple example about how to build a microservice in Go. It’s meant for a quick Go and Microservices tutorial series covering from the a RESTful API to gRPC on Kubernetes.

The purpose of this microservice is a catalog of movies. The code is at https://github.com/johandry/micro-media-service and every section is a branch, clone the repo and change branch for every section.

git clone https://github.com/johandry/micro-media-service

A simple RESTful API

Let’s start with a simple RESTfull API by making a simple web server. Get this section code by checking out the branch s01-restful-api:

git checkout s01-restful-api

All the microservice terminal output is done using the log package instead of the fmt's prints. So let’s start this example with

package main

import "log"

const port = 8086

func main() {
	log.Printf("Starting movies microservice on port %d", port)
}

Now lets create a simple web server to print the microservice version when we hit the URL /api/v1/version

 1package main
 2
 3import (
 4	"fmt"
 5	"log"
 6	"net/http"
 7)
 8
 9const port = 8086
10
11var version = "0.1.0"
12
13func main() {
14	http.HandleFunc("/api/v1/version", handleVersion)
15
16	log.Printf("Starting movies microservice on port %d", port)
17	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
18}
19
20func handleVersion(rw http.ResponseWriter, r *http.Request) {
21	fmt.Fprintf(rw, "Version: %s", version)
22}

At line #4 we call the HandleFunc() method to create a Handler type on the default handler DefaultServerMux from the net/http package, mapping the path "/api/v1/version" to the function handleVersion() defined at line #20.

Then at line #17 we start the HTTP server with ListenAndServe() that takes two parameters, the TCP network address to bind the server and the handler to route the requests. In this example the bind address is ":8086" (port 8086 on every available IP) and, as the handler is nil, it will use the default one (DefaultServerMux).

The return value of ListenAndServe() is an error and if there is any (the error is not nil) it will be printed by log.Fatal method and exit with code 1 by calling os.Exit(1). The ListenAndServe() method blocks the program until there is an error or someone stop it.

To view your masterpiece in action, start your program with go run main.go and open the URL http://localhost8086/api/v1/version in a browser or with curl.

What will happen if you start the program twice?

Let’s speak in JSON

The output in this first example is the version number in plain text, now let’s see how can we print it in JSON format. The package encoding/json is going to help us to encode and decode JSON to/from Go type structures using the Marshal, Unmarshal, Encoder and Decoder functions.

To return the version we are going to encapsulate it in a structure (line #1 to #3), modify the global variable version to be of the defined Version type (line #5) and use the init() function to initialize it (line #8).

 1type Version struct {
 2	version string
 3}
 4
 5var version Version
 6
 7func init() {
 8	version = Version{"0.1.0"}
 9}
10
11func handleVersion(rw http.ResponseWriter, r *http.Request) {
12	verJSON, err := json.Marshal(version)
13	if err != nil {
14		panic("Error marshaling version")
15	}
16	fmt.Fprintf(rw, string(verJSON))
17}

The version handler function was also modified to marshal (to encode) the version variable to JSON. If there is an error marshaling the structure the program will panic, if not we convert verJSON to string because Marshal() returns the JSON as a []byte type but Fprintf() is waiting for a string.

If we run this the response will be {} and this is because the version property inside the struct Version is not exported. Changing the property to Version string will return this output {"Version":"0.1.0"} which is kind of the expected result but the JSON field should be in lowercase. The Marshal() method by default uses the name of the struct property to name the JSON field, to change this default behavior we have to use struct field attributes. So the final Version struct should like this:

type Version struct {
	Version string `json:"version"`
}

In this example it’s ok to return the JSON in one line but if we want to print it in pretty format we use the function MarshalIndent() like this:

verJSON, err := json.MarshalIndent(version, "", "  ")

The main function of this microservice is to return movie objects. So, let’s have a movie structure and initialize it with some testing values:

type Movie struct {
	ID          int      `json:"id, string"`
	Title       string   `json:"title"`
	Description string   `json:"desc"`
	Genre       string   `json:"genre"`
	Artists     []string `json:"artists"`
	Director    string   `json:"director"`
	Rating      float64  `json:"-"`
	ReleaseDate string   `json:",omitempty"`
}

var movies  []Movie

func init() {
	version = Version{"0.1.0"}
	movies = []Movie{
		...
	}
}

func main() {
	http.HandleFunc("/api/v1/movies", handleMovies)
	...
}

If you want to see all the movies, please, get the code. It’s a long list even with 5 items.

We are also mapping the path "/api/v1/movies" to the handler function handlerAllMovies to respond all the movies in JSON but in this case we’ll use the Encoder object from encoding/json that’s more efficient than marshaling and return a string, so the handler function to respond all the movies looks like this:

func handleMovies(rw http.ResponseWriter, r *http.Request) {
	encoder := json.NewEncoder(rw)
	encoder.Encode(&movies)
}

When we open the URL http://localhost:8086/api/v1/movies we do not get the JSON pretty but if we can get it pretty if we use curl and jq

curl -s http://localhost:8086/api/v1/movies | jq

Returning just one movie

Before continue working with a single file (main.go) with all the code, let’s first split all the code in different files. Change to the branch s02-read-json where you can see the files main.go, version.go and movies.go. Now use go run *.go to run the microservice.

Let’s create a path that receives something after "/movies/" like an ID and map it to a handler function to return the movie with that ID.

In main.go:

func main() {
  http.HandleFunc("/api/v1/movies/", handleMovieFromID)
  ...
}

In movies.go we create a RegEx to parse a number after "/movies/", for this we create a compiled RegEx structure that we’ll use later to find all the strings that match the pattern. If this match is a number and it is a valid ID of a movie, then respond with the movie in JSON format.

var reMovieID *regexp.Regexp

func handleMovieFromID(w http.ResponseWriter, r *http.Request) {
  values := reMovieID.FindStringSubmatch(r.URL.Path)
  if len(values) < 1 {
		http.Error(w, fmt.Sprintf("Bad request. Not valid ID (%v) in request '%s'", path.Base(r.URL.Path), r.URL.Path), http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(values[1])
	if err != nil {
		http.Error(w, fmt.Sprintf("Bad request. Non numeric ID (%v) in request '%s'", values[1], r.URL.Path), http.StatusBadRequest)
		return
	}
	if id <= 0 || id > len(movies) {
		http.Error(w, fmt.Sprintf("Bad request. Out of range ID (%d) in request '%s'", id, r.URL.Path), http.StatusBadRequest)
		return
	}
	encoder := json.NewEncoder(w)
	encoder.Encode(&movies[id-1])
}

func init() {
	reMovieID, _ = regexp.Compile("/movies/([0-9]+)")
	...
}

Check it by opening in a browser or using curl the following URLs:

  • http://localhost:8086/api/v1/movies/foo/2
  • http://localhost:8086/api/v1/movies/6
  • http://localhost:8086/api/v1/movies/3
  • http://localhost:8086/api/v1/movies/0

All these kind of complex routes can be managed very easy with the gorilla/mux package. However, let’s look at other way to send information to the API.

Let’s read JSON

Besides receive input data from the URL the API can receive input data in the body of the request using JSON format. To implement this let’s create a struct to store the request (this may not be necessary but will make the code more human readable).

type movieRequest struct {
	Title string `json:"title"`
}

Now modify the function handleMovies to read the request body, if there is no body then respond with all the movies, if there is a body and it contain a JSON with the title field, it will search for the movie and return the it in JSON format if it is found.

func handleMovies(w http.ResponseWriter, r *http.Request) {
  var req movieRequest
	encoder := json.NewEncoder(w)
	decoder := json.NewDecoder(r.Body)

	err := decoder.Decode(&req)
	if err != nil {
		encoder.Encode(&movies)
		return
	}

	if movieResponse, ok := searchMovie(req.Title); ok {
		encoder.Encode(&movieResponse)
	} else {
		http.Error(w, fmt.Sprintf("Not found movie with title '%s'", req.Title), http.StatusBadRequest)
	}
}

To check this use curl and pass the JSON request with the parameter -d:

curl -s http://localhost:8086/api/v1/movies -d '{"title":"Kagemusha"}'
curl -s http://localhost:8086/api/v1/movies -d '{"title":"Frankestein"}'
curl -s http://localhost:8086/api/v1/movies | jq

Build and Ship it

It’s not done yet, there are many things missing but to see this beauty running as a microservice we have to containerize it, bake it into a container. This section works with the branch s03-container.

git checkout s03-container

We’ll use Docker to create the container and multi-stage builds to create a tiny Docker image. Let’s starts with a single-stage Dockerfile file to create a Docker image based on Alpine to build our new microservice.

FROM golang:alpine

WORKDIR /app
ADD . /app

RUN cd /app && go build -o movie

EXPOSE 8086

ENTRYPOINT [ "./movie" ]

Now build, run, test and destroy the container

docker build -t johandry/movie .
docker run --rm -p 80:8086 --name movie johandry/movie &
curl -s http://localhost/api/v1/movies/1 | jq
docker stop movie

As you can see we access the API on port 80 because the container expose the API on port 8086 and we map it to port 80 with the option -p 80:8086.

The image size is 276MB (we can see this with docker images) it’s considerable smaller compared with the 718MB of an image based on Debian Jessie (try it replacing FROM golang:alpine by FROM golang:1.8-jessie) and we should make it smaller because in containers, size matters. Let’s start with changing to a multi-stage build by replacing the Dockerfile

# Build stage
FROM golang:alpine AS build

ARG PKG_NAME=github.com/johandry/micro-media-service

ADD . /go/src/${PKG_NAME}

RUN cd /go/src/${PKG_BASE}/${PKG_NAME} && \
    go build -o /movie

# Run stage and microservice image
FROM alpine

COPY --from=build /movie .

EXPOSE 8086

ENTRYPOINT [ "./movie" ]

If you build, run, test and destroy the container with the same instructions you get the same results but the big difference is the size of the image, it’s now 10.6MB. In this example we are using Alpine but you can get the same size replacing it with Debian Stretch (FROM golang:stretch)

Can we make it smaller?

Yes, we can build the image from Scratch instead of Apline but not all the applications can support it. Some Go applications require libraries that are not provided using Scratch but in this yet simple microservice we can do it. Replace the Go build line to CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /movie and the FROM instruction in the second stage to FROM scratch.

# Build stage
FROM golang:alpine AS build

ARG PKG_NAME=github.com/johandry/micro-media-service

ADD . /go/src/${PKG_NAME}

RUN cd /go/src/${PKG_BASE}/${PKG_NAME} && \
    CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /movie

# Run stage and microservice image
FROM scratch

COPY --from=build /movie .

EXPOSE 8086

ENTRYPOINT [ "./movie" ]

The size of the image: 6.62MB

But wait, it can be smaller. We can use the build ldflags "-s -w" to omit the symbol table and debug information to reduce the size of the binary. If we add to the build line the parameter -ldflags="-s -w" we can have an image of 4.54MB.

Do you want to see something crazy? We can make it even more smaller but this change comes with a performance cost. According to this article we can use upx to create a self-extracting compressed file but there is an overhead when the process starts. If you realy need a small container and it’s rarely executed then this may be a good option for you.

Let’s see the new Docker file using the ldflags and upx:

# Build stage
FROM golang:alpine AS build

ARG PKG_NAME=github.com/johandry/micro-media-service

ADD . /go/src/${PKG_NAME}
ADD https://github.com/lalyos/docker-upx/releases/download/v3.91/upx /bin/upx

RUN cd /go/src/${PKG_BASE}/${PKG_NAME} && \
    CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -a -installsuffix cgo -o /movie.bin && \
    chmod +x /bin/upx && \
    upx -f --brute -o /movie /movie.bin

# Run stage and microservice image
FROM scratch

COPY --from=build /movie .

EXPOSE 8086

ENTRYPOINT [ "./movie" ]

Will take some time to build the image, around 2 minutes in this example, but the final result is amazing.

The final size of the image: 1.26MB!

We went from a 718MB single-stage image based on Debian Jessie to a 1.26MB multi-stage image based on Scratch, using the ldflags "-s -w" and UPX compression.

In a next post about gRPC and Kubernetes, we’ll use this and other containers interacting.

One more thing …

Before go to the next post I’d like to add to this microservice the option to configure it. This is a simple code so there isn’t much to configure but let’s give it the option to run in verbose mode.

Besides the build-in log package, there are many others like logrus and glog. I have a preference for logrus so instead of implement my own, I’ll use it in this example. If you don’t have it, get this package with

go get github.com/sirupsen/logrus

Modify the main.go to define and get the flag --verbose, and get the value of the environment variable MOVIE_VERBOSE in lowercase. If the environment variable is "true" or if the flag --debug is used, then set the debug log level. This means that when I use the logrus function Debug(), Debugf() or Debugln() it will print the message, otherwise it won’t because the default log level is Info.

As logrus is API-compatible with the standard log package we can create the alias log to logrus by replacing the import of log to:

    log "github.com/sirupsen/logrus"

And add to main.go the following lines:

var verboseFlag bool

func init() {
	flag.BoolVar(&verboseFlag, "verbose", false, "Enable verbose mode")
	flag.Parse()
	if verboseEnv := strings.ToLower(os.Getenv("MOVIE_VERBOSE")); verboseEnv == "true" || verboseFlag {
		log.SetLevel(log.DebugLevel)
	}
	formatter := &log.TextFormatter{
		FullTimestamp: true,
	}
	log.SetFormatter(formatter)
}

Modify the log.Printf() line in verbose.go to log.Debugf() and run it with:

go run *.go --verbose &
curl http://localhost:8086/api/v1/version

Regarding the Docker image, some changes are required as we need an external package and to get it we also need Git installed. The new Docker file is like this:

# Build stage
FROM golang:alpine AS build

ARG PKG_NAME=github.com/johandry/micro-media-service

ENV UPX_VER 3.94

ADD . /go/src/${PKG_NAME}
ADD https://github.com/upx/upx/releases/download/v3.94/upx-${UPX_VER}-amd64_linux.tar.xz /

# Install upx and git to use `go get`
RUN apk --update add git openssh && \
    rm -rf /var/lib/apt/lists/* && \
    rm /var/cache/apk/* && \
    tar xf /upx-${UPX_VER}-amd64_linux.tar.xz && \
    mv /upx-${UPX_VER}-amd64_linux/upx /bin/upx

RUN cd /go/src/${PKG_BASE}/${PKG_NAME} && \
    go get github.com/sirupsen/logrus && \
    CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -a -installsuffix cgo -o /movie.bin && \
    upx -k --best --ultra-brute -o /movie /movie.bin

# Run stage and microservice image
FROM scratch

COPY --from=build /movie .

EXPOSE 8086

ENTRYPOINT [ "./movie" ]

You can modify the formatter to change the output format or create your own. (here is an example)

I also recommend to use the Viper and Cobra packages to implement the configuration of your Go programs. Viper manage the settings from environment variables and configuration files (yaml, json, toml and others). Cobra manage the parameters and flags. Both have the same author and play very well together.

Stay in tune for the next post to cover gRPC and Kubernetes. I’ll update this line as soon as I have it.

 
comments powered by Disqus