☰
Docker实战入门:用Node.js容器化部署从零到一
2026/10/21 17:14:20 网站建设 项目流程

1. 为什么总有人卡在入门第一步:先搞懂容器和镜像的关系

用Docker跑NodeJS程序,最容易被概念绊住。很多人一上来就照着教程敲命令,敲完发现根本不理解自己在做什么,遇到报错就彻底蒙圈。我当初入坑的时候也是这样,所以这篇笔记的开头,我想先花点篇幅把这层窗户纸捅破。

容器和镜像的关系,你可以直接理解成"做饭的菜谱"和"按菜谱做出来的菜"。镜像就是菜谱,里面写清楚了需要什么原料、按什么顺序操作、最后成品长什么样;容器就是照着菜谱实际做出来的那道菜,你可以同时做十份一模一样的,也可以把某一碗吃几口再倒掉重新做。Docker的镜像不可变,但它可以被反复实例化成无数个运行中的容器,每个容器之间互不干扰。

NodeJS程序跑在Docker里,本质上就是把你项目需要的运行时环境、依赖包、源码全部打包成一个镜像,然后让Docker帮你在隔离环境里把这个镜像跑起来。它解决的核心痛点是:你不需要在服务器上安装NodeJS,不需要手动执行npm install,不需要担心多项目之间的依赖冲突。换个机器、换个服务器,同一个镜像跑出来的结果完全一致。

再往深一层说,Docker之所以能做到这一步,依靠的是Linux内核的namespace和cgroup机制。namespace负责隔离进程、网络、文件系统这些视角,让容器里的进程以为自己独占了一台机器;cgroup则用来限制CPU、内存这些资源的使用量。这两个机制配合镜像的分层存储,才让"Docker和菜谱一样可复制"这件事成立。

不过在入门阶段,你不需要把这些内核机制吃透。你只需要建立两个条件反射:第一,镜像是静态的模板,容器是动态的运行实例;第二,容器删除后,里面生成的数据默认不会保留,除非你提前做了目录映射。这两个条件反射能帮你避开绝大多数新手会踩的坑。

2. Docker的安装与守护进程配置:不同系统下的实测差异

2.1 安装前先确认你的系统环境

安装Docker的第一步不是执行安装命令,而是确认你的操作系统类型和发行版本。我这边使用的是Ubuntu 20.04版本,这也是目前资料最全、遇到问题最容易搜到解决方案的版本。如果你用的是CentOS、Windows或macOS,安装路径会不同,但核心逻辑一致。

对于Linux系统,Docker官方提供了两种安装方式:直接使用apt或yum安装,以及使用官方安装脚本。网上很多教程推荐用官方脚本,一条命令搞定,但我不建议新手这么做。原因很简单:安装脚本会默默帮你配置好源、装好依赖、启用服务,一旦某个环节出了问题,你根本不知道去哪里排查。手动分步安装虽然多敲几条命令,但每一步都知道自己在干什么,出问题也知道往哪个方向查。

2.2 分步安装与配置过程记录

我用的是Ubuntu系统,安装过程如下:

# 更新软件源 sudo apt update # 安装依赖包,让apt可以通过HTTPS访问仓库 sudo apt install -y apt-transport-https ca-certificates curl software-properties-common # 添加Docker官方的GPG密钥 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - # 添加Docker稳定版仓库 sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" # 再次更新源,然后安装Docker sudo apt update sudo apt install -y docker-ce

安装完成后,先用sudo docker version确认Docker客户端和服务器版本都正常,再用sudo docker run hello-world跑一个测试容器验证安装是否成功。看到"Hello from Docker!"的输出,说明一切正常。

这里有个新手很容易忽略的细节:刚装好的Docker,每次执行命令都需要sudo,因为Docker守护进程默认以root权限运行,普通用户想调用它的接口必须提权。如果你不想每次都敲sudo,可以把当前用户加入docker组:

sudo usermod -aG docker $USER

执行完这条命令后,需要退出当前终端重新登录,或者重启系统,组权限才会生效。大部分教程会提这个操作,但没有明确说明"必须重新登录才能生效",导致很多人在新开的终端里测试docker ps时依然提示权限不足,误以为操作失败了。实测经验是:newgrp docker命令可以直接在同一个终端会话中激活新组,不用退出登录,更省事。

2.3 守护进程配置与常见环境变量

Docker的守护进程叫dockerd,它的配置文件放在/etc/docker/daemon.json。这个文件默认不存在,需要手动创建。我一开始跑通的第一个项目后,给daemon.json配了两个核心参数:

sudo tee /etc/docker/daemon.json <<-'EOF' { "registry-mirrors": ["https://docker.mirrors.example.com"], "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } } EOF

registry-mirrors用来配置镜像加速源。Docker Hub在国内的访问速度不稳定,拉取大镜像时经常卡在等待状态,配置加速源能大大缩短下载时间。选加速源时注意别选已经失效的,多试几个能访问的,自己ping通了再写进去。

log-driver和log-opts用来限制容器日志的体积。很多人的服务器磁盘被撑爆,就是因为容器日志无限增长,而他们根本没配置过这里。我把单容器日志上限设为100MB且最多保留3份,长期运行下来稳定可靠。

配置完成后执行sudo systemctl restart docker重启守护进程,然后用docker info查看配置是否生效。我见过有同学修改daemon.json后直接跑容器,结果发现配置没生效,仔细一查才发现没重启服务,白白浪费时间排查。

3. 准备一个NodeJS程序并编写Dockerfile

3.1 用最简Node程序做演示

为了把注意力集中在Docker操作本身,我准备了一个极简单的NodeJS应用:一个HTTP服务,监听端口返回当前系统时间。代码就三行内容,但足以cover镜像构建、容器运行、端口映射、日志查看这几个核心流程。

// app.js const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(`Hello Docker, 当前时间: ${new Date().toLocaleString()}`); }); server.listen(3000, () => { console.log('NodeJS服务已启动,监听端口 3000'); });
{ "name": "docker-nodejs-demo", "version": "1.0.0", "description": "Docker入门实战的NodeJS示例项目", "main": "app.js", "scripts": { "start": "node app.js" }, "dependencies": {} }

不用安装任何npm依赖,这样即使你本地没有NodeJS环境,也不影响后续步骤。实际项目中你会有express、axios、数据库驱动这些依赖,到时候把package.json换成你项目真实的文件,其余流程完全一致。

3.2 编写Dockerfile并理解每条指令的含义

Dockerfile是构建镜像的说明书,它的一条条指令就是"菜谱"的一步步操作。我这里写了一个最典型的NodeJS Dokerfile,并逐行解释为什么这么写:

# 指定基础镜像,相当于"从什么底盘开始改造" FROM node:18-alpine # 设置容器内的工作目录,后续命令都基于这个路径执行 WORKDIR /app # 把本地的package.json复制到容器工作目录 COPY package.json ./ # 安装项目依赖 RUN npm install --registry=https://registry.npmmirror.com # 把当前目录下所有文件复制到容器内 COPY . . # 声明容器对外提供服务所用的端口 EXPOSE 3000 # 容器启动时执行的命令 CMD ["npm", "start"]

FROM node:18-alpine这一行,选择node官方镜像的alpine版本,而不是默认的latest版本。alpine是Linux的一种极简发行版,体积通常在几十MB级别,比完整版Linux镜像小好几倍。镜像体积直接影响推送和拉取的时间,也会影响磁盘占用。NodeJS程序跑在alpine上几乎没有兼容性问题,所以我所有NodeJS项目都优先选alpine基础镜像。

WORKDIR /app这行决定了你后续所有指令的执行目录。我可以明确告诉你,标准实践是:先在容器里创建好工作目录并切换进去,再把代码放进去。这能避免一些COPY操作的路径混淆,也能保证基础镜像自带的默认工作目录不会干扰你的项目。

RUN npm install里的--registry参数是为国内网络环境准备的。npm官方源的下载速度在实际使用中经常遇到卡顿,我在Dockerfile里直接指定国内镜像源,构建镜像时不用再单独配置npm的registry文件,一次构建直接生效。如果你在海外部署或者网络速度稳定,可以去掉这个参数。

CMD ["npm", "start"]必须是JSON字符串数组格式,命令拆分成一个个参数,而不是CMD npm start这种字符串格式。数组格式直接作为exec形式执行,不会经过shell中转,这样容器收到的1号进程就是NodeJS本身,能正确处理信号。字符串格式会启动一个shell子进程,处理Ctrl+C或docker stop时可能出现信号传递问题,导致容器停止不干净。

3.3 构建镜像的完整流程与.tag规范

Dockerfile写好后,在项目目录下执行构建命令:

docker build -t nodejs-demo:1.0.0 .

-t参数的作用是给镜像打标,格式是"名称:版本号"。我见过很多人图省事只写-t nodejs-demo不写版本号,这样标签会自动变成latest。用latest本身没有错,但一旦项目多了,你已经无法从镜像列表里分清哪个对应哪次构建——相同名称的latest会被覆盖,旧版本镜像变成悬空镜像。从第一次构建就养成名称:版本号的习惯,后续上线发布维护会省很多事。

命令末尾的.是构建上下文路径,不是随便敲上去的。Docker会把.指向的目录整个发送给Docker守护进程,作为构建过程中COPY指令可用的文件来源。如果你不小心在这个目录里放了大型文件,构建过程会变慢,所以项目根目录最好放一个.dockerignore文件,把node_modules、.git、日志文件、临时目录排除掉:

node_modules .git *.log .tmp .DS_Store

构建过程中,每个RUN、COPY指令都会生成一个新的镜像层。RUN npm install这一层把依赖安装好,COPY . .这一层把项目源码放进去。之后你修改了代码重新构建,Docker发现前面几层没变,会直接复用缓存,只重新生成源码那一层,速度飞快。很多初学者不知道这个机制,每次改一行代码就重新ninstall所有依赖,白白浪费了缓存能力。核心经验是:依赖相关指令往下放,代码复制指令往上放。

构建完成后执行docker images查看镜像列表,你应该能看到nodejs-demo:1.0.0,并且SIZE在100MB上下。这个体积已经算很精简了,如果你用默认的node镜像,体积可能冲到900MB以上,这就是选择alpine版本的意义所在。

4. 容器加载运行与端口映射的完整链路

4.1 四种常见运行模式的取舍

镜像构建好了,真正运行容器的命令是docker run。这个命令的参数组合非常多,我先说最基础的一组,然后逐步展开:

docker run -d --name node-demo-container -p 8080:3000 nodejs-demo:1.0.0

拆开来看:

  • -d:后台运行模式(detached)。不加这个参数,容器会占用当前终端,Ctrl+C会终止容器进程。
  • --name node-demo-container:给容器起个名字。不加的话,Docker会生成一个随机的组合名,后期启动、停止、查看日志都很不方便。
  • -p 8080:3000:端口映射。左边8080是宿主机端口,右边3000是容器内端口。外部请求访问服务器的8080端口,Docker会把它转发到容器内的3000端口。这个方向不能搞反,搞反了你会以为容器没有启动成功。
  • nodejs-demo:1.0.0:指定使用哪个镜像来运行,这个就是刚才构建出来的镜像名和标签。

还有一种场景:你想临时跑个容器看看效果,完事就删掉,可以用--rm参数。它会在容器停止后自动删除容器文件,不留垃圾。适合测试镜像、调试Dockerfile时使用,省得反复手动清理容器。

如果你需要和容器里的进程交互,比如进到容器里敲命令查看环境,用-it参数:

docker exec -it node-demo-container /bin/sh

在容器里执行/bin/sh,就能进入一个交互式Shell。这个命令在排查容器内部问题时是生存技能级别的存在:比如代码报错、环境变量配置错误、文件缺失,先进来看一眼再下结论。

4.2 验证运行状态与访问服务

运行docker run后,先用docker ps查看容器状态。重点看STATUS列,显示"Up"说明运行正常;显示"Exited"说明进程退出了,用docker logs node-demo-container查看输出日志。我见过最多的退出原因,是端口被占用或代码在容器内抛异常。

docker ps默认只显示运行中的容器,要看所有容器(包括已停止的)需要加参数docker ps -a。这个区别极其基础,但很多新手的容器退出后便以为凭空消失了,一查docker ps没有输出就开始怀疑人生。

然后验证服务有没有真的跑通:

curl http://localhost:8080

返回"Hello Docker, 当前时间: ..."就成功了。如果你的curl命令没安装,直接打开浏览器访问http://localhost:8080是一样的效果。

有些网络环境里容器端口会映射失败,最典型的表现是浏览器迟迟打不开页面,但docker ps显示容器状态是Up。这种情况先用docker logs看容器内部有没有报错,如果没有报错,再用ss -tlnp检查宿主机端口是否真的在监听。排查链路大概是:容器状态 → 容器日志 → 宿主端口 → 防火墙规则,按这个顺序走基本不会迷失方向。

4.3 日志查看与容器生命周期管理

日志管理是日常运维里使用频率最高的操作之一。docker logs命令有几个实用参数:

# 查看全部日志 docker logs node-demo-container # 实时跟踪日志输出,类似tail -f docker logs -f node-demo-container # 只看最后50行 docker logs --tail 50 node-demo-container

NodeJS应用在容器内通过STDOUT输出内容,Docker会自动捕获这些输出并交给json-file日志驱动处理。前提是你不要在代码里把日志写到文件,而是用console.log输出到标准输出流,这样才能被Docker收集。很多后端服务默认用log4js写文件日志,放进容器里就看不到日志了,需要改成输出到stdout,或保留文件日志同时额外配置日志采集器。这里我强烈建议从第一天起就让所有应用日志走标准输出。

容器生命周期管理的常用命令一并列出来:

# 停止容器 docker stop node-demo-container # 启动已停止的容器 docker start node-demo-container # 重启容器(先stop再start,config不变) docker restart node-demo-container # 删除已停止的容器 docker rm node-demo-container # 强制删除运行中的容器 docker rm -f node-demo-container

docker stop默认会等待容器进程优雅退出,最多等10秒(可以加参数-t指定时间)。如果10秒后进程还没退出,Docker才会强制kill。这里有个值得注意的细节:NodeJS应用通过CMD指令直接启动,成为容器的1号进程后,docker stop发送SIGTERM信号,NodeJS默认会直接退出。但如果你的NodeJS代码里注册了对SIGTERM的监听做优雅关闭,那就必须确保在回调里调用process.exit(),否则进程永远不会结束,容器每次停止都要等满10秒。我在生产中遇到过好几次这种"stop不下去"的问题,最后都定位在优雅退出的回调没有主动退出进程。

5. 数据持久化与目录映射:容器生命周期之外的内容

5.1 一个容易踩坑的特性:容器删除后数据消失

反反复复提到容器是隔离的、可随意删除的,那容器里产生的数据呢?答案是:默认情况下一删就没。假设你的NodeJS应用往容器内的/app/data目录写了一个文件,然后你执行docker rm -f node-demo-container,那个文件会随容器一起消失得无影无踪。

这个特性在入门阶段看起来无关紧要,但如果你拿Docker跑数据库、跑文件存储类型的应用,必须当时就意识到需要把数据存到容器外面。解决手段就是"目录映射",官方术语叫挂载卷(volume)。

我用NodeJS和Docker组合的实际项目中,最常用的一种目录映射方式是bind mount:

docker run -d --name node-demo-container -p 8080:3000 -v /home/ubuntu/nodejs-demo/data:/app/data nodejs-demo:1.0.0

-v /宿主机路径:/容器内路径,把宿主机的/home/ubuntu/nodejs-demo/data目录绑定到容器的/app/data目录。容器内对这个目录的写入,实际会落到宿主机目录上,容器删了文件依然保留,下次用同样的参数运行新容器,数据无缝衔接。

还可以用命名卷(named volume)方式:

docker volume create mydata docker run -d --name node-demo-container -p 8080:3000 -v mydata:/app/data nodejs-demo:1.0.0

命名卷由Docker管理,路径不需要你关心,数据放在Docker的数据目录下。bind mount适合你需要直接查看或备份文件内容的场景,命名卷则更省心,不用管文件在哪。我的经验是:持久化的数据结构选命名卷,需要和宿主机共享修改选bind mount。

5.2 只读挂载与配置注入的落地方式

另一个挂载方向的细节是:你可以给挂载加上只读权限。比如你的应用只需要读取配置文件,不希望运行中被意外修改:

docker run -d --name node-demo-container -p 8080:3000 -v /home/ubuntu/nodejs-demo/config:/app/config:ro nodejs-demo:1.0.0

末尾的:ro把目录挂载为只读。容器内如果尝试写这个目录会直接报错,有效防止了"容器权限过大"这类问题。入门阶段可能用不上,但一旦你开始给自己的服务配置生产环境,这个参数会变成保护底线。

配置注入方面,环境变量也是Docker里跑应用最通用的传参方式:

docker run -d --name node-demo-container -p 8080:3000 -e NODE_ENV=production -e PORT=3000 nodejs-demo:1.0.0

NodeJS程序里通过process.env.NODE_ENV读取对应的值。这样做的好处是:镜像里放通用代码,环境差异全部在运行时用环境变量注入,同一份镜像可以在开发、测试、生产三个环境跑出不同的行为。这与"镜像构建一次、到处运行"的思想完全一致。

我踩过比较深的一个坑,是在Dockerfile里用ENV指令写了环境变量,后来发现改环境变量需要重新构建镜像,非常麻烦。后来改成运行时-e传参,镜像保持单一,环境差异全部外置,构建一次镜像可以开无数种配置的容器,才真正体会到这个设计的好处。

6. 常见报错与经典实用命令清单

6.1 三个高频报错的完整排查过程

场景一:Docker Hub拉取镜像超时或卡住

现象:执行docker pull node:18-alpine时进度条停滞不动,或者反复出现Error response from daemon: Get https://registry-1.docker.io/v2/: net/http: request canceled。

根因:默认的Docker Hub源在你的网络环境下访问不畅。解决方案是配置镜像加速源,前面daemon.json里已经处理过。配置完务必sudo systemctl restart docker,然后重新pull。如果还是不行,换个源的地址再试。记住:配置文件改完不重启,等于没改。

场景二:端口冲突导致容器无法启动

现象:docker run执行后立刻退出,docker logs node-demo-container显示Error: listen EADDRINUSE: Address already in use :::3000。

根因:宿主机上已有进程占用了3000端口,或者另一个容器在用同一端口。处理方法:

  • lsof -i:3000或ss -tlnp | grep 3000查看占用进程;
  • 杀掉占用进程,或换一个宿主机端口,比如-p 8081:3000。

如果你换了端口,记得curl测试的地址也跟着换,不要再用8080。这个坑我当时反复试了三遍才反应过来是自己在用旧端口访问。

场景三:docker rm删不掉容器,提示resource busy

现象:执行docker rm -f node-demo-container时报Error response from daemon: conflict: unable to remove repository reference之类信息。

根因:容器还在运行中,或者有依赖它的网络。更常见的是你忘了指定强制删除参数。处理方式用docker rm -f,确认无误后执行。如果docker ps里已经看不到容器,那就是已经删除成功了,不用反复操作。

这三个报错覆盖了网络、端口、生命周期三大核心痛点,把它们彻底搞清楚,你自己的Docker项目出现类似问题时就有一个清晰的排查思路。

6.2 日常使用频率最高的命令速查

在这里我汇总一份自己实测后最常用的docker命令列表,并按使用频率排序,方便到时候直接翻:

命令作用使用频率
docker ps -a查看所有容器极高
docker images查看本地镜像列表高
docker logs -f <容器名>实时查看容器日志高
docker exec -it <容器名> /bin/sh进入容器内部高
docker stop/start/restart <容器名>生命周期管理高
docker rm <容器名>删除容器(已停止)中
docker rm -f <容器名>强制删除容器中
docker rmi <镜像名>删除镜像中
docker build -t 名称:版本号 .构建镜像高
docker run -d --name 名称 -p 本机端口:容器端口 镜像名:版本号运行容器高
docker volume ls查看命名卷列表低
docker system df查看磁盘占用统计低

docker system df是我最常用来排查磁盘空间的命令,它会把镜像、容器、卷、构建缓存分别占了多大空间列举出来,一眼看出是谁吃掉了你服务器硬盘。清理资源时用docker system prune -a,它会删除所有不再使用的镜像、容器、网络和缓存,执行前记得确认没有正在使用的容器。这个命令在我磁盘告急时救过好几次命,但你要知道它的后果,别在生产环境随手执行。

6.3 让Docker命令少踩坑的若干趁手技巧

先养成给容器命名和给镜像打版本号的习惯。不命名、latest满天飞的坏习惯,一时很省事,等到容器多起来你就知道自己给自己挖了什么坑。每次都必须知道自己启动的是哪个容器、跑的是哪个版本。

再养成写.dockerignore文件的习惯。我见过一个项目因为忘了把node_modules放进忽略列表,构建时把几百MB的node_modules全部打包发送给守护进程,构建时间从30秒变成300秒,完全没必要。

还有一个少有人提及但极其实用的技巧:用别名简化高频命令。你的Shell配置里加一行:

alias dps='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'

之后每次敲dps,就直接输出容器名、运行状态、端口映射,省去默认docker ps那冗长的表格信息占用。刚开始用Docker的时候,我的终端全是长到不行的ps输出,这时候一个精简格式的别名是实实在在的效率提升。先跑通一个最简单的NodeJS程序,再慢慢扩展数据库、反向代理、多容器编排这些内容,Docker的世界会逐渐打开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询