why immutable

There is a lot of discussion in the world of containers, kubernetes and DevOps, but I think there are some statements all enthusiasts and experts can agree on, one of them is: "Container images should be immutable."
This statement is agreed upon also in the twelve-factor-app as it defines two factors which come in to play for this:


III. Config (Store config in the environment)
X. Dev/Prod parity (Keep development, staging, and production as similar as possible)

The main idea behind this concept is to make applications less prune to environment specific errors. For this reason our DevOps specialists invest hours of work to provide us with even immutable environments for our different stages, which leads to the question why we -by default- build angular applications differently from environment to environment.

the problem

To see what we actually do differently we can take a standard angular.json configuration as an example.
 

{
  "configurations": {
    "dev": {
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "3mb",
          "maximumError": "4mb"
        },
        {
          "type": "anyComponentStyle",
          "maximumWarning": "15kb",
          "maximumError": "180kb"
        }
      ],
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.dev.ts"
        }
      ],
      "outputHashing": "all",
      "sourceMap": {
        "hidden": true,
        "scripts": true,
        "styles": true
      }
    },
    "test": {
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "3mb",
          "maximumError": "4mb"
        },
        {
          "type": "anyComponentStyle",
          "maximumWarning": "15kb",
          "maximumError": "180kb"
        }
      ],
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.test.ts"
        }
      ],
      "outputHashing": "all"
    },
    "production": {
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "3mb",
          "maximumError": "4mb"
        },
        {
          "type": "anyComponentStyle",
          "maximumWarning": "15kb",
          "maximumError": "180kb"
        }
      ],
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }
      ],
      "outputHashing": "all",
      "sourceMap": {
        "hidden": true,
        "scripts": true,
        "styles": true
      }
    },
    "development": {
      "buildOptimizer": false,
      "optimization": false,
      "vendorChunk": true,
      "extractLicenses": false,
      "sourceMap": true,
      "namedChunks": true
    }
  },
  "defaultConfiguration": "production"
} 

 

The file defines a different build task for each deployment of our application so we can run the corresponding command:

ng build --configuration <config-name>

This is great for understanding what is going on as a developer. I have a file for each stage and can easily update the config values myself, it is clear which build I run for which stage and angular takes care of optimizing my bundle size by including the values in the `environment.ts` file in my minified js code.
So what is wrong with this? Statically importing the config into our compiled output will not allow an easy exchange of configuration, to be precise it does not at all offer an option to replace it.
Instead when a different configuration is needed a new built must be triggered.

This then leads to a stage dependent build process as described in the image below.

 

Looking at the image we can imagine how more stages would increase the complexity of our CI pipeline, configuration files and overhead in a linear way but the amount of effort needed will be split across developers and DevOps specialists as you need a change in the code (new environment file), the config (new configuration in `angular.json`), the pipeline (additional build step) and ultimately in your actual deployment descriptor (e.g. kustomize).

the solution

Now to solve this overhead and deliver a single image we need to get rid of the environment file replacement via the build. However angular does not promote other ways to configure your application but it comes with a set of tools to use for exactly that.

The way to reach the goal is to load the configuration which was beforehand provided by the `environment.ts` as an asset that can be configured via the deployment environment.
So that the application fetches the file with the configuration values on startup and provides the content wherever needed.
A service for this could look like this:

import { AppConfig } from '@shared/services/app-config/app-config.service'
import { AppConfig } from '@shared/services/app-config/app-config.service'
import { AppModule } from './app/app.module'
fetch(`${__webpack_public_path__}assets/config/app.config.json`).then( 
async (res) => {
var appConfig = await res.json() 
// Here you can use the config 
platformBrowserDynamic() 
.bootstrapModule(AppModule)
.then(() => { 
// Show devtools only in development 
if (!appConfig.production) {
devTools()
}
})
.catch((err) => console.error(err)))

Now we use the dynamically loaded app.config.json across our application.
This means we can simply reduce our angular json to include a single build for production.

Ultimately we end up with a single build process and an app.config.json which can be set as needed in our deployment. The effort involved in setting up a new environment is narrowed down to simply adding it reusing the image we already have and setting up a corresponding config file.

 

Of course this is not the only way to solve the issue but it is a very simple and straightforward solution which has proven to be a great setup for us.