Cloud computing would not be possible if not for virtual machines. They are the fundamental computing resource for cloud-native applications.
Virtual machines allow us to virtualize an entire server, known as full virtualization. The virtual machine runs a full copy of the operating system as well as a virtual copy of the hardware. Enough hardware is simulated such that a “guest” operating system can run unmodified within the virtual machine. The guest operating system runs without being aware that it is executing virtually.
Virtual machines are enabled by the hypervisor, specialized software that runs directly on the host computer. The hypervisor is responsible for managing the physical resources of the underlying server. The hypervisor ensures that each virtual machine is allocated its own exclusive set of resources, such as CPU and memory.
Thanks to the hypervisor, each virtual machine is isolated from all other virtual machines. They each have their own guest operating system and kernel. Because of these strong boundaries, virtual machines offer great security and strong workload isolation.
However, there are downsides with traditional virtual machines. Because they are designed to run any operating system without modification, they must provide broad functionality and a robust set of simulated hardware. Consequently, virtual machines are “heavyweight” solutions that require significant computing resources, which lead to poor resource utilization. Virtual machines also typically have long boot times, limiting the rate of how fast they can be created.
Then containers came along.
With containers, we can virtualize just our applications, rather than the entire server. This makes containers an ideal abstraction for our cloud-native applications.
To work their virtualization magic, container implementations rely on operating system kernel functionality, such as Linux namespaces and cgroups. Functionally, containers are simply processes running under the host operating system. The kernel partitions resources among these processes and isolates them by placing them in separate namespaces.
Because containers are not virtualizing an entire server and all its hardware, containers are faster and less resource intensive than virtual machines.
But we pay a price for the performance and resource efficiency gains of containers. By relying on kernel functionality to enable virtualization, containers must share a single operating system kernel between the host and all other containers running on that host. This sharing of the kernel makes containers less secure than virtual machines.
The best of both worlds
What if we could have the performance and resource efficiency of containers coupled with the enhanced security and isolation of virtual machines?
Let’s explore three of the most promising technologies aiming to combine the best of both virtual machines and containers: microVMs, unikernels and container sandboxes.
MicroVMs are a new way of looking at virtual machines. Rather than being general purpose and providing all the potential functionality a guest operating system may require, microVMs seek to address the problems of performance and resource efficiency by specializing for specific use cases.
For example, a cloud-native application only needs a few hardware devices, such as for networking and storage. There’s no need for devices such as full keyboards and video displays. Why run the application in a virtual machine that provides a bunch of unnecessary functionality?
By implementing a minimal set of features and emulated devices, microVM hypervisors can be extremely fast with low overhead. Boot times can be measured in milliseconds (as opposed to minutes for traditional virtual machines). Memory overhead can be as little as 5MB of RAM, making it possible to run thousands of microVMs on a single bare metal server.
Perhaps one of the most talked about microVMs is Firecracker. AWS created Firecracker to specifically address the need to run serverless applications quickly, efficiently and with utmost security. By specializing on a very specific use case, AWS was able to build a virtualization environment that perfectly suits the needs of cloud-native applications.
But remember, a big advantage of containers is that they virtualize at the application level, not the server level like virtual machines. This is a natural fit with our development lifecycle – after all, we build, deploy and operate applications, not servers.
Containers are a mature technology, supported by a rich ecosystem of tooling and services that provide end-to-end coverage for the entire application lifecycle. Build tools, packaging formats, runtimes and orchestration systems allow us to work much more efficiently than with virtual machines.
A better virtual machine by itself doesn’t help us much if we have to go back to deploying servers and give up our rich container ecosystem. The goal is to keep working with containers but run them inside their own virtual machine to address the security and isolation problem.
Most microVM projects provide a mechanism to integrate with the existing container runtimes. Instead of directly launching a container, the microVM-based runtime first launches a microVM, and then creates the container inside that microVM. Containers are encapsulated within a virtual machine barrier, without any impact on performance or overhead.
It’s like having our cake and eating it too. MicroVMs give us the enhanced security and workload isolation of virtual machines, while preserving the speed, resource efficiency and rich ecosystem of containers.
Unikernels aim to solve some of the same problems as microVMs. Like microVMs, unikernels allow us to run cloud-native applications with high performance and low overhead, while providing a strong security posture.
Although unikernels address the same issues as microVMs, they do so in a radically different way.
A unikernel is a lightweight, immutable OS compiled specifically to run a single application. During compilation, the application source code is combined with the minimal device drivers and OS libraries necessary to support the application. The result is a machine image that can run without the need for a host operating system.
Unikernels achieve their performance and security benefits by placing severe restrictions on execution. Unikernels can only have a single process. When packaged as a unikernel, your application cannot spawn sub-processes. With no other processes running, there is less surface area for security vulnerabilities.
In addition, unikernels have a single address space model, with no distinction between application and operating system memory spaces. This increases performance by removing the need to “context switch” between user and kernel address spaces. Note that with a single address space, there is no protection of the kernel from application errors. But, given that the unikernel can only run a single process – the application – there is not much use for this type of protection. If the application dies, there is no use keeping around the OS. It’s better to simply restart the unikernel.
However, one of the big drawbacks with unikernels is that they are implemented entirely differently than containers. The rich container ecosystem is not interchangeable with unikernels. In order to adopt unikernels, you will need to pick an entirely new stack, starting with choosing a unikernel implementation. There are many disparate unikernel platforms to choose from, each with their own requirements and constraints. For example, to build unikernels with MirageOS, you’ll need to develop your applications in the OCaml programming language.
It is interesting to note that Docker acquired Unikernel Systems back in January 2016. The expectation was that this would combine the familiar tooling and portability of Docker with the efficiency and specialization of unikernels. Unfortunately, it didn’t exactly work out that way. Docker abandoned the concept of unikernels and remains focused on containers.
Container sandboxes are designed to address the security issues of a shared operating system kernel. Sandboxes provide a “kernel proxy” for each container. Rather than each container directly addressing the host operating system, it gets assigned its own kernel proxy. The kernel proxy implements all the kernel features expected by the container, such that the container can run in the sandbox without any modifications.
One prominent project that implements container sandboxes is Google’s gVisor project. gVisor provides a kernel proxy module, written in the Go language, that acts as an intermediary between the container and the host operating system.
Container sandboxes specifically address the security issues posed by sharing the OS kernel between containers by introducing a new layer of separation. So, while sandboxes may provide additional isolation, they do come with an additional performance penalty incurred with translations between the proxy and kernel.
So, What’s Next?
Container sandboxes are an interesting approach to solving workload isolation, but don’t really offer enough benefits to warrant a switch. Looking ahead to the future, I think that microVMs and unikernels deserve the most attention.
If you are using containers, microVMs should definitely be on your roadmap. MicroVMs integrate with existing container tooling, making adoption rather painless. As microVMs mature, they will become a natural addition to the runtime environment, making containers much more secure.
Unikernels, on the other hand, require an entirely new way of packaging your application. For very specific use cases, unikernels may be worth the investment of converting your workflow. But for most applications, containers delivered within a microVM will provide the best option.