1. 为什么选择Docker来部署这套Web应用
很多开发者第一次接触Docker,是因为被"环境不一致"这个问题折磨得够呛。本地跑得好好的Vue前端、Node后端、MongoDB数据库,一放到服务器上就各种报错——Node版本不对、MongoDB连接超时、Nginx配置路径写错。这套组合(Vue + Node + MongoDB)是目前非常典型的前后端分离架构,也是Docker最能发挥价值的场景之一。
Docker解决的核心问题不是"让应用跑起来",而是"让应用在任何地方都以同样的方式跑起来"。它把应用和它的运行环境打包成一个镜像,镜像里包含了操作系统层、运行时、依赖库、配置文件,甚至环境变量。你把这个镜像丢到任何装了Docker的机器上,它跑出来的行为都是一致的。
这套Web应用的部署涉及三个独立服务:前端静态资源、后端API服务、数据库。传统部署方式下,你需要手动在服务器上装Node、装MongoDB、配Nginx、设开机自启,每一步都可能出问题。用Docker Compose编排之后,三个服务通过一个配置文件定义清楚,一条命令全部拉起,服务之间的网络、依赖关系、数据持久化都由Docker管理。
这篇文章适合已经能独立写出Vue前端和Node后端、但对Docker部署还不太熟悉的开发者。我会从镜像构建的细节讲起,把每个配置项为什么这么写说清楚,再分享几个我在实际部署中踩过的坑。读完之后,你应该能独立把这套架构部署到任何一台Linux服务器上。
2. 三个服务的镜像构建策略与Dockerfile细节
2.1 Vue前端:多阶段构建是必选项
Vue项目最终产物是静态文件(HTML、CSS、JS),不需要Node运行时。但构建过程需要Node环境。如果你直接在服务器上装Node然后npm run build,会有两个问题:一是服务器上多了一套Node环境,二是构建产物和构建环境耦合。
多阶段构建的思路是:第一阶段用Node镜像完成构建,第二阶段用Nginx镜像只拷贝构建产物。这样最终镜像里只有Nginx和静态文件,体积从几百MB降到几十MB。
# 第一阶段:构建 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --registry=https://registry.npmmirror.com COPY . . RUN npm run build # 第二阶段:运行 FROM nginx:1.25-alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这里有几个细节值得说。npm ci比npm install更适合CI/CD场景,它严格按照package-lock.json安装,不会因为版本范围导致每次构建结果不同。--registry参数指向国内镜像源,构建速度会快很多,这个在实际部署中很关键,否则npm ci可能卡几分钟。
Nginx配置里必须处理Vue Router的history模式。如果前端用了history模式,直接访问/user/profile这种路径,Nginx会去找对应的文件,找不到就404。需要加一段try_files:
server { listen 80; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意proxy_pass后面的backend:3000,这里的backend是Docker Compose里定义的服务名,Docker内置的DNS会自动解析。这就是为什么用Docker Compose比手动docker run方便——服务之间可以直接用服务名通信,不用管IP。
2.2 Node后端:生产环境镜像的瘦身技巧
Node后端的Dockerfile看起来简单,但有几个容易忽略的点。首先是NODE_ENV=production,这个环境变量会影响很多库的行为,比如Express会关闭调试输出,某些库会跳过开发依赖。其次是npm ci --only=production,只装生产依赖,镜像能小不少。
FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production --registry=https://registry.npmmirror.com COPY . . ENV NODE_ENV=production EXPOSE 3000 USER node CMD ["node", "server.js"]USER node这一行很多人会漏掉。Node官方镜像里默认有个node用户,用它来运行应用比用root安全。如果应用被攻破,攻击者拿到的只是普通用户权限,不能直接改系统文件。这个细节在生产环境很重要。
还有一个坑:如果你用了nodemon或者ts-node这类开发工具,它们属于devDependencies,--only=production不会安装。所以启动命令必须是node server.js,不能是nodemon server.js。我见过有人本地开发用nodemon,Dockerfile里也写nodemon,结果生产镜像里根本没这个命令,容器起来就退出。
2.3 MongoDB:用官方镜像还是自己构建
MongoDB直接用官方镜像就行,没必要自己构建。但有几个配置必须做。第一是数据持久化,容器删了数据不能丢,需要挂载volume。第二是认证,默认MongoDB没有密码,暴露到公网就是灾难。第三是初始化脚本,第一次启动时自动创建数据库和用户。
mongo: image: mongo:6.0 restart: always environment: MONGO_INITDB_ROOT_USERNAME: admin MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} MONGO_INITDB_DATABASE: myapp volumes: - mongo_data:/data/db - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro networks: - app-networkMONGO_INITDB_ROOT_USERNAME和MONGO_INITDB_ROOT_PASSWORD只在第一次启动时生效,如果volume里已经有数据了,这两个环境变量会被忽略。所以如果你改了密码,要么删掉volume重新初始化,要么进容器手动改。这个机制很多人不知道,改了密码发现没生效,排查半天。
init-mongo.js是初始化脚本,容器第一次启动时会自动执行。里面可以创建应用专用的数据库用户,而不是直接用root账号连数据库:
db = db.getSiblingDB('myapp'); db.createUser({ user: 'appuser', pwd: 'apppassword', roles: [{ role: 'readWrite', db: 'myapp' }] });用专用用户而不是root,是最小权限原则的体现。应用只需要读写自己的数据库,不需要管理员权限。
3. Docker Compose编排:服务依赖与网络配置
3.1 服务启动顺序与健康检查
Docker Compose默认不保证启动顺序,depends_on只保证容器启动的先后,不保证服务真正可用。Node后端启动时如果MongoDB还没准备好接受连接,就会报错退出。这个问题在第一次部署时特别常见。
解决方案是加健康检查。MongoDB官方镜像自带mongosh,可以用它来检测:
mongo: healthcheck: test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet interval: 10s timeout: 5s retries: 5 start_period: 30s backend: depends_on: mongo: condition: service_healthystart_period是给MongoDB留的初始化时间,第一次启动要建数据目录、初始化用户,可能需要十几秒。retries: 5配合interval: 10s,最多等50秒,一般够用。
Node后端这边,如果连接失败就退出,Docker的restart: always会自动重启容器。但更好的做法是在代码里做重试。比如用Mongoose连接时:
const connectWithRetry = () => { mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 5000, }).catch(err => { console.error('MongoDB连接失败,5秒后重试', err.message); setTimeout(connectWithRetry, 5000); }); }; connectWithRetry();这样即使MongoDB晚几秒启动,Node服务也能自己恢复,不用依赖Docker重启。
3.2 网络隔离与端口暴露策略
Docker Compose默认会创建一个bridge网络,所有服务在同一个网络里,可以通过服务名互相访问。但默认情况下,所有端口都会暴露到宿主机。这有两个问题:一是MongoDB的27017端口暴露到公网非常危险,二是端口冲突。
正确的做法是只暴露必要的端口。前端Nginx需要暴露80或443给外部访问,Node后端只需要在内部网络里被Nginx访问,MongoDB只需要被Node访问。所以:
services: frontend: ports: - "80:80" backend: expose: - "3000" mongo: expose: - "27017"ports是把容器端口映射到宿主机,expose只是声明容器端口对内部网络可见,不映射到宿主机。这样MongoDB和Node都只能通过Docker内部网络访问,外部完全碰不到。
如果你需要从宿主机直接连MongoDB调试,可以临时用docker exec -it mongo mongosh进容器操作,不需要暴露端口。
3.3 环境变量管理与敏感信息处理
数据库密码、API密钥这些敏感信息不能硬编码在docker-compose.yml里,否则提交到代码仓库就泄露了。Docker Compose支持.env文件,把敏感信息放在里面,.env加入.gitignore。
# .env MONGO_PASSWORD=your_strong_password_here JWT_SECRET=your_jwt_secret_here然后在docker-compose.yml里引用:
environment: MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} JWT_SECRET: ${JWT_SECRET}Node后端连接MongoDB的URI也要用环境变量拼:
environment: MONGO_URI: mongodb://appuser:${MONGO_APP_PASSWORD}@mongo:27017/myapp?authSource=myapp注意authSource=myapp,因为应用用户是在myapp数据库里创建的,认证也要在这个数据库里做。如果不加这个参数,MongoDB会默认在admin数据库里认证,然后报认证失败。这个坑我踩过,排查了很久才发现是authSource的问题。
4. 数据持久化与备份恢复的实操方案
4.1 Volume vs Bind Mount的选择
Docker有两种数据持久化方式:volume和bind mount。Volume由Docker管理,存在/var/lib/docker/volumes/下,跨平台一致,性能好。Bind mount直接把宿主机目录挂到容器里,方便直接查看文件,但依赖宿主机的目录结构。
MongoDB的数据目录建议用volume,因为Docker对volume做了优化,而且不会因为宿主机目录权限问题导致MongoDB启动失败。我见过用bind mount挂载MongoDB数据目录,结果因为宿主机目录属主是root,MongoDB容器里的mongodb用户没权限写,启动直接报错。
volumes: mongo_data: driver: local如果确实需要直接访问数据文件,可以用docker cp从容器里拷出来,或者用mongodump导出。
4.2 定期备份的自动化脚本
生产环境必须做定期备份。MongoDB官方提供了mongodump工具,可以在容器里执行:
#!/bin/bash BACKUP_DIR=/backup/mongo DATE=$(date +%Y%m%d_%H%M%S) docker exec mongo mongodump \ --username admin \ --password ${MONGO_PASSWORD} \ --authenticationDatabase admin \ --out /tmp/backup_${DATE} docker cp mongo:/tmp/backup_${DATE} ${BACKUP_DIR}/ docker exec mongo rm -rf /tmp/backup_${DATE} # 删除7天前的备份 find ${BACKUP_DIR} -type d -mtime +7 -exec rm -rf {} \;这个脚本可以加到crontab里每天执行。注意--authenticationDatabase admin,因为用的是root账号,认证在admin库。备份文件拷出来后要删掉容器里的临时文件,否则容器磁盘会越来越大。
恢复的时候用mongorestore:
docker cp ${BACKUP_DIR}/backup_20240101_120000 mongo:/tmp/restore docker exec mongo mongorestore \ --username admin \ --password ${MONGO_PASSWORD} \ --authenticationDatabase admin \ --drop \ /tmp/restore--drop参数会先删除现有集合再恢复,确保数据完全一致。如果不加,恢复时会追加数据,可能导致重复。
4.3 数据迁移的注意事项
从开发环境迁移到生产环境时,数据库连接字符串会变。开发环境可能是mongodb://localhost:27017/myapp,生产环境是mongodb://appuser:password@mongo:27017/myapp?authSource=myapp。这个变化通过环境变量控制,代码里不要写死。
另外,MongoDB的版本要一致。开发用6.0,生产也用6.0。大版本升级(比如5.0到6.0)可能有兼容性问题,需要先测试。Docker镜像的tag要写具体版本,不要用latest,否则某天重新构建时拉到了新版本,可能就跑不起来了。
5. 部署上线的完整流程与常见故障排查
5.1 从零到上线的操作步骤
假设你有一台全新的Linux服务器,装了Docker和Docker Compose,部署流程如下:
- 把代码克隆到服务器,进入项目根目录。
- 创建
.env文件,填入数据库密码、JWT密钥等敏感信息。 - 执行
docker compose build,构建前端和后端镜像。第一次构建会比较慢,因为要下载基础镜像和npm依赖。 - 执行
docker compose up -d,后台启动所有服务。 - 执行
docker compose ps,检查所有容器是否都是Up状态。 - 执行
docker compose logs -f backend,查看后端日志,确认MongoDB连接成功。 - 浏览器访问服务器IP,确认前端页面正常加载,API请求正常返回。
如果第6步看到MongoServerError: Authentication failed,检查.env里的密码和docker-compose.yml里的MONGO_URI是否一致,以及authSource参数是否正确。
5.2 容器启动失败的排查链路
容器起不来,第一步是看日志:docker compose logs <service_name>。如果日志里没有有用信息,看容器状态:docker compose ps -a,看退出码。退出码137通常是内存不足被OOM Killer杀了,退出码1是应用报错。
如果容器一直在重启,用docker compose up <service_name>前台启动,这样能看到完整的输出。或者用docker run -it <image> sh进镜像里手动执行启动命令,看报什么错。
网络问题排查:docker compose exec backend ping mongo,如果能ping通,说明网络没问题。如果ping不通,检查两个服务是否在同一个网络里。docker network ls可以看到Compose创建的网络,docker network inspect <network_name>可以看到哪些容器接入了。
端口问题排查:docker compose port frontend 80可以看到宿主机上实际映射的端口。如果显示的不是80,说明80被占用了,Compose自动换了端口。用netstat -tlnp | grep 80看谁占了80端口。
5.3 性能调优的几个实用参数
Node后端在生产环境需要调几个参数。NODE_OPTIONS=--max-old-space-size=512限制Node堆内存,防止内存泄漏把服务器拖垮。UV_THREADPOOL_SIZE=4根据CPU核心数调整线程池大小,默认是4,如果服务器核心多可以调大。
MongoDB这边,--wiredTigerCacheSizeGB控制WiredTiger引擎的缓存大小,默认是物理内存的一半。如果服务器还跑了其他服务,可以调小一点,比如--wiredTigerCacheSizeGB 1。
Nginx的worker_processes设为auto,worker_connections设为1024,对于一般Web应用够用了。如果并发高,可以开gzip压缩,减少传输体积:
gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1000;5.4 我踩过的三个坑
第一个坑是时区问题。容器默认用UTC时间,Node里new Date()出来的时间比北京时间少8小时。解决方案是在Dockerfile里设ENV TZ=Asia/Shanghai,或者挂载宿主机的/etc/localtime。我建议用环境变量,更干净。
第二个坑是文件上传的路径。Node后端如果把上传的文件存在容器里的/app/uploads,容器重建后文件就丢了。正确做法是把上传目录挂载到volume或者宿主机目录:
backend: volumes: - uploads_data:/app/uploads第三个坑是前端API地址写死。Vue项目里如果axios.defaults.baseURL写的是http://localhost:3000,部署到服务器后浏览器会去访问用户自己的localhost,而不是服务器。正确做法是用相对路径/api,让Nginx代理转发。或者用环境变量在构建时注入,但相对路径更简单可靠。
6. 日常运维:日志、更新与监控
6.1 日志收集与查看技巧
Docker默认把容器日志存在/var/lib/docker/containers/下,用docker compose logs查看。但默认的json-file驱动不会自动轮转,日志会一直涨。需要在docker-compose.yml里配置:
services: backend: logging: driver: "json-file" options: max-size: "10m" max-file: "3"这样每个日志文件最大10MB,最多保留3个,总共不超过30MB。对于大多数应用够用了。
查看日志时,docker compose logs -f --tail=100 backend可以只看最后100行并持续跟踪。docker compose logs --since 30m backend看最近30分钟的日志。这些参数在排查问题时很实用。
6.2 滚动更新与回滚
更新应用时,不要直接docker compose down再up,这样服务会中断。正确的做法是重新构建镜像,然后docker compose up -d,Compose会检测到镜像变化,重建对应的容器。但默认是停止旧容器再启动新容器,还是有短暂中断。
如果要零中断,需要配合Nginx的upstream和健康检查,或者用Docker Swarm的滚动更新。对于一般项目,短暂中断可以接受的话,docker compose up -d就够了。
回滚的话,把代码切回上一个版本,重新docker compose build && docker compose up -d。所以每次上线前要打tag,方便回滚。
6.3 资源监控的简易方案
docker stats可以实时看容器的CPU、内存、网络IO。但这个命令是交互式的,不适合长期监控。可以用docker stats --no-stream输出一次快照,配合cron定期记录。
更完整的方案是部署Prometheus + Grafana,但这对于小项目有点重。一个折中方案是用cAdvisor,它专门收集容器指标,自带Web界面:
cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro ports: - "8080:8080"访问http://服务器IP:8080就能看到所有容器的资源使用情况。注意这个端口不要暴露到公网,只在内网访问。
我个人在实际运维中的体会是,监控不用一开始就搞得很复杂。先用docker stats和日志,等真正遇到性能问题了再上专业工具。过早引入复杂的监控系统,维护成本可能比收益还高。