Build a Docker image like Heroku with Buildpacks.io
[Buildpacks.io] is bringing buildpacks, like Heroku uses, to anyone. Buildpacks are a better way to build [Docker] images, partly due to caching layers, but also helps build reusable build processes. This is a better intro [Turn Your Code into Docker Images with Cloud Native Buildpacks](https://blog.heroku.com/docker-images-with-buildpacks).
Buildpacks.io is bringing buildpacks, like Heroku uses, to anyone. Buildpacks are a better way to build Docker images, partly due to caching layers, but also helps build reusable build processes. This is a better intro Turn Your Code into Docker Images with Cloud Native Buildpacks.
The docs are good and I recommend going though them before jumping in here.
The existing buildpacks didn't work for my needs, but it's easy to build your own. It's more work to make it available to everyone, but I think eventualy there will be prebuilt packs that "just work". Currently, there are some edge cases that didn't meet my needs.
If you find a pack that works for you, you only need a project.toml
file.
[project]
id = "com.myapp"
name = "MyApp"
version = "1.0.0"
[build]
exclude = [
".bundle",
"log",
"tmp",
"storage",
"public/assets",
".byebug_history",
"public/packs",
"public/packs-test",
"node_modules",
"yarn-error.log",
".DS_Store",
"config/master.key",
"config/credentials/production.key",
"config/credentials/staging.key",
".env",
".envrc",
".sprockets-manifest-*.json",
".git",
".gitignore",
".ebextensions",
".elasticbeanstalk",
"dist"
]
The buildpack
There are more details to this process than I'm using, but this has been working well. You can use any executable for these. I went the simple route with BASH scripts.
There is a detect phase and a build phase. I didn't use the features of the detech phase much and this is where you'd want to put in more time for a published pack. The detect phase can determine what's getting built and help interact with other buildpacks.
Here's the file structure for my custom buildpack.
dist/ruby-buildpack
├── bin
│ ├── build
│ └── detect
└── buildpack.toml
To start we need a buildpack.toml
file.
# Buildpack API version
api = "0.2"
# Buildpack ID and metadata
[buildpack]
id = "net.candland.buildpacks.ruby"
version = "0.0.1"
name = "Ruby Buildpack"
# Stacks that the buildpack will work with
[[stacks]]
id = "io.buildpacks.stacks.bionic"
Next we need a detect
file. This only makes sure there is a Gemfile
. You can do more interesting
things here and pass values to the build phase.
#!/usr/bin/env bash
set -eo pipefail
if [[ ! -f Gemfile ]]; then
exit 100
fi
The real work is in the build
file. This has multiple caching layers so building an image doesn't
require a fresh start each time. For example, gems and node_modules are cached between builds saving a lot of time.
This is a long file and could (probably should) be split into different buildpacks. However, each section follows a similar pattern of setting up a caching layer, building into that layer, and setting some configuration for that layer.
Here's the big file and we'll look at some details after.
#!/usr/bin/env bash
set -eo pipefail
echo "---> Ruby Buildpack"
# GET ARGS
layersdir=$1
# DOWNLOAD RUBY
echo ""
echo "---> Ruby Install"
rubylayer="$layersdir"/ruby
mkdir -p "$rubylayer"
ruby_url=https://s3-external-1.amazonaws.com/heroku-buildpack-ruby/heroku-18/ruby-2.7.1.tgz
if [[ ! -f "$rubylayer"/bin/ruby ]] ; then
echo " Downloading and extracting Ruby"
wget -q -O - "$ruby_url" | tar -xzf - -C "$rubylayer"
echo -e 'cache = true\nlaunch = true' > "$rubylayer.toml"
fi
# MAKE RUBY AVAILABLE TO THIS SCRIPT
RUBY_PATH="$rubylayer/bin"
export PATH="$RUBY_PATH:$PATH"
export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}"$rubylayer/lib"
echo " ruby version $(ruby -v)"
# INSTALL NODE & YARN
echo ""
echo "---> NodeJS"
nodelayer="$layersdir/node"
mkdir -p "$nodelayer"
echo -e 'cache = true\nlaunch = true' > "$nodelayer.toml"
node_url=https://nodejs.org/download/release/v12.20.0/node-v12.20.0-linux-x64.tar.gz
if [[ ! -f "$nodelayer"/node/bin/node ]] ; then
echo " Downloading and extracting NodeJS"
wget -q -O - "$node_url" | tar -xzf - -C "$nodelayer"
mv "$nodelayer"/node-v12.20.0-linux-x64 "$nodelayer"/node
fi
NODE_PATH="$nodelayer/node/bin"
export PATH="$NODE_PATH:$PATH"
echo " node version $(node -v)"
# YARN
echo ""
echo "---> Yarn"
yarn_url=https://github.com/yarnpkg/yarn/releases/download/v1.22.10/yarn-v1.22.10.tar.gz
if [[ ! -f "$nodelayer"/yarn/bin/yarn ]] ; then
echo " Downloading and extracting Yarn"
wget -q -O - "$yarn_url" | tar -xzf - -C "$nodelayer"
mv "$nodelayer"/yarn-v1.22.10 "$nodelayer"/yarn
fi
YARN_PATH="$nodelayer/yarn/bin"
export PATH="$YARN_PATH:$PATH"
mkdir -p "$nodelayer/profile.d/"
echo " Prepending $NODE_PATH:$YARN_PATH to PATH"
echo "export PATH=$NODE_PATH:$YARN_PATH:\$PATH" > "$nodelayer/profile.d/01_path.sh"
echo " yarn version $(yarn -v)"
# Modules layer
echo ""
echo "---> Node modules layer"
moduleslayer="$layersdir/modules"
mkdir -p "$moduleslayer"
local_modules="$(pwd)/node_modules"
rm -rf "$local_modules"
mkdir -p "$moduleslayer/node_modules"
ln -s "$moduleslayer/node_modules" "$local_modules"
echo -e 'cache = true\nlaunch = true' > "$moduleslayer.toml"
NODE_MODULES_PATH="$moduleslayer/node_modules/.bin"
export PATH="$NODE_MODULES_PATH:$PATH"
mkdir -p "$moduleslayer/profile.d/"
echo " Prepending $NODE_MODULES_PATH to PATH"
echo "export PATH=$NODE_MODULES_PATH:\$PATH" > "$moduleslayer/profile.d/01_path.sh"
# INSTALL GEMS
echo ""
echo "---> Bundle"
bundlerlayer="$layersdir/bundler"
mkdir -p "$bundlerlayer"
local_bundler_checksum=$(sha256sum Gemfile.lock | cut -d ' ' -f 1)
remote_bundler_checksum=$(cat "$bundlerlayer.toml" | yj -t | jq -r .metadata 2>/dev/null || echo 'not found')
bundle config --local path "$bundlerlayer" >/dev/null
bundle config --local bin "$bundlerlayer/bin" >/dev/null
bundle config --local without development:test >/dev/null
if [[ ! -z "$SKIP_GEM_CACHE" ]] ; then
echo " SKIPPING GEM CACHE"
fi
if [[ -f Gemfile.lock && $local_bundler_checksum == $remote_bundler_checksum && -z "$SKIP_GEM_CACHE" ]] ; then
# Determine if no gem dependencies have changed, so it can reuse existing gems without running bundle install
echo "---> Reusing gems"
else
# Determine if there has been a gem dependency change and install new gems to the bundler layer; re-using existing and un-changed gems
echo "---> Installing gems"
echo -e "cache = true\nlaunch = true\nmetadata = \"$local_bundler_checksum\"" > "$bundlerlayer.toml"
bundle install
fi
bundle binstubs --all --path="$bundlerlayer/bin"
# ASSETS
echo ""
echo "---> compile assets"
bundle exec rails assets:precompile
echo ""
echo "---> PATH"
echo " $PATH"
echo ""
echo "---> launch commands"
echo " Writing to: $layersdir/launch.toml"
# SET DEFAULT START COMMAND
cat > "$layersdir/launch.toml" <<EOL
[[processes]]
type = "web"
command = 'rails db:migrate && rails server -b 0.0.0.0 -p \${PORT:-5000} -e \$RAILS_ENV'
# our worker process
[[processes]]
type = "sidekiq"
command = 'bundle exec sidekiq -C config/sidekiq.yml -e \$RAILS_ENV'
# console
[[processes]]
type = "console"
command = 'bundle exec rails console -e \$RAILS_ENV'
# rails
[[processes]]
type = "rails"
command = 'bundle exec rails'
EOL
It's all pretty specific to this project. Versions could be detected in the detect phase and then built accordingly in the build phase.
Layers
Anything written to a layer can be available for later builds and included when launching. You write
another toml
file for each later to describe how it's going to be used.
echo -e "cache = true\nlaunch = true\nmetadata = \"$local_bundler_checksum\"" > "$bundlerlayer.toml"
There we're saying this layer should be around again for the next build cache = true
and should be
available to the running containter launch = true
. We're also saving a checksum we read back next build.
Environment vars are not available in the launched image unless we write them to a file in the layer. This
adds the node_modules path to the environment by writing a script that will get sourced on launch. It
needs to go into the profile.d
directory of a layer. The launcher will handle the rest.
echo "export PATH=$NODE_MODULES_PATH:\$PATH" > "$moduleslayer/profile.d/01_path.sh"
Node_modules also presented a tricky issue, that as far as I can tell, you can symlink to the directory
as long as the directory you're linking too is also named node_modules
.
ln -s "$moduleslayer/node_modules" "$local_modules"
Launcher
The launcher is the entry point when running your image. It makes sure everything is setup and ready, like environment vars. Here you'll see we have and entry point for Rails and one for Sidekiq. The other two are shortcuts, that aren't really needed.
cat > "$layersdir/launch.toml" <<EOL
[[processes]]
type = "web"
command = 'rails db:migrate && rails server -b 0.0.0.0 -p \${PORT:-5000} -e \$RAILS_ENV'
# our worker process
[[processes]]
type = "sidekiq"
command = 'bundle exec sidekiq -C config/sidekiq.yml -e \$RAILS_ENV'
# console
[[processes]]
type = "console"
command = 'bundle exec rails console -e \$RAILS_ENV'
# rails
[[processes]]
type = "rails"
command = 'bundle exec rails'
EOL
web
is the default and any of the others can be used when starting the Docker image.
An example to get to a bash shell
sudo docker-compose exec web launcher bash
Or run directly in a container
sudo docker exec -i -t my_application console
Building
Install the pack
application from Buildpacks.io. Then run the build command something like this.
pack build my_application --env NODE_ENV=production --env RAILS_ENV=production --env RAILS_PRODUCTION_KEY=$(cat config/credentials/production.key) --path . --buildpack ./ruby-buildpack --descriptor project.toml --builder paketobuildpacks/builder:full
The buider you use is the base image, paketobuildpacks/builder:full
in this case.
Once built you can use like any normal Docker image.
Next we'll look at running this on Elastic Beanstalk using the new AWS Linux2 Docker environment... Ruby on Rails deployment to Elastic Beanstalk
Webmentions
These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: