Last time we built three client-server Node.js apps that were talking to each other using ZeroMQ. However, running both client and server on localhost is a little bit lame. Let’s put them into containers! They’ll still be lame, but now with Docker around.
So, here’s the plan: let’s see what we need to do to last week’s fire-and-forget ZeroMQ app, so its client and server can work and communicate from within Docker containers.
The plan.
Firstly, let’s bring back code for client and server in order to see what we have to deal with.
The server (very slightly modified):
1 2 3 4 5 6 7 8 9 |
const socket = require(`zmq`).socket(`push`); // Create PUSH socket socket.bindSync(`tcp://127.0.0.1:3000`); // Bind to localhost:3000 setInterval(function () { const message = `Ping!`; console.log(`Sending '${message}'`); socket.send(message); // Send message once per 2s }, 2000); |
And the client:
1 2 3 4 5 6 7 |
const socket = require(`zmq`).socket(`pull`); // Create PULL socket socket.connect(`tcp://127.0.0.1:3000`); // Connect to same address socket.on(`message`, function (msg) { // On message, log it console.log(`Message received: ${msg}`); }); |
The server sends “Ping” message every two seconds and the client happily receives it. Not exactly a rocket science. Here’s what I think needs to be done:
- The first obvious problem is hardcoded IP address. Even if I decide to keep using port 3000 and make server app to listen at all network interfaces ( tcp://*:3000 ), client’s ‘connect’ address still has to be parametrized.
- Client and server Docker images won’t create themselves, so we need two Dockerfiles for that.
- When server app starts in a container, it’ll have a dynamic IP address assigned to it. But in order to pass it as a parameter to the client I need to know it in advance. That would’ve been a problem, if containers couldn’t talk to each other using their names instead of IPs. But they need to be connected to user-defined network first. If only we had a tool (*cough* docker-compose) that can easily assign names to containers, while attaching them to user-defined network.
These three steps should be enough.
Parametrize client’s ‘connect’ address
Containers, Docker and docker-compose provide simple way to define environmental variables. It’s also not hard to read them from Node.js app. Sounds like we found ourselves the way to pass server’s container name to the client. This change, as well as additional console.log for potential troubleshooting, results something like this (see highlighted rows):
1 2 3 4 5 6 7 8 9 |
const socket = require(`zmq`).socket(`pull`); const address = process.env.ZMQ_PUB_ADDRESS || `tcp://127.0.0.1:3000`; console.log(`Connecting to ${address}`); socket.connect(address); socket.on(`message`, function (msg) { console.log(`Message received: ${msg}`); }); |
Because now client can be told to connect to any port, let’s parametrize server’s ‘bind’ address as well:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const socket = require(`zmq`).socket(`push`); const address = process.env.ZMQ_BIND_ADDRESS || `tcp://*:3000`; console.log(`Listening at ${address}`); socket.bindSync(address); const sendMessage = function () { const message = `Ping!`; console.log(`Sending '${message}'`); socket.send(message); }; setInterval(sendMessage, 2000); |
Behold! The apps are ready.
Create Dockerfile
Dockerfile contains steps necessary for building the image of app container. We have two apps, so there’re two Dockerfiles to create:
Dockerfile itself will be quite trivial: use official node image, copy app files on top of it, install dependences, open ports and start client/server whenever container starts. Here’s what I ended up with for the server:
1 2 3 4 5 6 7 8 9 |
FROM node COPY ./app /app WORKDIR /app RUN rm -rf node_modules && \ apt-get update -qq && \ apt-get install -y -qq libzmq-dev && \ npm install --silent EXPOSE 3000 CMD ["node", "/app/server.js"] |
Installing dependences ( RUN ... ) is the only tricky step. Firstly, I want to reinstall all NPM modules, that’s what the first and the last lines of RUN section are for. Secondly, ZeroMQ has a dependency – libzmq-dev , but before we can install it, apt-get itself should be updated. node image tries to be as small as possible, so apt-get’s sources lists will be empty and it’ll have no idea where to find zeromq-dev.
Let’s build it and run:
1 2 3 4 5 6 7 8 9 10 |
$ docker build -t zmqserver:latest . # Sending build context to Docker daemon 2.276 MB # Step 1 : FROM node # ---> 9873603dc506 # ......... # Successfully built f1da9ff9cf85 $ docker run zmqserver # Listening at tcp://*:3000 # Sending 'Ping' # Sending 'Ping' |
So far, so good. Client’s Dockerfile is almost identical to server’s – just another JavaScript file to run.
Prepare docker-compose.yml file
We need to assign names to containers and attach them to user-defined network – otherwise they won’t be able to talk to each other by name. The easiest way to do that is through docker-compose – a tool for managing distributed app as a whole. I’ll put app names, links to Dockerfiles and env variables to docker-compose.yml , and through some ancient magic docker-compose will be able to launch it. I don’t have to do anything about networks in it – compose attaches containers to newly created user-defined network by default.
Now I put config file along with client and server folders and here’s how it looks in the end:
1 2 3 4 5 6 7 8 9 10 |
version: '2' services: client: build: ./client/ environment: - ZMQ_PUB_ADDRESS=tcp://server:3000 server: build: ./server/ environment: - ZMQ_BIND_ADDRESS=tcp://*:3000 |
No tricky parts at all. Let’s run it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ docker-compose up # Building client # ......... # WARNING: Image for service client was built because it did not already exist... # Building server # ......... # WARNING: Image for service server was built because it did not already exist... # Creating fireandforget_client_1 # Creating fireandforget_server_1 # Attaching to fireandforget_client_1, fireandforget_server_1 # client_1 | Connecting to tcp://server:3000 # server_1 | Listening at tcp://*:3000 # server_1 | Sending 'Ping' # client_1 | Message received: Ping |
It works! What’s cool about it, if I wanted to create more clients or servers just to see what difference it makes, I’d just copy-pasted few lines in docker-compose.yml and that would do it.
Summary
We briefly took a look at how to convert existing client-server app that uses ZeroMQ for communication into set of Docker containers that are still able to talk. That appeared to be relatively easy. We had to replace hardcoded IP addresses with environmental variables, create Dockerfile for both client and server and use docker-compose to launch them. Compose wasn’t strictly speaking necessary for that, but it simplified name and network management, as well as made it easy to specify values for environmental vars. Complete working example of code above can be found here.
Your fire-and-forget code example is missing the client and server .js files and package.json. Thank you for the example but I cannot get it running without the package.jsons
Yup, you’re right. I wonder how I managed to screw that up: git was thinking that server and client directories were submodules. I reuploaded the files and it should be fine now.
Thanks for letting me know.
Very useful, I borrow the idea of passing the address by environment variable.
But I didn’t managed to install zmq on the latest node image, libzmq-dev not found and libzmq3-dev gives errors when I install my npm module. I had to first download the zmq package and compile it.
Thanks Pavel!
@Ric docker-compose version 3 doesn’t need an environment variable anymore. I recently made an example with just pyzmq and Docker: https://github.com/NumesSanguis/pyzmq-docker