Building containerized RESTful microservices from scratch

Here at Open Data Group, we’re big proponents of delivering
software and models as containerized microservices. In fact,
it’s a core part of our value proposition! Because we find ourselves
doing this a lot, our team has standardized around a
systematic approach using existing tooling to allow us to
rapidly prototype and develop production-grade containerized
applications. In this blog post, I’ll talk about three parts
to this approach, specifically focusing on applications
implemented in Go.

Why Go?

In this post, I’m going to specifically focus on the most
common subset of containerized microservices: lightweight,
restricted-functionality webservers implementing a RESTful
API. From Python to
every language has a great framework for easily building out
a server. Go, however, has a couple of features that make it
a particularly appealing choice of language for a

• A very strong standard library as well as a rich ecosystem
of community packages
• Highly performant, simple to use concurrency abstractions
• The ability to easily generate statically linked binaries

The last point is especially valuable when targeting Docker
as the code deployment mechanism.

A Step-By-Step guide to building a RESTful microservice

In contrast to some traditional development patterns,
when building out a RESTful microservice at Open Data Group,
we like to take an “outside in” approach. By this I mean that
we start by designing the REST API of the service we’re
building, then generate a server stub, and finally start
filling out the “inside” (i.e. implementing the underlying
functionality). There’s two reasons for this approach:

1. Most RESTful microservices do not exist in isolation:
by beginning with an interface definition, development
of and integration with other components can be done in parallel. And, because
the server stub can be rapidly generated from that
interface definition, we can naturally apply a test-driven
development methodology to guide and check the ultimate
implementation of the underlying functionality.
2. In general, interfaces tend to be longer-lived than
the innards of the service: other services or client
applications may depend on functionality defined in
that particular interface, so by starting with the
interface design we ensure that we’re focusing our
attention first on what is generally the most critical
part of the application.

Additionally, we leverage a standard, language-neutral
format for defining our API specifications: OpenAPI (formerly known as Swagger).
OpenAPI is designed and guided by Swagger,
who also publishes a number of tools for working with these API
specs.
The OpenAPI
specification is sufficiently general to capture most of the
functionality we’ve ever been interested in implementing. In
my experience, it is particularly well-suited to describing
API calls where the request or response are encoded as JSON
objects; it can be a little more cumbersome for edge cases
where the request encoding doesn’t map to JSON or YAML.

Here’s an example OpenAPI specification for a very simple
microservice (stolen from [one of our tutorials](/Knowledge Center/Tutorials/A Simple Microservice/)):

swagger: "2.0"
info:
description: "An example FastScore microservice."
version: "0.1"
title: "My FastScore Microservice"

paths:
/:
get:
produces:
- text/html
operationId: get_index
responses:
200:
description: Returns the Dashboard homepage.
schema: { type: string }
/{engine}/model:
get:
parameters:
- name: engine
description: The name of the engine instance
type: string
in: path
required: true
produces:
- application/json
operationId: get_model
responses:
200:
description: Returns a description of the currently running model.
schema: { type: object }


Generating a server stub

Swagger provides a number of tools for working with OpenAPI
specifications, including one called swagger-codegen, which
can take in an API specification and generate client and
server stubs. However, the official swagger-codegen has a few issues
with its Go support, so instead we use
the Go package go-swagger.

To generate a server stub with go-swagger, just run

swagger generate server -f api.yaml


This will spit out a few different directories:

• cmd, whose subdirectory contains the main entrypoint
• models, which contains definitions for all of the types
• restapi, which contains the actual API implementation.

For convenience, let’s assume that the name of our microservice
is myservice. Of these, at a minimum the two files we need to
edit are main.go, located in cmd/myservice-server, and
configure_myservice.go, located in restapi.

The latter of these files we need to edit to implement the actual
functionality of our service. This consists of filling out the various
API functions defined in this file (the functions named api.NameOfOperation)
and replacing the middleware.NotImplemented(...) return values to appropriate
returns for the outcomes of the functions. A typical “filled out” API function
will look something like:

api.NameOfOperation = operations.NameOfOperationFunc(func(params operations.NameOfOperationParams) middleware.Responder {

result := DoSomething(params.Parameter1, params.Parameter2)

})


where NameOfOperation is the name of the operation defined in the OpenAPI spec,
Parameter1 and Parameter2 are two of the defined inputs for that function,
result is an appropriate response for this API call, and DoSomething is
the actual “under the hood” function that should be called.

Once this is done, you can build and run your server by navigating to the
cmd/myservice-server directory and running go install, and then running
(for example)

./myservice-server --tls-host=0.0.0.0 --tls-port=8080 --tls-key=keys/key.key --tls-certificate=keys/crt.crt


If all goes well, you’ll see a log message that you’re now serving
your API at port 8080 on your local machine, and you should be able to
connect to it from other machines.

Building a container from scratch

The astute reader will notice that I haven’t described what should be modified
in main.go, nor have I taken advantage of Go’s functionality around statically
linked binaries. Here’s where both of these parts come in. But first, a slight

Most Docker images use a particular Linux distribution as their base. Among the
most common choices are Ubuntu and Alpine (Ubuntu because it’s easy to add other
dependencies in subsequent layers, and Alpine because it’s much smaller than
most other Linux flavors). So, if you’re interested in getting the smallest
container image possible, your Dockerfile should start with FROM alpine, right?

Wrong! We can do better. There’s a special, completely empty base layer we can
use in Docker called scratch. The advantage of using scratch as a base layer
is that we have complete control over what goes into the container image, which
is especially important for minimizing image sizes and satisfying security
requirements. Note that scratch behaves somewhat differently
from other base layers:

• There’s no tty or terminal or anything that a user can attach to, so you can’t
get “into” the container with docker exec
• Key filesystem abstractions are missing, so you generally can’t provide
command-line arguments to applications you start in the container
• And there are other differences.

One important thing that scratch base layers do still allow is setting
environment variables. With this in mind, there are two steps remaining before
we have our containerized microservice:

1. Change main.go to take arguments via environment variables.
2. Compile a statically linked binary of our server and put it in a
Dockerfile based on scratch.

The first part is straightforward: just add a few lines to main.go to set
the various arguments the microservice needs. For example, to set the TLS
host via environment variable, after the line

server.ConfigureAPI()


in main.go, add the line

host, exists := os.LookupEnv("HOST")
if exists {
server.TLSHost = host
} else {
server.TLSHost = "0.0.0.0" // or whatever the default value should be
}


Putting it all together

Finally, we have to build the statically linked binary and put it
in a Dockerfile. A good practice for repeatable builds is to use
a multi-stage build. This essentially creates an intermediate Docker container
which is used to compile any relevant binaries, and then those assets
are copied over to the final output container. This gives a convenient
and transferable encapsulation of the build environment.

Here’s an example of our typical Dockerfile for building a statically

FROM golang:1.10.3-alpine3.8

COPY . /go/src/github.com/myorg/myservice
WORKDIR /go/src/github.com/myorg/myservice/restapi/

WORKDIR /go/src/github.com/myorg/myservice/cmd/myservice-server

RUN go get
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo

FROM scratch

COPY --from=0 /go/src/github.com/myorg/myservice/cmd/myservice-server/myservice-server /myservice-server
COPY keys /keys

ENV PORT 8123
ENV TLS_CERT keys/dummy.crt
ENV TLS_KEY keys/dummy.key
ENV TLS_HOST 0.0.0.0

CMD [ "/myservice-server" ]


To build the image, just run

docker build -t myorg/myservice -f Dockerfile


and then we’re done!

Concluding thoughts

By combining a standardized API specification, stub generation tools, and Go,
it’s really easy and quick to build out lightweight, secure containerized
microservices. This has allowed Open Data Group to very quickly generate,
iterate on, and refine prototypes for new microservices, and keeps us close
to the guiding Docker philosophy of light, ephemeral, and portable application
design. Some additional related topics that I didn’t cover here (but might in
a future post) include how to handle stateful containerized microservices,
equivalent practices for other languages such as Python, and building out
client libraries from an API specification. If you want to learn more
about these topics, Swagger and Docker both have a wealth of resources available. Happy coding!

You might also enjoy

Don’t Frustrate Your Data Scientists (If You Want Them to Stay)

Data scientists are producing models that are making substantial contributions to the business, and thus more and more models are being used in production applications.  But as a result, data scientists face several challenges.

Off Grid CTO: Starlink in Action

Exciting news up here at the off grid cabin location, we now have actual real high speed internet! With Starlink service we have a true high speed connection, and it even uses less power than the Viasat installation did.

Investing In AI Doesn’t Need To Be A Leap Of Faith: How To Track Your AI ROI

Many enterprise leaders have taken a “Field of Dreams” approach to AI ROI — “If we build it, profits will come.” While it’s one thing to take that approach for smaller pilot projects, it’s another thing when you’re consistently being asked to fund initiatives with seven-figure price tags.

Get the Latest News in Your Inbox

Major Financial Company Leverages ModelOps to Maximize Value from AI Initiatives

Case Study Reveals Significant Gains from Model Operations Platform and Organizational Alignment.