Complete Docker + Wasm Tutorial: From C++ Code to Wasm Container

In our previous lesson, we talked about the Docker + Wasm integration. Theory is always helpful, but nothing beats experience! So let's demystify some concepts, by actually seeing all of this in action. What will we see? Well, pretty much everything.

To learn more about Wasm, check out our video

What better way to understand how Docker runs Wasm, than actually building a Wasm container from zero? Here's what we will do:

Tutorial Steps

  1. Install Docker Desktop, an application that includes a graphical user interface. Currently, this is the only Docker build that can actually run Wasm. In the future, this might change. We might be able to run Wasm with the usual Docker Community Edition build we use on servers, which does not require the graphical app.
  2. Next, we'll write a simple C++ application.
  3. Then we'll learn how to compile this into Wasm bytecode.
  4. After that, we'll package this Wasm app into a Docker image.
  5. And, finally, we'll run a Wasm application with Docker.

Try the Docker Run Lab for free

Docker Run Lab
Docker Run Lab

Operating System Notes

Many people use the Windows operating system. So we'll explore how to take all of our steps on this platform. However, we know some of you use MacOS, or even Linux-based operating systems. So our steps will be as platform-agnostic as possible. For example, the command we use to compile C++ to bytecode on Windows, can also be used on MacOS, or Linux. After you install Docker Desktop on the operating system you love, you can easily follow the same steps described here to test things for yourself.

Prerequisite - Installing Windows Subsystem for Linux (WSL2)

Modern Windows makes it easy to run Linux apps on top of it. The technology that allows this is called Windows Subsystem for Linux, or WSL, for short. And WSL2 is the newest version of this tech. Docker Desktop actually makes use of this and runs the Linux-based Docker application, on top of Windows.

There is a bit of virtualization magic going on behind the scenes. If you want to test these steps yourself, make sure virtualization is fully enabled in your UEFI/BIOS settings. On most computers, this is usually enabled by default.

To install WSL2 on Windows, we go to the Start menu and type "cmd". Then we open up Command Prompt and enter this command:

wsl --install

Next, we restart the operating system. After the restart, WSL2 will automatically continue with the installation process.

From this point on, the rest of the steps should be pretty similar no matter what operating system you're using.

Step 1 - Installing Docker Desktop

Now we're ready to install Docker Desktop. We'll head over to docker.com and download the installation package. We begin the install process and keep the default installation settings, especially the one that tells Docker to use WSL2.

At the end, the installation process might ask us to restart Windows. After the restart, Docker Desktop should start up automatically. If it doesn't, it can just be started manually, like any other regular application.

Enable Beta Feature that Allows Docker to Run Wasm Containers

At this point, Docker Desktop does not support Wasm containers by default. It's a beta feature, still in the early stages, not yet tested enough in the real world. But we need it, so let's enable it.

We'll go to settings by clicking on the top-right gear icon. Then we'll click on "Features in development" and enable the setting to "Use containerd for pulling and storing images".

Future Docker Desktop versions will probably have this enabled by default.

We can click "Apply & restart" and wait until Docker Desktop fully restarts.

Test Wasm Container

Now it's time to test if Wasm containers actually work. To do this, on Windows, we can open up a Command Prompt. On Linux, or MacOS, we'd open a terminal emulator. We'll use this command to try to run a Wasm container:

docker run --rm --runtime=io.containerd.wasmedge.v1 --platform=wasi/wasm32 sl1ck/wasm-test

Let's go through these arguments in order.

  • --rm simply tells Docker to remove the container after it finishes execution.
  • With --runtime we tell Docker to use the WasmEdge runtime integrated in containerd. WasmEdge is simply software that can run Wasm bytecode. There are many other runtimes available. Docker simply chose to use this one.
  • With the --platform argument, we tell Docker what kind of application this is. In this case, we say it's a 32-bit Wasm app, with an additional WASI integration. WASI stands for WebAssembly System Interface. We'll see what that is, later on.

The message "Docker + Wasm is working!" confirms that a Wasm container was successfully launched. That's cool, stuff works. But at this point, it might just feel like "so what?". There seems to be no difference. Just another "docker run" command with the same effects we've seen 1000 times before. But this perception will change when we go through the next steps.

Step 2 - Writing a C++ Application

After we'll take some code, transform it into a Wasm app, and turn that into a Docker image, the differences will become obvious.

Remember the C++ function we looked at in the previous lesson?

int factorial(int n) {
  if (n == 0)
    return 1;
  else
    return n * factorial(n-1);
}

Let's actually turn this into Wasm bytecode.

The first thing we'll do is create a factorial.cpp file. On Windows, we can write this in Command Prompt:

notepad factorial.cpp

We want to make our code able to interact with the user. So we'll start with these two lines:

#include <iostream>
using namespace std;

This essentially gives our small C++ program the ability to interact with iostream, Input/Output stream. In a nutshell, this lets the program display text on our screen (output). But it also allows the program to accept input from a user. We want to be able to type a number and instruct our application to calculate the factorial of that number. Factorial of 3 is the result of 1 * 2 * 3. Factorial of 4 is 1 * 2 * 3 * 4, and so on.

Now let's add the factorial function:

int factorial(int n) {
  if (n == 0)
    return 1;
  else
    return n * factorial(n-1);
}

This is a recursive function that calculates the result we want. Recursive functions are a neat programming trick, but they do require a long time to explain. In case you're unfamiliar with how this works, you can watch this video about recursion.

At this point, we just have a function that calculates something. But we have no way for the user to interact with the program. So let's add that too:

int main() {
int x;
cout << "Enter number:";
cin >> x;
cout << "Factorial of " << x << " is: " << factorial(x) << "\n";
return 0;
}

This will display the text "Enter number:" on the screen. The user can then type a number. And, finally, the program will output the factorial value of the number, x, that the user provided.

Our entire C++ code will look like this:

#include <iostream>
using namespace std;

int factorial(int n) {
  if (n == 0)
    return 1;
  else
    return n * factorial(n-1);
}

int main() {
  int x;
  cout << "Enter number:";
  cin >> x;
  cout << "Factorial of " << x << " is: " << factorial(x) << "\n";
  return 0;
}

Let's save our factorial.cpp file.

Now, for the next step. How do we compile this to Wasm bytecode?

Step 3 - Compiling Code to Wasm Bytecode

Every programming language has its own compilers. And many already have their own methods for compiling code to Wasm bytecode. Some support it out of the box. Some need helper tools. But users are usually free to choose from multiple solutions. In our case, we'll use a compiler toolchain called emscripten. This will also help us with an additional thing, besides compiling.

Remember how in the previous lesson we mentioned Wasm bytecode runs in a sort of mini virtual computer, called a virtual stack machine? So it's isolated from the rest of the system. Well, that would be a problem for us. If it's isolated, it has no way of displaying text on our screen. And we have no way of inputting numbers to our small Wasm application. But developers are smart and they already came up with an awesome solution, called WASI -- WebAssembly System Interface. This gives Wasm bytecode a way to interact with the outside world. In an overly simplified way, you can view it like this:

  1. Wasm bytecode wants to display some text on the screen, but it can't do that. It's isolated to its virtual stack machine.
  2. But WASI, in a way, sits in the middle, connected to both our operating system, and our Wasm application.
  3. So our Wasm bytecode tells WASI: "Hey, I want to write this on the screen used by the operating system."
  4. WASI says "Ok buddy, I'll tell the operating system to do that".
  5. WASI then sends this message along to the operating system, and text appears on the screen.

The reverse is true for inputting a number to our Wasm bytecode. The user types it, WASI picks it up, sends it to the Wasm app, and just like that, Wasm can get input from the outside world. Pretty cool stuff. This gives us the best of both worlds. The virtual stack machine can communicate with the operating system, but we still have a good level of isolation and security. WASI makes sure to only allow certain pre-approved interactions between the operating system and Wasm bytecode. For example, our Wasm app cannot read files on our computer, unless we specifically allow it.

emscripten can be installed on almost any operating system. But let's do it the easy way. We'll actually use a Docker image where emscripten is packaged inside. This way, we avoid having to go through extra installation steps.

Now let's compile our C++ app to Wasm bytecode, with WASI enabled.

Back to our command prompt, we'll write this command on Windows:

docker run --rm -v %cd%:/src emscripten/emsdk emcc factorial.cpp -o factorial.wasm

If you're trying this out on a Linux-like operating system, the command should be:

docker run --rm -v $(pwd):/src emscripten/emsdk emcc factorial.cpp -o factorial.wasm

Let's look at the arguments. We already saw what --rm does.

  • The next argument, -v %cd%:/src allows emscripten to access our factorial.cpp file. It basically connects our current directory on our computer, with the directory /src inside the container. The %cd% notation is a trick to specify the path to the current directory. For example, if we're currently in C:\Users\kodekloud, then -v %cd%:/src basically becomes -v C:\Users\kodekloud:/src. The $(pwd) notation does the same thing on Linux as %cd% does on Windows.
  • Next, we use the emcc command from the remote container image located at emscripten/emsdk. emcc is simply a compiler application. The emcc command takes the factorial.cpp file as input. So it's simply instructed to compile code found in that file.
  • Finally, we pass the -o factorial.wasm command line option to emcc. With that, we tell it to save its output in a file called factorial.wasm. By looking at the extension, .wasm, emcc figures out it needs to compile this into Wasm bytecode.

And just like that, we get our first Wasm bytecode; an application neatly packaged into a single .wasm file.

We can see the file on Windows with this command:

dir

Or with this command on Linux:

ls -lh

Awesome! We actually compiled C++ code into Wasm bytecode! And with a WASI interface enabled, to top it all off. That's why the file is actually quite large, for Wasm at least. On this system, it was around 166 Kilobytes. All that WASI stuff adds programming routines needed to interface with the outside world. And that's a lot of extra code added to the Wasm app. Without it, the file would be much smaller. Some Wasm files can have just a few hundred bytes, or just a few kilobytes.

But we can still reduce the size of our app a little bit, if we want. We can do that with compiler optimizations. For example, we could run this command on Windows:

docker run --rm -v %cd%:/src emscripten/emsdk emcc -O3 factorial.cpp -o factorial.wasm

Or this one on Linux:

docker run --rm -v $(pwd):/src emscripten/emsdk emcc -O3 factorial.cpp -o factorial.wasm

The -O3 option tells the emcc compiler to do some optimizations. And if we run the

dir

command again, we'll see that indeed, the file is smaller.

Long story short, where did we arrive? Well, we have a 150 kilobytes file. And this contains our entire application. In fact, it's the only file we need to create a container image. One file, and just 150 kilobytes of data is not a bad deal for a container. In fact, it's rather awesome. A regular container image doing the same thing as our Wasm container image could be a lot larger and could need many more files.

Now let's see how we package our factorial.wasm file into a Docker container image.

Step 4 - Building a Wasm Docker Container Image

First, we'll need to create a directory:

mkdir factorial-image

Next, we'll copy our Wasm file to this directory:

copy factorial.wasm factorial-image

Finally, let's step into this directory:

cd factorial-image

And we'll create a Dockerfile here. This will contain instructions that will help a Docker utility called buildx generate the container image.

notepad Dockerfile

Let's add this content to the file:

FROM scratch
 COPY factorial.wasm /factorial.wasm
 ENTRYPOINT [ "/factorial.wasm" ]

With these lines, we basically tell the utility:

  1. Build an image from scratch, start from nothing.
  2. Copy the factorial.wasm file, from our local system, to the container image, at /factorial.wasm (the "/" slash symbolizes the root directory, or top-level directory)
  3. When Docker tries to run this image, it should execute this file: /factorial.wasm, the so-called entry point.

Let's save the file.

Notepad automatically added the .txt extension to this file, but we don't need it. We'll rename Dockerfile.txt to plain Dockerfile, effectively removing the extension:

move Dockerfile.txt Dockerfile

Now we can build the image:

docker buildx build --platform wasi/wasm32 . -t your_username/factorial-in-wasm

Don't forget to replace "your_username" with the actual username you have on Docker Hub if you intend to upload this online.

We basically use the buildx utility, instruct it to build an image for the wasi/wasm32 platform. Otherwise said, 32-bit wasm bytecode, with the WASI interface enabled. The . dot tells it to build the image using the Dockerfile and files contained in the current directory.

Now guess what. Our job is done. Let's give ourselves a pat on the back.

  • We wrote C++ code.
  • We compiled it to Wasm bytecode.
  • We enhanced it with the WASI interface so that our app can interact with our operating system.
  • We built a Wasm Docker image.

Now we can see that this is not the same as a regular Docker image. It's especially obvious if you've also built regular images in the past.

Step 5 - Running Wasm Container

Finally, we can test if our hard work pays off. Remember, we wrote our app in C++. But we didn't compile it as a regular Windows application, or a Linux, or MacOS one. No, we compiled it as Wasm bytecode. And this can run anywhere, on any operating system, any processor architecture. Anywhere you can install a Wasm runtime, like WasmEdge, or anything else, you can run this Wasm app. And you don't need to change a single line of code. So this is super-portable, even more portable than regular Docker containers.

Now let's run our Wasm container image:

docker run --rm -i --runtime=io.containerd.wasmedge.v1 --platform=wasi/wasm32 your_username/factorial-in-wasm

The -i option tells Docker to run this interactively, so we can actually input a number when the container launches. The --runtime instruction tells Docker to use this special wasmedge "engine" that can run Wasm bytecode. Finally, we tell this engine, technically called a runtime, that this is a WASI-enabled 32-bit Wasm bytecode app.

And it works perfectly! Use 12 as the maximum number to test our simple app, as that's the largest that our code can calculate correctly, due to the variable types we used.

Now, only the imagination is your limit. You have a super small image to use with Docker. You can push this to an image repository. Then you can potentially run it in your Kubernetes cluster. All you need to do is add support for a runtime like WasmEdge in your cluster. So you get super small Wasm containers that can start amazingly fast, and perform a bit better than regular containers. And to top it all off, they're also super-well isolated, so you get some security benefits as well. Some workloads can be improved tremendously. You could bring costs down considerably in some cases. And you can make the experience of your users fast and buttery-smooth.

Personally, I find Wasm an exciting piece of tech that will open a lot of doors in the near future. What possibilities do you think Wasm containers bring to the table?