Dockerizing The Application
The following article will take our two services, the BFF and the core service, as well as RabbitMQ, and put them into what are called Docker containers. These containers will be identical to what is in production, so understanding them and how to use them locally is important. As the complexity of your work grows, Docker containers provide us an isolated environment that guarantees our local stack will function as intended on the production stack.
Docker
Docker is a platform of containers. These containers can, and should be thought of, as immutable servers. These containers do not care if they are run on your machine or if they are run on a remote machine in the cloud. This affords us the obvious portability as found in the JVM, but it also provides to work directly on a deliverable, only releasing it when ready. By leveraging this concept and with proper orchestration measures in place, we reduce the develop to production release cliff, allowing us to perform better as developers.
What is a Docker container?
A Docker container differs from a virtual machine in the sense that it is not a full operating system. The container has only the required libraries to run, where as a virtual machine has the entire operating system. This means Docker containers are lightweight and efficient, which allows us to replicate, or even restart them, very quickly.
Docker Machine
Docker itself has been quickly adapting to the ever changing software development lifecycle, and because of so, there are many options how to provide automated orchestration among containers. We have decided to use Docker Machine. Docker Machine uses Virtualbox to orchestrate our containers. Because it uses Virtualbox, we can then not worry about the quality of the desktop or laptop, nor do we have to worry about what operation system and version is being run. Once configured, we can then run standard Docker commands to manage our containers.
Configuring Docker Machine
Docker Machine will require a local registry. The registry is simply a repository of local images. While the Docker Machine documentation suggests building a default
image, we will replace that with a registry
image instead.
docker-machine create -d virtualbox \
--virtualbox-memory "4096" \
--virtualbox-hostonly-cidr "192.168.131.1/24" \
registry
Upon success, if you run docker-machine ls
, you will see your newly minted machine.
With this, we now have a machine running, but need to tell Docker to work with it. The following bash commands will link the two
eval $(docker-machine env registry)
REG_IP=$(docker-machine ip registry)
docker run -d -p 80:5000 --restart=always --name registry-srv \
-v /Users/$(whoami)/Documents/Docker/registry:/var/lib/registry \
registry:2
With the machine and Docker linked, you will next need to ssh into the machine and update some configurations.
docker-machine ssh registry
Once in the machine, edit /var/lib/boot2docker/profile
to look like the following.
Once saved, make sure to exit from the registry machine's ssh session. Changing this configuration will require you to restart the registry.
docker-machine restart registry
Installing the Registry Web UI
One of the great things about Docker images is that each new Docker machine builds upon a previous Docker machine by creating a new layer. Not only will the Web UI provide insight to what machines you have registered, it will also provide you the layers of the machine over time. So let us install the Docker container for the Web UI.
docker run -d -p 5080:8080 --restart=always --name registry-web -e REGISTRY_URL=http://$REG_IP:80/v2 -e REGISTRY_NAME=$REG_IP:80 hyper/docker-registry-web
Once complete, visit the Web UI
open -a Safari.app http://$REG_IP:5080
Creating a Dockerized Application
You will notice that there is no images in the registry, so for example's sake, let us create a quick Grails 3 application
grails create-app demo
We will dockerize this application and deploy it to the registry. To do so, we need to have a Dockerfile
in the root of the application.
touch demo/Dockerfile
Now open that file in your editor and add the following commands
FROM openjdk:8-jdk-alpine
# exposed ports
EXPOSE 8080
# create a tmp folder
RUN mkdir /app_home
# copy java war file into image
COPY build/libs/demo-0.1.war /app_home
# set working folder
WORKDIR /app_home
# set perms
RUN chmod -R 700 /app_home
RUN chown -R daemon /app_home
# run as user
USER daemon
# run container
CMD ["java", "-jar","demo-0.1.war", "run"]
FROM command
The FROM command tells Docker which layer to build upon. In this case we will use openjdk:8-jdk-alpine
. This will provide us the libraries needed to run a JVM.
EXPOSE command
Since Docker applications are container based applications, we need to map ports. Our quick demo
application only has port 8080, but remember that our addressbook
application has ports 8080 and 777. When a request comes into the ip address set up by Docker Machine, eventually we will map the requested port to the applications port. Exposing the port allows us to do that.
RUN command
The RUN command simply tells Docker to execute a command inside the container. In the case of RUN mkdir /app_home
, we are simply telling Docker to make a directory in which we will place our applications binary image.
COPY command
Again, the COPY command is pretty simple. We are telling Docker to copy a file from one place to another, which in this case is our binary from the build location to the where we decided the application should execute from.
WORKDIR command
The WORKDIR command tells Docker that the working directory is the argument passed. In our example, that is the application home we decided upon earlier.
USER command
The USER command tells Docker which user context we should execute commands as.
CMD command
When we run the Docker image from our shell, the CMD command is the command that will execute. All other commands are for building the image and configuration. When we run our demo
application, we are telling Docker to execute our war image with the java
command.
Building a Dockerized Image
Now that we have a Dockerfile
in our demo
application, we want to use Docker to use the Dockerfile
to build the container. From the demo
applications root directory, we first need to compile the source code.
./gradlew assemble
When the compilation completes successfully, you will have demo.war
located in build/libs
. This is our target file we set in the Dockerfile
.
Now we can build our Docker image.
docker build -t demo -f Dockerfile .
As the image builds, Docker will print to the terminal each step we supplied it. If you ever run into a build issue, you can use this output to help debug the issue.
The build command builds our image. It does not run it. You can verify this by using docker images
to see the build image and use docker ps
to note that our image is not running.
Tagging an Image
Because we are using our own registry, we want to be able to push our images to it. This means we need to tag the image before hand. This is almost identical in concept to tagging a release image on a repository.
docker tag demo $REG_IP:80/demo:latest
The above command tags demo
with our docker-machine registry $REG_IP:80
reference. This is for identification purposes only, and we also give it a version of latest
. We could just as easily give a semantic version if needed.
Pushing an Image
Now that we have successfully built and tagged our demo
application Docker image, we need to push it to our registry.
docker push $REG_IP:80/demo:latest
Once completed, you can revisit the registry and see our demo
application image.
open -a Safari.app http://$REG_IP:5080
Running a Dockerized Image
To run a Docker image, we simply need to tell it to run.
docker run -d --restart=always --name demoapp -p 9999:8080 $REG_IP:80/demo:latest
To understand the arguments passed, lets cover them one by one.
run
This argument runs our CMD command found in the Dockerfile
-d
This argument tells Docker to detach from the shell (run in background).
--restart=always
Restarts our container
--name
We name our container demoapp
for our own reference
-p
Tells the port mapping used. In our case, we will use port 9999 and it will map to our applications port 8080
Conclusion
We could run the application before pushing our image to the registry, but that defeats the purpose. When we use the registry as we have, after we have brought down the docker-machine registry for whatever reason, we would only need to restart the registry to run each container versus needing to start each one manually without. You can visit our demo
application with the following bash command.
open -a Safari.app http://$REG_IP:9999
Dockerizing the Distributed AddressBook Application
To dockerize our addressbook
application, we will need three containers. The first is RabbitMQ and the other two are our BFF and core service. Because each container is unique, working from localhost
will no longer make any sense. Because of so, we will also need to update our configurations to match.
Dockerizing RabbitMQ
Dockerizing RabbitMQ is very straight forward. We will simply use their image.
docker run -d --restart=always --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:alpine
Once started, check the RabbitMQ image logs to see if the plugins have started. To do this, you will need to execute docker ps
, recording the container id given for the image. Then execute docker logs [container id]
. If it says the server started the plugins, you may skip the next part. If not, we will need to manually drive the plugins with the following.
docker exec -it [container id] rabbitmq-plugins enable rabbitmq_management rabbitmq_shovel rabbitmq_shovel_management
Unfortunately, RabbitMQ will only accept the default username/password pair of guest
when accessed by localhost
. Since we will now be accessing a remote machine, we will need to use the admin portal for RabbitMQ to build a virtual host with permissions. The port 15672
we mapped provides this interface.
open -a Safari.app http://$REG_IP:15672
When prompted for username and password, use guest
for both of them. Once logged in, go the Admin
tab on the top bar. From here, we can create a vhost
by clicking Virtual Host
on the right a-side. When you enter the Virtual Hosts page, you will notice a default guest. Under that listing is Add a new virtual host
.
Clicking that accordion will provide the following form. Add a vhost named
addressbook
and click the Add virtual host
button.
Now that we have a new vhost, we need to add users to it. Return to the Admin
panel and add a user named addressbookuser
.Once the user is added, click the new user in
All users
and update their vhost from /
to addressbook
.
Clicking
Set permission
will not provide us a vhost and username/password pair to use RabbitMQ remotely.
RabbitMQ Conclusion
We created a vhost with a unique username/password pair to allow our two services to communicate. Why creating a new vhost is wise over reusing some default one is because while it make seem like we are adding clutter, we are actually keeping it organized. As we add many more services, having a discrete vhost for the contextual domain will allow other developers to quickly pick up on what the vhost is intended for. Of course, as we use the vhost, we can have topics unique to new services. Try to keep this in mind when administering RabbitMQ.
Dockerizing the AddressBook-BFF
Because we are now using remote services, we are going to have to update the configuration as well as create the Dockerfile, tag the image, and push to the registry.
Updating the configuration
Our first stop to updating the configuration is within the application.yaml
file found in resources
. We need to add the username/password pair for the user we created and also inject the vhost and ipaddress. For the ipaddress, make sure to use echo $REG_IP
to get the ipaddress of your registry. While there, we can also delete the server.port
configuration since we will not have conflicts anymore.
# rabbitmq configs
rabbitmq:
username: addressbookuser
password: 9Yr-EJN-G7w-hYa
port: 5672
ipaddress: 192.168.131.100
vhost: addressbook
# camel/spring
camel:
springboot:
main-run-controller: true
# messaging routes
addressbook:
person:
findAll: addressbook.person.findAll
findOne: addressbook.person.findOne
Next we will have to update our source code to handle the new dependency injections. In PersonRoute
, update our class variables to the following.
...
...
...
@Service('personRouteService')
class PersonRoute extends RouteBuilder {
private final String apiVersionPath = '/v1'
@Value('${rabbitmq.ipaddress')
private String ipaddress
@Value('${rabbitmq.port')
private Integer port
@Value('${rabbitmq.username}')
private String username
@Value('${rabbitmq.password}')
private String password
@Value('${rabbitmq.vhost}')
private String vhost
@Value('${addressbook.person.findAll}')
private String findAllTopic
@Value('${addressbook.person.findOne}')
private String findOneTopic
...
...
...
}
We will also have to update the configure
method to reflect our injections.
@Override
void configure() throws Exception {
// add in vhost to options
def options = "username=${username}&password=${password}&vhost=${vhost}"
def findOneRoute = "rabbitmq://${ipaddress}:${port}/${findOneTopic}?${options}"
def findAllRoute = "rabbitmq://${ipaddress}:${port}/${findAllTopic}?${options}"
}
Create the Dockerfile
To dockerize anything, we need a Dockerfile. The Dockerfile for our BFF will look as follows.
FROM openjdk:8-jdk-alpine
# exposed ports
EXPOSE 8080
EXPOSE 7777
# create a tmp folder
RUN mkdir /app_home
# copy java war file into image
COPY target/addressbook-bff-0.0.1-SNAPSHOT.jar /app_home
# set working folder
WORKDIR /app_home
# set perms
RUN chmod -R 700 /app_home
RUN chown -R daemon /app_home
# run as user
USER daemon
# run container
CMD ["java", "-jar","addressbook-bff-0.0.1-SNAPSHOT.jar", "run"]
You will need to run mvn install
to create the .jar
image used in runtime. Once that compilation is completed, you can then build the docker image, tag it, and push it to the registry.
docker build -t addressbook-bff -f Dockerfile .
docker tag addressbook-bff $REG_IP:80/addressbook-bff:latest
docker push $REG_IP:80/addressbook-bff:latest
Verify you new image on the regsitry
open -a Safari.app http://$REG_IP:5080
And finally run your image, but before we do, let's stop and remove our previous demo
app. You will need to get the container id of the image with the command docker ps
Here, my demo
app has a container id of cdce4df10aa
. I will simply do the two docker commands
docker stop cdce4df10aa
docker rm cdce4df10aa
This will free me up to run my addressbook-bff
. Simply use docker as follows.
docker run -d --restart=always -p 7777:7777 -p 8080:8080 $REG_IP:80/addressbook-bff:latest
Conclusion
As of now we have dockerized both RabbitMQ and the AddressBook-BFF. We still to complete dockerizing our AddressBook service. If we were to access the BFF as is right now, we will get errors because we have no core service to use. But to check the status, you can use docker ps
to see it running or use docker logs [container id]
to see the log output.
Dockerizing the AddressBook
Similar to the AddressBook-BFF, we need to update configurations to use RabbitMQ remotely. So again, let's update application.yml
and have the rabbitmq
config look as follows.
# rabbitmq configs
rabbitmq:
username: addressbookuser
password: 9Yr-EJN-G7w-hYa
port: 5672
ipaddress: 192.168.131.100
vhost: addressbook
Again, use the ipaddress given to you in echo $REG_IP
in place of my registry's ipaddress. After updating the application's configuration, update the PersonRouteService
class variables to use our newly injected values.
...
...
...
class PersonRouteService extends RouteBuilder {
private final String apiVersionPath = '/v1'
@Value('${rabbitmq.ipaddress}')
private String ipaddress
@Value('${rabbitmq.port}')
private Integer port
@Value('${rabbitmq.username}')
private String username
@Value('${rabbitmq.password}')
private String password
@Value('${rabbitmq.vhost}')
private String vhost
@Value('${addressbook.person.findAll}')
private String findAllTopic
@Value('${addressbook.person.findOne}')
private String findOneTopic
...
}
And finally, update our configure
method to reflect these injections.
@Override
void configure() throws Exception {
def options = "username=${username}&password=${password}&vhost=${vhost}"
def findOneRoute = "rabbitmq://${ipaddress}:${port}/${findOneTopic}?${options}"
def findAllRoute = "rabbitmq://${ipaddress}:${port}/${findAllTopic}?${options}"
from(findOneRoute)
.unmarshal().json(JsonLibrary.Jackson, PersonDTO.class)
.to('log:persons-findall?level=INFO&showAll=false&multiline=true')
.to('bean:personService?method=findOne(${body})')
.marshal().json(JsonLibrary.Jackson)
from(findAllRoute)
.to('log:person-findall?level=INFO&showAll=false&multiline=true')
.to('bean:personService?method=findAll()')
.marshal().json(JsonLibrary.Jackson)
}
This will finish our configuration changes, now we just need to create the Dockerfile
. Create this Dockerfile in your application's root directory.
FROM openjdk:8-jdk-alpine
# exposed ports
EXPOSE 8080
EXPOSE 7777
# create a tmp folder
RUN mkdir /app_home
# copy java war file into image
COPY build/libs/addressbook-0.war /app_home
# set working folder
WORKDIR /app_home
# set perms
RUN chmod -R 700 /app_home
RUN chown -R daemon /app_home
# run as user
USER daemon
# run container
CMD ["java", "-jar","addressbook-0.1.war", "run"]
You will notice that it is pretty identical to our BFF, but also realize the paths to COPY are different. This is because mvn install
and ./gradlew assemble
build to different targets. Being said, build the .war
image as well.
./gradlew assemble
With a success compile, you should now be able to build
, tag
and push
the docker image.
docker build -t addressbook-core -f Dockerfile .
docker tag addressbook-core $REG_IP:80/addressbook-core:latest
docker push $REG_IP:80/addressbook-core:latest
Now run our newly newly named addressbook-core
.
docker run -d --restart=always -p 8081:8080 $REG_IP:80/addressbook-core:latest
Realize we are shifting the access port for up by one value. Remember that the ports we are using around 8xxx values are for administration purposes only. Our publically facing RESTful API is on port 7777.