|
| 1 | +--- |
| 2 | +title: Using `just` to properly tag and isolate docker compose projects |
| 3 | +tags: |
| 4 | + - observations |
| 5 | +--- |
| 6 | +You're developing a dockerized application, so you have a docker compose stack. |
| 7 | + |
| 8 | +```yaml |
| 9 | +services: |
| 10 | + frontend: |
| 11 | + image: 'frontend:develop' |
| 12 | + build: |
| 13 | + context: ./frontend |
| 14 | + backend: |
| 15 | + image: 'backend:develop' |
| 16 | + build: |
| 17 | + context: ./backend |
| 18 | +``` |
| 19 | +
|
| 20 | +This is pretty neat, but it does mean that all your images are built with the tag `develop`. Let's say you switch to another branch, and you want to test the application there. You are _forced_ to rebuild, possibly `docker compose down -v` and lose your old image and state entirely if you want to switch back. |
| 21 | + |
| 22 | +Instead, we can define the environment variable `TAG`, and then rewrite the docker compose to be: |
| 23 | + |
| 24 | +```yaml |
| 25 | +services: |
| 26 | + frontend: |
| 27 | + image: frontend:${TAG:-develop} |
| 28 | + # alternatively, force tag to be set with |
| 29 | + # image: frontend:${TAG?TAG not set} |
| 30 | + build: |
| 31 | + context: ./frontend |
| 32 | + backend: |
| 33 | + image: backend:${TAG:-develop} |
| 34 | + build: |
| 35 | + context: ./backend |
| 36 | +``` |
| 37 | + |
| 38 | +We can instead do `TAG=my-branch docker compose build`, and docker compose will interpolate it for you. Still, not optimal. It involves typing _at least_ 4 extra characters, and if you're anything like me, you're going to forget more often than not. We _could_ export it globally, but global terminal variables tend to be scary. |
| 39 | + |
| 40 | +Instead, we can use a [`justfile`](https://just.systems/man/en/) to perform a little bit of magic for us. A `justfile` is just a `makefile` without a million and a half footguns. I use them extensively and I love them. They also have a neat ability to set environment variables based on command execution. |
| 41 | + |
| 42 | +```just |
| 43 | +export TAG=`(git rev-parse --abbrev-ref HEAD)` |
| 44 | + |
| 45 | +just build *FLAGS: |
| 46 | + docker compose build {{FLAGS}} |
| 47 | + |
| 48 | +just up *FLAGS: |
| 49 | + docker compose up {{FLAGS}} |
| 50 | +``` |
| 51 | +
|
| 52 | +Pretty cool! Now, on `my-branch`, `docker compose build` will build our frontend and backend images and push them to our internal docker registry, correctly tagged with `my-branch`. This isn't as scary as it sounds in terms of space usage, as if your images are correctly layered, docker will de-duplicate the shared layers. |
| 53 | + |
| 54 | +However, this could create docker tags that aren't syntactically valid, like if the branch is called `my/FEATURE-branch`. Instead, we can normalize it, following exactly what gitlab does to generate [`CI_COMMIT_REF_SLUG`](https://gitlab.com/gitlab-org/gitlab-runner/-/blame/af6932352f8ed15d1a6d9c786399607bc6be2c2d/Makefile.build.mk?page=1#L25). |
| 55 | + |
| 56 | +```just |
| 57 | +export TAG=`(git rev-parse --abbrev-ref HEAD | tr '[:upper:]' '[:lower:] | cut -c -63 | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/^-*([a-z0-9-]+[a-z0-9])-*$$/\1/g')` |
| 58 | + |
| 59 | +just build *FLAGS: |
| 60 | + docker compose build {{FLAGS}} |
| 61 | + |
| 62 | +just up *FLAGS: |
| 63 | + docker compose up {{FLAGS}} |
| 64 | +``` |
| 65 | +
|
| 66 | +This branch would get normalized to `my-feature-branch`, all lowercase. |
| 67 | +# Appendix |
| 68 | + |
| 69 | +## Parity with CI |
| 70 | + |
| 71 | +Let's say you have a CI/CD process that builds containers and pushes them to your Gitlab/Github container registry. Assuming your CI tags and your docker compose tags are identical, you can pull your images directly from CI, bypassing a potentially expensive build step. |
| 72 | + |
| 73 | +```yaml |
| 74 | +name: my-project-${TAG:-develop} |
| 75 | +services: |
| 76 | + frontend: |
| 77 | + image: ${REGISTRY}/frontend:${TAG:-develop} |
| 78 | + build: |
| 79 | + context: ./frontend |
| 80 | + backend: |
| 81 | + image: ${REGISTRY}/backend:${TAG:-develop} |
| 82 | + build: |
| 83 | + context: ./backend |
| 84 | +``` |
| 85 | + |
| 86 | +Where `${REGISTRY}` is `gitlab.com:5050/my/project/registry` or whatever. Now, with a modified `justfile`: |
| 87 | + |
| 88 | +```just |
| 89 | +export TAG=`(git rev-parse --abbrev-ref HEAD | tr '[:upper:]' '[:lower:] | cut -c -63 | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/^-*([a-z0-9-]+[a-z0-9])-*$$/\1/g')` |
| 90 | +export REGISTRY=gitlab.com:5050/my/project/registry # note that I would probably set this in a .env file since it's static |
| 91 | + |
| 92 | +just pull *FLAGS: |
| 93 | + docker compose up {{FLAGS}} |
| 94 | + |
| 95 | +just up *FLAGS: |
| 96 | + docker compose up {{FLAGS}} |
| 97 | +``` |
| 98 | +
|
| 99 | +`just pull` can pull the base images, potentially saving a huge amount of time on initial build if layering is done correctly. |
| 100 | + |
| 101 | +For a bonus-bonus round, if you use buildkit caching ([github](https://docs.docker.com/build/ci/github-actions/cache/) and [gitlab](https://docs.gitlab.com/ee/ci/docker/docker_layer_caching.html)), you can use the `cache_from` directive to save yourself some substantial time by pre-seeding your cache with dependencies in python, typescript, etc (again, assuming you are cache-mounting your layers correctly). |
| 102 | + |
| 103 | +```yaml |
| 104 | +name: my-project-${TAG:-develop} |
| 105 | +services: |
| 106 | + frontend: |
| 107 | + image: ${REGISTRY}/frontend:${TAG:-develop}$ |
| 108 | + build: |
| 109 | + context: ./frontend |
| 110 | + cache_from: |
| 111 | + - ${REGISTRY}/frontend:buildcache |
| 112 | + backend: |
| 113 | + image: ${REGISTRY}/backend:${TAG:-develop}$ |
| 114 | + build: |
| 115 | + context: ./backend |
| 116 | + cache_from: |
| 117 | + - ${REGISTRY}/frontend:buildcache |
| 118 | +``` |
| 119 | +## Using the tag to achieve pure isolation |
| 120 | + |
| 121 | +If you're running a multiple copies on your machine, say, to test multiple branches, you can use the `name` top-level element to achieve pure isolation between each stack. |
| 122 | + |
| 123 | +```yaml |
| 124 | +name: my-project-${TAG:-develop} |
| 125 | +services: |
| 126 | + frontend: |
| 127 | + image: frontend:${TAG:-develop}$ |
| 128 | + build: |
| 129 | + context: ./frontend |
| 130 | + backend: |
| 131 | + image: backend:${TAG:-develop}$ |
| 132 | + build: |
| 133 | + context: ./backend |
| 134 | +``` |
| 135 | + |
| 136 | +Let's say you're doing feature development on tag `my-feature`, but you need to switch to a new branch `my-hotfix` . You can `git switch`, and then with `just up`, it creates a set of containers entirely prefixed with `my-project-${TAG}` without conflicting with the original set of containers. If your spin up process is expensive, this can be a huge time-saver. |
| 137 | + |
| 138 | +Additionally, if you have a local docker volume, say to persist database data, the volume is created with `my-project-${TAG}` as its prefix. Your data won't be polluted between branches, so you can perform database migrations, seeding, etc without getting into a funky state. |
0 commit comments