Recently, CircleCI launched a new version of their continuous integration platform, CircleCI 2.0. Several of our clients at work upgraded and immediately saw a huge reduction in build times. I decided to try it out on my Elixir side project.

You can see all of the details of the changes I made in the pull request.

Getting Elixir Installed

On CircleCI 1.0, there wasn’t built-in support for Elixir, so I had to install it manually. I found some helpful posts online and eventually had a shell script that installed the asdf version manager and then used that to install the appropriate versions of Erlang and Elixir.

On CircleCI 2.0, everything is built on top of Docker images. CircleCI maintains a pretty big list of images for many languages, all based on the official Docker images for those languages.

Following the Elixir Guide in Circle’s documentation, I added the following to my .circleci/config.yml file:

Docker Image
version: 2
jobs:
build:
docker:
- image: circleci/elixir:1.4.5
# ...remaining configuration...

With that, I was able to delete my installation script entirely. This is a welcome change!

It looks like CircleCI is doing a pretty good job of keeping their Docker images up-to-date with new language releases. No more waiting weeks or months for official support of that new version of Ruby, Node.js, or Elixir, or explicitly installing Ubuntu packages!

Caching

With CircleCI 2.0, all build steps must be configured explicitly; it no longer performs common tasks for you automatically. This includes the caching of library dependencies.

They’ve provided very good general documentation about how to set up caching, and the new approach allows for a lot of flexibility.

Unfortunately, the Elixir language guide doesn’t talk about caching at all, so I had to figure it out for myself.

The first thing I wanted to do was cache installed dependencies. Here’s what I ended up with:

Dependency Caching
version: 2
jobs:
build:
# ...other configuration...
steps:
# ...other steps...
- restore_cache:
keys:
- v1-dependency-cache-{{ checksum "mix.lock" }}
- v1-dependency-cache
- run: mix local.hex --force
- run: mix local.rebar --force
- run: mix deps.get
- run: mix deps.compile
- run: mix compile
- run:
command: mix compile
environment:
MIX_ENV: test
- save_cache:
key: v1-dependency-cache-{{ checksum "mix.lock" }}
paths:
- _build
- deps
- ~/.mix
# ...remaining configuration...

Before installing dependencies and compiling, I restore from a cache whose key is based on my mix.lock file.

If that cache doesn’t exist (because mix.lock has changed), I fall back to the most recent cache whose key starts with v1-dependency-cache. CircleCI 2.0 does prefix-matching on cache keys, so this allows a partial cache hit. Even if mix.lock has changed, CircleCI still has a similar-enough cache to fall back on to save time.

After running a number of commands, I then save the cache with the same key. I make sure to include the _build directory, the deps directory, and ~/.mix, where a few files get installed.

This caching strategy seems to work well for this application. But keep in mind that it’s a solo project, and I really only have one branch going at a time. On a multi-developer team with multiple branches in play, I’d want to improve this approach by taking advantage of the .Branch cache key template as well as what I’m doing here.

One thing to note is that CircleCI 2.0 never re-saves to the same cache key. Each cache is immutable once created. Thus, if I change any of the settings for a cache (adding a new cache directory, for example), I need to bump the version prefix of the cache key to force it to recache everything.

Dialyzer

I ran into problems running Dialyzer on CircleCI 2.0 with Elixir 1.4.5 and Erlang/OTP 19.3.

The first time Dialyzer runs, it needs to generate its Persistent Lookup Table (PLT). In addition to taking a really long time, it also apparently uses more memory than CircleCI 2.0’s standard memory limit for a container, because my mix dialyzer --plt command kept getting killed.

CircleCI 2.0 provides a resource_class setting to allow the use of more CPUs and RAM, but you have to ask to have support for that setting enabled for your account. I have a request in to their support team. After an initial response, I’m still waiting to hear from their Customer Success team about whether they’ll enable this setting for me.

As a result, I had to disable the Dialyzer steps in my build.

However, after getting the rest of the build working, I decided to upgrade to Elixir 1.5.1 and Erlang/OTP 20.0. The release notes for the latter say that they’ve reduced the peak memory usage of Dialyzer, so I was hopeful.

After the upgrade, I was able to get through the initial build of Dialyzer’s PLT successfully. So I now have my entire build working under CircleCI 2.0.

I added a separate cache for the PLT. It is also based on my mix.lock file and also uses a v1- prefix for the cache key. When I upgrade to a new version of Elixir and/or Erlang/OTP, I’ll bump the version number in this cache key to invalidate the cache and rebuild the PLT for the new version.

Dialyzer PLT Caching
version: 2
jobs:
build:
# ...other configuration...
steps:
# ...other steps...
- restore_cache:
keys:
- v1-plt-cache-{{ checksum "mix.lock" }}
- v1-plt-cache
- run: mix dialyzer --plt
- save_cache:
key: v1-plt-cache-{{ checksum "mix.lock" }}
paths:
- _build
- ~/.mix
# ...remaining configuration...

Again, I save the _build and ~/.mix directories, as those are the locations where Dialyzer stores its PLT files. I don’t save the deps directory, as dialyzer doesn’t put anything there.

The Results

The current version of my configuration can be found in the GitHub project.

On CircleCI 1.0 with my old configuration, builds took about 3 - 3.5 minutes or so. For builds where Dialyzer needed to rebuild its entire PLT, the time ballooned to 16 - 19 minutes.

My most recent successful build under CircleCI 2.0 took 29 seconds with full cache re-use. For builds where Dialyzer rebuilds its entire PLT, the time is more like 8.5 minutes.

I don’t have good timings for dependency cache misses yet, but even on this small project, I’m seeing a significant speedup over CirceCI 1.0. Some of that is because I no longer need to install and cache Erlang and Elixir, but it also seems like overall build performance has improved dramatically.