Frequently, though, we developers are writing our applications the way we always have. We do all the coding on our host system (as we‘ve always done), and then we look at containers as a deployment technology.
But when we do this, we’re missing out in a big way. We’re passing up an opportunity to use tools that will make a qualitative difference in our development experience.
We should be doing our development in a container-native way.
What is container native development?
When I say container-native development, what I mean is that our daily development practices include the standard building blocks of containerized (or Dockerized) applications. Here are a few examples of what I mean.
- Builds are done using Docker (this includes compiling and even ideally dependency management).
- Tests are run in containers
- The artifact of interest that we produce is a container image, not a binary, a tarball, or a bundle
- Our manual testing and debugging cycle is done against containers, not a locally running app
- Our target deployment environment (Docker/Kubernetes) is the same as our development environment
At first, this might feel like a big commitment, but there are two reasons not to let that deter us: (a) there are a list of reasons why this makes development life better, and (b) tooling exists that can make the above cycle painless.
5 reasons why container native makes development better
Drawing from my own experiences, here are five reasons using container technologies as part of your development cycle will make your life easier. They roughly fall into two categories: Avoiding bugs, and simplifying runtime.
1. Avoid cross-environment bugs
I was working on a Node.js app using the old-fashioned local host development loop. I’d edit the code, run
npm test locally, test it out with an
npm start, and so on. Then I’d commit to Git and continue on. At release time, I’d build a Docker image and push to DockerHub.
Then, on a whim I decided to test something in my local minikube (Kubernetes) cluster, which meant doing a
docker build and then running the image. While the build went fine, running the image gave a startling result that looked something like this:
internal/modules/cjs/loader.js:550 throw err; ^ Error: Cannot find module './foo' at Function.Module._resolveFilename (internal/modules/cjs/loader.js:548:15) at Function.Module._load (internal/modules/cjs/loader.js:475:25) at Module.require (internal/modules/cjs/loader.js:598:17) at require (internal/modules/cjs/helpers.js:11:18)
My immediate response went something like this:
Cannot load module
foo? What?! It’s running fine locally. And there’s the
Foo.jsfile right… oh…
See, I’d forgotten that macOS and Linux have a few critical differences. And among them, macOS is not case-sensitive on file names. And the crazy thing is that I would have cut a full release not even knowing I had this bug. I would have been guilty of that classic programmer excuse: “it worked fine on my machine!”
This bug pointed to a bigger problem: I was assuming that the similarity of my dev and target platforms was close enough that I could not make such mistakes. This is a deeply flawed assumption. Even between two Linux distros, there are often subtle differences that can introduce bugs into our code.
Consistently using containers in our development cycle means we can avoid this class of errors, for the environment is stable all the way through the development cycle and on into release. These little cross-platform bugs simply don’t manifest because our platform is abstracted away from the host OS.
2. Unidentified dependencies
The problem above can take a more noxious form, for it’s not always the OS or distro that causes problems.
Sometimes our local code has a hidden, unacknowledged, or even unknown dependency on something else on the local system.
This bug bit me when I had some critical libraries on my local system’s global path that were not captured in the code’s dependencies. I was writing Go code, and my host system’s global
$GOPATH held some dependencies that I didn’t even notice. Things compiled fine locally… but when it came to building the binary on CI, everything failed.
Libraries are not the only hidden dependency type, either. Often times our runtime tooling (start scripts, etc.) also have dependencies. (“Oh yeah! I used
jq in that script…”) Often we spend less time testing our support tools, which makes them likely candidates for failure.
Again, containerizing during development stops these issues cold. The container holds all the resources your app is allowed to use. If you install something (like a library or
jq ) into the container, it’s there each and every build. Dependencies have nowhere to hide in a containerized development flow.
3. Unanticipated interactions
My local development environment suffers from the client-side version of the very problem that we use containers to solve server-side!
I easily have thousands of applications and tools installed on my laptop, ranging from Firefox to
/bin/[ (yup, that’s really a program). But I have a problem…
My process of managing all these applications is not at all streamlined or safe. Some applications update themselves. Some get updated by other tools. Some I update when I want. And still others I update only when something breaks and I absolutely have to. But they all share the same file system, the same devices, the same configuration areas, etc.
In short, I run a deeply connected ecosystem of tools in a totally haphazard way.
And anyone who’s run a few Ruby, Python, or Go applications know why this is a problem. I update X. It updates Y. But my other application Z relied upon the older version of Y. So now I have either a broken X or a broken Z. And my solution is… to install another tool that can keep X’s version of Y separate from Z’s version of Y. Which means I have one more tool to manage – a tool itself sometimes susceptible to the same class of problem.
This gets even worse when the “dependency” in question is the compiler or runtime (ahem, Ruby).
For me personally, I find this class of problem to be the most frustrating. Nothing puts me in a sour mood like spending hours negotiating a truce between two (or more) demanding applications.
Containers solved this problem neatly for us on the server by simply providing us with a reasonable pattern: Containers isolate each app (along with all of its dependencies). Two Ruby apps never have to duke it out over which version of Ruby to run. Two Go apps never have to fight for package management supremacy on a shared
$GOPATH. We can deploy thousands of containers into Kubernetes, and never even consider whether container A is running the same version of Ruby as container B. It makes absolutely no difference!
4. Separate dev runs from dev space
I think we have long practiced a pattern that is a little dubious. When coding, we need a place to run our code as we develop. Where do we do that? Well, in the directory that contains the source code, of course!
Aside from this behavior encouraging unidentified dependencies and being a victim of unanticipated interactions, running our code right where we develop it has some other negative side effects.
So what do we do? We write scripts! We add custom build targets that bypass some of the generation (at the risk of introducing a bug when we turn the generation back on). We write scripts to delete generated files between runs. And we even right scripts to calculate things like whether some of our intermediate objects need to be regenerated or recalculated. In short, we introduce complexity and risk in the name of efficiency (because I don’t want to carefully delete things by hand).
We can start listing the kinds of bugs that we’ve all encountered because of this practice:
- A cached minified copy of an old CSS file is still hanging around causing havoc after you deleted the base CSS file
- For some reason, and old compiled object file doesn’t get regenerated on compilation
- You accidentally check in 20M of generated files because you forgot to
- The make/grunt/ant/etc. files are getting hard to maintain because there are separate dev and prod targets that you have to remember to keep in parity
Again, this is where containers can really pay off. We give the container a snapshot of where we are in the current dev cycle. The container starts up, generates all that stuff, runs for a while, then we terminate it. And all the generated stuff is gone. It was destroyed with the container instance. The next time we start that container, it’ll be in its pristine starting condition. Every container run starts in a clean state.
5. Delivering and deploying
The Holy Grail of DevOps is reducing the disparity between development and production to zero. Honestly, we can’t get there today, but we can get a lot closer now than we could only a few years ago.
There are several key tenants of the Dev-to-Ops transition that I believe containers can now satisfy:
- It should not be the case that ops receives a bundle of source code and must turn that into a runnable artifact
- It should not be the case that ops are responsible for installing and managing the dependencies of an application
- It should be the case that ops controls the allocation of resources (network, processing, storage, etc.) for an app
- It should be the case that ops can pass configuration values as the primary method of configuration
- It should be the case that ops can instrument the running artifacts for metrics, logging, and monitoring
If we can satisfy these (and similar) requirements, we might not attain world peace, but the often frosty relationship between application-focused developers and runtime-focused devops will certainly thaw. It’s a human principle at the heart of this one: containerizing applications means developers take responsibility for what is ours, and gladly give authority to operators to manage running things… but without leaving them to clean up our messes.
Getting started with container native development
Yes, there are some good reasons to favor container native development. If done well, we reduce bugs in a few key areas: subtle cross-platform issues, unidentified dependencies, and inter-application dependency. Along the way, we improve the hand-off from development to ops.
But let’s be honest: Setting up and maintaining a container native workflow by hand is repetitive and bland.
This is where Draft comes in. Draft is an open source toolkit for container native development. Here are some of the things it does for you:
- Autodetect your application’s language and frameworks
- Generate the appropriate Dockerfile and a base Helm chart
- Manage building, deploying, and connecting to your app
- Streamline logging and debugging
- And when development is done, package the application as a Helm chart and Docker image, which makes life easy on operations
Draft is designed to remove the mundane and tedious work from your container native workflow, but give you all of the advantages discussed in this article.
For developers serious about embracing the Docker/Kubernetes trend, container native development is the right path forward. With tools like Draft, we can take advantage of all of those promises of the container ecosystem. And the nice thing is that our new workflow is as simple as