Docker & NodeJS
Recommendations for dealing with NodeJS in Docker environments

Compose YAML v2 vs v3

    v3 does not replace v2
    v2 focus: single-node dev / test
    v3 focus: multi-node orchestration
    If not using Swarm / Kubernetes, stick to v2

Some best practices

    Use COPY to copy files, use ADD only if there is a reason to do so
    Always cleanup after npm install
    RUN npm install && npm cache clean --force
    CMD 'node', instead of 'npm'. Reasons:
      requires another application to run (npm launches, and then starts node)
      not as literal in Dockerfiles
      'npm' does not work well as an 'init' or 'PID 1' process, container will not be terminated correctly as npm might not send SIGTERM to node process
    Use WORKDIR not RUN mkdir (unless you need to 'chown')

FROM base image guidelines

    Stick to even numbered major releases
    Don't use :latest tag, be specific about the version
    Start with Debian if migrating
    Move to Alpine later (or start with Alpine, if it's a new project)
    Don't use :slim (it's just a smaller version of Debian, and using Alpine is preferred)
    Don't use :onbuild

Least privilege: Using 'node' User

    Official node images have a 'node' user, but it's not used by default
    Do this after 'apt/apk' and 'npm install -g'
    Do this before 'npm install'
    This may cause permissions issues with write access
    May require 'chown node:node'
    Change user from 'root' to 'node': USER node
    Only the CMD, ENTRYPOINT and RUN will use a user set by user, and everything else will use root, and that can lead to problems (like installing packages, writing to files, bind mounts permissions, etc)
    Set permissions on application directory
    RUN mkdir app && chown -R node:node .
    Run a command as a root in a container
    docker-compose exec -u root
    since docker will assume 'node' as default user otherwise

Making Images Efficiently

    Pick proper FROM (be as specific as possible with image tags)
    Line order matters (layers that change more often should be close to the bottom, and vice versa, layers that don't change that often should be close to the top )
    COPY package.json (and lock) first, run npm install and then copy rest of the code
    COPY package*.json ./
    RUN npm install && npm cache clean --force
    COPY . .
    One 'apt-get' (or other package manager) line per Dockerfile
    RUN apt-get update && apt-get install curl && apt-get clean (or apt-get autoclean)

Node process management in containers

    No need for nodemon, forever or pm2 on server
    Use nodemon in dev for watching file changes
    Docker manages app start, stop, restart, healthcheck
    Node multi-threaded: Docker manages multiple "replicas"
    One npm / node problem: they don't listen for proper shutdown signal by default
    PID 1 (process identifier) is the first process in a system (or container), AKA init
    Init process in the container has two jobs:
      reap zombie processes
      pass signals to subprocesses
    Zombie processes is not a big Node issue
    Focus on proper Node shutdown
    Docker uses Linux signals to stop app (SIGINT / SIGTERM / SIGKILL)
    SIGINT / SIGTERM allow graceful stop
    NPM doesn't respond to SIGINT / SIGTERM
    Node doesn't respond by default, but a code can be added to handle it
    Docker provides a init PID 1 replacement option (tini, for instance)

Proper Node shutdown options

    Temporary: use --init to fix Ctrl+C for now
    docker run --init -d nodeapp
    Workaround: add 'tini' to your image
    RUN apk add --no-cache tini
    ENTRYPOINT ["sbin/tini", "--"]
    CMD ["node", "./bin/www"]
    Production: Your app captures SIGINT / SIGTERM for proper exit
1
// quit on ctrl-c when running docker in terminal
2
process.on('SIGINT', function onSigint () {
3
console.info('Got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
4
shutdown();
5
});
6
7
// quit properly on docker stop
8
process.on('SIGTERM', function onSigterm () {
9
console.info('Got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
10
shutdown();
11
})
12
13
// shut down server
14
function shutdown() {
15
// NOTE: server.close is for express based apps
16
// If using hapi, use `server.stop`
17
server.close(function onServerClosed (err) {
18
if (err) {
19
console.error(err);
20
process.exitCode = 1;
21
}
22
process.exit();
23
})
24
}
Copied!

Multi-stage builds

    New feature in Docker 17.06 (mid-2017)
    Build multiple images from one Dockerfile
    Those images can FROM each other
    COPY files between them
    Space and security benefits (included only what is needed for the app, nothing else)
    Great for "artifact only" (see above)
    Great for dev + test + prod
Example:
1
# a base stage for all future stages
2
# with only prod dependencies and
3
# no code yet
4
FROM node:10 as base
5
ENV NODE_ENV=production
6
WORKDIR /app
7
COPY package*.json ./
8
RUN npm install --only=production \
9
&& npm cache clean --force
10
ENV PATH /app/node_modules/.bin:$PATH
11
12
# a dev and build-only stage. we don't need to
13
# copy in code since we bind-mount it
14
FROM base as dev
15
ENV NODE_ENV=development
16
RUN npm install --only=development
17
CMD ["/app/node_modules/.bin/nodemon"]
18
19
FROM dev as build
20
COPY . .
21
RUN tsc
22
# you would also run your tests here
23
24
# this only has minimal deps and files
25
FROM base as prod
26
COPY --from=build /app/dist/ .
27
CMD ["node", "app.js"]
Copied!
    To build image from specific stage
    docker build -t myapp:prod --target prod .

Cloud Native application guidelines

    Follow 12factor.net principles, especially:
      Use environment variables for config. Docker and Compose are great at this with multiple options
      Log into stdout / stderr
      Pin all versions: images, packages installed locally and globally
      Graceful exit with SIGINT / SIGTERM
    Create a .dockerignore
      prevent bloat and unneeded files
        .git/
        node_modules/
        npm-debug/
        docker-compose*.yml
        any other file(s) or folder(s) that should not be a part of the image
      not needed but useful in image
        Dockerfile
        README.md

Compose Project Tips: Do's

1
version: '2.4'
2
# GOOD: stick with 2.x versions if you aren't using this yaml file with swarm/k8s
3
4
services:
5
6
ghost:
7
image: ghost:alpine
8
ports:
9
- 8090:2368
10
# GOOD: v2 compose file supports depends_on, it's useful for establishing
11
# dependencies. For true wait-for-it style, you need a healthcheck: and
12
# condition: object
13
depends_on:
14
- db
15
# GOOD:
16
volumes:
17
- ./content:/var/lib/ghost/content:delegated
18
environment:
19
database__client: mysql
20
database__connection__host: db
21
database__connection__user: root
22
database__connection__password: YOURDBPASSWORDhere
23
database__connection__database: ghost
24
25
db:
26
image: mysql:5
27
volumes:
28
#GOOD: created a named volume so our data is kept between docker-compose ups
29
- db:/var/lib/mysql
30
environment:
31
MYSQL_ROOT_PASSWORD: YOURDBPASSWORDhere
32
33
volumes:
34
db:
Copied!

Compose project tips: Don'ts

1
version: '2.4'
2
3
services:
4
5
my-ghost:
6
image: ghost:alpine
7
# BAD: just make your service name the desired DNS name. docker-compose
8
# handles the rest. If more then one replica are started, docker-compose
9
# creates aliases for them (DNSRR)
10
alias: ghost
11
# BAD: links are legacy. All compose services are added to a default
12
# bridge network and can freely talk via service name as their DNS name
13
links:
14
- db
15
ports:
16
- 8090:2368
17
# BAD: all ports are exposed in docker networks by default
18
# so this does nothing. Note it's still good to have EXPOSE
19
# in dockerfile for better documentation
20
expose:
21
- "2368"
22
# BAD: see networks at bottom. A default network is already created, making
23
# this one moot
24
networks:
25
- ghostnetwork
26
# BAD: if bind-mounting folders or files to host, always use relative file
27
# paths (starting with .). This makes your compose file reusable for others,
28
# and won't break if you move your project around.
29
volumes:
30
- /my/path/on/host/:/var/lib/ghost
31
environment:
32
database__client: mysql
33
database__connection__host: db
34
database__connection__user: root
35
database__connection__password: YOURDBPASSWORDhere
36
database__connection__database: ghost
37
38
db:
39
image: mysql:5.7
40
# BAD: this is rarely needed. You can use docker-compose commands that
41
# reconize service names, like `docker-compose logs db` or
42
# `docker-compose exec db bash` so you rarely need to control container
43
# directly from docker CLI (which is why people usually manually set
44
# container_name
45
container_name: db
46
# BAD: see my-ghost:links above
47
links:
48
- my-ghost
49
# BAD: see networks at bottom. A default network is already created, making
50
# this one moot
51
networks:
52
- ghostnetwork
53
volumes:
54
# BAD: don't bind-mount databases to host OS. You'll get bad performance
55
# and many times it won't even work. Best to use named volumes.
56
- ./database:/var/lib/mysql
57
environment:
58
MYSQL_ROOT_PASSWORD: YOURDBPASSWORDhere
59
60
volumes:
61
db:
62
# BAD: no need to set driver local, it's default
63
driver: local
64
65
networks:
66
# BAD: no need to create a network if only one is needed. By default
67
# compose creates a bridge network for all services to connect to
68
# custom networks are only needed if you need more then one, or special
69
# drivers or settings are needed
70
ghostnetwork:
71
driver: bridge
Copied!

node_modules in Images

    Images should not be build with node_modules from host, some NPM packages are build when they're installed for specific architecture (for example, node-gyp).
    Add .gitignore to exclude node_modules. This will have added benefit of not sending unnecessary payload as context when image builds.

Startup order and dependencies

    Problem: multi-service apps start out of order, node might exit or cycle
    Multi container apps need:
      dependency awareness
      name resolution (DNS)
      connection failure handling
    'depends_on' (from compose.v2): when "up X", start Y first
      Fixes name resolution issues with "can't resolve "
      Only for Compose, not for orchestration
      compose V2: works with healthchecks like a "wait for script"
    Connection failure handling
      restart: on-failure
        good: helps slow db startup and NodeJS failing. Better: 'depends_on'
        bad: could spike CPU with restart cycling
      Solution: build connection timeout, buffering, retries and exponential backoffs in your app

Example of the compose with healthchecks

1
version: '2.4'
2
3
services:
4
5
frontend:
6
image: nginx
7
depends_on:
8
api:
9
# this requires a compose file version => 2.3 and < 3.0
10
condition: service_healthy
11
12
api:
13
image: node:alpine
14
healthcheck:
15
test: curl -f http://127.0.0.1
16
depends_on:
17
postgres:
18
condition: service_healthy
19
mongo:
20
condition: service_healthy
21
mysql:
22
condition: service_healthy
23
24
postgres:
25
image: postgres
26
healthcheck:
27
test: pg_isready -U postgres -h 127.0.0.1
28
29
mongo:
30
image: mongo
31
healthcheck:
32
test: echo 'db.runCommand("ping").ok' | mongo localhost:27017/test --quiet
33
34
mysql:
35
image: mysql
36
healthcheck:
37
test: mysqladmin ping -h 127.0.0.1
38
environment:
39
- MYSQL_ALLOW_EMPTY_PASSWORD=true
Copied!

Templating

A relatively new and lesser-known feature is Extension Fields, which lets you define a block of text in Compose files that is reused throughout the file itself. This is mostly used when you need to set the same environment objects for a bunch of microservices, and you want to keep the file DRY:
1
version: '3.4'
2
3
x-logging:
4
&my-logging
5
options:
6
max-size: '1m'
7
max-file: '5'
8
9
services:
10
ghost:
11
image: ghost
12
logging: *my-logging
13
nginx:
14
image: nginx
15
logging: *my-logging
Copied!
You'll notice a new section starting with an x-, which is the template, that you can then name with a preceding & and call it from anywhere in your Compose file with * and the name. Once you start to use microservices and have hundreds or more lines in your Compose file, this will likely save you considerable time and ensure consistency of options throughout.

Avoiding devDependencies in Production

    Multi-stage Dockerfile can solve this
    prod stages: npm i --only=production
    dev stages: npm i --only=development
    Use npm ci to speed up builds
    Ensure NODE_ENV is set

Dockerfile documentation

    Document every line that isn't obvious
    FROM stage, document why it's needed
    COPY = don't document
    RUN - maybe document
    Add LABELs
      LABEL has OCI standard now
      LABEL org.opencontainers.image.
      Use ARG to add info to labels like build date or git commit
      Docker Hub has built-in env.vars for use with ARGs
    Inlcude "RUN npm config list" to get information about NPM / Node in the logs

Dockerfile Healthchecks

    Always include HEALTHCHECK
    Docker run and docker-compose: info only
    Docker Swarm: Key for uptime and rolling updates
    Kubernetes: Not used, but helps in others making readiness / liveness probes
1
# option 1, using curl to GET the default app url
2
# NOTE: ensure curl is installed in image
3
HEALTHCHECK --interval=5m --timeout=3s \
4
CMD curl -f http://localhost/ || exit 1
5
6
# option 2, using curl to GET a custom url with app logic
7
# NOTE: ensure curl is installed in image
8
HEALTHCHECK CMD curl -f http://localhost/healthz || exit 1
9
10
# option 3, a custom code healthcheck that could
11
# do a lot more things then a simple curl
12
# or simply avoid needing curl to begin with
13
HEALTHCHECK --interval=30s CMD node hc.js
Copied!
Last modified 1yr ago