Introducing Hyperlight: Virtual machine-based security for functions at scale
The Microsoft Azure Core Upstream team is excited to announce the Hyperlight…
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.
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.
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.
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 theFoo.js
file 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.
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.
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!
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.
It clutters up our dev environment. Runtime artifacts (generated caches, compiled objects, that 5M minified JavaScript megalith…). We’re like chefs who keep all the leftover trimmings and toss-outs right there on the kitchen counter with the food we’re prepping!
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:
.gitignore
themAgain, 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.
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:
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.
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:
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 draft up
.