These scripts have been tested with Docker version
18.06.0-ce, build0ffa825.
- Use Docker's cached layers for gems, npm packages, and assets, if the relevant files have not been modified (
Gemfile,Gemfile.lock,package.json, etc.) - Use a multi-stage build so
that Rails assets and the webpack build are cached independently.
- e.g. Changing assets doesn't invalidate the webpack layers, and vice versa.
- Use the webpack DllPlugin to split the main dependencies into a separate file. This means that we only need to compile the main libraries once (e.g. React, Redux)
- I used a separate
package.jsonto take advantage of Docker's caching.
- I used a separate
- If there are any changes to
Gemfileorpackage.json, re-use the gems and packages from the first build. (Don't download everything from scratch.) - If there are any changes to assets, re-use the assets and cache from the previous build.
- Only include necessary files in the final image.
- A production Rails app doesn't need any files in
app/assets,node_modules, or front-end source code. A lot of gems also have some unnecessary files that can be safely removed (e.g.spec/,test/,README.md)
- A production Rails app doesn't need any files in
- Include the bootsnap cache in the final image, to speed up server boot and rake tasks.
- After building a new image, create a small "diff layer" between the new image and the previous image. This layer only includes the changed files.
- Create a nested sequence of diff layers, but reset the sequence if there are too many layers (> 30), or if the diff layers add up to more than 80% of the base layer's size.
# Clone rails_docker_example repo + submodules
git clone --recursive https://github.com/FormAPI/rails_docker_example.git
cd rails_docker_example
./scripts/build_ruby_node
./scripts/build_base
./scripts/build_app
docker-compose up --no-start
docker-compose run --rm web rake db:create db:migrate
docker-compose upThen visit localhost:3000. The app should be running and you should be able to add a comment. (If you open the app in two different tabs, the comments should update in real-time via websockets.)
Make a change in react-webpack-rails-tutorial/app/views/layouts/application.html.erb:
echo "hello world" >> react-webpack-rails-tutorial/app/views/layouts/application.html.erbNow run ./scripts/build_app
When you run docker history demoapp/app:latest, you should see a small rsync layer at the top, which only includes the changed file:
IMAGE CREATED CREATED BY SIZE COMMENT
fcb9dfcf1fcf 14 seconds ago rsync --recursive --links --progress --check… 809B
e3cdd669d928 2 hours ago 2.1MB merge sha256:68ac83c9746edb79dd90791ac6c07016a1d065dfb3ea4f49cc81faa2073cb510 to sha256:402c0958413f4c061c293413010ab300b3735aae518f8b48a85aeafa78fe94dd
<missing> 2 hours ago /bin/sh -c #(nop) CMD ["foreman" "start"] 0B
<missing> 2 hours ago /bin/sh -c #(nop) EXPOSE 80 0B
Note: The second build uses an updated base image, so the Docker layers are not fully cached.
bundle installandyarn installshould still be very fast, since they don't have to download anything. All the builds after this one will use cached layers, so they'll be even faster (if you don't change any gems or npm packages.)
Now remove the awesome_print gem from the Gemfile and update Gemfile.lock:
cd react-webpack-rails-tutorial
cat Gemfile | grep -v "awesome_print" > Gemfile.new && mv Gemfile.new Gemfile
bundle install
cd ..Run ./scripts/build_app
Notice that while the bundle install is not fully cached, it is still using all of the gems from the previous build.
Now change a Rails asset:
echo "body { color: blue; }" >> react-webpack-rails-tutorial/app/assets/stylesheets/test-asset.cssRun ./scripts/build_app
You'll see that the webpack steps are fully cached, but the assets:precompile task is run.
Now change a webpack asset in client:
echo "body { color: green; }" >> react-webpack-rails-tutorial/client/app/assets/styles/app-variables.scssRun ./scripts/build_app
You'll see that the assets:precompile task is fully cached, but the webpack build is run.
The build_app script uses the following Docker tags:
Contains specific versions of Ruby, Node.js, and Yarn.
(I started by using some ruby-node
images from Docker Hub, but I prefer to have full control over the versions.)
Based on demoapp/ruby-node. Installs Linux packages, such as build-essential, postgresql-client, and rsync. It also sets up some directories and environment variables.
The base image for the webpack build. The initial build uses demoapp/base as the base image, and then tags the resulting image with demoapp/app:base-webpack-build. All the subsequent builds use this first build as the base image. We only set the base-webpack-build once and don't update it very often, because if it keeps changing then Docker can't cache any layers.
The base image for the assets build. The first build will use demoapp/base:latest as the base image (a clean slate), and the following builds will use the first build as the base image.
The most recent build environments. We run docker build multiple times, targeting different stages in Dockerfile.app. If you change a lot of gems or npm packages, you can update the base images to point to these tags: docker tag demoapp/app:latest-assets-build demoapp/app:base-assets-build
Next time you change the Gemfile, you won't have to install as many gems. But don't update this too often, because most of the time you'll be using Docker's cached layers.
The in-progress production build that contains the final squashed layer. We don't override the demoapp/app:latest tag immediately, because the last step is to produce a small diff layer between demoapp/app:latest and demoapp/app:current
This is the final production image after running ./scripts/build_app. It will contain a small diff layer between the current image and the most recent image.
I added webpack's DllPlugin and DllReferencePlugin as a proof-of-concept for vendored libraries. The main idea is that if you use a separate package.json and webpack.config.js, Docker will cache these layers when the files haven't changed, so you cache the vendored packages and libraries. You just have to remember to run yarn add <package> in client/vendor, instead of client. If you add a package to client/package.json instead of client/vendor/package.json, it will work fine, but the package won't be included in your vendored DLL.
This works great as a demo, and the DllPlugin can definitely be used in production, but this implementation isn't very clean and there are a lot of things that can be improved.
react-webpack-rails-tutorial was already using the CommonsChunkPlugin to create their own vendored libaries. This runs during the main webpack build, so it can't be cached independently.