Docker部署monorepo项目

最近在使用pnpm和monorepo搭建一个前后端项目,为了方便部署,决定统一走Docker环境,本文主要记录整个过程中遇到的一些问题。

<!--more-->

整个项目位于github上面。

1. docker部署node开发环境

这一步可以参考nodejs官网上面的示例:Dockerizing a Node.js web app

下面整理一些相关的问题

1.1. 减少镜像体积

参考

查了一下发现Node官方的Docker镜像有三种版本

  • node:<version>,基于Debian,常规版本,体积最大,功能最全
  • node:<version>-shim,基于Debian,移除了很多公共软件包,只有最小的node运行环境
  • node:<version>-alpine,基于alpine,体积最小,某些C环境的软件可能不支持,但大部分兼容还是可以的

由于整个项目基于Node V16开发,打包时发现默认FROM node:16.13.0打出来的镜像有900多M,改成使用FROM node:16.13.0-alpine打出来的包只有100来M了

那node_modules里面的依赖文件该怎么处理呢?是打在镜像里面,还是在容器初始化的时候安装?

看起来出于镜像大小考虑,我们应该避免把node_modules放在镜像里面;换言之,镜像只保存package.json,依赖在容器初始化的时候安装,Dockerfile就类似于下面的结构

FROM node:16.13.0-alpine

WORKDIR /usr/src/app

COPY . .

EXPOSE 8080

CMD npm i && node index.js

但实际上,如果每个容器都要安装依赖,这在需要频繁修改镜像和启动容器的情况下,安装依赖就会耗费非常庞大的时间。

Docker在构建镜像时,会使用缓存layer,根据Building Efficient Dockerfiles - Node.js这篇文章的介绍,将node_modules放在镜像里面构建更为合理

FROM node:16.13.0-alpine

WORKDIR /usr/src/app

# 先安装依赖
COPY package*.json ./

RUN  npm install

# 在package.json不发生改变的情况下,前面RUN得到的结果都会被缓存,将会节省大量的构建和启动时间。
COPY . .

EXPOSE 8080

CMD node index.js

由于Docker分层构建,以及复用公共层缓存的设计,如果对于镜像的大小不是特别在意,感觉并不需要过分关注如何优化镜像大小这类问题。

1.2. 开发环境

在开发环境下,不可避免地需要频繁改动源码,如果每改动一下源码都要重新构建镜像启动容器,那还不如本地开发呢?

因此需要使用volume来挂载源代码,把docker容器想象成一台远程虚拟机,volume就像是使用remote ftp一样在本地更新文件,然后定期同步到远程机器上去,只不过这里是宿主机与docker容器的文件目录映射。

因此在开发环境下,源码应该是放在volume下频繁更新的,在运行容器的时候指定对应的volume

docker run -p 8080:8080  -v ~/docker_demo/server:/usr/src/app docker_test_server 

如果node index.js里面依赖了node_modules里面的模块的话,上面的命令运行并不会成功,参考stack overflow上面的这个问题。

原因是我们构建镜像时就安装了node_modules目录到/usr/src/app,然后在启动时容器将~/docker_demo/server这个目录声明为vloumn挂载到容器的/usr/src/app,相当于覆盖了容器中的/usr/src/app目录,因此在运行时,源码文件就找不到node_modules了。

因此,要解决这个问题,需要先将容器里面的/usr/src/app/node_modules映射到宿主机~/docker_demo/server/node_modules下,再将宿主机~/docker_demo/server整个目录映射到容器/usr/src/app下,可以通过Dockerfile中的VOLUME关键字实现

COPY package*.json ./

RUN  npm install

VOLUME [ "/usr/src/app/node_modules/" ]

COPY . .

这样,更新源码之后,重启镜像就可以看见最新效果了。如果是使用了代码热更新之类的工具,比如webpack、vite等前端项目,或者nodemon --watchsupervisor等工具,甚至连容器都不需要再重启了,可以复刻与本地基本一致的开发体验。

前面提到,node_modules里面的代码是放在镜像里面,通过虚拟卷的方式映射到本地目录,存在的问题

  • 在本地编辑器里面打开node_modules里面看不到具体的文件(不知道是不是我操作的问题)
  • 即使能看到文件,容器(Linux)与宿主机(Mac、Windows)可能不是同一个系统,文件可能也不一样

这可能影响eslint、代码补全等功能,因此宿主机本地也可以安装一份node_modules用于开发,然后将其放在.dockerignore里面,这样本地的node_modules文件就不会直接复制到镜像里面了

.dockerignore文件的语法格式与.gitignore基本一致

node_modules
npm-debug.log

1.3. 多容器之间通信

参考

既然都用上了Docker了,那整个开发环境都可以用Docker来实现。

现在我们用docker启动了多个容器,一个是node server服务,一个是monogo 数据库服务,一个前端vite服务,这三个服务之间肯定是需要通信的。

容器之间的通信通过network实现,参考:https://blog.csdn.net/weixin_39729840/article/details/109906703。因为在同一宿主机内的容器都接入了同一个网桥,因此这些容器之间就能够通过容器IP直接通信。

首先需要创建一个network

docker network create xxx_network

获得network并启动容器之后,可以看到每个容器分配到的ip

# 启动容器时指定network
docker run --network xxx_network xxx_image

# 查看network
docker network ls
docker network inspect xxx_network

可以看到network下具体的结果

然后通过ip和端口号访问目标容器即可

由于每个容器分配的虚拟IP貌似是会变化的,为了保证代码里面访问地址的可靠性,可以使用container name作为host域名来访问

// await mongoose.connect("mongodb://172.30.0.2/test"); // 通过ip链接其他容器里面的数据库
await mongoose.connect("mongodb://database/test"); // 通过容器名称访问

1.4. docker-compose

参考

容器逐渐变多的话,按个手动管理肯定是非常麻烦的,docker-compose可以让我们使用一份声明来件来管理镜像创建、容器启动、容器network等

目前用的最多的一个技巧是build字段:除了直接通过image字段指定镜像名称之外,也可以通过build字段指定要镜像打包上下文,这在monorepo项目里面非常有用,下面就会提到了

2. pnpm workspace管理monorepo

相较于npmyarnpnpm的有点在于下载速度非常快,同时解决了一些潜在的安全问题,如影子依赖等

目前越来越多的前端库使用pnpm monorepo的形式构建,比如vite等。

使用pnpm构建monorepo也非常简单,按照下面这三步来就可以

第一步:在项目根目录创建文件pnpm-workspace.yaml

packages:
  - 'packages/**'

第二步:安装依赖

# 安装全局依赖
pnpm i react -w

# 安装某个模块的局部依赖
cd packages/mod1
pnpm i axios -S

第三步:--filter将mod1添加为mod2的依赖

pnpm i mod1 -r --filter mod2

接下来就是愉快的编码环节了。

3. docker部署monorepo开发环境

参考

monorepo部署在Docker项目中,麻烦的还是老朋友node_modules。

不同的monorepo构建工具实现可能有差异,但总的结构大概都是一个项目根目录下的node_modules和各个package目录下面的node_modules,然后由根目录下的*-lock文件来锁定整个项目的依赖版本。

pnpm提供了fetch命令从pnpm-lock.yaml文件直接下载文件到.pnpm-store中,然后pnpm install --offline可以只从本地store目录中安装模块。

基于这个功能,我们可以在构建镜像时将lock文件的包打包到镜像中,然后复制整个monorepo项目,最后再执行install --offline,这样可以尽量复用已下载的模块,避免了install安装远程模块的耗时,相当于只有packages里面的模块会随volume更新

假设我们现在的monorepo项目结构是这样的

├── README.md
├── docker-compose.yml
├── node_modules
│   ├── @babel
│   ├── xxx
├── package.json
├── packages
│   ├── admin
│   ├── example-vue
│   ├── sdk
│   └── server
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json

对于admin这个管理后台项目而言,其Dockerfile可以按照如下形式书写

FROM node:16.13.0-alpine

WORKDIR /projects

COPY ./pnpm-lock.yaml ./

RUN npm install -g pnpm \
    && pnpm fetch

# 将整个项目复制下来
COPY . .

RUN pnpm install -r --offline

VOLUME ["/projects/node_modules/", "/projects/packages/admin/node_modules/", "/projects/.pnpm-store/"]

# 启动admin项目的开发环境
CMD cd packages/admin && npm run dev

在启动容器时,再将当前目录挂载到容器的/projects目录下面,就可以体验monorepo的Docker开发环境了。

由于存在多个项目,因此打包镜像就体现了docker-compose的优势

version: '3.7'
services:
  database:
    image: mongo
    restart: always
    volumes:
      - ~/data/db:/data/db
    networks:
      - app-network
  server:
    build:
      context: ./
      dockerfile: ./packages/server/Dockerfile # 每个package对应的Dockerfile
    volumes:
      - .:/projects
    ports:
      - 1546:1546
    tty: true
    networks:
      - app-network
  admin:
    build:
      context: ./
      dockerfile: ./packages/admin/Dockerfile
    volumes:
      - .:/projects
    ports:
      - 3000:3000
    tty: true
    networks:
      - app-network
networks:
  app-network:
    driver: bridge

在目录下面启动一下docker-compose up -d即可,非常方便

4. 小结

本文主要记录了使用Docker构建monorepo开发环境的过程,主要包括

  • Docker搭建Node开发和部署环境
  • pnpm workspace实现monorepo
  • Docker部署monorepo,主要借助了pnpm fetchpnpm install --offline这两个特点

由于之前对于Docker的了解基本上只停留在概念和可视化界面上,经过这次的操作,更加体会到了Docker的一些优点:这才叫打包嘛~

关于Docker,还有很多要学习的地方,比如Dockerfile优化构建、私有Docker镜像,k8s等等,接下来有空的话再继续学习吧。