Materials for the LauzHack Days workshop on "Intro to Docker" (April 11, 2022)
Inspired by https://github.com/ageapps/lauzhack-docker-workshop
-
Install a code editor (example: Visual Studio Code)
-
Install NodeJS (Linux, MacOS, Windows) a JavaScript runtime execution environment for back-end and desktop systems to write and run general-purpose JavaScript code
-
Install Docker Engine
-
Install Docker Compose (depending on your system, this step is not required if you installed Docker through Docker Desktop)
-
Create a new directory for the project (for instance in your
Documents
folder) and move inside:cd ~/Documents mkdir lauzhack-docker-workshop cd lauzhack-docker-workshop
In this task, we will write a simple web server using ExpressJS, a popular web framework library. We will simply display a Hello World message to visitors.
mkdir express-web-server
cd express-web-server
Initialize a new project (press Enter until npm
stops asking for input):
npm init
Install the Express dependency:
npm install express
You should now see a "node_modules" folder, a "package.json" file and a "package-lock.json" file in your folder. These files contain all the dependencies of your software.
Create a new file named app.js
copy the following web server code:
// import and initialize dependencies
const express = require('express')
const app = express()
const port = 3000
// define a text "Hello World!" response on the root GET route
// i.e. when a client performs a request to http://<hostname>:3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
// wait for requests indefinitely
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
That's it! You now have a working web server.
Once this is complete, you should be able to run and access your web server with:
node app.js
And then by typing http://localhost:3000 in your web browser. You should see a "Hello World!" message.
We will now dockerize this web application by writing a Dockerfile. Create a new file named Dockerfile
(no extension) in the project directory and write the instructions to build an image which will contain the application.
Hints: find each Dockerfile instruction in the reference which does the following:
- Start with the
node
base image, specifically thelts-alpine
tag, which is the latest long-term support NodeJS distribution based on Alpine Linux (a very slim image) and which will contain the NodeJS runtime. - Set the working directory inside your container to the
/app
folder for instance - Copy
package.json
andapp.js
into the container to this same folder - Install the dependencies inside the container using
npm install
- As a good practice, document the port to expose (3000)
- Change the user to an unprivileged user (the
node
image already includes an unprivileged user namednode
). In general, the default user is root by default, it is hence a good practice to use a least privileged user (in case an attacker is able to escape the process sandbox). - Instruct Docker to run the application with the same command as before
You can now build your application into an immutable image with name express-web-server
and a tag v1
:
docker build -t express-web-server:v1 .
Once successfully built, run the container using
docker run -d -p 3000:3000 express-web-server:v1
The -p
(publish) option binds the internal port 3000 inside the container to the local host port 3000 of the machine.
You should be able to access the web server again using your web browser at http://localhost:3000.
You can also list running containers using:
docker ps
To stop the container (since we did not implement graceful handling of SIGTERM
in our application), find the container id using docker ps
and then:
docker kill <container id>
-
Now run the the container using the following command:
docker run -d -p 8000:3000 express-web-server:v1
. On what virtual and local ports does the app run, and what is the URL that you should now type in your web browser to see your app? -
Create a new
Dockerfile
using the newestcurrent-alpine
NodeJS image version, and give it a new tagv2
. -
Run locally 2 versions of the app with both versions of Node.js (use different host ports)
-
In
app.js
, replace- const port = 3000 + const port = process.env.PORT || 3000
Use the
-e
option to change the virtual port of the container -
Create a new
Dockerfile
and instead of using thenode
base image, start with theubuntu:latest
base image.- You will need to automate the installation of NodeJS inside the container, as well as create an unprivileged
node
user
- You will need to automate the installation of NodeJS inside the container, as well as create an unprivileged
In this task, we will create a more complex application composed of multiple services. We will build a real-time chat application, which is able to persist messages into a MongoDB database instance, which can send real-time updates to clients, and which can query a dice roll service to get random numbers through the virtual network.
Go back to the folder you first created, e.g. ~/Documents/lauzhack-docker-workshop
and create a new directory named diceroll
and access inside of it:
cd ~/Documents/lauzhack-docker-workshop
mkdir diceroll
cd diceroll
We will create a microservice which will generate a dice roll on each request:
npm init
npm install express
Create a new file app.js
and copy the following content:
const express = require('express')
const app = express()
const port = 3001
function rollDice() {
// random integer between 1 and 6
return Math.floor(Math.random() * 6) + 1;
}
app.get('/', (req, res) => {
// return the value as structured JSON
res.json({ value: rollDice() })
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
You can now roll random numbers by running:
node app.js
in the diceroll
folder and accessing http://localhost:3001/ (notice the 3001). Refresh the page for new random numbers.
Create a new Dockerfile
and copy the following contents:
FROM node:lts-alpine
WORKDIR /app
COPY . /app
RUN npm install
EXPOSE 3001
USER node
CMD ["node", "app.js"]
Go back to the folder you first created, e.g. ~/Documents/lauzhack-docker-workshop
and create a new directory named chat
and access inside of it:
cd ~/Documents/lauzhack-docker-workshop
mkdir chat
cd chat
We will create yet another microservice for the main chat application:
npm init
npm install express bulma socket.io mongodb cross-fetch
We use bulma
as CSS library to style our front-end, socket.io
to provide real-time communication (using websockets or polling), mongodb
as our database driver and cross-fetch
to perform HTTP requests from the NodeJS process directly.
Create a new file app.js
and copy the following content:
// import and initialize dependencies
const express = require('express')
const http = require('http')
const Socket = require('socket.io').Server
const { MongoClient } = require('mongodb')
const fetch = require('cross-fetch')
const app = express()
const server = http.createServer(app)
const socket = new Socket(server)
// URI of other services (database, dice roll)
const MONGO_URI = "mongodb://root:example@mongodb:27017?maxPoolSize=20&w=majority"
const DICEROLL_URI = "http://diceroll:3001/"
// initialize database client
const db = new MongoClient(MONGO_URI)
const port = 3002
// expose client front-end libraries for browsers
app.use('/static', express.static('node_modules/bulma/css'))
app.use('/static', express.static('node_modules/socket.io/client-dist'))
// main route will send the pretty HTML + front-end JS app
app.get('/', (req, res) => {
res.send(`
<html>
<head>
<link rel="stylesheet" href="/static/bulma.min.css">
<title>Chat app</title>
<script src="/static/socket.io.min.js"></script>
<script>
const socket = io()
function usernameInput() {
return document.getElementById("username")
}
function messageInput() {
return document.getElementById("message")
}
function onSubmit(event) {
event.preventDefault()
const username = usernameInput()
const message = messageInput()
if (username.value && message.value)
socket.emit("message", { user: username.value, msg: message.value })
message.value = ""
return false
}
socket.on('chat', (data) => {
document.getElementById("messages").insertAdjacentHTML("afterbegin",\`
<div class="box">
<article class="media">
<div class="media-content">
<div class="content">
<p>
<strong>\$\{data.user\}</strong> <small>\$\{(new Date(data.time)).toLocaleTimeString()\}</small>
<br>
\$\{data.msg\}
</p>
</div>
</div>
</article>
</div>
\`)
})
</script>
</head>
<body style="padding:20px">
<div id="form">
<form class="box" action="" onsubmit="onSubmit(event)">
<div class="field">
<label class="label">User name</label>
<div class="control">
<input class="input" type="text" id="username">
</div>
</div>
<div class="field">
<label class="label">Message</label>
<div class="control">
<input class="input" type="text" id="message">
</div>
</div>
<button class="button is-primary">Send</button>
</form>
</div>
<div id="messages"></div>
</body>
</html>
`)
})
// hook triggered when socket.io sees a new client
socket.on('connection', (client) => {
console.log('New user connected')
// hook triggered when client disconnects
client.on('disconnect', () => {
console.log('User disconnected')
})
// on first connection, send all past messages
db.db("chat").collection("messages").find({}).forEach(doc => {
client.emit('chat', doc)
})
// hook triggered when a client sends something through socket.io
client.on('message', (data) => {
console.log(JSON.stringify(data))
const { user, msg } = data
const time = Date.now()
const out = { user, msg, time }
// if the message is "/diceroll", make a request to the service
if (msg === "/diceroll") {
fetch(DICEROLL_URI)
.then(res => {
if (res.status >= 400) throw new Error("Bad response from diceroll server")
return res.json()
})
.then(data => {
const number = data.value
// send the dice roll value as system message
socket.emit('chat', { user: "System", msg: `${user} requested a dice roll: ${number}`, time })
})
.catch(console.err)
} else {
// otherwise just broadcast the message to everyone
socket.emit('chat', out)
// and store it in database
db.db("chat").collection("messages").insertOne(out).catch(console.err)
}
})
})
// try to connect to DB first
db.connect()
.then(() => db.db("admin").command({ ping: 1 }))
.then(() => {
console.log("Database connected")
// if DB is up, then listen for requests indefinitely
server.listen(port, () => {
console.log(`Chat server listening on port ${port}`)
})
})
// otherwise just die
.catch(console.error)
You will however not be able to run it directly, as this code will attempt to connect to a MongoDB server instance and fail.
Add a Dockerfile
and copy the following contents:
FROM node:lts-alpine
WORKDIR /app
COPY . /app
RUN npm install
EXPOSE 3002
USER node
CMD ["node", "app.js"]
In the parent directory of the chat and diceroll services, create a new file named docker-compose.yml
. Have a look at the Compose specification reference for examples and syntax.
It should define a services section with 3 containers:
- the chat application, which should be built from its Dockerfile and which needs to export a public port (e.g. 3002)
- the dice roll microservice, which should be built from its Dockerfile. The chat application expects the service to be named
diceroll
, and will connect to its default port 3001. - a MongoDB instance, which should be pulled from the
mongo
image. The chat application expects the service to be namedmongodb
, with root usernameroot
and passwordexample
. - optionally, you can add a
mongo-express
instance, which will help you debug the contents of the database
It should define a volumes section with a volume dedicated to the persistent data of the Mongo database, such that data is not lost across application restarts. The volume should be bound to the MongoDB container.
-
Connect all the computers from a small group of people to the same local area network (e.g. by using the "4G modem" functionality of a smartphone for instance). Boot up the server stack from one computer, find out its local IP address: all computers should be able to access the web interface through the local IP - chat port pair, and you should be able to chat together.
-
Modify the code and the docker-compose file such that all environment configuration (such as host names, ports, passwords, ...) are passed as environment variables (for instance, directly through the docker-compose file).