Making a React application container environment-aware at Kubernetes deployment

No Comments

Motivation

This blog is based on the use case of a React web application that is supposed to be deployed to a Kubernetes cluster. To give more context and a better introduction to the problem to be solved, a few practical business logic and infrastructure assumptions will be made. First, the business logic requirement is that this application is available in different variants based on country and product brand. Content will be conditionally rendered depending on these variables. Additionally, the Kubernetes cluster defines multiple environments and passes environment variables for brand and country to a container at runtime through deployment files as follows:

apiVersion: "extensions/v1beta1"
kind: "Deployment"
metadata:
  labels:
    app.kubernetes.io/name: "react-app-test-marke—de"
    app.kubernetes.io/instance: "react-app-test-marke—de"
    app.kubernetes.io/version: "$VERSION"
  name: "react-app-test-marke—de"
spec:
  replicas: 2
  ...
  template:
    metadata:
      labels:
        app.kubernetes.io/name: "react-app-test-marke—de"
        app.kubernetes.io/instance: "react-app-test-marke—de"
    spec:
      containers:
        - name: "react-app-test-marke—de"
          image: "${IMAGE}:${VERSION}"
          env:
            - name: "BRAND"
              value: "Test-Marke"
            - name: "COUNTRY"
              value: "DE"
...

The same format of deployment yaml file is also applicable to other environments, with BRAND/COUNTRY variants like “Another-Test-Brand”/”UK”, “Test-Marchio”/”IT”.

Of course, this React application should be packaged within a Docker container. Once this container is built, it should be ready to accept environment variables passed from Kubernetes at runtime and apply it to the React app accordingly.
The problem is that npm produces static files that do not know anything about variables Kubernetes passes at runtime. So environment variables need to be injected in some other way at runtime.

Possible (but not optimal) approach

The alternative approach to these requirements would be to build each variant separately with environment variables passed to Docker at build phase and then deploy each container to the appropriate environment. This seems like a legitimate approach as npm is able to accept environment variables at npm run build and inject them into the React application, and they can be accessed within the process.env object in React. Still, the obvious problem with this approach is an overuse of build resources, because the same application will be built multiple times with just environment variables being different. Also, it would require quite some CI/CD pipeline infrastructure changes and reorganisation.

A better approach

As already mentioned, the static files (html, js, css, images…) npm produces during the build phase are meant to be final, ready for serving. Still, there is a slight chance to make them more dynamic and inject environment-specific configuration right before serving. To achieve this, the React app needs a configuration file which will contain runtime environment variables. A good place for storing environment variables would be the JavaScript window object as it is application-scope available. Here is an example of such a configuration file:



window.REACT_APP_BRAND=undefined;
window.REACT_APP_COUNTRY=undefined;

At first, these variables are assigned with undefined values to serve as placeholders. These values will be overwritten at runtime with injected values. This file should be located in the public folder of the React app. Then npm will pick it up and package it with static files produced during the build. The next step would be to refer this file in the index.html to make it available at application loading. So, in the index.html the following line is added:

<html lang="en">
  <head>
    <script type="text/javascript" src="%PUBLIC_URL%/config.js"></script>
    ...
  </head>
  <body>
    <div id="root"></div>
    ...
  </body>
</html>

The next step would be the automation of replacing variables placeholders with provided environment values. The natural approach for this would be to create a shell script which will be executed on running a Docker container. Here is a script example (named generate_config_js.sh):

#!/bin/sh -eu
if [ -z "${BRAND:-}" ]; then
    BRAND_JSON=undefined
else
    BRAND_JSON=$(jq -n --arg brand '$BRAND' '$brand')
fi
if [ -z "${COUNTRY:-}" ]; then
    COUNTRY_JSON=undefined
else
    COUNTRY_JSON=$(jq -n --arg country '$COUNTRY' '$country')
fi
 
cat <<EOF
window.REACT_APP_BRAND=$BRAND_JSON;
window.REACT_APP_COUNTRY=$COUNTRY_JSON;
EOF

What this script does is check the existence of BRAND and COUNTRY variables passed from the invoking process (which is Docker run in this case) and then appending those variables to the window object. At this point these variables have no connection to the config.js file yet, so it is needed to write output of the script into the config.js file. This can be achieved using following command:



generate_config_js.sh > /usr/share/nginx/html/config.js

After environment variables are injected into the config.js file, static files are ready to be served in the nginx server. Starting the nginx server can be chained with previous command in a separate script like this (named docker-entrypoint.sh):

#!/bin/sh -eu
./generate_config_js.sh >/usr/share/nginx/html/config.js
nginx -g "daemon off;"

This last command is very convenient to serve as an entry point for the Docker container run.

This is actually a key point in this blog – these scripts are set in a Docker container during the container build but they are not executed immediately. Cleverly, these scripts will be executed when the Docker container runs within Kubernetes with provided variables. This is what creates an opportunity to manipulate static files right before serving.

Docker container

Finally, the Dockerfile should look like this:



FROM node:9.6.1 as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package*.json ./
RUN npm install --silent
RUN npm install react-scripts@1.1.1 -g --silent
COPY . /usr/src/app
RUN npm run build
 
FROM nginx:1.14.1-alpine
RUN apk add --no-cache jq
RUN rm -rf /etc/nginx/conf.d
COPY conf /etc/nginx
COPY --from=builder /usr/src/app/build /usr/share/nginx/html
COPY docker-entrypoint.sh generate_config_js.sh /
RUN chmod +x docker-entrypoint.sh generate_config_js.sh
 
ENTRYPOINT ["/docker-entrypoint.sh"]

What this Dockerfile does is the following:
It uses multistage build to achieve both build and serving within the same container.

It starts from a Node base image and builds React applications the usual way.
Then, it takes the nginx base image for serving purposes and copies built static files to a new image, while the previous intermediate image is removed and the image size is reduced.

Finally, it copies shell scripts, attaches execution permissions and exposes the entry point command.
To build the Docker container with the previous configuration, as usual, run:


docker build -t sample-container .

To try out this configuration locally, the following command can be run:



docker run -it -e BRAND=Test-Brand -e COUNTRY=DE -p 80:80 --rm sample-container:latest

This command will simulate how the Docker container will actually be run by Kubernetes.
If everything went well, there should be a React application running on http://localhost (port 80). In the browser inspector (for example, Google Chrome Dev Tools) there should be a config.js file rewritten with variable values passed from Kubernetes deployment:

window.REACT_APP_BRAND='Test-Brand';
window.REACT_APP_COUNTRY='DE';

Accessing environment variables in React code is done through the window object:

...
switch (window.REACT_APP_COUNTRY){
  case 'DE':
    return GermanHeaderComponent;
  case 'UK':
    return EnglishHeaderComponent;
  case 'IT':
    return ItalianHeaderComponent;
  default:
    return undefined;
}
...

Conclusion

To sum up, the approach described in this blog consists of the following steps at a high level:


  • Create config.js file with environment variable placeholders in public folder in the React application
  • Add config.js reference in script tag in index.html
  • Create shell scripts for rewriting config.js file at runtime
  • Use scripts in Dockerfile as entry point

With this approach, the React application container will be aware of environment variables at runtime through a dynamically rewritten config.js file. The obvious benefit of the above described approach is that only one container is built and reused for various environments depending on runtime variables and also build time will be shortened significantly.

– Special thanks to Sergey Grebenshchikov for review and suggestions –

Ivan Perkucin

Software developer at codecentric since October 2016.
Oracle Certified Professional, Java SE 7 programmer
AWS Certified Solutions Architect – Associate

Comment

Your email address will not be published. Required fields are marked *