8. Runtime Behavior

When deploying containers, it is typical to modify and/or configure their states and behavior at runtime. For example, we may run a Flask application on port 5000 during development, but on production, we want to run it on a different port. Or, we may be using a development API key for a remote service during development, but would want to substitute for a production API key on the production cluster.

How do we modify the states and behavior of our containers and the applications within them? The answer is typically through passing environment variables to the containers. The applications inside the containers may then reconfigure based on the environment settings. However, this approach is only available for server-side applications like Flask. What about client-side applications like Angular? An Angular application is transpiled to static content (HTML, CSS, JavaScript) and are served back to the user, and these static content resources typically do not exist in a runtime container that would enable them access to the environment. Let’s investigate a couple of ways we may handle these situations.

8.1. Flask

The Flask application app.py can acquire environment variables through the os package. We can supply the key and a default value when attempting to acquire an environment variable os.getenv(KEY, DEFAULT_VALUE). In this application, we get database connection parameters from the environment, and if we fail, we fallback to the application configuration. When specifying on which port the Flask application will run, we do not use the application configuration, rather, we set a default value of 5000 in the case that the environment variable corresponding to this value does not exists.

 1from flask import Flask, jsonify, request, Response, abort
 2from flask_cors import CORS
 3import json
 4import time
 5import os
 6
 7app = Flask(__name__)
 8app.config.from_object('config')
 9CORS(app, resources={r'/*': {'origin': '*'}})
10
11@app.route('/', defaults={'path': ''})
12@app.route('/<path:path>')
13def catch_all(path):
14    return json.dumps({'message': app.config['AUTHOR']})
15
16
17@app.route('/v1/test', methods=['GET'])
18def hello():
19    return json.dumps({
20        'author': app.config['AUTHOR'],
21        'app_name': app.config['APP_NAME'],
22        'timestamp': current_milli_time()
23    })
24
25
26@app.route('/v1/db', methods=['GET'])
27def db_params():
28    return json.dumps({
29        'DB_USER': get_env_value('DB_USER', app.config['DB_USER']),
30        'DB_PW': get_env_value('DB_PW', app.config['DB_PW']),
31        'DB_INSTANCE': get_env_value('DB_INSTANCE', app.config['DB_INSTANCE']),
32        'DB_HOST': get_env_value('DB_HOST', app.config['DB_HOST']),
33        'DB_PORT': get_env_value('DB_PORT', app.config['DB_PORT']),
34        'timestamp': current_milli_time()
35    })
36
37
38def current_milli_time():
39    return int(round(time.time() * 1000))
40
41
42def get_env_value(key, def_val):
43    return os.getenv(key, def_val)
44
45
46if __name__ == '__main__':
47    port = get_env_value('FLASK_PORT', 5000)
48
49    app.run(debug=True, host='0.0.0.0', port=port)

The Flask application configuration config.py.

1APP_NAME = 'rest-app'
2AUTHOR = 'One-Off Coder'
3DB_USER = 'oneoffcoder'
4DB_PW = 'isthebest'
5DB_INSTANCE = 'school'
6DB_HOST = 'localhost'
7DB_PORT = 3306

The Flask Dockerfile has 5 ENV instructions corresponding to the database connection parameters. Note the values of the environment variables are the same as the values in the application configuration config.py, however, they are suffixed with two underscores __.

 1FROM python:3
 2
 3ENV DB_USER="oneoffcoder__"
 4ENV DB_PW="isthebest__"
 5ENV DB_INSTANCE="school__"
 6ENV DB_HOST="localhost__"
 7ENV DB_PORT=3333
 8
 9WORKDIR /rest-app
10COPY ./rest-app .
11RUN pip install --no-cache-dir requests flask flask-cors
12
13CMD [ "python", "./app.py" ]

Now let’s build this container.

1docker build --no-cache -t rest-app:local .

We run the container as follows.

1docker run -it --rm -p 5000:5000 rest-app:local

When we access the url http://localhost:5000/v1/db, we will see the following output. These values are the ones coming from the ENV instruction in the Dockerfile.

1{
2    "DB_USER":"oneoffcoder__",
3    "DB_PW":"isthebest__",
4    "DB_INSTANCE":"school__",
5    "DB_HOST":"localhost__",
6    "DB_PORT":"3333",
7    "timestamp":1574985251133
8}

Let’s try to override them at runtime. We use the flag -e to pass in these environment key-value pairs. Essentially, at runtime, we override what’s set in Dockerfile and config.py.

1docker run -it --rm \
2    -p 5000:5000 \
3    -e DB_USER="alan" \
4    -e DB_PW="turing" \
5    -e DB_INSTANCE="ai" \
6    -e DB_HOST="enigma" \
7    -e DB_PORT=1729 \
8    rest-app:local

Now we get the following output from http://localhost:5000/v1/db. Note that the numeric value 1729 is a string instead of a number? You will have to add logic to convert that in the Python application code.

1{
2    "DB_USER":"alan",
3    "DB_PW":"turing",
4    "DB_INSTANCE":"ai",
5    "DB_HOST":"enigma",
6    "DB_PORT":"1729",
7    "timestamp":1574985447823
8}

How about the Flask port? How do we override that value? There was no FLASK_PORT variable set in Dockerfile or config.py. We override by using -e again. Note that we will map the local port 5000 to the container port 5001 through the flag -p 5000:5001.

1docker run -it --rm \
2    -p 5000:5001 \
3    -e DB_USER="alan" \
4    -e DB_PW="turing" \
5    -e DB_INSTANCE="ai" \
6    -e DB_HOST="enigma" \
7    -e DB_PORT=1729 \
8    -e FLASK_PORT=5001 \
9    rest-app:local

8.1.1. Flask downloads

8.2. Angular

Angular applications manage configuration as a first class citizen through envrionment files. If you use the Angular CLI or ng-cli to initialize, scaffold and build your Angular application, there will be two files environment.ts and environment.prod.ts where you may declare the development and production settings for using with ng build. After you build your application (e.g. with either ng build or ng build --prod), a distribution will be created. In this example, we set environment.ts and environment.prod.ts to be as follows.

 1// This file can be replaced during build by using the `fileReplacements` array.
 2// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
 3// The list of file replacements can be found in `angular.json`.
 4
 5export const environment = {
 6  production: false,
 7  serviceUrl: 'ENV_SERVICE_URL',
 8  apiKey: 'ENV_API_KEY'
 9};
10
11/*
12 * For easier debugging in development mode, you can import the following file
13 * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
14 *
15 * This import should be commented out in production mode because it will have a negative impact
16 * on performance if an error is thrown.
17 */
18// import 'zone.js/dist/zone-error';  // Included with Angular CLI.

The code to acquire these environment variables are stored in app.component.ts.

 1import { Component, OnInit } from '@angular/core';
 2import { environment } from './../environments/environment';
 3
 4@Component({
 5  selector: 'app-root',
 6  templateUrl: './app.component.html',
 7  styleUrls: ['./app.component.css']
 8})
 9export class AppComponent implements OnInit {
10  production = false;
11  apiKey = 'NOT SET';
12  serviceUrl = 'NOT SET';
13
14  constructor() {
15    this.production = environment.production;
16    this.apiKey = environment.apiKey;
17    this.serviceUrl = environment.serviceUrl;
18  }
19
20  ngOnInit(): void {
21    console.log(`production = {this.production}`);
22    console.log(`apiKey = {this.apiKey}`);
23    console.log(`serviceUrl = {this.serviceUrl}`);
24  }
25}

The HTML rendering/presentation code is in app.component.html.

 1<h1>One-Off Coder</h1>
 2
 3<table>
 4    <tr>
 5        <td>production</td>
 6        <td>{{production}}</td>
 7    </tr>
 8    <tr>
 9        <td>service url</td>
10        <td>{{serviceUrl}}</td>
11    </tr>
12    <tr>
13        <td>api key</td>
14        <td>{{apiKey}}</td>
15    </tr>
16</table>

Here is what the files for ng build will generate.

ui-app/
├── favicon.ico
├── index.html
├── main-es2015.js
├── main-es2015.js.map
├── main-es5.js
├── main-es5.js.map
├── polyfills-es2015.js
├── polyfills-es2015.js.map
├── polyfills-es5.js
├── polyfills-es5.js.map
├── runtime-es2015.js
├── runtime-es2015.js.map
├── runtime-es5.js
├── runtime-es5.js.map
├── styles-es2015.js
├── styles-es2015.js.map
├── styles-es5.js
├── styles-es5.js.map
├── vendor-es2015.js
├── vendor-es2015.js.map
├── vendor-es5.js
└── vendor-es5.js.map

Here is what the files for ng build --prod will generate.

ui-app/
├── 3rdpartylicenses.txt
├── favicon.ico
├── index.html
├── main-es2015.c2c754009562ee4be6a8.js
├── main-es5.c2c754009562ee4be6a8.js
├── polyfills-es2015.2987770fde9daa1d8a2e.js
├── polyfills-es5.6696c533341b95a3d617.js
├── runtime-es2015.edb2fcf2778e7bf1d426.js
├── runtime-es5.edb2fcf2778e7bf1d426.js
└── styles.3ff695c00d717f2d2a11.css

The key is in the main-es*.js files. If you reference the environment settings from within your application, they will be exported as literals in the main-es*.js files (if you do not, then ng build --prod will shake these unnecessary literals off).

Here is a snippet from of the code in main-es2015.js.

1var environment = {
2    production: false,
3    serviceUrl: 'ENV_SERVICE_URL',
4    apiKey: 'ENV_API_KEY'
5};

Here is a snippet from of the code in main-es2015.c2c754009562ee4be6a8.js.

1Ra={production:!0,serviceUrl:"ENV_SERVICE_URL",apiKey:"ENV_API_KEY"}

When the Angular static contents are placed in a webserver like nginx, they have no runtime environment that will allow the code to sense the environment. The code is strictly meant to be sent back to the user’s browser and then then browser interprets them, at which point, the code is away from the webserver environment, and cannot (and should not) access the environment from which they came. So then, how do we replace these files at runtime? The key is with the string literals, environment variables passed into the Docker container -e, and string substitution. Let’s see how we may use these elements to modify the string literals at runtime.

This override.py file will acquire environment variables and substitute the string literals in .js and .map files.

 1import os
 2from os import listdir
 3from os.path import isfile, join
 4import argparse
 5import sys
 6
 7def replace_values(file_path, env_vals):
 8    count_matches = lambda s, k: s.count(k, 0, len(s)) 
 9    
10    with open(file_path, 'r') as f:
11        print('processing {}'.format(file_path))
12        s = f.read()
13        for key, val in env_vals.items():
14            n = count_matches(s, key)
15            if n > 0:
16                print('\t{} matched {} times'.format(key, n))
17                s = s.replace(key, val)
18        return s
19
20def save_file(file_path, s):
21    with open(file_path, 'w') as f:
22        f.write(s)
23
24def get_file_paths(dir_path):
25    files = (f for f in listdir(dir_path) if isfile(join(dir_path, f)))
26    files = filter(lambda f: f.endswith('.js') or f.endswith('.map'), files)
27    files = map(lambda f: join(dir_path, f), files)
28
29    return files
30
31def get_env_vals():
32    get_value = lambda key, def_val: os.getenv(key, def_val)
33
34    def_vals = {
35        'ENV_SERVICE_URL': 'NONE',
36        'ENV_API_KEY': 'NONE'
37    }
38
39    return {key: get_value(key, def_vals[key]) for key in def_vals.keys()}
40
41def parse_args(args):
42    parser = argparse.ArgumentParser('Externalize Angular Values')
43    
44    parser.add_argument('-d', '--dir', 
45        help='directory with Angular files are located', 
46        required=False,
47        default='/usr/share/nginx/html')
48    
49    parser.add_argument('-v', '--version', action='version', version='%(prog)s v0.0.1')
50
51    return parser.parse_args(args)
52
53def main(args):
54    env_vals = get_env_vals()
55
56    file_paths = get_file_paths(args.dir)
57    
58    for file_path in file_paths:
59        s = replace_values(file_path, env_vals)
60        save_file(file_path, s)
61
62if __name__ == "__main__":
63    args = parse_args(sys.argv[1:])
64    main(args)

This override.ini file specifies the override service for supervisor.

1[program:override]
2command=/usr/bin/python /tmp/override.py
3exitcodes=0
4startsecs=0
5priority=1

This nginx.ini file specifies the nginx service for supervisor.

1[program:nginx]
2command=nginx -g "daemon off;"
3priority=999

This is the Dockerfile. In the container, we copy over the *.ini and *.py files. Additionaly, install the supervisor service to help us do multiple things; namely, perform the string substitution with Python and then run nginx.

 1FROM node:lts as NodeBuilder
 2WORKDIR /tmp/ui-app
 3COPY ./ui-app .
 4RUN npm install -g @angular/cli@8.3.19
 5RUN npm install
 6RUN ng build
 7
 8FROM nginx:alpine  
 9
10RUN apk update && apk add --no-cache supervisor
11
12COPY ./nginx.ini /etc/supervisor.d/
13COPY ./override.ini /etc/supervisor.d/
14COPY ./override.py /tmp/override.py
15
16COPY --from=NodeBuilder /tmp/ui-app/dist/ui-app /usr/share/nginx/html
17EXPOSE 80
18
19CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf", "-n"]

Build the container.

1docker build --no-cache -t ui-app:local .

Run the container without any environment specification.

1docker run -it --rm -p 80:80 ui-app:local

Run the container with environment specification.

You may now go to http://localhost and observe the runtime string substitution.

8.3. nginx

nginx typically runs on port 80. What if we want nginx to run on a different port? Again, we can use string substitution. Whereas in the Angular application we used Python, in this example, we use a simple bash script.

The nginx-default.conf.template file is the template that we want to modify and place into /etc/nginx/conf.d/default.conf.

 1server {
 2    listen ${PORT};
 3    server_name  localhost;
 4    root   /usr/share/nginx/html;
 5    index  index.html index.htm;
 6    include /etc/nginx/mime.types;
 7    access_log /dev/stdout;
 8
 9    location / {
10        try_files $uri $uri/ /index.html;
11    }
12}

The docker-entrypoint.sh script does the actual string substitution with the environment variable value.

1#!/usr/bin/env sh
2set -eu
3
4envsubst '${PORT}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
5
6exec "$@"

The Dockerfile. Notice how we modify ENTRYPOINT and CMD and specify a default port with ENV.

1FROM nginx:alpine
2
3ENV PORT=80
4
5COPY nginx-default.conf.template /etc/nginx/conf.d/default.conf.template
6COPY docker-entrypoint.sh /
7
8ENTRYPOINT ["/docker-entrypoint.sh"]
9CMD ["nginx", "-g", "daemon off;"]

Build the container.

1docker build --no-cache -t web-app:local .

Run the container on different ports.

1docker run -p 81:81 -e PORT=81 --rm web-app:local
2docker run -p 82:82 -e PORT=82 --rm web-app:local
3docker run -p 83:83 -e PORT=83 --rm web-app:local

Depending on how you specified the port through -e PORT=<port>, you may access the site as follow.