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.