DEV Community

Cover image for Christmas Hacking: Squeezing Kotlin Native into Docker
Marc Reichelt
Marc Reichelt

Posted on

Christmas Hacking: Squeezing Kotlin Native into Docker

Some time ago, I had a conversation with my fellow GDG organizer Randy Gupta. He's a Google Developer Expert on cloud, and he blew my mind when he said that you can create Docker images containing a single, self-contained executable. Think about it: no libraries, no package manager - just a single program doing it's job. That's amazing!

So because I love Kotlin, I wondered how far we can take this using Kotlin Native. With that, we can compile Kotlin code as an executable. Then we can add this to a Docker image. Let's begin, and have some fun! 😊

Kotlin, Go Native

As a first step, we'll create a simple hello world program and compile that to native. There's a good tutorial to start. Our hello_world.kt program:

fun main() {
    println("Hello, World!")
}
Enter fullscreen mode Exit fullscreen mode

Now we can compile and run it natively:

$ kotlinc-native hello_world.kt -o hello_world

$ ./hello_world.kexe
Hello, World!

$ file hello_world.kexe
hello_world.kexe: Mach-O 64-bit executable x86_64
Enter fullscreen mode Exit fullscreen mode

Ok, this works! But as you can see, because I ran this on my Mac, this created a Mac executable. For Docker, we will need a Linux executable. We can pass -target linux as a parameter to kotlinc-native to do that:

$ kotlinc-native -target linux hello_world.kt -o hello_world_linux

$ ll *.kexe
-rwxr-xr-x  1 mreichelt  mreichelt   607K 25 Dez 19:06 hello_world.kexe
-rwxr-xr-x  1 mreichelt  mreichelt   375K 25 Dez 19:14 hello_world_linux.kexe

$ file hello_world_linux.kexe
hello_world_linux.kexe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.16, BuildID[xxHash]=421d2b66bd8de945, not stripped
Enter fullscreen mode Exit fullscreen mode

Ok, cool - we have a Linux binary! Docker will be so happy… πŸŽ‰

Docker + Kotlin Native = ❀️

Let's pack this into a Docker image using a Dockerfile:

FROM ubuntu
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
Enter fullscreen mode Exit fullscreen mode

Now, we can build the Docker image & run it:

$ docker build --tag hello_kotlin .
Sending build context to Docker daemon  5.179MB
Step 1/3 : FROM ubuntu
 ---> a2a15febcdf3
Step 2/3 : COPY hello_world_linux.kexe /hello_world
 ---> 9438118fba59
Step 3/3 : ENTRYPOINT ["/hello_world"]
 ---> Running in 2454425124e7
Removing intermediate container 2454425124e7
 ---> 47b7a9b05993
Successfully built 47b7a9b05993
Successfully tagged hello_kotlin:latest

$ docker run --rm hello_kotlin
Hello, World!

$ docker image list hello_kotlin
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello_kotlin        latest              47b7a9b05993        2 minutes ago       64.6MB
Enter fullscreen mode Exit fullscreen mode

Yay, it works! Think of what you can do now:

  • Compile Kotlin code to a native binary
    • you don't have to use the CLI compiler - this should work for your Gradle-builds from the IDE as well!
  • no need for the JVM, so our Docker image already has 64.6MB in size (not more than 600MB)! 🌟

And because Docker caches the lower layers (for us it's Ubuntu), there is no actual need to make this smaller. This will be the best result for most people, because if you need a shell or other Ubuntu tools, this image will give you that flexibility. But if you're courious how we could make this smaller, and you're willing to leave some comfort behind: read on!

Honey, I Shrunk the Docker Image

Docker offers a way to create images from scratch. So let's do that - we create a file shrinked.Dockerfile, and we'll build a new Docker image hello_kotlin_shrinked containing only our Kotlin Native executable!

FROM scratch
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
Enter fullscreen mode Exit fullscreen mode

So let's build and run that:

$ docker build --tag hello_kotlin_shrinked . --file shrinked.Dockerfile
[…]
Successfully tagged hello_kotlin_shrinked:latest

$ docker run --rm hello_kotlin_shrinked
standard_init_linux.go:211: exec user process caused "no such file or directory"
Enter fullscreen mode Exit fullscreen mode

Hmm, that didn't work out. The reason is simple: the Kotlin Native compiler created a dynamically linked binary, meaning this file contains references to some shared libraries (.so files on Linux, .dll on Windows). Bummer!

I couldn't find any option for the kotlinc-native compiler to build a static, self-contained executable. I played around a little, but I didn't find anything close to how Go creates self-contained binaries. I found this excellent blog post on how to do this from Go.

I also asked around in the #kotlin-native channel of the Kotlin Slack. I got a reply by Dominic Fischer that there's an -include-binary option which could do what I want. I didn't come around to try that out yet, but if I do I'll update this post. Thanks, Dominic!

So, our binary doesn't run because it can't find those shared libraries. What happens if we add those to the Docker image directly? We can find which are needed by using the ldd tool. Let's use our previously created, Ubuntu-based Docker image to debug this. We're overriding the entrypoint to be a bash. Also we can mount the current working directory to /host if we need this later.

$ docker run --rm --volume $(pwd):/foo --entrypoint /bin/bash -it hello_kotlin

root@a34eeb5c7bef:/# ldd /hello_world
    linux-vdso.so.1 (0x00007ffc187bd000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f1a39a92000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f1a396f4000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f1a394d5000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f1a392bd000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1a38ecc000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f1a39c96000)
Enter fullscreen mode Exit fullscreen mode

Ok, so now we know which shared libraries we need! linux-vdso.so.1 is a special library - it is directly provided by the Linux kernel. So we only need to care about the others.

I copied the entire Ubuntu content over to my host, and I created 2 empty directories lib and lib64 and copied over only the mentioned files and symbolic links from the original Ubuntu image. Let's see how this looks:

File structure of lib and lib64 directories

Now we can modify our shrinked.Dockerfile and add those libs:

FROM scratch
COPY lib /lib
COPY lib64 /lib64
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
Enter fullscreen mode Exit fullscreen mode

Let's see if we can now build and run this!

$ docker build --tag hello_kotlin_shrinked . --file shrinked.Dockerfile
[…]
Successfully tagged hello_kotlin_shrinked:latest

$ docker run --rm hello_kotlin_shrinked
Hello, World!

$ docker images 'hello_kotlin*'
REPOSITORY              TAG                 IMAGE ID            CREATED              SIZE
hello_kotlin_shrinked   latest              a66abb1efa53        About a minute ago   4.54MB
hello_kotlin            latest              47b7a9b05993        3 hours ago          64.6MB
Enter fullscreen mode Exit fullscreen mode

Wow, that's cool - we now have a Docker image of 4.54MB in size that runs a Kotlin Native binary successfully. Party time! πŸŽ‰πŸŽ†πŸŽ„

Conclusion

We looked at how to cross-compile a Kotlin program for Linux. Then we created a Docker image, using that program as an entrypoint. By leaving the JVM behind, we could already produce a Docker image of 64.6MB, most of which coming from the Ubuntu image itself.

Then, we entered the danger zone and looked at how we can strip most things of Ubuntu away. We included only the necessary libraries, and that way we got the image down to 4,54MB.

Some final note: If you compile your own (real) Kotlin Native program, you probably need more files than you might think. Using https connections? I bet you'll need the SSL root certificates provided in each Linux distro. Using some Kotlin Native libs? Maybe some of those add references to other native .so libs, and your program won't start otherwise. So always test sensibly!

If you liked this post, please give it a ❀️ and follow me on Twitter!
I would love to know more if you use this somewhere. Also, I'm pretty sure there must be some tools / tricks out there that would help in this matter. If you know something I don't: please write! πŸ™Œ

Cover photo by Denise Johnson on Unsplash.

Top comments (0)