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.