在Docker中使用Node,中间遇到了相当多的问题,这里就简单记录下,以防忘记。下述的所有范例都是使用typescript进行逻辑编写的,并在Docker中进行编译制作镜像的,周知。
在Dockerfile里的npm安装记得要加上--unsafe-perm
,具体可以看:grpc/grpc-node#604。
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}
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 /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,如果在构建过程中还要用到一些其他东西的话,效果(体积变化)就会非常明显。
node作为一个单进程单线程的应用程序,在利用CPU上实在是不行,所以就需要一些外部程序的辅助来充分利用物理CPU。一般有两个解决方案。下述解决方案中的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
这个方案比较粗暴,之前提到的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;
}
}
在容器内运行的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