Ken Muse

Running Hugo in macOS Dev Containers

Moving my writing to a MacBook has created some interesting challenges. One challenge is that my environment is now ARM-based (M1). That means that any assumptions I have made about the underlying platform being Intel are likely to create challenges. It’s true that Macs have Rosetta to help run Intel workloads. That said, it definitely has some limitations you have to keep in mind.

And that brings me to Dev Containers.

I do most of my work inside a container. When I’m running in Codespaces (or on a PC), that container is using an x64-based Linux environment. When I use it on my Mac, the container defaults to using an ARM-based runtime. While I can override the FROM --platform=linux/amd64 to force emulation, there’s a noticeable performance hit. If my container requires me to compile code, emulation can add several minutes to the process. In addition, there are times where emulated processes seem to fail or lock.

Working with Hugo, I can use any Linux base for my images. My original image used Alpine and downloaded the Hugo binary. This worked until I used my Mac. I realized that I was downloading the an x86 binary, but I was using the arm64 version of Alpine as the base for my image. No surprises here — it didn’t work. The surprise was there is no build available for Hugo Extended on ARM. That means I either need to build it myself or force my platform to amd64. Because the steps are different for the two platforms, this would mean my image needs code that understands both environments. Challenge accepted!

The trick to making this work is realizing that there are Docker global variables we can use to understand the environment. They are broadly classified into Build and Target variables. The Build variables capture the details of the host system that is performing the build, while Target variables capture the platform. In many cases, there’s not much difference. However, I’m using an x64 machine to compile ARM64 images, then Build will capture the AMD64 details and Target will represent the ARM64 environment.

Going a bit further, I can use the TARGETARCH global variable to know the architecture that is being targeted. It will be configured with “amd64” for Intel/AMD x86 chipsets, and “arm64” for an ARM-based targets. To make the variable available to me, I need to declare ARG TARGETARCH in my Dockerfile. I can combine that value with the bash if command to run architecture-specific commands:

 3RUN if ["${TARGETARCH}" = "amd64" ]; then \
 4    { \
 5        echo "I'm in an x64 container"; \
 6    }\ 
 7    else \
 8    { \
 9        echo "I'm in an ARM64 container"; \
10    } \
11    fi

This means I can optimize each image for its environment. When an image is targeting an x64 processor, I can directly download the executables. Since I’m using Alpine, I can use muslstack to extend the thread stack size to 8MB to avoid segmentation faults. When targeting ARM64, I can download and compile the source code for the platform. Once I have a platform-specific binary, I can copy it to a common location.

 5RUN if ["${TARGETARCH}" = "amd64" ]; then \
 6    { \
 7        $(curl -L${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-64bit.tar.gz | tar -xz); \
 8        go get; \
 9        muslstack -s 0x800000 hugo; \
10    }\
11    else\
12    {\
13        $(curl -L$HUGO_VERSION.tar.gz | tar -xz -C /); \
14        go build --tags extended; \
15        chmod +x hugo; \
16    }\
17    fi \
18    && mv hugo /usr/bin/hugo

With a few minor tweaks, I have a working environment, an optimized image build process, and platform-native performance. It’s the best of all worlds with minimal effort.

Hello, Mac development containers!