There are many benefits to running your applications on containers. Some of the high level benefits you get when containerizing your workloads are:
In order to migrate our application to run on containers, we first need to package our application artifact as a Docker image. Let’s look at two approaches to getting up and running with containers.
A Buildpack offer a simplified way to build and manage docker images. This is accomplished by simply providing your source code with the Buildpacks doing the rest. For those who aren’t familiar with Dockerfiles and want to avoid the boilerplate, Buildpacks can help with the heavy lifting. Check out the documentation for more information.
Let’s test it out and see what the experience looks like.
The first thing we’ll do is set a default builder for our images. You have the choice of setting the default builder or you can specify the builder at build time. A builder an image that contains all the components necessary to execute a build. A builder image is created by taking a build image and adding a lifecycle, buildpacks, and files that configure aspects of the build including the buildpack detection order and the location(s) of the run image.
pack config default-builder gcr.io/buildpacks/builder:v1
Next, we’ll build the image.
cd ~/environment/ecsdemo-migration-to-ecs/app pack build user-api
During this process, pack is doing the work to determine the programming language being used and building a Docker image to enable us to run our code.
If you look in the
app directory, you’ll see a file named
This file is all we need to tell pack how to run our application.
Once the build is complete, we will have a docker image that we can run locally.
The name of the image artifact is
user-api which is what we passed into the
pack build command.
Using Docker, we will run the image we just built as a container on our Cloud9 instance.
docker run \ --rm \ --name userapi \ -e DYNAMO_TABLE=UsersTable-test \ -p 8080:8080 \ -d user-api
The above command is going to run our image as a container. We are defining the port that we want to run on the host as well as our container. We also rely on an environment variable inside of our application that defines what DynamoDB table the application will talk with. For the workshop we will point to the test environment’s table.
Now let’s test that our container is working as we expect.
The response should show
Success! Our application works exactly how it does running on an EC2 instance as well as running locally.
We can also check the logs via the docker service to see how the application logged the request.
docker logs userapi
Stop the docker container
docker stop userapi
This is a great way to get started, but at some point you will likely want to have more control over your Docker images and how they are defined. In the next section, we’ll build our image using a Dockerfile.
For this portion of the workshop, we will interact use native Docker tooling.
A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image.
Essentially we use a Dockerfile as a way to define a simple set of instructions for how we want our build our artifacts.
To build our Docker image, we will start with creating our
cat << EOF > Dockerfile FROM public.ecr.aws/bitnami/python:3.7 EXPOSE 8080 HEALTHCHECK --interval=5s --timeout=5s --start-period=5s --retries=2 \ CMD curl -f http://localhost:8080/health || exit 1 WORKDIR /user-api COPY main.py \ dynamo_model.py \ requirements.txt \ users.csv \ /user-api/ RUN pip3 install -r requirements.txt CMD [ "python3", "main.py"] EOF
Let’s quickly walk through each of the commands that are included in the Dockerfile and what they will do.
The FROM instruction initializes a new build stage and sets the base image for subsequent instructions.
This is a required command as this includes the operating system and any other binaries based on the upstream image.
You may notice that we are pulling a
python image, which is another benefit of using Docker as we can use images that are scoped to specific use cases.
In this example, we’re using an image that has what we need to run our python code.
EXPOSE: The EXPOSE instruction informs Docker that the container listens on the specified network ports at runtime. You can specify whether the port listens on TCP or UDP, and the default is TCP if the protocol is not specified.
HEALTHCHECK: The HEALTHCHECK instruction tells Docker how to test a container to check that it is still working. This can detect cases such as a web server that is stuck in an infinite loop and unable to handle new connections, even though the server process is still running.
WORKDIR: The WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile. For our application, we want to simplify the operations by running inside the directory where our code and other dependencies exist. We’ll run the application in the working directory as well as install our python packages via the requirements.txt file.
The COPY instruction copies new files or directories from
<src> and adds them to the filesystem of the container at the path
<src> resources may be specified but the paths of files and directories will be interpreted as relative to the source of the context of the build.
We’re using the COPY command to move all of our code and dependencies into the image.
The RUN instruction will execute any commands in a new layer on top of the current image and commit the results. The resulting committed image will be used for the next step in the Dockerfile.
We’re using the RUN command to install our application dependencies via
There can only be one CMD instruction in a Dockerfile. If you list more than one CMD then only the last CMD will take effect.
The main purpose of a CMD is to provide defaults for an executing container. These defaults can include an executable, or they can omit the executable, in which case you must specify an ENTRYPOINT instruction as well.
Similar to how defined the
Procfile with our buildpack image, we are using the CMD command to define the default behavior for running our application when the container runs.
Ok, so now that we have our instructions on how we expect our application to run as a container, we need to build the image.
Run the command to build our Docker image.
docker build -t user-api-docker:latest .
Similar to what we did earlier, let’s run a container from the image we just built and confirm it works.
docker run \ --rm \ --name userapi \ -e DYNAMO_TABLE=UsersTable-test \ -p 8080:8080 \ -d user-api-docker:latest
Now let’s test that our container is working as we expect.
curl -s localhost:8080/all_users | jq
curl -s 'localhost:8080/user/?first=Sheldon&last=Cooper' | jq
That’s it! We packaged our application into a container image using Docker and ran it locally. This is very impactful to our workflow as this image will be no different than the one running in a production environment, adjusting only the environment variables to determine what database we interact with.
In this section, we packaged our application into a Docker image and tested it locally by running it in Docker. Now we’re ready to productionize this application, but where do we start? Let’s move on to the next section and look at how we can achieve this using the AWS Copilot CLI.