davideisinger.com

My personal website
Log | Files | Refs | README

index.md (14338B)


      1 ---
      2 title: "Local Docker Best Practices"
      3 date: 2022-05-05T00:00:00+00:00
      4 draft: false
      5 canonical_url: https://www.viget.com/articles/local-docker-best-practices/
      6 featured: true
      7 references:
      8 - title: "Ruby on Whales: Dockerizing Ruby and Rails development—Martian Chronicles, Evil Martians’ team blog"
      9   url: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development
     10   date: 2024-10-02T13:37:32Z
     11   file: evilmartians-com-6ehmrb.txt
     12 - title: "Docker: Right for Us. Right for You? | Viget"
     13   url: https://www.viget.com/articles/docker-right-for-us-right-for-you-1/
     14   date: 2024-10-02T13:37:33Z
     15   file: www-viget-com-h4onv6.txt
     16 - title: "Docker + Rails: Solutions to Common Hurdles | Viget"
     17   url: https://www.viget.com/articles/docker-rails-solutions-to-common-hurdles/
     18   date: 2024-10-02T13:37:33Z
     19   file: www-viget-com-1hyo6b.txt
     20 ---
     21 
     22 Here at Viget, Docker has become an indispensable tool for local
     23 development. We build and maintain a ton of apps across the team,
     24 running different stacks and versions, and being able to package up a
     25 working dev environment makes it much, much easier to switch between
     26 apps and ramp up new devs onto projects. That's not to say that
     27 developing with Docker locally isn't without its
     28 drawbacks[^1], but
     29 they're massively outweighed by the ease and convenience it unlocks.
     30 
     31 Over time, we've developed our own set of best practices for effectively
     32 setting Docker up for local development. Please note that last bit ("for
     33 local development") -- if you're creating images for deployment
     34 purposes, most of these principles don't apply. Our typical setup
     35 involves the following containers, orchestrated with Docker Compose:
     36 
     37 1.  The application (e.g. Rails, Django, or Phoenix)
     38 2.  A JavaScript watcher/compiler (e.g. `webpack-dev-server`)
     39 3.  A database (typically PostgreSQL)
     40 4.  Additional necessary infrastructure (e.g. Redis, ElasticSearch,
     41     Mailhog)
     42 5.  Occasionally, additional instances of the app doing things other
     43     than running the development server (think background jobs)
     44 
     45 So with that architecture in mind, here are the best practices we've
     46 tried to standardize on:
     47 
     48 1.  [Don't put code or app-level dependencies into the
     49     image](#1-don-t-put-code-or-app-level-dependencies-into-the-image)
     50 2.  [Don't use a Dockerfile if you don't have
     51     to](#2-don-t-use-a-dockerfile-if-you-don-t-have-to)
     52 3.  [Only reference a Dockerfile once in
     53     `docker-compose.yml`](#3-only-reference-a-dockerfile-once-in-docker-compose-yml)
     54 4.  [Cache dependencies in named
     55     volumes](#4-cache-dependencies-in-named-volumes)
     56 5.  [Put ephemeral stuff in named
     57     volumes](#5-put-ephemeral-stuff-in-named-volumes)
     58 6.  [Clean up after `apt-get update`](#6-clean-up-after-apt-get-update)
     59 7.  [Prefer `exec` to `run`](#7-prefer-exec-to-run)
     60 8.  [Coordinate services with
     61     `wait-for-it`](#8-coordinate-services-with-wait-for-it)
     62 9.  [Start entrypoint scripts with `set -e` and end with
     63     `exec "$@"`](#9-start-entrypoint-scripts-with-set-e-and-end-with-exec)
     64 10. [Target different CPU architectures with
     65     `BUILDARCH`](#10-target-different-cpu-architectures-with-buildarch)
     66 11. [Prefer `docker compose` to
     67     `docker-compose`](#11-prefer-docker-compose-to-docker-compose)
     68 
     69 ------------------------------------------------------------------------
     70 
     71 ### 1. Don't put code or app-level dependencies into the image
     72 
     73 Your primary Dockerfile, the one the application runs in, should include
     74 all the necessary software to run the app, but shouldn't include the
     75 actual application code itself -- that'll be mounted into the container
     76 when `docker-compose run` starts and synced between the container and
     77 the local machine.
     78 
     79 Additionally, it's important to distinguish between system-level
     80 dependencies (like ImageMagick) and application-level ones (like
     81 Rubygems and NPM packages) -- the former should be included in the
     82 Dockerfile; the latter should not. Baking application-level dependencies
     83 into the image means that it'll have to be rebuilt every time someone
     84 adds a new one, which is both time-consuming and error-prone. Instead,
     85 we install those dependencies as part of a startup script.
     86 
     87 ### 2. Don't use a Dockerfile if you don't have to
     88 
     89 With point #1 in mind, you might find you don't need to write a
     90 Dockerfile at all. If your app doesn't have any special dependencies,
     91 you might be able to point your `docker-compose.yml` entry right at the
     92 official Docker repository (i.e. just reference `ruby:2.7.6`). This
     93 isn't very common -- most apps and frameworks require some amount of
     94 infrastructure (e.g. Rails needs a working version of Node), but if you
     95 find yourself with a Dockerfile that contains just a single `FROM` line,
     96 you can just cut it.
     97 
     98 ### 3. Only reference a Dockerfile once in `docker-compose.yml`
     99 
    100 If you're using the same image for multiple services (which you
    101 should!), only provide the build instructions in the definition of a
    102 single service, assign a name to it, and then reference that name for
    103 the additional services. So as an example, imagine a Rails app that uses
    104 a shared image for running the development server and
    105 `webpack-dev-server`. An example configuration might look like this:
    106 
    107 ```yaml
    108 services:
    109   rails:
    110     image: appname_rails
    111     build:
    112       context: .
    113       dockerfile: ./.docker-config/rails/Dockerfile
    114     command: ./bin/rails server -p 3000 -b '0.0.0.0'
    115 
    116   node:
    117     image: appname_rails
    118     command: ./bin/webpack-dev-server
    119 ```
    120 
    121 This way, when we build the services (with `docker-compose build`), our
    122 image only gets built once. If instead we'd omitted the `image:`
    123 directives and duplicated the `build:` one, we'd be rebuilding the exact
    124 same image twice, wasting your disk space and limited time on this
    125 earth.
    126 
    127 ### 4. Cache dependencies in named volumes
    128 
    129 As mentioned in point #1, we don't bake code dependencies into the image
    130 and instead install them on startup. As you can imagine, this would be
    131 pretty slow if we installed every gem/pip/yarn library from scratch each
    132 time we restarted the services (hello NOKOGIRI), so we use Docker's
    133 named volumes to keep a cache. The config above might become something
    134 like:
    135 
    136 ```yaml
    137 volumes:
    138   gems:
    139   yarn:
    140 
    141 services:
    142   rails:
    143     image: appname_rails
    144     build:
    145       context: .
    146       dockerfile: ./.docker-config/rails/Dockerfile
    147     command: ./bin/rails server -p 3000 -b '0.0.0.0'
    148     volumes:
    149       - .:/app
    150       - gems:/usr/local/bundle
    151       - yarn:/app/node_modules
    152 
    153   node:
    154     image: appname_rails
    155     command: ./bin/webpack-dev-server
    156     volumes:
    157       - .:/app
    158       - yarn:/app/node_modules
    159 ```
    160 
    161 Where specifically you should mount the volumes to will vary by stack,
    162 but the same principle applies: keep the compiled dependencies in named
    163 volumes to massively decrease startup time.
    164 
    165 ### 5. Put ephemeral stuff in named volumes
    166 
    167 While we're on the subject of using named volumes to increase
    168 performance, here's another hot tip: put directories that hold files you
    169 don't need to edit into named volumes to stop them from being synced
    170 back to your local machine (which carries a big performance cost). I'm
    171 thinking specifically of `log` and `tmp` directories, in addition to
    172 wherever your app stores uploaded files. A good rule of thumb is, if
    173 it's `.gitignore`'d, it's a good candidate for a volume.
    174 
    175 ### 6. Clean up after `apt-get update`
    176 
    177 If you use Debian-based images as the starting point for your
    178 Dockerfiles, you've noticed that you have to run `apt-get update` before
    179 you're able to `apt-get install` your dependencies. If you don't take
    180 precautions, this is going to cause a bunch of additional data to get
    181 baked into your image, drastically increasing its size. Best practice is
    182 to do the update, install, and cleanup in a single `RUN` command:
    183 
    184 ```dockerfile
    185 RUN apt-get update && \
    186   apt-get install -y libgirepository1.0-dev libpoppler-glib-dev && \
    187   rm -rf /var/lib/apt/lists/*
    188 ```
    189 
    190 ### 7. Prefer `exec` to `run`
    191 
    192 If you need to run a command inside a container, you have two options:
    193 `run` and `exec`. The former is going to spin up a new container to run
    194 the command, while the latter attaches to an existing running container.
    195 
    196 In almost every instance, assuming you pretty much always have the
    197 services running while you're working on the app, `exec` (and
    198 specifically `docker-compose exec`) is what you want. It's faster to
    199 spin up and doesn't carry any chance of leaving weird artifacts around
    200 (which will happen if you're not careful about including the `--rm` flag
    201 with `run`).
    202 
    203 ### 8. Coordinate services with `wait-for-it`
    204 
    205 Given our dependence on shared images and volumes, you may encounter
    206 issues where one of your services starts before another service's
    207 `entrypoint` script finishes executing, leading to errors. When this
    208 occurs, we'll pull in the [`wait-for-it` utility
    209 script](https://github.com/vishnubob/wait-for-it), which takes a web
    210 location to check against and a command to run once that location sends
    211 back a response. Then we update our `docker-compose.yml` to use it:
    212 
    213 ```yaml
    214 volumes:
    215   gems:
    216   yarn:
    217 
    218 services:
    219   rails:
    220     image: appname_rails
    221     build:
    222       context: .
    223       dockerfile: ./.docker-config/rails/Dockerfile
    224     command: ./bin/rails server -p 3000 -b '0.0.0.0'
    225     volumes:
    226       - .:/app
    227       - gems:/usr/local/bundle
    228       - yarn:/app/node_modules
    229 
    230   node:
    231     image: appname_rails
    232     command: [
    233       "./.docker-config/wait-for-it.sh",
    234       "rails:3000",
    235       "--timeout=0",
    236       "--",
    237       "./bin/webpack-dev-server"
    238     ]
    239     volumes:
    240       - .:/app
    241       - yarn:/app/node_modules
    242 ```
    243 
    244 This way, `webpack-dev-server` won't start until the Rails development
    245 server is fully up and running.
    246 
    247 ### 9. Start entrypoint scripts with `set -e` and end with `exec "$@"`
    248 
    249 The setup we've described here depends a lot on using
    250 [entrypoint](https://docs.docker.com/compose/compose-file/#entrypoint)
    251 scripts to install dependencies and manage other setup. There are two
    252 things you should include in **every single one** of these scripts, one
    253 at the beginning, one at the end:
    254 
    255 -   At the top of the file, right after `#!/bin/bash` (or similar), put
    256     `set -e`. This will ensure that the script exits if any line exits
    257     with an error.
    258 -   At the end of the file, put `exec "$@"`. Without this, the
    259     instructions you pass in with the
    260     [command](https://docs.docker.com/compose/compose-file/#command)
    261     directive won't execute.
    262 
    263 [Here's a good StackOverflow
    264 answer](https://stackoverflow.com/a/48096779) with some more
    265 information.
    266 
    267 ### 10. Target different CPU architectures with `BUILDARCH`
    268 
    269 We're presently about evenly split between Intel and Apple Silicon
    270 laptops. Most of the common base images you pull from
    271 [DockerHub](https://hub.docker.com/) are multi-platform (for example,
    272 look at the "OS/Arch" dropdown for the [Ruby
    273 image](https://hub.docker.com/layers/library/ruby/2.7.6/images/sha256-1af3ca0ab535007d18f7bc183cc49c228729fc10799ba974fbd385889e4d658a?context=explore)),
    274 and Docker will pull the correct image for the local architecture.
    275 However, if you're doing anything architecture-specific in your
    276 Dockerfiles, you might encounter difficulties.
    277 
    278 As mentioned previously, we'll often need a specific version of Node.js
    279 running inside a Ruby-based image. A way we'd commonly set this up is
    280 something like this:
    281 
    282 ```dockerfile
    283 FROM ruby:2.7.6
    284 
    285 RUN curl -sS https://nodejs.org/download/release/v16.17.0/node-v16.17.0-linux-x64.tar.gz \
    286   | tar xzf - --strip-components=1 -C "/usr/local"
    287 ```
    288 
    289 This works fine on Intel Macs, but blows up on Apple Silicon -- notice
    290 the `x64` in the above URL? That needs to be `arm64` on an M1. The
    291 easiest option is to specify `platform: linux/amd64` for each service
    292 using this image in your `docker-compose.yml`, but that's going to put
    293 Docker into emulation mode, which has performance drawbacks as well as
    294 [other known
    295 issues](https://docs.docker.com/desktop/mac/apple-silicon/#known-issues).
    296 
    297 Fortunately, Docker exposes a handful of [platform-related
    298 arguments](https://docs.docker.com/engine/reference/builder/#automatic-platform-args-in-the-global-scope)
    299 we can lean on to target specific architectures. We'll use `BUILDARCH`,
    300 the architecture of the local machine. While there's no native
    301 conditional functionality in the Dockerfile spec, we can do a little bit
    302 of shell scripting inside of a `RUN` command to achieve the desired
    303 result:
    304 
    305 ```dockerfile
    306 FROM ruby:2.7.6
    307 
    308 ARG BUILDARCH
    309 
    310 RUN if [ "$BUILDARCH" = "arm64" ]; \
    311   then curl -sS https://nodejs.org/download/release/v16.17.0/node-v16.17.0-linux-arm64.tar.gz \
    312     | tar xzf - --strip-components=1 -C "/usr/local"; \
    313   else curl -sS https://nodejs.org/download/release/v16.17.0/node-v16.17.0-linux-x64.tar.gz \
    314     | tar xzf - --strip-components=1 -C "/usr/local"; \
    315   fi
    316 ```
    317 
    318 This way, a dev running on Apple Silicon will download and install
    319 `node-v16.17.0-linux-arm64`, and someone with Intel will use
    320 `node-v16.17.0-linux-x64`.
    321 
    322 ### 11. Prefer `docker compose` to `docker-compose`
    323 
    324 Though both `docker compose up` and `docker-compose up` (with or without
    325 a hyphen) work to spin up your containers, per this [helpful
    326 StackOverflow answer](https://stackoverflow.com/a/66516826),
    327 "`docker compose` (with a space) is a newer project to migrate compose
    328 to Go with the rest of the docker project."
    329 
    330 *Thanks [Dylan](https://www.viget.com/about/team/dlederle-ensign/) for
    331 this one.*
    332 
    333 ---
    334 
    335 So there you have it, a short list of the best practices we've developed
    336 over the last several years of working with Docker. We'll try to keep
    337 this list updated as we get better at doing and documenting this stuff.
    338 
    339 If you're interested in reading more, here are a few good links:
    340 
    341 -   [Ruby on Whales: Dockerizing Ruby and Rails
    342     development](https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development)
    343 -   [Docker: Right for Us. Right for
    344     You?](https://www.viget.com/articles/docker-right-for-us-right-for-you-1/)
    345 -   [Docker + Rails: Solutions to Common
    346     Hurdles](https://www.viget.com/articles/docker-rails-solutions-to-common-hurdles/)
    347 
    348 [^1]: Namely, there's a significant performance hit when running Docker
    349 on Mac (as we do) in addition to the cognitive hurdle of all your
    350 stuff running inside containers. If I worked at a product shop,
    351 where I was focused on a single codebase for the bulk of my time,
    352 I'd think hard before going all in on local
    353 Docker.