Thursday, 11 August 2016

Docker - Multi Container App

In my last post I used Docker to build and run a simple Spring Boot application. This post will take things a little further by introducing a second container, showing you how distinct components can be deployed in separate containers and how those containers can communicate.

We'll build a simple Spring Boot app with a REST endpoint that takes an incoming message and adds it to an ActiveMQ message queue. A second endpoint will use a receiver component to consume the next message from the queue and return it to the client.  The application itself is simple but it'll provide a pretty realistic use case for how you might use multiple Docker containers during development.
Fig 1.0 - Boot & ActiveMQ - Multi Container App

Application Components

I'm not going to cover the application code in a huge amount of detail but I'll describe the fundamentals components and how they fit together. The MessageController below exposes 2 endpoints, one for posting a message and one for retrieving a message.  I've injected a MessageSender and MessageReceiver which are used to publish messages to and retrieve messages from an ActiveMQ message broker.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RestController
public class MessageController {

    private final MessageSender messageSender;
    private final MessageReceiver messageReceiver;

    @Autowired
    public MessageController(MessageSender messageSender, MessageReceiver messageReceiver){
       this.messageSender = messageSender;
       this.messageReceiver = messageReceiver;
    }
 
    @RequestMapping(value = "/message/", method = RequestMethod.GET)
    public String retrieveMessage() {
 
       return messageReceiver.retrieveMessage();
    }
  
    @RequestMapping(value = { "/message" }, method = { RequestMethod.POST })
    public void publishMessage(@RequestBody String message) {

       SimpleMessage simpleMessage = new SimpleMessage(message, new Date());
       messageSender.publishMessage(simpleMessage);
    }

}

MessageSender uses a jmsTemplate to send a message to the queue. This method is called from the REST controller to publish the message received via the HTTP endpoint.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Component
@Slf4j
public class MessageSender {

    private final JmsTemplate jmsTemplate;

    @Autowired
    public MessageSender(JmsTemplate jmsTemplate, MessageConverter messageConverter) {
       this.jmsTemplate = jmsTemplate;
       this.jmsTemplate.setMessageConverter(messageConverter);  
    }

    public void publishMessage(SimpleMessage simpleMessage){
  
       log.info("Sending message to queue: {}", simpleMessage.toString());
       jmsTemplate.convertAndSend("TestQueue", simpleMessage);
    }
 
}

MesssageReceiver uses a JmsTemplate to retrieve a message from the queue. The retrieveMessage method is invoked by the REST controller which will return the message to the client.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Component
@Slf4j
public class MessageReceiver {

    private final JmsTemplate jmsTemplate;

    @Autowired
    public MessageReceiver(JmsTemplate jmsTemplate, MessageConverter messageConverter) {
       this.jmsTemplate = jmsTemplate;
       this.jmsTemplate.setMessageConverter(messageConverter);
    }

    public String retrieveMessage() {

       String message = (String) jmsTemplate.receiveAndConvert("TestQueue");
       log.info("Retrieved message from queue: {}", message);
       return message;
    }
}

SimpleMessageConverter is used by the MessageSender and MessageReceiver components to do some simple transformation. MessageSender uses this class to convert a simple message POJO to a String before publishing to the queue. MessageReceiver uses this class to extract the message String from the Message returned from the queue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Component
public class SimpleMessageConverter implements MessageConverter {

    @Override
    public Message toMessage(Object messageObject, Session session) throws JMSException, MessageConversionException {

       SimpleMessage simpleMessage = (SimpleMessage) messageObject;
       MapMessage message = session.createMapMessage();
       message.setString("message", simpleMessage.toString());
       return message;
    }

    @Override
    public Object fromMessage(Message message) throws JMSException, MessageConversionException {

       MapMessage mapMessage = (MapMessage) message;
       return mapMessage.getString("message");
    }

The application.properties below contains a single property that defines the message broker URL. We'll see later that the host name value (activemq) is significant when we come to linking Docker containers.

Running ActiveMQ

Now that we've defined the app lets look at setting up ActiveMQ. We need an ActiveMQ instance with a single queue definition called TestQueue. Remember that we reference TestQueue  as the destination in MessageSender and MessageReceiver. To save you time I've already created such a Docker image and pushed it to DockerHub.  Running the command below will pull the image from DockerHub and start a container.

1
docker run --name activemq -p 8161:8161 briansjavablog/activemq

The --name flag allows us to provide a name for the container we're starting. We'll use this name later when we are linking the app container to this ActiveMQ container.  The -p 8161:8161 tells Docker to expose port 8161 to the host machine on port 8161. This is required so that we can access the message broker admin console when it starts and check that the message queue is configured and ready to use. When the container starts up open a browser and go to http://localhost:8161/admin/queues.jsp. You should see a single queue defined as shown below.

Fig 1.1 - ActiveMQ Queue Definition

Building the Application Image

We'll start by defining a simple Dockerfile that takes a pre built JAR from the target directory and adds it to the image. The ENTRYPOINT tells Docker to run the executable JAR on container startup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM anapsix/docker-oracle-java8

# Creating working directory
WORKDIR /app

# Add src to working directory
ADD target/docker-boot-activemq-0.1.0.jar /app/docker-boot-activemq-0.1.0.jar

# Start app
ENTRYPOINT ["java","-jar","/app/docker-boot-activemq-0.1.0.jar"]     

To create an image form the Dockerfile simply run docker build -t "docker-activemq" . as shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
brianh@brianh-VirtualBox:~/apps/docker-boot-activemq$ docker build -t "docker-activemq" .
Sending build context to Docker daemon 19.98 MB
Step 1 : FROM anapsix/docker-oracle-java8
 ---> a8a9dcb0ac64
Step 2 : WORKDIR /app
 ---> Using cache
 ---> 4a5482ed5d76
Step 3 : ADD target/docker-boot-activemq-0.1.0.jar /app/docker-boot-activemq-0.1.0.jar
 ---> 4dd383ddc5aa
Removing intermediate container b720dc09ee95
Step 4 : ENTRYPOINT java -jar /app/docker-boot-activemq-0.1.0.jar
 ---> Running in 398ca91a7205
 ---> 74e632bb7020
Removing intermediate container 398ca91a7205
Successfully built 74e632bb7020


Linking Containers

If you've followed the steps so far, you should have an ActiveMQ container up and running and an application image built and ready to run. We're now going to start a second container to run our application and link it to the ActiveMQ container we started earlier.

1
docker run -p 8080:8080 --link activemq:activemq docker-activemq
  • -p 8080:8080 exposes port 8080 on the container to port 8080 on the host machine. This is required as the Boot app will start on container port 8080 as soon as the image is started.
  • --link activemq:activemq defines the container we want to link to and an alias that we can use to reference it. In this case we are linking to the activemq container we started earlier.
  • docker-activemq is the name of the application Docker image we built earlier.,
When we defined the message broker URL earlier in application.properties, we set the URL as follows.

1
spring.activemq.brokerurl=tcp://activemq:61616

So how does the app container know how to resolve the above URL to a service exposed in another container? To see how, we're going to open a shell in the app container and have a look. After the app container has started run the following from another terminal window. Note that the value inside quotes is the ID of the application container. To get the container ID run docker ps.

1
docker exec -it "1a4db2b586c3" bash

You should now have shell access to the app image and your current working directory should be /app. Remember /app is the working directory we created earlier to add the JAR to. Now lets take a look at the hosts file. Simply run vi /etc/hosts as shown below.

1
2
3
root@1a4db2b586c3:/app# ls
docker-boot-activemq-0.1.0.jar
root@1a4db2b586c3:/app# vi /etc/hosts

On container startup when we specified the --link command we supplied the container we wanted to link to and an alias that we'd use to reference it. Docker used that alias to create a hosts entry (line 7) to map the container alias activemq to the IP of the activemq container.

1
2
3
4
5
6
7
8
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2      activemq fa0b6f943d62
172.17.0.3      1a4db2b586c3

When the application in one container attempts to connect to ActivceMQ in the other container, the broker URL tcp://activemq:61616 will resolve to tcp://172.17.0.2:61616. To check that this IP address is indeed the IP address of the ActiveMQ container run the following command.

1
2
brianh@brianh-VirtualBox:~$ docker inspect --format '{{ .NetworkSettings.IPAddress }}' fa0b6f943d62
172.17.0.2


Testing the Application

Now that we've started and established a link between the application and ActiveMQ containers, its time to test it. Post a message to the MessageController endpoint using CURL as follows.

1
curl -i -H "Content-Type: application/json" -X POST -d "Test Message 1" localhost:8080/message/

In the app logs you should see a message saying that the message was added to the queue.

1
2016-08-10 17:44:52.789  INFO 1 --- [nio-8080-exec-1] com.blog.sample.app.mq.MessageSender     : Sending message to queue: SimpleMessage(message=Test Message 1, creationTime=Wed Aug 10 17:44:52 UTC 2016)

You can confirm that the message is indeed on the queue by opening the ActiveMQ admin console.
To retrieve the message from the queue, run curl -i localhost:8080/message/.

1
2
3
4
5
6
7
brianh@brianh-VirtualBox:~$ curl -i localhost:8080/message/
HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 80
Date: Thu, 11 Aug 2016 06:44:51 GMT

SimpleMessage(message=Test Message 1, creationTime=Wed Aug 10 17:44:52 UTC 2016)brianh@brianh-VirtualBox:~$ 


Source Code

The full source code for this post can be found on Github and the ActiveMQ image is available on DockerHub. As always, if you have any questions, comments or suggestions please leave a note below.