"在我本地跑得好好的啊"——这句话在容器化之前可能是个梗,但上了Docker之后,本地和生产环境不一致的问题确实少了很多。不过Docker本身的坑,还是得一个一个趟过去。以下是我在把一个Express + TypeScript项目部署到Docker时遇到的问题和解决过程。
坑一:每次构建都重新安装依赖
最初的Dockerfile写法很朴素:COPY整个项目目录进去,然后RUN npm install,再RUN npm run build。问题是每次改一行业务代码,Docker构建都会重新执行npm install,因为COPY . .会让所有后续层缓存失效。一个项目依赖装完要3分钟,每次部署都等3分钟,完全不能接受。
解决方案是利用Docker的层缓存机制:先只COPY package.json和package-lock.json,RUN npm ci安装依赖,再COPY其余代码。这样只要依赖没变,npm ci那一层就会命中缓存,构建时间从3分钟降到20秒。
用npm ci而不是npm install
npm ci严格按照lock文件安装,速度更快且结果确定。npm install可能会更新lock文件,在CI/CD环境中可能导致不一致。
坑二:容器内时间不对
上线后发现日志时间全是UTC,跟实际时间差了8小时。排查后发现容器默认时区是UTC。解决方式是在Dockerfile里加一行:ENV TZ=Asia/Shanghai,再RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime。如果用的是alpine镜像,需要先apk add tzdata。
坑三:容器重启后日志没了
应用把日志写在容器内的/app/logs目录下,每次容器重启或更新镜像,旧容器销毁,日志就跟着没了。这个问题用Volume挂载解决:docker run的时候加-v /host/logs:/app/logs,日志文件就持久化到宿主机了。
更好的做法是直接把日志输出到stdout/stderr,然后用Docker的日志驱动(比如json-file或fluentd)统一收集。Node.js里把console.log的输出指向进程标准输出就行,别用fs.writeFile写本地文件。
坑四:健康检查没配好导致反复重启
在docker-compose.yml里配了healthcheck,curl命令检查/health接口。但Node.js应用启动需要几秒钟,healthcheck的interval设成了5秒、retries设成了2次,结果应用还没完全启动就被判定为unhealthy然后重启,陷入无限重启循环。
修改方案:加上start_period: 30s给应用足够的启动时间,interval调到15秒,retries改为3次。另外/health接口别做复杂逻辑(比如查数据库),直接返回200就行,数据库连接检查用单独的/readiness接口。
坑五:镜像体积过大
基于node:18构建的镜像将近1GB。改用node:18-alpine后降到了180MB左右。进一步优化是用多阶段构建:第一阶段用完整的node镜像做编译(TypeScript → JavaScript),第二阶段用alpine镜像只COPY编译产物和node_modules,最终镜像控制在120MB以内。
实用建议清单
Dockerfile里加上.dockerignore文件,排除node_modules、.git、测试文件等不需要进镜像的内容。生产环境用npm ci --omit=dev只装生产依赖。不要用root用户运行应用,Dockerfile里加USER node。设置合理的内存限制,Node.js默认堆内存上限约1.5GB,容器内存限制要大于这个值否则会被OOM Kill。