Skip to content
Bytes by Ying
Go back

#lifeprotip: Haskell-inspired "lifting into structure" for individual shell commands within a Docker context

Edit page

You could say this is a continuation of my experiment using Docker as a server for development workflows. In the end though, I’ve found deployments to production in AWS to be far more complex than that model allowed, and I ended up using a more conventional model of docker-compose to model systems locally and to build Dockerfile images to ship to the remote Docker registries.

In contrast, I’ve been using an adjusted version of the prior workflow to develop my frontends. This is mostly due to my usage of hugo, the static website generator. I double-checked CloudFront and I have something like 15 different hugo bundles in production right now, created at different times. The problem is that hugo is still primarily a work-in-progress project, and there are breaking changes made between different hugo versions. This impacts changes between versions of different hugo themes. I’ve encountered this difficulty when upgrading dependency versions for my personal blog. I already had to request help from the maintainer, Munif Tanjim, who very graciously walked me through the upgrade process and even created some diffs himself. I felt embarrassed that I needed help doing that, and I didn’t really want to go through that experience again when creating TinyDevCRM’s documentation.

When I was working with Docker for TinyDevCRM I learned of command exec "@", which if set as the Dockerfile CMD, allows you to pipe in a command to run at runtime. Combined with having its own independent build process, you can lock into place a set of fixed dependencies that version separately from your other projects.


Let’s walk through a project to see what I mean.

The final Dockerfile should look something like this:

FROM debian:buster-20200514
LABEL maintainer="me@yingw787.com"

# Set build arguments.
ARG DEBIAN_FRONTEND=noninteractive

# Get package lists, important for getting 'curl' and such.
RUN apt-get -y update

# Install build dependencies.
RUN apt-get install -y curl

# Install golang.
RUN curl https://dl.google.com/go/go1.14.4.linux-amd64.tar.gz -o /tmp/go1.14.4.linux-amd64.tar.gz
RUN tar -C /usr/local -xvzf /tmp/go1.14.4.linux-amd64.tar.gz
ENV PATH=$PATH:/usr/local/go/bin

# Install hugo. It's important to lock down the version of `hugo` since it's
# still a development version with breaking changes.
#
# Use '-L' to follow redirects from GitHub releases.
RUN curl -L https://github.com/gohugoio/hugo/releases/download/v0.62.0/hugo_0.62.0_Linux-64bit.deb -o /tmp/hugo_0.62.0_Linux-64bit.deb
RUN dpkg -i /tmp/hugo_0.62.0_Linux-64bit.deb

# Setup workdirectory.
RUN mkdir /app
WORKDIR /app

# Setup permissions.
RUN chown -R 1000:1000 /app

# Run commands.
CMD [ "exec", "\"@\"" ]

I use make and Makefiles in order to template out commands, as it’s easier in order to consistently pass in CLI arguments for docker run. It’s also a bit safer and transportable than using bash.

The final Makefile should look something like this:

#!/usr/bin/env make

.PHONY: version check setup run start

export AWS_PROFILE ?= tinydevcrm-user
export APP_VERSION ?= $(shell git rev-parse --short HEAD)
export GIT_REPO_ROOT ?= $(shell git rev-parse --show-toplevel)

export DOCKER_IMAGE_NAME ?= tinydevcrm-api-docs
export HUGO_PORT ?= 1320

export USERID ?= $(shell id -u $(whoami))
export GROUPID ?= $(shell id -g $(whoami))

# Use the custom fork, for styling purposes.
setup:
    docker build \
        --file $(GIT_REPO_ROOT)/Dockerfile \
        --tag $(DOCKER_IMAGE_NAME):$(APP_VERSION) \
        $(GIT_REPO_ROOT)
    docker run \
        -v $(GIT_REPO_ROOT):/app \
        --net=host \
        -u $(USERID):$(GROUPID) \
        $(DOCKER_IMAGE_NAME):$(APP_VERSION) \
        git clone https://github.com/tinydevcrm/docuapi /app/themes/docuapi || true
    docker run \
        -v $(GIT_REPO_ROOT):/app \
        --net=host \
        -u $(USERID):$(GROUPID) \
        $(DOCKER_IMAGE_NAME):$(APP_VERSION) \
        hugo mod get -u

# From: https://stackoverflow.com/a/14061796
# If the first argument is "run"...
ifeq (run,$(firstword $(MAKECMDGOALS)))
  # use the rest as arguments for "run"
  RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
  # ...and turn them into do-nothing targets
  $(eval $(RUN_ARGS):;@:)
endif

# Lifts command into `docker run` context.
run:
    docker run \
        -v $(GIT_REPO_ROOT):/app \
        -v ~/.aws:/root/.aws \
        --net=host \
        -u $(USERID):$(GROUPID) \
        $(DOCKER_IMAGE_NAME):$(APP_VERSION) \
        $(RUN_ARGS)

start: setup
    $(MAKE) run "hugo server -p $(HUGO_PORT)"

This isn’t a terribly large amount of work, and it’s a lot easier than say, using Bazel (which I had wanted to use for TinyDevCRM, but eventually decided against after seeing just how much work building out my own source would entail).

It’s also useful for integrating debuggers and needing to expose a process to a stdout stream. For example, running python -m ipdb manage.py runserver for a Django process hits a pdb trace before starting the Django process. This means without having a stdout stream available, either you can’t use ipdb or pdb within Docker, or you have to somehow elide that first pdb trace and attach to the Docker process manually before adding a pdb trace and hitting the server process with a request to see it. This is a comfortable third option.

There’s still problems with this stack; I usually use Ctrl+C in order to exit the Docker container, resulting it merely stopping instead of deleting itself, but otherwise it’s not bad. I like to think it keeps the Haskell philosophy of “lifting into structure”, which I’ve found applicable outside of one programming language.

You can find tinydevcrm-api-docs I’ve used for inspiration for this blog post here.


Edit page
Share this post on:

Previous Post
Postgres, as an App! (Now with one-click deploys to AWS + Heroku!)
Next Post
Postgres...as an App?