Table of Contents

1. 前言

在Docker中使用Node,中间遇到了相当多的问题,这里就简单记录下,以防忘记。下述的所有范例都是使用typescript进行逻辑编写的,并在Docker中进行编译制作镜像的,周知。

2. 镜像制作

2.1 npm

在Dockerfile里的npm安装记得要加上--unsafe-perm,具体可以看:grpc/grpc-node#604

2.2 镜像制作

Docker的COPY命令是将命令对应的文件夹的所有内容拷贝到目标位置,而不包含命令中的文件夹本身,这是必须先了解的基础。

一般来说node项目的资源都会比较多比较散,不会像go应用程序一样build完成之后就是一个binary文件,node会有很多零碎的文件和代码都必须拷贝到镜像里。这里就需要先制作一个context。

假设项目的文件夹结构如下:

/- 
 | bash /-              # bash脚本
 |       | compose.sh   # 制作compose配置文件的脚本
 |       | docker.sh    # 制作镜像的脚本
 | build                # 编译完成的js文件
 | node_modules         # npm包
 | schema               # sql文件
 | src                  # typescript源码
 | .gitignore
 | Dockerfile
 | package.json
 | config.yml
 | README.md
 | tsconfig.json
 | tslint.json
 | yarn.lock

则可以使用如下脚本制作一个context子集,并进行镜像制作:

#!/usr/bin/env bash

FULLPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd ${FULLPATH}/..

VERSION=`cat ./package.json | jq -r '.version'`

# prepare docker context
rm -rf ./docker                 # 删除之前的context,如果有的话
mkdir -p ./docker/context       # 制作context文件夹
mkdir -p ./docker/context/pm2   # 制作PM2日志文件夹,后面会说到
cp -r \                         # 拷贝制作镜像需要的资源到context内
    src \
    config.yml \
    package.json \
    tsconfig.json \
    tslint.json \
    yarn.lock \
    ./docker/context

# build image
docker build \
    --no-cache \
    --tag your_app_name:${VERSION} \
    --file ./Dockerfile \
    ./docker

# remove images without tags
docker rmi $(docker images | awk '/^<none>/ {print $3}')  # 当制作tag重复的镜像时,这个命令就很有用

# remove tmp file
rm -rf ./docker

# push image
ORIGIN_TAG="you_app_name:${VERSION}"
TARGET_TAG="your_dockerhub_account/you_app_name:${VERSION}"
docker tag ${ORIGIN_TAG} ${TARGET_TAG}
docker push ${TARGET_TAG}

2.3 镜像stage

node的镜像在制作过程中,需要一些命令进行辅助,而这些命令在基准的node镜像上是不存在的,因此就需要在Dockerfile中先npm安装它们。比如说typescript、yarn等。而这些安装行为都会显著增大镜像的体积,因此这里就需要使用到按stage进行构建的技术。

官方文档在:Use multi-stage builds

先放一个例子:

FROM node:10.16.3-alpine as builder

WORKDIR /opt

COPY ./context ./

RUN npm i typescript -g --unsafe-perm && \
    npm i --only=prod --unsafe-perm --loglevel verbose && \
    tsc

FROM node:10.16.3-alpine

WORKDIR /app

COPY --from=builder /opt .

ENTRYPOINT ["node", "./build/index.js"]

该Dockerfile的上半部分将./context下的所有内容拷贝到/opt这个工作路径下。然后安装了typescript,并根据package.json的内容进行npm包的安装。接下来使用刚才安装好的typescript命令tsc根据tsconfig.json的配置将src文件夹下的源码编译为build文件夹下的js源码。

从下半部分的FROM node:10.16.3-alpine开始,起了一个干净的node镜像,并将builder这个阶段做好的内容从builder阶段的/opt文件夹下拷贝到/app下。这里仍旧需要注意COPY命令是不会拷贝目标文件夹自身的,只会拷贝文件夹下的内容。这样制作完成的镜像中就不会包含之前安装的typescript了。

这个例子简单了点,只有一个typescript,如果在构建过程中还要用到一些其他东西的话,效果(体积变化)就会非常明显。

3. 集群处理及反向代理

node作为一个单进程单线程的应用程序,在利用CPU上实在是不行,所以就需要一些外部程序的辅助来充分利用物理CPU。一般有两个解决方案。下述解决方案中的Nginx跑在容器里或跑在主机上都是可以的,没有任何区别。

3.1 pm2 + nginx

这个解决方案只需要启动一个应用程序容器,在容器内使用PM2对应用程序进行cluster化,由PM2监听单个端口并转发所有的进入请求。在应用程序容器之外,由Nginx处理所有到达主机的请求。

这里就涉及到在容器内使用PM2。首先需要安装PM2,需要在之前范例中的Dockerfile中的COPY --from=builder /opt .之后添加一行:RUN npm i pm2 -g --unsafe-perm --loglevel verbose

然后在容器运行的ENTRYPOINT上,需要修改成:ENTRYPOINT ["pm2-runtime", "start", "./build/index.js"]。这里注意,启动命令中使用的不是pm2而是pm2-runtime。这个命令是为了在容器内使用而专门特化出来的,普通的pm2命令在启动后就会转后台,导致容器退出。

此外,在使用时可以附加一些参数:

docker run -d -it --name your_app_name \
    -p 3000:3000 \
    -v dir_of_host:/app/pm2 \                       # 映射到主机上的日志文件路径
    your_app_name:version \
    --name=your_app_name_in_pm2 \
    --instances=max \                               # 以cluster模式启动pm2,并按CPU数量启动node应用的进程
    --output=/app/pm2/your_app_name.stdout.log \    # 输出日志到pm2文件夹下,也就是之前在做context时特地做出来的文件夹
    --error=/app/pm2/your_app_name.stderr.log 

3.2 docker-compose + nginx

这个方案比较粗暴,之前提到的Dockerfile不需要改动,容器仍旧只有一个进程一个线程,容器本身并不做任何改动。而是使用docker-compose命令启动多个容器,然后在Nginx中配置upstream来进行反向代理负载均衡。

使用如下bash脚本来生成compose配置文件:

#!/usr/bin/env bash

FULLPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd ${FULLPATH}/..

VERSION=`cat ./package.json | jq -r '.version'`

CONF=${FULLPATH}/../compose.yml

PROCESS_COUNT=8

PORT_BASE=3000

COMPOSE_TEMPLATE="$(cat <<-EOC
version: "2.1"

networks:
  net:
    driver: bridge

services:
EOC
)"

function generate_compose() {
    OUTPUT=${COMPOSE_TEMPLATE}

    for (( i=1; i<=${PROCESS_COUNT}; i++ ))
    do
        ID=${i}
        PORT="$((PORT_BASE + ID))"

        SERVICE_TEMPLATE="$(cat <<-EOS
  you_app_name_${ID}:
    image: your_dockerhub_account/you_app_name:${VERSION}
    container_name: you_app_name_${ID}
    hostname: you_app_name_${ID}
    networks:
      - net
    ports:
      - ${PORT}:3000
    logging:
      driver: json-file
      options:
        max-size: 512m
    restart: always
    volumes:
      - dir_of_host:/app/pm2
    command: [
      "--name=your_app_name_in_pm2_${ID}",
      "--instances=max",
      "--output=/app/pm2/your_app_name_${ID}.stdout.log",
      "--error=/app/pm2/your_app_name_${ID}.stderr.log"
    ]
EOS
)"

        NL=$'\n'
        OUTPUT="${OUTPUT}${NL}${SERVICE_TEMPLATE}"
    done

    echo "${OUTPUT}" > ${CONF}
}

generate_compose

当然也可以编写一个脚本来制作nginx的配置文件,这里就简略点,直接改了:

upstream upstream_node {
    server 127.0.0.1:3001 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3004 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3005 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3006 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3007 max_fails=3 fail_timeout=60 weight=1;
    server 127.0.0.1:3008 max_fails=3 fail_timeout=60 weight=1;
}

server {
    listen 80;

    listen 443 ssl;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;

    ssl_certificate         cert/yourapp.youhost.com.key.pem;
    ssl_certificate_key     cert/yourapp.youhost.com.key;
    ssl_dhparam             cert/dhparam.pem;

    access_log /var/log/nginx/yourapp.youhost.com.access.log;
    error_log /var/log/nginx/yourapp.youhost.com.error.log;

    root /usr/share/nginx/html;

    index index.html index.htm;

    server_name yourapp.youhost.com;

    location / {
        proxy_set_header x-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header HOST $http_host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_redirect http:// https://;
        proxy_connect_timeout 240;
        proxy_send_timeout 240;
        proxy_read_timeout 240;
        proxy_pass http://upstream_node;
    }
}

4. 健康检查

在容器内运行的node程序,可以设置一个专门用来进行健康检查的端点,然后在运行时进行配置,这样docker就可以了解程序是否存活。在配合consul等服务发现程序使用时,也可以使用该端点。

应用程序代码改动:

app.use(async (ctx: Koa.Context, next) => {
    if (ctx.path === "/health") {
        ctx.status = 200;
        ctx.body = "OK";
    }
    return next();
});

Docker compose yml改动:

restart: always
healthcheck:
  test: wget http://127.0.0.1:3000/health -q -O - > /dev/null 2>&1
  interval: 10s
  timeout: 20s
  retries: 10

EOF