BLOG

Packaging Swift apps for Alpine Linux

Remko Tronçon ·

While trying to build my Age Apple Secure Enclave plugin, a small Swift CLI app, on Alpine Linux, I realized that the Swift toolchain doesn’t run on Alpine Linux. The upcoming Swift 6 will support (cross-)compilation to a static (musl-based) Linux target, but I suspect an Alpine Linux version of Swift itself isn’t going to land soon. So, I explored some alternatives for getting my Swift app on Alpine.

You can find all the scripts used in this post in the age-plugin-se repository.

Option 1: Running a pre-built binary

A first option is to use a pre-built dynamically linked binary compiled by Swift on e.g. Debian, and get it to run on Alpine.

Installing gcompat is usually the easiest way of getting glibc binaries to run Alpine. Unfortunately, this doesn’t seem to cut it: the binary crashes at load time (most likely due to incompatibilities of libraries such as libstdc++):

Error relocating age-plugin-se: fts_open: symbol not found
Error relocating age-plugin-se: fts_read: symbol not found
Error relocating age-plugin-se: fts_close: symbol not found
Error relocating age-plugin-se: fts_set: symbol not found

The second recommended workaround is installing a glibc-based distribution in a chroot, and set up the loader using symlinks, as described in this article. After setting up a Debian chroot this way, the pre-built binaries generated by Swift on Debian work as expected. However, having to go through this setup on every installation just to get a simple app working is still a nuisance.

Option 2: Packaging a binary with loader & libraries

A second option is to create a package containing the (glibc-based) Swift-compiled binary, bundled with all its dynamic library dependencies, including the glibc ld dynamic linker used to load the binary.

For age-plugin-se, I run the entire procedure from a shell script running on Alpine. The script has following steps:

  1. Create a Debian chroot (using debootstrap)

  2. Install the Swift compiler (and all its dependencies) in the chroot

  3. Copy the sources of the Swift app into the chroot

  4. Use Swift inside the chroot to compile the sources into a (glibc-based) executable.

    This executable is loaded using the glibc ld loader, which will be bundled at a package-specific private location (/usr/lib/my-package/ld-....). The loader is always hard-coded as an absolute path into an executable, so you need to tell the Swift compiler the full path where the custom loader will be located after installation. This is done using the --dynamic-linker linker flag.

    All the dynamic library dependencies will also be included in the package. To make the linker find these (and not pick up the system ones if they exist), you also have to set the run-time search path of the executable to a relative path where these libraries will be shipped. This is done using the -rpath linker flag. Since the executable will be installed in /usr/bin, the relative path where the dynamic libraries will end up will be ../lib/my-package.

    The full Swift compiler invocation looks like:

    swift build -c release --static-swift-stdlib \
      -Xlinker --dynamic-linker=/usr/lib/my-package/ld-linux-x86-64.so.2 \
      -Xlinker -rpath='$ORIGIN'/../lib/my-package
    
  5. Copy all files from the chroot dir to the system (or package) dir: the resulting executable is copied to /usr/bin, and the dynamic linker (ld-linux-x86-64.so.2 for x86-64 platforms, ld-linux-aarch64.so.1 for aarch64 platforms) and all dynamic libraries (libc.so.6, libstdc++.so.6, libgcc_s.so.1, libm.so.6) to /usr/lib/my-package.

Note that, because the script runs commands in a chroot, it has to be run with root privileges.

The chroot build script can finally be integrated into an APKBUILD script to create a self-contained Alpine package:

$ abuild -r
>>> age-plugin-se: Building main/age-plugin-se 0.1.3-r0 (using abuild 3.13.0-r3) 
...
>>> age-plugin-se: Build complete

$ ls ~/packages/main/aarch64/*.apk
age-plugin-se-0.1.3-r0.apk
age-plugin-se-doc-0.1.3-r0.apk

The resulting package can then be installed on a clean Alpine system using apk add, without any other steps or requirements:

$ doas apk add ./age-plugin-se-0.1.3-r0.aarch64.apk 
(1/1) Installing age-plugin-se (0.1.3-r0)
OK: 237 MiB in 72 packages

$ age-plugin-se --version
v0.1.3

Option 3: (Cross-)Compiling a static Linux binary

Swift 6 will support compiling a fully static Linux binary, using musl as its standard C library. The complete instructions for creating a static Linux build of your app can be found on the Swift.org page.

Since you can even create Linux binaries using the macOS toolchain, and since there currently only is a Swift 6 build for macOS, I adapted the age-plugin-se packaging procedure to create a static Linux binary on macOS for all architectures.

Although the resulting static Linux binaries run fine on Alpine Linux, I wanted to have a cleaner way of installing the package instead of just extracting the package tarball somewhere. Since I’m building the binaries on macOS, and Alpine’s abuild package build system doesn’t run on macOS, I created a Go script to convert the binary release tarball into an .apk file. This script implements the APK package specification in pure Go, and creates and signs an .apk without relying on external tools (such as abuild). This script is integrated into the GitHub workflow to package a binary release of age-plugin-se.

Package size

Here’s a comparison of the package sizes of the above approaches:

PackageOSSize
DynamicmacOS252 KiB
DynamicLinux1.2 MiB
Dynamic + static Swift stdlibLinux43.4 MiB
Dynamic + static Swift stdlib + dylibsLinux48.1 MiB
StaticLinux100.4 MiB

The minimal baseline package is the dynamically linked binary on macOS, which comes in at 252 KiB. This binary links dynamically against the Swift standard library and all required system libraries.

Linking dynamically on Linux yields a bigger binary (1.2 MiB). This is because on Linux, age-plugin-se compiles against Swift Crypto, a drop-in replacement for the CryptoKit framework on macOS. Swift Crypto uses BoringSSL, which is linked statically into the binary (contrary to CryptoKit on macOS, which is a dynamic system library). This causes the binary to be bigger, although the size increase is still limited in absolute numbers.

Using dynamic linking on Linux would require the Swift standard library to be present on the target system. Since Swift isn’t always available on Linux distributions (including Alpine), this isn’t a practical solution. By statically linking the Swift standard library into the binary (using --static-swift-stdlib), the dependency on Swift can be removed. Doing this makes the binary a lot larger, though: 43.4 MiB.

The binary with static Swift standard library still depends on system libraries (libc, libstdc++, libm, …). As explained above, these aren’t available on Alpine Linux, so we package these together with the binary. Doing this adds a few megabytes to the package, resulting in a total of 48.1 MiB.

Finally, creating a statically linked Swift binary, which avoids any dependency on system libraries, results in more than double the size of the packaged dynamic binary with all its deendencies: 100.4 MiB. I’m not sure why the package is so much larger (maybe some link-time optimizations and dead-code elimination that isn’t done), and I don’t think there’s a good reason in theory for it to be this way. I hope this is something that will be improved in later releases.