CHRIS RAO - DEVELOPER

Adding environments to your Single Page App

In my last post, I led you through building a simple single-page app template that includes both the web assets and the api server in one project.

The problem was that in order to update the ui asset, the ui container had to be rebuild and rerun, and then the updated asset had to be copied into the api server. Also, in order to update the api server, the api container also had to be rebuilt and rerun.

In this tutorial, we're going to use docker-compose and multiple build targets to maintain both the existing production build process as well as a second build process for development.

In the development build process, both the ui and api source will be constantly monitored and automatically rebuilt when any changes are made.

Multiple Build Targets

UI

In order to split our current build process into two different build processes, we will give each of our docker files multiple build targets, dev and prod.

Let's start with the ui. Here is our original Dockerfile

FROM node:10-stretch WORKDIR /content COPY . /content/ RUN npm ci ENTRYPOINT ["npm"] CMD ["run", "build"]

The part where the dev build is going to diverge from the prod build is the command that is run when the container is run. In the prod build process, the container simply exits after building. In the dev build process, we want to instead watch for any changes to the source code and rebuild when changes are made.

We'll start by adding a script, watch, to our npm package to watch for and rebuild on changes.

... "scripts": { "build": "webpack --mode=production", "watch": "webpack --mode=development --watch" } ...

Both build processes will share some base instructions, so we'll start the splitting of our build processes, by separating the base instructions from the prod instructions:

FROM node:10-stretch as base WORKDIR /content COPY . /content/ RUN npm ci ENTRYPOINT ["npm"] # prod mode FROM base as prod CMD ["run", "build"]

We've added as base to the first FROM command to give the name "base" to the base set of instructions, creating a base build target. We've also added a second FROM command to give the name "prod" to the subsequent instructions, creating a prod build target.

Now we'll add the dev build target:

FROM node:10-stretch as base WORKDIR /content COPY . /content/ RUN npm ci ENTRYPOINT ["npm"] # prod mode FROM base as prod CMD ["run", "build"] # dev mode FROM base as dev CMD ["run", "watch"]

At this point, our original run.sh script is broken, because when the ui docker image is built, every instruction in the ui Dockerfile will be executed. When the dev CMD instruction is reached, the image run command will be set to the new watch script.

To fix this, we will specify a target to the first build command. We're not ready to actually change the behaviour of this script yet, so we'll specify the "prod" target.

docker rm $(docker ps -aq --filter="name=simple-spa-ui-1") docker build --tag=simple-spa-ui-1 --target=prod ./ui docker run --name=simple-spa-ui-1 simple-spa-ui-1 docker cp simple-spa-ui-1:/content/dist ./api/ui-dist docker build --tag=simple-spa-api-1 ./api docker run -p 5000:5000 simple-spa-api-1

The gitlab script should be similarly modified like so:

... build: stage: build script: - docker build --tag=simple-spa-ui-1 --target=prod ./ui - docker run --name=simple-spa-ui-1 simple-spa-ui-1 - docker cp simple-spa-ui-1:/content/dist /builds/$CI_PROJECT_PATH/api/ui-dist - docker build --tag ${IMAGE_ID} ./api - docker push ${IMAGE_ID}

It is important to note that when the dev build target is chosen, we do not actually skip over the instructions in the prod build target. When we specify a target, we're only specifying the final target which will be allowed to execute. So, when we choose the prod build target, docker halts when it reaches the dev target instructions. In this instance however, when we choose the dev target, it will appear that only the dev build target is running because the dev CMD will overwrite the prod CMD.

API

For the api build target, we want to similarly watch for any changes. When a flask app is run with the debug argument set to true, it will rerun when any changes are made. So for our run.sh script, we'll pass the container an envionment variable DEV to let the app know to watch for changes:

docker rm $(docker ps -aq --filter="name=simple-spa-ui-1") docker build --tag=simple-spa-ui-1 --target=prod ./ui docker run --name=simple-spa-ui-1 simple-spa-ui-1 docker cp simple-spa-ui-1:/content/dist ./api/ui-dist docker build --tag=simple-spa-api-1 ./api docker run --port 5000:5000 --env DEV=true api

Then, we'll modify wsgi.py to run in debug mode when this environment variable is set:

import os from server import server if __name__ == '__main__': app = server.build() app.run(host="0.0.0.0", port=5000, debug=os.environ.get('DEV'))

Now, on to splitting the build targets. The only difference between the dev and prod build processes is that we're not going to copy the ui asset into the api container in it's build steps. We'll explore why later.

FROM python:3.6-slim as base RUN pip install 'pipenv>=8.3.0,<8.4.0' COPY ["Pipfile.lock", "/tmp/"] RUN cd /tmp && \ pipenv install --ignore-pipfile --dev --system WORKDIR /content COPY ["wsgi.py", "/content/"] COPY ["server/", "/content/server/"] # dev mode from base as dev CMD ["python", "wsgi.py"] # prod mode from base as prod COPY ["ui-dist/", "/content/ui-dist/"] CMD ["python", "wsgi.py"]

We've specified the dev build target first to ensure we halt in dev mode before we reach the COPY command.

Updating GitLab

Now that we have our build targets defined for both the UI and the API, we can update our gitlab script to build the API image with the prod build target:

... build: stage: build script: - docker build --tag=simple-spa-ui-1 --target=prod ./ui - docker run --name=simple-spa-ui-1 simple-spa-ui-1 - docker cp simple-spa-ui-1:/content/dist /builds/$CI_PROJECT_PATH/api/ui-dist - docker build --tag ${IMAGE_ID} --target=prod ./api - docker push ${IMAGE_ID}

Docker Compose

We're almost ready to switch over to our dev build process, but there's a problem. Our current run script expects the ui container to exit right after building it's asset. The ui container created from the image built from the dev build target doesn't do that. The api container also has no way of picking up the newly built asset when the watch script builds it. We want both containers to run together and exit together, and we also want them to share the ui asset that the watch script builds. To accomplish this, we're going to replace our run script with docker-compose.

Docker-compose consumes a docker-compose.yaml file, which configures one or more docker containers to spin up together. Add the following docker-compose.yaml file to the root of your project.

version: '3.4' services: ui: build: context: ./ui target: dev volumes: - ./ui/src:/content/src - ./ui-dist:/content/dist api: build: context: ./api target: dev environment: - DEV=true ports: - "5000:5000" volumes: - ./ui-dist:/content/ui-dist - ./api/server:/content/server

The yaml file defines our two services, ui and api.

The build section of the ui service definition specifies that it's build directory is in ./ui, where docker-compose will look for it's dockerfile. The build target is also set to dev. We're also mounting the src directory, so the ui container will be able to watch for changes to the code. The last directory we're mounting is a new directory ui-dist, which is mounted to the continer's build directory so that we'll get the updated ui asset on our host whenever it rebuilds.

The api service definition similarly specifies the api directory and dev target for it's build. It also mounts the ui-dist directory so it can receive the updated ui asset on rebuild. The ports to expose and the environment variables are specified.

And we're done! Now go forth and build your next side project!

See the full working code for this tutorial here: https://gitlab.com/chrisrao/simple-spa-part-2