3 years of Go in Production
For the last 3 years, my microservices in production were divided into the following platforms:
- Core: Ruby and JRuby with Rails and Sinatra
- Satellites, scale: Node.js, Clojure and later: Go
A âcoreâ toolset would live long. It would also move fast. It would depict the domain of the business and the core product solution that provides raw value.
Mostly, its performance profile doesnât really introduce any infrastructural concerns. The satellites and âscaleâ toolset exhibits use cases where we bumped into scalability issues and had to tear apart a piece of the core, and rebuild it on top of a more performant stack.
It also represent a pure infrastructural concern; such as a push infrastructure, or analytics services. These things donât change as fast as the problem domain, and they do need to be robust, fast and dependable.
I want to talk about that âscaleâ toolset and share a bit of my own experience. Letâs start at the end. While migrating from Node to Go, the following are the things I have noticed to be different.
Crashes
Node liked to crash when handling unexpected performance, mostly due to large use of connections and the overhead in managing them and keeping their resources in check. True, this is mostly solved by proper capacity planning, and usage of hardening patterns like circuit breaker, and throttling.
However, using these, my Node services looked like forced concoctions that crashed hard and had a horrible GC profile of minutes per collection (this is early Node days Iâm talking about where you had to patch V8 to support a larger heap). It kept reminding me V8 was originally built to be run on the desktop.
Even in its early days, Go didnât have any of that, and it was enough. And when it did crash, it recovered crazy fast, and I liked that property, and made use of it; failing processes crashed fast.
Performance
Around 2011â12, Node was at the apex of performance, and Go wasnât. That is, until Go 1.1 mixed up that equation. I first noticed it through the excellent TechEm benchmarks:
- Round 1, March 2013 (pre Go 1.1): Go at 13k req/s, Node at 10k req/s. No big deal.
- Round 10 (latest): Go at 380k req/s Node at 225k req/s. Around 175% increase in favor of Go, and when you compare that to Node with Express, you get 145k req/s for Node, which is 260% increase in favor of Go.
Although these are a merely but a specialized variant of a micro benchmark, think about the overhead of the web framework (Express) superimposed on the host platform (Node). When Go is straddled with a typical web framework (Gin), it doesnât react that hysterically and the reduction in performance is in the 1â3% range, Node however had a dramatic reaction.
It stands to hint which stack youâd want to pack infrastructure on. Think about it. Is this why a lot of full-on infrastructure projects ( Docker, Kubernetes, Vagrant etc.) were built on Go (hint: yes)?
Deployment
To deploy Node or Ruby, you have to deal with dependencies. Tools such as Bundler, rubygems, and npm were created to overcome dependency hell, and provided us with an ever helpful layer of abstraction, which split our products into two:
- Product (essence)
- Product (dependencies)
Essentially we could snapshot our deps, and ship our product. But notice, with Bundler and npm, we snapshot a description of our deps (unless we choose to vendor. IMHO, with npmâââpeople usually donât).
Every deploy might have modified the dependency tree of the product, and servers hosting these products had to reflect that. For those wanting to solve this problem, they had to ask these questions:
- Is that the responsibility of the Configuration Management infra?
- Should the deployment process or framework take care of dependencies?
- Should you bundle dependencies along with your product?
- What happens when your dependencies die? (i.e. pulled off from Rubygems)
And their answers would typically be:
- Configuration management should take care of the servers. Resources are not products.
- Yes. The deployment process should take care of deps.
- No, bundling dependencies is an anti-pattern. At worst case make our own local cache or proxy.
- When dependencies die, we can use a local cache. Or: dependencies never die.
Docker
Docker seals these questions shutâââeverything is snapshotted into an image, and you deploy that. This provides a layer of abstraction on top of the dependencies ideaâââSnapshot all the things.
But still, for whatâs written here, weâre talking pre-production-docker era here (which is, only a year and a half ago).
Go
Even without Docker, Go packs a binary which is self-contained. And the answers to the above questions, are:
- Go builds its dependencies into the binary, making a self-inclusive deliverable
- Deployment framework caring about dependencies doesnât matter anymore
- Bundling dependencies doesnât matter anymore
- Dying dependencies doesnât matter, dependencies live within your source tree
And even with Docker, we have no drama. A 5mb image plus your binary size, makes pulling an new image and starting up (and failing, when needed) crazy fast.
The Surprise Factor
Go makes portable Code. Java made that possible too. However, Go makes for a different kind of portability. It doesnât compile to every platform under the sun (yet), but it does build for x86 and ARM.
Building for ARM means building for mobile, and Raspberry Pis.
My tipping point for using Go was when I looked into Python and C, for building my several ideas for home projects. I had to look at Python because it looks like thatâs what the entire RPi community used, and I had to look at C because I found that a typical Python app took 27Mb of RAM blank.
Obviously for the first Raspberry Pi model I had, I didnât have a lot of memory. So, I decided to try Go, and I cleared up a day to do that, because I guessed cross compilation and ARM were going to be a nightmare and I really wanted to use Go (better yet, I didnât want to use C as bad).
The first 5 minutes passed and I cross-compiled and built a hello world Go binary, SCPâd it to the Pi, and it printed a âHello worldâ and exited. This was Go 1.0 or something of the sort.
Not making peace with how smooth everything wentâââI spent the following 10 minutes making sure, and double-making-sure, that I copied the correct binary, and it truly was my own Go program that was running.
I had a day to spare because everything worked perfectly, so I started working on what eventually became Ground Control.
Go is About Forgiveness
Let me tell you a story about forgiveness, and Go code.
Go is verbose. It lacks generics, it adopts code generation as an escape hatch for many things the core language lacks.
To everyone with experienceâââcode generation is a bad smell, and this is a problem; and it should be.
However, coming from Node.js code bases, with the dreaded callback hell and a particularly low quality factor for community packages (early Node days) a Go code base looks like heaven. So we forgive.
Just when you are starting to get used to punching out very verbose Go code, you start noticing these verbosity issues you have overlooked; they bug you on a daily basis, and they are everywhere. But then, this kind of codebase would usually indicate youâre a bit more serious with Go. My guess is that youâd be at the same stage where you want to start doing concurrency work.
You discover Goâs channels, its concurrency model, and its nonblocking I/O.
Once again, you learn to appreciate it and become forgiving. By this time, the systems youâre developing are complex (in a good way), and so they want to be generic.
You want to start building infrastructure for yourself.
Generics, and lack of language abstractions start to hit you, hard. And once again, at the same time, you notice that your production environment is quiet. So quiet that it allows you to even ponder these things.
Youâre noticing everything you build is very robust, performant, without a special effort on your part. Additionally, you remember that the last commits and fixes you made were relatively easy because everything was super spelled out.
You accept Go
At this point, you accept Go.
Code generation, its CSP concurrency model, its simplicity, its hyper-focused single-purpose tools such as go-vet, go-fmt, and the likes and make peace with the fact that by using Go, youâre building and getting accustomed to a colorful, vivid, tool set.
You become forgiving, because strangely, you doubted Go at every crossroad, and it didnât let you down.