The Go programming language, often referred to as "golang", has a lot of well-deserved traction in the devops community. Many of the most popular tools such as docker, kubernetes, and terraform are written in go, but it can also be a great choice for building web applications and APIs.
In this post, I'm going to show you how to develop a simple web application in go, package it as a lightweight docker image, and deploy it to Heroku.
I'm going to use go's built in module support for this article.
Go 1.0 was released in March 2012, and up until version 1.11 (released in August 2018), developing Go applications involved managing a GOPATH for each "workspace", analogous to java's JAVA_HOME, and all of your go source code and any third-party libraries would be stored below the GOPATH.
I always found this a bit off-putting, compared to developing code in languages like Ruby or Javascript where I could have a simpler directory structure isolating each project. In both of those languages, a single file (Gemfile for Ruby, package.json for Javascript) lists all the external libraries, and the package manager keeps track of managing and installing dependencies for me.
I'm not saying you can't manage the
GOPATHenvironment variable to isolate projects from each other, just that I find the package manager approach easier.
Thankfully, go now has excellent package management built in, so this is not a problem anymore, but you might find GOPATH mentioned in many older blog posts and articles, which can be a little confusing.
Let's get started on our web application. As usual, this is going to be a very simple "Hello, World!" app, because I want to focus on the development and deployment process, and keep this article to a reasonable length.
You'll need:
- A recent version of golang - I'm using 1.14.9
- Docker
- A Heroku account
- The Heroku command-line client
- git
To create our new project, we need to create a directory for it, and use the go mod init command to initialise it as a go module.
mkdir helloworld
cd helloworld
go mod init digitalronin/helloworldIt's common practice to use your github username to keep your project names globally unique, and avoid name conflicts with any of your project dependencies, but you can use any name you like.
You'll see a go.mod file in the directory now. This is where go will keep track of any project dependencies. If you look at the contents of the file, it should be something like this:
module digitalronin/helloworld
go 1.14Let's start committing our changes:
git init
git add *
git commit -m "Initial commit"We're going to use gin for our web application. Gin is a lightweight web framework, similar to Sinatra for Ruby, express.js for Javascript or Flask for Python.
Create a file called hello.go containing this code:
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/hello", func(c *gin.Context) {
c.String(200, "Hello, World!")
})
r.Run(":3000")
}Let's break this down a little:
r := gin.Default()This creates a router object, r, using the built-in defaults that come with gin.
Then we assign a handler function which will be called for any HTTP GET requests to the path /hello, and will return the string "Hello, World!" and a 200 (HTTP OK) status code:
r.GET("/hello", func(c *gin.Context) {
c.String(200, "Hello, World!")
})Finally we start our webserver and tell it to listen on port 3000:
r.Run(":3000")To run this code, execute:
go run hello.goYou should see output like this:
go: finding module for package github.com/gin-gonic/gin
go: found github.com/gin-gonic/gin in github.com/gin-gonic/gin v1.6.3
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /hello --> main.main.func1 (3 handlers)
[GIN-debug] Listening and serving HTTP on :3000Now if you visit http://localhost:3000/hello in your web browser, you should see the message "Hello, World!".
Notice that we didn't have to install gin separately, or even edit our go.mod file to declare it as a dependency. Go figures that out and makes the necessary changes for us, which is what's happening when we see these lines in the output:
go: finding module for package github.com/gin-gonic/gin
go: found github.com/gin-gonic/gin in github.com/gin-gonic/gin v1.6.3If you look at the go.mod file, you'll see it now contains this:
module digitalronin/helloworld
go 1.14
require github.com/gin-gonic/gin v1.6.3 // indirectYou will also see a go.sum file now. This is a text file containing references to the specific versions of all the package dependencies, and their dependencies, along with a cryptographic hash of the contents of that version of the relevant module.
The go.sum file serves a similar function to package-lock.json for a Javascript project, or Gemfile.lock in a Ruby project, and you should always check it into version control along with your source code.
Let's do that now:
git add *
git commit -m "Add 'Hello world' web server"I'm not going to go very far into what you can build with gin, but I do want to demonstrate a little more of its functionality. In particular, sending JSON responses, and serving static files.
Let's look at JSON responses first. Add the following code to your hello.go file, right after the r.GET block:
api := r.Group("/api")
api.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})Here we're creating a "group" of routes behind the path /api with a single path /ping which will return a JSON response.
With this code in place, run the server with go run and then hit the new API endpoint:
curl http://localhost:3000/api/pingYou should get the response:
{"message":"pong"}Finally, let's make our webserver serve static files. Gin has an additional library for this.
Change the import block at the top of the hello.go file to this:
import (
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
)Most popular code editors have golang support packages you can install which will take care of the
importdeclarations for you automatically, updating them for you whenever you use a new module in your code.
Then, add this line inside the main function:
r.Use(static.Serve("/", static.LocalFile("./views", true)))The full code for our web application now looks like this:
hello.go
package main
import (
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/hello", func(c *gin.Context) {
c.String(200, "Hello, World!")
})
api := r.Group("/api")
api.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Use(static.Serve("/", static.LocalFile("./views", true)))
r.Run()
}The r.Use(static.Serve... line enables our webserver to serve any static files from the views directory, so let's add a few:
mkdir -p views/cssviews/css/stylesheet.css
body {
font-family: Arial;
}
h1 {
color: red;
}
views/index.html
<html>
<head>
<link rel="stylesheet" href="/css/stylesheet.css" />
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>Now restart the webserver using go run hello.go and visit http://localhost:3000 and you should see the styled message.
We've written our go web application, now let's package it up as a docker image.
So far, we've been running our code with the go run command. To compile it into a single, executable binary, we simply run:
go buildThis will compile all our go source code and create a single file. By default, the output file will be named according to the module name, so in our case it will be called helloworld.
We can run this:
./helloworldAnd we can hit the same HTTP endpoints as before, either with curl or our web browser.
The static files are not compiled into the binary, so if you put the
helloworldfile in a different directory, it won't be able to find theviewsdirectory to serve our HTML and CSS content.
That's all we need to do to create a binary for whatever platform we're developing on (in my case, my Mac laptop), but to run inside a docker container (for eventual deployment to Heroku) we need to compile a binary for whatever architecture our docker container will run on.
I'm going to use alpine linux, so let's build our binary on that OS. Create a Dockerfile with the following content:
FROM golang:1.14.9-alpine
RUN mkdir /build
ADD go.mod go.sum hello.go /build/
WORKDIR /build
RUN go build
In this image, we start with the golang base image, add our source code and run go build to create our helloworld binary.
We can build our docker image like this:
docker build -t helloworld .Don't forget the trailing
.at the end of that command. It tells docker we want to use the current directory as the build context.
This creates a docker image with our helloworld binary in it, but it also contains all the go tools needed to compile our code, and we don't want any of that in our final image for deployment, because it makes the image unnecessarily large. Also, installing executables you don't need on your docker images can be a security risk.
We can see the size of our docker image like this:
$ docker images helloworld
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest 9657ec1ca905 4 minutes ago 370MBFor comparison, the alpine image (a lightweight linux distribution, often used as a base for docker images) is much smaller:
$ docker images alpine
REPOSITORY TAG IMAGE ID CREATED SIZE
alpine latest caf27325b298 20 months ago 5.53MBOn my Mac, the helloworld binary is around 14MB, so the golang image is much bigger than it needs to be.
What we want to do is use this Dockerfile to build our helloworld binary to run on alpine linux, then copy the compiled binary into an alpine base image, without all the extra golang tools.
We can do exactly this using a "multistage" docker build. Change the Dockerfile to look like this:
FROM golang:1.14.9-alpine AS builder
RUN mkdir /build
ADD go.mod go.sum hello.go /build/
WORKDIR /build
RUN go build
FROM alpine
RUN adduser -S -D -H -h /app appuser
USER appuser
COPY --from=builder /build/helloworld /app/
COPY views/ /app/views
WORKDIR /app
CMD ["./helloworld"]
On the first line, we label our initial docker image AS builder.
Later, we switch to a different base image FROM alpine and then copy the helloworld binary from our builder image like this:
COPY --from=builder /build/helloworld /app/Build the new docker image:
docker build -t helloworld .Now, it's the size you would expect for a base alpine image plus our helloworld binary:
$ docker images helloworld
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest 1d6d9cb64c7e 8 seconds ago 20.7MB
We can run our webserver from the docker image like this (if you have another version running using go run hello.go or ./helloworld, you'll need to stop that one first, to free up port 3000).
docker run --rm -p 3000:3000 helloworldThe dockerised webserver should behave just like the
go run hello.goand./helloworldversions except that it has its own copies of the static files. So, if you change any of the files inviews/you won't see the changes until you rebuild the docker image and restart the container.
Now that we have our dockerised web application, let's deploy it to Heroku. To do this, you'll need the Heroku command-line application.
We've hard-coded our webserver to run on port 3000, but that won't work on Heroku. Instead, we need to alter it to run on whichever port number is specified in the PORT environment variable, which Heroku will supply automatically.
To do this, alter the r.Run line near the bottom of our hello.go file, and remove the ":3000" string value so the line becomes:
r.Run()The default behaviour of gin is to run on whatever port is in the PORT environment variable (or port 8080 if nothing is specified). This is exactly the behaviour Heroku needs.
First, login to Heroku:
heroku loginNow, create an app:
heroku createTell Heroku we want to build this project using a Dockerfile, rather than a buildpack:
heroku stack:set containerTo do this, we also need to create a heroku.yml file:
build:
docker:
web: Dockerfile
run:
web: ./helloworld
Git add and commit these files, then push to heroku to deploy:
git push heroku mainMy git configuration uses
mainas the default branch. If your default branch is calledmaster, then rungit push heroku masterinstead.
You should see Heroku building the image from your Dockerfile, and pushing it to the Heroku docker registry. Once the command has completed, you can view the deployed application in your browser by running:
heroku open
We've covered:
- Creating a golang web application, using go modules and the gin web framework, to serve strings, JSON and static files
- Using a multistage Dockerfile to create a lightweight docker image
- Deploying a docker-based application to Heroku using
heroku stack:set containerand aheroku.ymlfile
I've only scratched the surface in this article, and I hope this gives you enough to get started on your own golang web applications.
