Integration testing with Maven and Docker
Docker is one of the new hot things out there. With a different set of technologies and ideas compared to traditional virtual machines, it implements something similar and at the same time different, with the idea of containers: almost all VMs power but much faster and with very interesting additional goodies.
In this article I assume you already know something about Docker and know how to interact with it. If it’s not the case I can suggest you these links to start with:
- http://www.docker.io/gettingstarted
- http://coreos.com/docs/launching-containers/building/getting-started-with-docker/
- http://robknight.org.uk/blog/2013/05/drupal-on-docker/
My personal contribution to the topic is to show you a possible workflow that allows you to start and stop Docker containers from within a Maven job.
The reason why I have investigated in this functionality is to help with tests and integration tests in Java projects built with Maven. The problem is well known: your code interacts with external systems and services. Depending on what you are really writing this could mean Databases, Message Brokers, Web Services and so on.
The usual strategies to test these interactions are:
- In memory servers; implemented in java that are usually very fast but too often their limit is that they are not the real thing
- A layer of stubbed services, that you implement to offers the interfaces that you need.
- Real external processes, sometimes remote, to test real interactions.
Those strategies work but they often require a lot of effort to be put in place. And the most complete one, that is the one that uses proper external services, poses problems for what concerns isolation: imagine that you are interacting with a database and that you perform read/write operations just while someone else was accessing the same resources. Again, you may find the correct workflows that invovle creating separate schemas and so on, but, again, this is extra work and very often a not very straight forward activity.
Wouldn’t it be great if we could have the same opportunities that these external systems offers, but in totaly isolation? And what do you think if I also add speed to the offer?
Docker is a tool that offers us this opportunity.
You can start a set of Docker container with all the services that you need, at the beginning of the testing suite, and tear it down at the end of it. And your Maven job can be the only consumer of these services, with all the isolation that it needs. And you can all of this easily scripted with the help of Dockerfile
s, that are, at the end, not much more than a sequential set of command line invocations.
Let see how to enable all of this.
The first prerequisite is obviously to have Docker installed on your system. As you may already know Docker technology depends on the capabilities of the Linux Kernel, so you have to be on Linux OR you need the help of a traditional VM to host the Docker server process.
This is the official documentation guide that shows you how to install under different Linux distros: http://docs.docker.io/en/latest/installation/
While instead this is a very quick guide to show how to install if you are on MacOSX: http://blog.javabien.net/2014/03/03/setup-docker-on-osx-the-no-brainer-way/
Once you are ready and you have Docker installed, you need to apply a specific configuration.
Docker, in recents versions, exposes its remote API, by default, only over Unix Sockets. Despite we could interact with them with the right code, I find much easier to interact with the API over HTTP. To obtain this, you have to pass a specific flag to the Docker daemon to tell it to listen also on HTTP.
I am using Fedora, and the configuration file to modify is /usr/lib/systemd/system/docker.service
.
[Unit] Description=Docker Application Container Engine Documentation=http://docs.docker.io After=network.target [Service] ExecStart=/usr/bin/docker -d -H tcp://127.0.0.1:4243 -H unix:///var/run/docker.sock Restart=on-failure [Install] WantedBy=multi-user.target
The only modification compared to the defaults it’s been adding -H tcp://127.0.0.1:4243
.
Now, after I have reloaded systemd
scripts and restarted the service I have a Docker daemon that exposes me a nice REST API I can poke with curl
.
sudo systemctl daemon-reload sudo systemctl restart docker curl http://127.0.0.1:4243/images/json # returns a json in output
You probably also want this configuration to survive future Docker rpm updates. To achieve that you have to copy the file you have just modified to a location that survives rpm updates. The correct way to achieve this in systemd
is with:
sudo cp /usr/lib/systemd/system/docker.service /etc/systemd/system
If you are using Ubuntu you have to configure a different file. Look at this page: http://blog.trifork.com/2013/12/24/docker-from-a-distance-the-remote-api/
Now we have all we need to interact easily with Docker.
You may at this point expect me to describe you how to use the Maven Docker plugin. Unluckily that’s not the case. There is no such plugin yet, or at least I am not aware of it. I am considering writing one but for the moment being I have solved my problems quickly with the help of GMaven plugin, a little bit of Groovy code and the help of the java library Rest-assured.
Here is the code to startup Docker containers
import com.jayway.restassured.RestAssured import static com.jayway.restassured.RestAssured.* import static com.jayway.restassured.matcher.RestAssuredMatchers.* import com.jayway.restassured.path.json.JsonPath import com.jayway.restassured.response.Response RestAssured.baseURI = "http://127.0.0.1" RestAssured.port = 4243 // here you can specify advance docker params, but the mandatory one is the name of the Image you want to use def dockerImageConf = '{"Image":"${docker.image}"}' def dockerImageName = JsonPath.from(dockerImageConf).get("Image") log.info "Creating new Docker container from image $dockerImageName" def response = with().body(dockerImageConf).post("/containers/create") if( 404 == response.statusCode ) { log.info "Docker image not found in local repo. Trying to dowload image '$dockerImageName' from remote repos" response = with().parameter("fromImage", dockerImageName).post("/images/create") def message = response.asString() //odd: rest api always returns 200 and doesn't return proper json. I have to grep if( message.contains("404") ) fail("Image $dockerImageName NOT FOUND remotely. Abort. $message}") log.info "Image downloaded" // retry to create the container response = with().body(dockerImageConf).post("/containers/create") if( 404 == response.statusCode ) fail("Unable to create container with conf $dockerImageConf: ${response.asString()}") } def containerId = response.jsonPath().get("Id") log.info "Container created with id $containerId" // set the containerId to be retrieved later during the stop phase project.properties.setProperty("containerId", "$containerId") log.info "Starting container $containerId" with().post("/containers/$containerId/start").asString() def ip = with().get("/containers/$containerId/json").path("NetworkSettings.IPAddress") log.info "Container started with ip: $ip" System.setProperty("MONGODB_HOSTNAME", "$ip") System.setProperty("MONGODB_PORT", "27017")
And this is the one to stop them
import com.jayway.restassured.RestAssured import static com.jayway.restassured.RestAssured.* import static com.jayway.restassured.matcher.RestAssuredMatchers.* RestAssured.baseURI = "http://127.0.0.1" RestAssured.port = 4243 def containerId = project.properties.getProperty('containerId') log.info "Stopping Docker container $containerId" with().post("/containers/$containerId/stop") log.info "Docker container stopped" if( true == ${docker.remove.container} ){ with().delete("/containers/$containerId") log.info "Docker container deleted" }
Rest-assured fluent API should suggest what is happening, and the inline comment should clarify it but let me add a couple of comments. The code to start a container is my implementation of the functionality of docker run
as described in the official API documentation here: http://docs.docker.io/en/latest/reference/api/docker_remote_api_v1.9/#inside-docker-run
The specific problem I had to solve was how to propagate the id of my Docker container from a Maven Phase to another one. I have achieved the functionality thanks to the line:
// set the containerId to be retrieved later during the stop phase project.properties.setProperty("containerId", "$containerId")
I have also exposed a couple of Maven properties that can be useful to interact with the API:
docker.image
– The name of the image you want to spindocker.remove.container
– If set to false, tells Maven to not remove the stopped container from filesystem (useful to inspect your docker container after the job has finished)
Ex.
mvn verify -Ddocker.image=pantinor/fuse -Ddocker.remove.container=false
You may find here a full working example. I have been told that sometimes my syntax colorizer script eats some keyword or change the case of words, so if you want to copy and paste it may be a better idea cropping from Github.
This is a portion of the output while running the Maven build with the command mvn verify
:
... [INFO] --- gmaven-plugin:1.4:execute (start-docker-images) @ gmaven-docker --- [INFO] Creating new Docker container from image {"Image":"pantinor/centos-mongodb"} log4j:WARN No appenders could be found for logger (org.apache.http.impl.conn.BasicClientConnectionManager). log4j:WARN Please initialize the log4j system properly. [INFO] Container created with id 5283d970dc16bd7d64ec08744b5ecec09b57d9a81162826e847666b8fb421dbc [INFO] Starting container 5283d970dc16bd7d64ec08744b5ecec09b57d9a81162826e847666b8fb421dbc [INFO] Container started with ip: 172.17.0.2 ... [INFO] --- gmaven-plugin:1.4:execute (stop-docker-images) @ gmaven-docker --- [INFO] Stopping Docker container 5283d970dc16bd7d64ec08744b5ecec09b57d9a81162826e847666b8fb421dbc [INFO] Docker container stopped [INFO] Docker container deleted ...
If you have any question or suggestion please feel free to let me know!
Full Maven `pom.xml` available also here: https://raw.githubusercontent.com/paoloantinori/gmaven_docker/master/pom.xml
<!--?xml version="1.0"?--> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemalocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelversion>4.0.0</modelversion> <artifactid>gmaven-docker</artifactid> <groupid>paolo.test</groupid> <version>1.0.0-SNAPSHOT</version> <name>Sample Maven Docker integration</name> <description>See companion blogpost here: </description> <build> <plugins> <plugin> <groupid>org.codehaus.gmaven</groupid> <artifactid>gmaven-plugin</artifactid> <version>1.4</version> <configuration> <providerselection>2.0</providerselection> </configuration> <executions> <execution> <id>start-docker-images</id> <phase>test</phase> <goals> <goal>execute</goal> </goals> <configuration> <source><!--[CDATA[ import com.jayway.restassured.RestAssured import static com.jayway.restassured.RestAssured.* import static com.jayway.restassured.matcher.RestAssuredMatchers.* RestAssured.baseURI = "http://127.0.0.1" RestAssured.port = 4243 // here you can specify advance docker params, but the mandatory one is the name of the Image you want to use def dockerImage = '{"Image":"pantinor/centos-mongodb"}' log.info "Creating new Docker container from image $dockerImage" def response = with().body(dockerImage).post("/containers/create") if( 404 == response.statusCode ) { log.info "[INFO] Docker Image not found. Downloading from Docker Registry" log.info with().parameter("fromImage", "pantinor/centos-mongodb").post("/images/create").asString() log.info "Image downloaded" } // retry to create the container def containerId = with().body(dockerImage).post("/containers/create").path("Id") log.info "Container created with id $containerId" // set the containerId to be retrieved later during the stop phase project.properties.setProperty("containerId", "$containerId") log.info "Starting container $containerId" with().post("/containers/$containerId/start").asString() def ip = with().get("/containers/$containerId/json").path("NetworkSettings.IPAddress") log.info "Container started with ip: $ip" System.setProperty("MONGODB_HOSTNAME", "$ip") System.setProperty("MONGODB_PORT", "27017") ]]--> </configuration> </execution> <execution> <id>stop-docker-images</id> <phase>post-integration-test</phase> <goals> <goal>execute</goal> </goals> <configuration> <source><!--[CDATA[ import com.jayway.restassured.RestAssured import static com.jayway.restassured.RestAssured.* import static com.jayway.restassured.matcher.RestAssuredMatchers.* RestAssured.baseURI = "http://127.0.0.1" RestAssured.port = 4243 def containerId = project.properties.getProperty('containerId') log.info "Stopping Docker container $containerId" with().post("/containers/$containerId/stop") log.info "Docker container stopped" with().delete("/containers/$containerId") log.info "Docker container deleted" ]]--> </configuration> </execution> </executions> </plugin> </plugins> </build> <dependencies> <dependency> <groupid>com.jayway.restassured</groupid> <artifactid>rest-assured</artifactid> <version>1.8.1</version> <scope>test</scope> </dependency> </dependencies> </project>
Why using Docker remotely with its HTTP API ? Is it because Docker is not available on the Maven project machine ? I’m confused. And can’t we stick to Java and not mix in Groovy in there ? I dropped off. Too bad. It looked so promising to me. I guess I’m too much of a Docker noob for it.
Dont be confused. Ive recently had a similar issue. There are a few ways more to build such an integration-test-solution. 1) Docker provides a Standard-REST-Service which could be called even with Standard-Java-Technologies. But theres also already a prebuilt Java-Dockerclient (https://github.com/docker-java/docker-java) for really easy access to docker (available on Maven-Central). 2) Another way are the Maven-Docker-Plugins. There are a few on Maven-Central. for example (https://github.com/fabric8io/docker-maven-plugin) or spotify also releases one. They’re controlling Docker directly based on your Maven-Config of your Project-POM Just have a look at google. NO Groovy needed – I also avoid to use Groovy :-) Docker is a… Read more »