loading...
Amplifr.com

Sane Defaults

dreikanter profile image Alex Musayev ・4 min read

This post explains one of the practices we use to improve web service configuration management. As an example, we will use a Ruby on Rails application running on the Kubernetes environment. However, the general concept of sane defaults is technology-agnostic and entirely applicable to different programming languages and frameworks.

Imagine medium to large scale web service maintained by a team of 5 engineers. The production environment constitutes a distributed system of Rails servers, auxiliary microservices, and data storage. Everything is running on a Kubernetes cluster. As usual, besides the production, there is also a staging configuration — very similar, but not identical. It is smaller and cheaper. And there are also local development environments typically containing a minimal task-specific subset of services.

To maintain multi-environment Rails application, we need it to be configurable. Most common approach here is to follow The Twelve-Factor App methodology and store configuration in the system environment variables.

Say, we use dotenv gem to set some arbitrary configuration parameters for development environment:

# .env
MAIN_DOMAIN=app.local
REDIS_URL=redis://127.0.0.1:6379/0
AVATAR_SIZE=100
IMGPROXY_URL=https://imgproxy.local
SECRETS_IMGPROXY_KEY=key
SECRETS_IMGPROXY_SALT=salt

Now when we can address configuration values like so: ENV.fetch('MAIN_DOMAIN'). Dead-straight, cheap and easy? Sort of. Let's take a closer look at the potential shortcomings related to environment variables-based configuration in a mature Rails project.

Downsides of using environment variables

Configuration bloat. A more extensive project configuration may include hundreds of parameters. This fact itself is healthy — big configuration is better than hardcoding magic values all over the codebase, anyway. But you need to expect the config to require some gardening to prevent duplication, obsoletion, and other potential issues.

Each change in the configuration require manual effort. If your project uses dotenv on the development environment, each team member will have to maintain an up to date copy of the .env file with all the necessary configuration variables.

.env file may include individual settings — like a personal S3 bucket name you use for testing — and not supposed to be committed to the version control system. Therefore, it is not possible to automate config updates propagation amongst the team members.

Application startup errors. Typically it is a good practice to check mandatory configuration parameters during the application initialization stage and raise an error if something is missing (note #fetch method call in the above example). I will skip a detailed explanation here, assuming you already familiar with the fail-fast principle in system design. But in the practical context of this article, this validation means each configuration change may cause a hiccup in every team member's working process. Nobody likes it.

Fragile tests. Sometimes you need to use your configuration during testing, directly or indirectly. That means your test suite execution result depends on the system it is running on. A successful test run on a developer's machine does not guarantee the same result on CI, even in a containerized environment. And the reasons for the failure could be much less evident than the wrong DATABASE_URL.

Sane defaults

Our experience of maintaining configuration for larger web services shows that a significant part of configuration parameters don't change too often. Based on this observation, it is possible to mitigate most of the issues listed above by complementing configuration parameters with default values:

# const/defaults.rb

module Defaults
  AVATAR_SIZE = 100
  MAIN_DOMAIN = 'amplifr.local'
  IMGPROXY_MAX_SRC_RESOLUTION = 9000
end

Rails autoload mechanism will make Defaults module resolvable from everywhere in your project, making it possible to use the constants as a safe fallback each time you need the corresponding configuration parameter:

ENV.fetch('AVATAR_SIZE') { Defaults::AVATAR_SIZE }

Default constants make it safe to remove related environment variables from .env, or Dockerfile, ordocker-compose.yml, or whatever you use to keep your configuration. Configuration changes will automatically propagate over the version control system, preventing some of the application startup errors. And from now on, it is easy to keep an environment-agnostic test suite (just keep using Defaults::* values instead of the ENV).

Furthermore, I'd like to highlight that sane defaults plays well with the Twelve-Factor App methodology since environment variables always have higher priority over the constants.

There are some limitations, though: not every configuration parameter could have a default. Each default value should match the following conditions:

  • A sane default supposes to remain identical for all code execution environments, including Rails environments, individual developer machines, server instances, CI, etc.
  • Default values should never be secret. In other words, defaults do not apply to API keys or other configuration parameters that you don't want to keep in the repository.
  • A default does not change often or ever.

Making it easy to use

We solved the issues of the environment variables-based configuration, but at what cost? accessing configuration params with fetch calls is bulky and cumbersome:

ENV.fetch('AVATAR_SIZE', Defaults::AVATAR_SIZE)

Also, it would be a bad idea to rely on the developer to remember about the defaults each time they use a related configuration. And what about required parameter validation during the application initialization?

Luckily it is easy to solve all of these problems with config gem. It allows to define structured configuration with YAML files, similar to Rails' database.yml:

# config/settings.yml
---
main_domain: <%= ENV.fetch('MAIN_DOMAIN') { Defaults::MAIN_DOMAIN } %>
redis_url: <%= ENV.fetch('REDIS_URL') { Defaults::REDIS_URL } %>
avatar_size: <%= ENV.fetch('AVATAR_SIZE') { Defaults::AVATAR_SIZE } %>

imgproxy:
  base_url: <%= ENV.fetch('IMGPROXY_URL') %>
  key: <%= ENV.fetch('SECRETS_IMGPROXY_KEY') %>
  salt: <%= ENV.fetch('SECRETS_IMGPROXY_SALT') %>
  max_src_resolution: <%= ENV.fetch('IMGPROXY_MAX_SRC_RESOLUTION') { Defaults::IMGPROXY_MAX_SRC_RESOLUTION } %>

Being added to your Gemfile, config gem builds a globally available settings object during Rails application startup, providing a set of concise accessors to the configuration params, like Settings.main_domain or Settings.imgproxy.base_url.

Summary

Here is a list of benefits you gain from complementing environment-based configuration with sane defaults:

  • Prevent configuration bloat.
  • Automate shared configs propagation over developer machines.
  • Prevent startup errors caused by the lack of shared configuration params.
  • More stable tests.

Discussion

pic
Editor guide