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