Should you containerize your Go code?
Containers helps you distribute, deploy, run, and test your Golang projects.
I’m a huge fan of Go, and I’m also really interested in containers, and how they make it easier to deploy code, especially at scale. But not all Go programmers use containers. In this article I’ll explore some reasons why you really should consider them for your Go code — and then we’ll look at some cases where containers wouldn’t add any benefit at all.
First, let’s just make sure we’re all on the same page about what we mean by “containers.”
What is a container?
There are probably about as many different definitions of what a container is as there are people using them. For many, the word “container” is synonymous with Docker, although containers have been around a lot longer than either the Docker open-source project or Docker the company. If you’re new to containers, Docker is probably your best starting point, with its developer-friendly command line support, but there are other implementations available:
- Linux Containers – container implementations including LXC and LXD
- rkt – pod-native container engine from CoreOS
- runc – running containers per the OCI specification
- Windows Containers – Windows Server containers and Hyper-V containers
Containers are a virtualization technology — they let you isolate an application so that it’s under the impression it’s running in its own physical machine. In that sense a container is similar to a virtual machine, except that it uses the operating system on the host rather than having its own operating system.
You start a container from a container image, which bundles up everything that the application needs to run, including all its runtime dependencies. These images make for a convenient distribution package.
Containers make it easy to distribute your code
Because the dependencies are part of the container image, you’ll get exactly the same versions of all the dependencies when you run the image on your development machine, in test, or in production. No more bugs that turn out to be caused by a library version mismatch between your laptop and the machines in the data center.
But one of the joys of Go is that it compiles into a single binary executable. You have to deal with dependencies at build time, but there are no runtime dependencies and no libraries to manage. If you’ve ever worked in, say, Python, JavaScript, Ruby, or Java, you’ll find this aspect of Go to be a breath of fresh air: you can get a single executable file out of the Go compilation process, and it’s literally all you need to move to any machine where you want it to run. You don’t need to worry about making sure the target machine has the right version libraries or execution environment installed alongside your program.
Err, so, if you have a single binary, what’s the point of packaging up that binary inside a container?
The answer is that there might be other things you want to package up alongside your binary. If you’re building a web site, or if you have configuration files that accompany your program, you may very well have your static files separate. You could build them into the executable with go-bindata or similar if you prefer. Or you can build a container image that includes the binary file and its static resources together in one neat package. Wherever you put that container image, it has everything it needs for your program to run.
Containers help you deploy your code
To keep things simple, let’s assume you don’t have any static resources, just a single binary. You build that executable and then move it to the machine where it needs to run — you simply need to move that one file. Go makes cross-compilation easy, so it’s no big deal even if the target machine where you want to run the code differs from the one you’re building on. All you need to do is specify the target machine’s architecture and operating system in environment variables when you run go build.
In many traditional deployments, you know exactly which (virtual) machine is going to run each executable. You might have multiple hosts (e.g. for high availability), but now that we know how easy it is to build for the target machine, it’s not exactly rocket science to ship that lovely Go binary to where it needs to run.
But the modern approach to deploying code is to run a cluster of machines, and use an orchestrator like Kubernetes, ECS, or Docker Swarm to place containers somewhere in the cluster.
Containers are great for this, because an image acts as a standard “unit of deployment” for the orchestrator to act on. The orchestrator tells the machine what code to run by giving it an identifier for a container image; if the machine doesn’t already have a copy of that image it can pull it from a container registry.
It’s certainly possible to run an orchestrator to deploy code that isn’t packaged up in container images. But by using containers you’re taking advantage of a broadly common, language-agnostic deployment methodology that’s being used increasingly across industry. Even if your company is a pure Go shop today, that might not be the case forever. By using containers you’ll have a common mechanism for deploying different code components whatever language they might be written in, so you’re avoiding language lock-in.
When I said “modern approach to deploying code,” you might quite rightly have thought “serverless?” Serverless implementations are running each executable function inside a container. Deployment to serverless looks different today, but I wouldn’t be at all surprised to see a blurring of the terms – in some environments you can already ship your serverless function in the form of a Docker container image (not least so that it has all the dependencies it needs).
Containers help you restrict the resources your code can access
When you run a Go (or any other) executable on a Linux machine you’re starting a process. If you execute code inside a Linux container you’re also starting a process in almost exactly the same way — it’s just that the process has such a restricted view of the resources available on the machine that it practically thinks it has a machine to itself.
Restricting the process’s view of the world inside a container has many of the same advantages of a virtual machine for running multiple different applications on the same hardware. For example, a containerized process has no way to access files or devices outside its container unless you explicitly allow it, so it can’t affect those files or devices (either maliciously or simply due to a bug). It might think it’s thrashing the CPU to perform an intensive operation, but the system may have limited the amount of processing power it can use so that other applications and services can continue to operate.
That restricted view of resources is created using namespaces and cgroups. Exactly what those terms mean is a topic for another time, but people tell me they’ve found this talk I did at Golang UK to be helpful.
If you want to restrict an executable so that it only has access to a limited set of resources, containers give you a neat, friendly, and repeatable way of doing that.
It’s possible to create the same restrictions for an executable in other ways, but containers make it easy. For example, traditionally sysadmins have done lots of careful and potentially fiddly work to set up the right permissions for things like files, devices and network ports. In the world of containers it’s very easy for developers to convey their intent that the code should be able to use, say, certain ports or volumes (and no others), and that by default everything inside the container is private to the container. You’ll want to make sure your Dockerfiles follow security best practices, but there’s no need for bespoke operations work to get the permissions set up every time a development team deploys a new application or service.
Containers help you test locally with other components
Many applications need to access other components, like a database or a queuing service (or limitless other things). When you want to run your program locally for testing, you’ll need those components installed too.
But what if you need different versions of components for different applications? Or what if the configuration is different for different projects? For example, if you’re a contract developer you could easily have two clients running with different versions of, say, Postgres. It’s possible to run multiple copies on your laptop, but it can be painful (and you have to make sure you’re using the right version).
Life can be much simpler if you use the containerized versions of the services you need. You can set up a docker-compose file for each project to bring up the right set of components with all the correct configuration.
What can containers do for you?
In summary, containers make it easy to:
- distribute your code, in a package that can run anywhere
- deploy your software under an orchestrator
- constrain the resources your (or someone else’s) code can use on the host machine
- run and test your software locally along with all the services it needs
If you’re a Go developer working on “back-end” or systems software that will be deployed in the cloud, these can be compelling reasons to use containers. But if those don’t apply to you, should you be using containers for your Go code?
When not to use containers with Go
Docker have a catchphrase: “Build, Ship and Run Any App, Anywhere.” Go already has some of those attributes built in. As I mentioned earlier, among Go’s strong suits are its cross-compilation and the production of single executable files without dependencies. Unless you’re packaging up other files (or perhaps the new plugins) with your executable, or unless “run” means “deploy through an orchestrator,” containers are not going to make it any easier to build, ship or run anywhere new.
Perhaps you’re a Go developer who doesn’t have to worry about deploying code to a cluster of machines. If you build, say, a standalone desktop or mobile app that you distribute as a download, then containers don’t add any benefit that I’m (yet) aware of, and would just add unnecessary complexity to your workflow and build process.
Similarly I wouldn’t use a container if I were writing a standalone program that I’m only planning to run locally, perhaps for an experiment or demo, or a small utility that doesn’t need to interact with other components. As always, use the right tool for the job and don’t use containers if they won’t add value for you!
Got another reason for using containers for your Go code? I’d love to hear about your experiences.