A simple project showcasing how the Vapor server-side Swift framework can be used with Docker all the way from development to production deployment.
I won't go into too much detail about what Docker is, but essentially it allows you to create a lightweight "container" that packages up everything you need to run your server app. The main advantage of containerization is the ability to have complete control over the environment in which your app will run. This allows your development environment to mimic what will be used in production.
The Dockerfile specifies instructions for building a Docker image. We'll be creating both a development and production version of this file.
The Docker Compose file defines how our Docker containers are setup and operate together. We'll also be creating both a development and production version of this file.
The ps
command lists all currently running Docker containers. We'll use it to check which containers are running so that we can attach to them when needed. When a container is running, the output of this command looks something like:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7c61d587b9f8 api:dev "bash" About an hour ago Up About an hour 0.0.0.0:8080->8080/tcp vapordockerdemo_api_1
The attach
command will attach the current terminal instance to the specified container. If the container includes an entrypoint like bash
, then it will allow us to execute terminal commands inside of the running container.
The stop
command will stop the specified container. You'll need to use when you want to stop containers that are running in the background.
The build
command will take a Dockerfile
as input to generate a Docker container image. We'll be using this command to generate our production server image.
The docker-compose up
command will use a Docker Compose file to create and start a group of containers. We'll be using this command to launch our container ecosystem during both the development and production process.
Using Docker during the development process differs from how you might use it to deploy your production app. The main difference is that your server app won't automatically start when the container is launched. This allows you to be a little more "hands on" during the development process by attaching to the server app's container instance and executing compilation and run commands inside of the Docker container when necessary. A production setup would automatically launch your server app's binary when the container is started.
The Vapor Swift server app in this repo is just the standard Vapor API template app, which is a simple backend that stores Todo list items in a database. The source code has been modified slightly to use a PostgreSQL database rather than the default SQLite database, but all other functionality remains the same. The following endpoints are available when the app is running:
GET /
GET /hello
GET /todos
POST /todos
DELETE /todos
In order to get started, you'll need to install Docker. Since I'm on macOS, I'm using the Docker Community Edition for Mac.
Currently, Dockerfile-dev
only specifies the latest Swift Docker image as its base image. This is fine for our development environment since we just need something that can build and run Swift apps.
docker-compose.yml
currently specifies an "api" and a "db" container, which corresponds to our Vapor server app and PostgreSQL database, respectively. The file also maps directories and ports inside of the container to directories and ports on our host machine so that we can share the same development folder on the filesystem and easily test the app from our host machine.
Once Docker is installed and running, navigate to the project directory and run the following command to start up the containers:
docker-compose --file docker-compose-dev.yml up --build
In a separate terminal window, execute the following command to list the running containers:
docker ps
Take note of the CONTAINER ID
for the api:dev
image, which is the container that will be running the Vapor app.
Attach to the Vapor app container by executing the following command, replacing <container_id>
with the identifier output from the command above:
docker attach <container_id>
Your terminal prompt should now start with something like root@<container_id>:/app#
, which reflects the fact that you're now running as a bash instance inside of that container.
NOTE: If the command appears to stall or not do anything, you might try hitting return
again. I've needed to do this in the past, but it doesn't currently seem to be an issue for me.
Because of the way the development Docker Compose file is configured, your project directory on your local filesystem is actually mounted as a volume inside of the container. If you execute the ls
command inside of your Docker container's terminal instance, you should see the contents of the project directory.
To build the Swift Vapor app, execute the following commands in the terminal window that's attached to the Docker container:
swift build
Once the build has finished, execute the following command to start the Vapor app inside of the container:
swift run Run serve -b 0.0.0.0
The Vapor app should then startup and output the following to your terminal window:
Server starting on http://0.0.0.0:8080
Notice the -b 0.0.0.0
option passed to the serve
command. We need to specify the 0.0.0.0
IP address since that's the one that's been assigned to the Docker container (via the output of the docker ps
command that we ran previously). By default, a swift run
command will start the server on localhost
, which is not what we want (i.e. the app will not be accessible from our host machine).
A quick test can be performed in your web browser to verify the server app is working properly.
Because of the way the docker-compose.yml
file is configured, port 8080
inside of the container is mapped to port 8080
on our local machine. This means that you should be able to open up your favorite browser and navigate to http://localhost:8080/hello
to see the default "Hello, world!"
output of the Vapor app. Navigating to http://0.0.0.0:8080/hello
will also work.
In order to test the app's integration with the PostgreSQL database, you need to be able to execute POST
and DELETE
requests in additon to standard GET
requests. Inside of the Postman
directory, a Postman collection has been provided that contains these requests to create, delete, and return TODO items. Import the VaporDockerDemo.postman_collection.json
collection and VaporDockerDemo-Dev.postman_environment.json
environment into your Postman app and execute the provided requests. Note that an item identifier must be provided in the path of the "Delete TODO" request. By default, it will delete the first item so the request will only work once, assuming an item already exists.
Doing a standard CTL
+ C
in the Terminal instance that you executed the docker-compose
command will automatically shutdown the containers that were started. You can also manually stop each container by issuing docker stop
commands, as mentioned above.
Dockerfile-prod
uses a multi-stage build process to create a standalone Docker container image that contains our production server application. The first stage simply builds the release version of the server app, creating an application binary called Run
. The second stage builds upon a stock Ubuntu docker image, adding the necessary dependencies for running the binary produced in the first stage and specifies the container entrypoint, or command that gets run when the image is launched.
Docker images are tagged in the format name:version
. Execute the following command to build the Docker image from the production Dockerfile
:
docker build --file Dockerfile-prod --tag vapordockerdemo:0.0.1 .
Once the image is built, execute the following command to start it up:
docker run --publish 8080:8080 vapordockerdemo:0.0.1
The image will run, but the server application will immediately crash on launch with a message like the following:
Fatal error: Error raised at top level: NIO.ChannelError.connectFailed(NIO.NIOConnectionError(host: "db", port: 5432, dnsAError: Optional(NIO.SocketAddressError.unknown(host: "db", port: 5432)), dnsAAAAError: Optional(NIO.SocketAddressError.unknown(host: "db", port: 5432)), connectionErrors: [])): file /home/buildnode/jenkins/workspace/oss-swift-4.2-package-linux-ubuntu-16_04/swift/stdlib/public/core/ErrorType.swift, line 191
The app crashes because it can't initiate a connection to the PostgreSQL database. That's expected at this stage because we haven't yet created a production Docker Compose file that specifies the other containers that need to be launched with our server app (i.e. the PostgreSQL container).
In order to stand up all the necessary containers at once, we'll again use a Docker Compose file. The production Compose file is very similar to the one that's used for development, but differs in the following ways:
- The "api" container no longer includes a build step. Instead, it makes use of the
vapordockerdemo:0.0.1
image that we built using the productionDockerfile
. - The "api" container is no longer concerned with mounting the project directory on the host machine to the
/app
directory inside of the container. Likewise, a bash prompt is no longer the entrypoint for the container. - The "api" container no longer maps port
8080
on the host machine to port8080
of the container. Instead, port80
on the host machine gets mapped to port8080
on the container. This more accurately reflects what you would typically expect for a production application - HTTP traffic being served over port80
.
In the end, the only pieces of data that we need to define our production "api" container are the Docker image to use, the port mapping, and any environment variables.
Again, the docker-compose
command is used to launch the containers. However, this time we need to specify that we want to use the production compose file:
docker-compose --file docker-compose-prod.yml up --build
Once the containers are running, you should be able to follow the same steps above to test the server application using a web browser or HTTP client like Postman. Note that you will need to use port 80
instead of port 8080
this time, navigating to http://localhost/hello
in your web browser or using the VaporDockerDemo-Prod.postman_environment.json
Postman environment.
The content in this repo is based on this excellent article by bygri.
- Security
- Incorporate suggestions about running Docker apps as non-root user from this blog post.