前言

“代码在我的电脑上明明能运行,换一台电脑却报错”,通常不是代码突然失效,而是两台机器的 Node.js 版本、依赖版本、系统库或配置 不一致。比如接手一个多年前的 Vue 2 项目时,项目可能要求 Node.js 16 和 npm 8,而本机已经升级到 Node.js 22。直接降级会影响其他项目,同时维护多套环境又容易互相污染。

Docker 的价值正在于此:它把应用及其运行条件组织成一个可分发的整体,并通过隔离机制让不同项目拥有各自的运行空间。

核心理解:Docker 交付的不只是代码,而是“应用 + 运行环境 + 启动方式”。 同一个镜像可以在不同机器上创建行为相对一致的容器,从而减少环境差异带来的部署问题。

本文从一次真实的 macOS Docker Desktop 实践出发,先用 hello-world 验证环境,再运行 Nginx 并把浏览器对 80 端口的请求反向代理到宿主机 Node.js 服务的 1314 端口,最后部署 MySQL 8.0。过程中还会重点解释容器名称冲突、端口映射、配置文件挂载以及镜像名后多写命令等常见问题。

1. Docker 解决了什么问题

1.1 从环境依赖到可交付单元

一个 Web 应用真正运行起来,往往需要代码之外的许多条件:特定版本的运行时、依赖包、系统动态库、环境变量、端口和启动命令。传统部署依赖人工编写文档并在服务器上逐项安装,时间久了很容易出现版本漂移。

Docker 将这些条件固化在镜像构建过程里。应用启动时,不需要重新“猜”一遍环境,而是基于已经准备好的镜像创建容器。它主要解决以下问题:

  • 环境一致性:开发、测试和部署尽量使用同一份镜像。
  • 依赖隔离:旧项目使用 Node.js 16,新项目使用 Node.js 22,两者可以各自在容器内运行。
  • 交付标准化:镜像、端口、挂载和环境变量共同描述服务如何启动。
  • 快速重建:容器出现问题时,可以删除旧实例并基于镜像重新创建。

Docker 常被称为容器虚拟化技术,但它与传统虚拟机并不完全相同。传统虚拟机通常包含完整的客户操作系统;容器更接近 操作系统级隔离,多个容器共享宿主内核,因此启动更快、额外开销更小。在 macOS 上,Docker Desktop 会在背后提供运行 Linux 容器所需的轻量 Linux 虚拟环境。

对比维度传统虚拟机Docker 容器
隔离对象完整操作系统进程、网络、文件系统等运行空间
启动速度通常较慢通常为秒级甚至更快
交付内容虚拟磁盘与系统分层镜像与启动配置
资源开销相对较高相对较低
典型用途运行不同操作系统、强隔离场景应用打包、开发环境与微服务部署

1.2 Image、Container 与 Registry

初学 Docker 时,最容易混淆的是镜像和容器。可以把 镜像(Image) 理解为只读的安装模板,把 容器(Container) 理解为由模板创建出来的运行实例。一个镜像可以创建多个相互独立的容器,就像同一份程序安装包可以安装出多个实例。

镜像决定“容器里有什么”,容器体现“应用此刻怎样运行”。 镜像通常由多个只读层组成;容器启动后会在其上增加可写层,用于保存该实例运行期间产生的变化。

此外,镜像仓库(Registry) 用于集中存放和分发镜像。Docker Hub 是常见的公共镜像仓库,docker pull nginx 就是在本地没有目标镜像或需要更新时,从仓库拉取 Nginx 镜像。

概念作用本次实践中的例子
镜像 Image创建容器的只读模板hello-world:latestnginx:latestmysql:8.0
容器 Container镜像的一个运行实例my-nginx-demomysql-demo
镜像仓库 Registry存储与分发镜像Docker Hub
数据卷或挂载 Volume/Mount将数据或配置放到容器生命周期之外将本机 nginx.conf 挂载到容器

2. 从 hello-world 跑通 Docker 工作链路

2.1 检查版本并验证安装

首先检查 Docker 客户端版本:

docker --version

--version 用于输出当前 Docker 版本。此次环境返回 Docker version 29.7.2, build a7dcaa6,说明命令行客户端已经安装。仅看到版本还不能证明守护进程可用,因此继续运行官方测试镜像:

docker run hello-world

在这里插入图片描述

docker run 表示基于某个镜像创建并启动一个新容器,其中 hello-world 是必填的镜像名。当本地没有该镜像时,Docker 会自动从默认仓库拉取。终端出现 Hello from Docker!,表示客户端、Docker daemon、镜像下载、容器创建和日志回传这条链路已经全部打通。

本次下载结果还出现了 arm64v8,说明 Docker 根据当前 Mac 的 CPU 架构选择了 ARM64 镜像变体。一次看似简单的命令,背后实际经历了以下过程:

Docker CLI
    ↓ 请求
Docker daemon
    ↓ 本地查找镜像;不存在则从 Docker Hub 拉取
hello-world 镜像
    ↓ 创建并启动
容器执行 /hello
    ↓ 输出完成后主进程退出
终端收到日志,容器进入 Exited (0)

这里的 Exited (0) 不是失败。容器的生命周期与其主进程绑定,hello-world 的主程序输出提示后正常结束,容器自然停止;退出码 0 恰好表示正常执行完毕。

2.2 查看镜像和全部容器

运行以下命令查看本地镜像:

docker images

在这里插入图片描述

该命令会列出镜像名称、标签、镜像 ID 和占用空间等信息。执行 hello-world 后,可以看到 hello-world:latest 已存在于本地,其中 latest 是未显式指定标签时使用的默认标签。

接着查看容器:

docker ps -a

在这里插入图片描述

docker ps 默认只显示正在运行的容器,-a--all 的缩写,表示同时显示运行中与已停止的容器。因此,即使 hello-world 已经结束,仍可以在列表中看到它的容器 ID、命令、状态和随机生成的名称。

重复执行 docker run hello-world 会创建第二个容器,而不是复用第一个容器。这正是实践记录中出现 compassionate_pikeelated_saha 两个已退出容器的原因。
在这里插入图片描述

命令是否创建新容器主要结果
docker pull hello-world只把镜像拉到本地
docker run hello-world创建新容器、启动并附着其输出
docker start <容器>再次启动已经存在的容器
docker ps只查看运行中的容器
docker ps -a查看全部容器,包括已退出容器

2.3 理解 run、stop 与 rm 的边界

单独运行 docker run 会得到 requires at least 1 argument
在这里插入图片描述

因为该命令至少需要一个 IMAGE 参数。其基本语法如下:

docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

其中,OPTIONS 是端口、名称、挂载等容器配置;IMAGE 指定镜像;COMMANDARG 可选,用于覆盖镜像默认启动命令及其参数。

docker stop 用来向正在运行的容器发送停止信号,并在宽限时间后强制停止。对已经处于 Exited 状态的 hello-world 执行停止操作,没有让它继续运行的意义,因为其主进程早已正常结束。
删除不再需要的测试容器可使用:

docker rm <容器ID或容器名>

docker rm 删除已停止容器,但不会删除镜像。若容器仍在运行,可以先执行 docker stop,也可以在明确不需要保留该实例时使用 docker rm -f <容器> 强制停止并删除。-f 具有破坏性,不应对含有未持久化数据的容器随意使用。

3. 用 Nginx 完成反向代理实践

3.1 为什么需要反向代理

本次示例中的 Node.js 服务监听 1314 端口,而用户访问网站时更希望直接输入 http://localhost。HTTP 的默认端口是 80,因此可以让 Nginx 监听 80,再把请求转发给实际应用端口 1314

反向代理位于客户端与后端服务之间:客户端只访问代理服务器,并不需要知道后端应用实际监听的地址和端口。 Nginx 收到请求后选择目标服务,将请求转发过去,再把响应返回给客户端。

这一链路中有两个容易混淆的边界:-p 负责 宿主机与容器之间的端口映射,Nginx 的 proxy_pass 负责 Nginx 与上游应用之间的请求转发

浏览器访问 localhost:80
          ↓
宿主机 80 端口
          ↓ docker -p 80:80
Nginx 容器 80 端口
          ↓ proxy_pass
宿主机 Node.js 服务 1314 端口
所处层次配置解决的问题
浏览器到宿主机http://localhost使用 HTTP 默认的 80 端口访问
宿主机到容器-p 80:80把宿主机 80 映射到容器 80
容器加载配置-v 本机路径:/etc/nginx/nginx.conf:ro让容器使用本机 Nginx 配置
Nginx 到 Node.jsproxy_pass http://host.docker.internal:1314将请求反向代理到宿主机应用

3.2 启动宿主机 Node.js 服务

示例应用使用 Node.js 原生 http 模块,不依赖第三方包:

const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello world');
});

server.listen(1314, '0.0.0.0', () => {
  console.log('server is running on port 1314');
});

http.createServer 创建 HTTP 服务,每次收到请求都返回 hello worldserver.listen(1314, '0.0.0.0') 表示监听所有可用网络接口上的 1314 端口,而不只绑定某一个回环地址。进入示例目录后启动服务:

cd /Users/moss/study/workspace/moss_ai/backend/docker/demo
node index.js

cd 切换到存放 index.js 的目录,node index.js 使用本机 Node.js 执行入口文件。终端出现 server is running on port 1314 后,应暂时保持该进程运行。也可以先在另一个终端直接验证上游服务:

curl http://localhost:1314

curl 发起 HTTP 请求;若返回 hello world,说明后端服务本身正常。先完成这一步,可以在代理失败时快速区分问题究竟来自 Node.js 还是 Nginx。

3.3 读懂 nginx.conf

本次挂载的配置内容如下:

events {}

http {
    server {
        listen 80;

        location / {
            proxy_pass http://host.docker.internal:1314;
            proxy_set_header Host $host;
        }
    }
}

events {} 是 Nginx 主配置所需的事件模块配置块,此处使用默认设置;http {} 定义 HTTP 服务;server {} 表示一个虚拟服务器;listen 80 让 Nginx 在容器内监听 80 端口;location / 匹配从网站根路径开始的请求。

最关键的是 proxy_pass http://host.docker.internal:1314。由于 Nginx 运行在容器里,容器中的 localhost 指向容器自己,并不指向 Mac 宿主机。Docker Desktop 提供的特殊域名 host.docker.internal 可以让容器访问宿主机,因此 Nginx 才能找到运行在 Mac 上的 Node.js 服务。proxy_set_header Host $host 则把客户端请求中的主机信息传给上游。

3.4 拉取并运行 Nginx 容器

先显式拉取官方 Nginx 镜像:

docker pull nginx

docker pull 只下载镜像,不创建容器;省略标签时等价于拉取 nginx:latest。出现 Status: Downloaded newer image for nginx:latest 表示镜像下载完成。随后创建容器:

docker run \
  --name my-nginx-demo \
  -p 80:80 \
  -v /Users/moss/study/workspace/moss_ai/backend/docker/demo/nginx.conf:/etc/nginx/nginx.conf:ro \
  -d \
  nginx

各参数共同决定了容器怎样运行:

参数含义本次效果
--name my-nginx-demo设置易读且唯一的容器名后续可直接按名称查看、停止或删除
-p 80:80按“宿主机端口:容器端口”发布端口访问 Mac 的 80 端口会进入容器的 80 端口
-v 主机路径:容器路径:ro绑定挂载文件,ro 表示只读使用本机配置覆盖容器默认配置,并防止容器修改源文件
-d后台运行,即 detached mode成功后终端返回一串容器 ID
nginx用于创建容器的镜像使用官方 Nginx 镜像及其默认启动命令

当命令返回类似 568c72f... 的长字符串时,只能说明 Docker 已经创建并尝试启动容器。还需要检查运行状态和日志,才能确认服务持续可用:

docker ps --filter name=my-nginx-demo
docker logs my-nginx-demo
docker exec my-nginx-demo nginx -t
curl http://localhost

docker ps --filter name=... 只筛选目标运行中容器,预期状态为 Up,端口列应出现 0.0.0.0:80->80/tcp
docker logs 查看容器标准输出和错误输出;
docker exec 在运行中的容器内执行 nginx -t,用于检查配置语法;
最后的 curl 通过完整代理链路请求服务,预期得到 hello world

4. 名称冲突与启动命令排错

4.1 为什么会出现容器名称冲突

第一次执行 docker run --name my-nginx-demo ... 后,Docker 已经登记了这个容器名。再次执行相同的 docker run 会尝试创建另一个新容器,因此出现如下错误:

Conflict. The container name "/my-nginx-demo" is already in use.

容器名在同一个 Docker daemon 中必须唯一,而且该限制同时适用于运行中与已停止的容器。也就是说,即使旧容器已经退出,只要没有删除,名称仍会被占用。排错时先确认旧容器状态:

docker ps -a --filter name=my-nginx-demo

-a 保证已退出的容器也会显示,--filter name=... 缩小查询范围。接下来的处理方式取决于真实意图,而不是一看到冲突就删除:

实际意图推荐命令说明
继续使用旧容器docker start my-nginx-demo不创建新容器,保留原来的容器配置
查看失败原因docker logs my-nginx-demo先读日志,再决定是否重建
保留旧实例并使用新名称docker rename my-nginx-demo my-nginx-demo-old释放原名称,旧容器仍存在
确认不再需要旧实例docker rm -f my-nginx-demo强制停止并删除,随后可以复用名称

这次实践选择删除并重建,因此执行:

docker rm -f my-nginx-demo

命令输出 my-nginx-demo,表示目标容器已经被移除。之后再次执行正确的 docker run 命令即可复用该名称。需要注意,删除容器会丢失仅存在于容器可写层中的数据;Nginx 本次只有外部挂载的配置,重建成本很低,但数据库容器不能在没有持久化方案时照搬这一操作。

5. 部署 MySQL 8.0 容器

5.1 拉取镜像并创建数据库容器

Nginx 验证了无状态 Web 服务的运行方式,MySQL 则进一步涉及环境变量、端口以及数据持久化。先拉取明确版本的镜像:

docker pull mysql:8.0

冒号后的 8.0 是镜像标签。与直接使用 latest 相比,显式指定主版本可以减少环境随镜像更新而突然变化的风险。下载完成后,运行数据库容器:

docker run -d \
  --name mysql-demo \
  -p 3307:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  mysql:8.0
参数作用执行结果
-d在后台运行终端返回容器 ID,MySQL 在后台初始化
--name mysql-demo指定容器名后续可以使用名称执行 logsexec 等操作
-p 3307:3306发布数据库端口宿主机通过 3307 访问容器内 MySQL 的 3306
-e MYSQL_ROOT_PASSWORD=123456注入初始化环境变量首次启动时设置 root 密码
mysql:8.0指定镜像及标签基于 MySQL 8.0 创建容器

这里故意使用宿主机 3307,可以避开本机已有 MySQL 常用的 3306 端口。执行 docker ps -a 后,实践结果显示 mysql-demo 状态为 Up,端口列为 0.0.0.0:3307->3306/tcp,说明端口发布成功。不过 MySQL 第一次启动需要初始化,容器进入 Up 后仍应通过日志确认已经可以接受连接:

docker logs -f mysql-demo

-f--follow 的缩写,会持续跟随日志;看到数据库准备就绪的信息后,可按 Ctrl+C 退出日志跟随,这只会停止查看日志,不会停止容器。

5.2 进入容器并登录 MySQL

实践中先进入容器的 Bash,再使用客户端连接数据库:

docker exec -it mysql-demo /bin/bash

docker exec已经运行的容器中执行新命令;-i 保持标准输入打开;-t 分配伪终端;mysql-demo 是目标容器;/bin/bash 是要运行的交互式 Shell。进入后执行:

mysql -uroot -p123456

-u root 指定 root 用户,-p123456 提供密码。出现 mysql> 提示符以及 Server version: 8.0.46,说明已经成功连接 MySQL。
在这里插入图片描述

执行 exit 会先退出 MySQL 客户端,再执行一次 exit 才会退出容器 Shell,返回宿主机终端。

实践中终端也给出了 Using a password on the command line interface can be insecure 警告,因为密码可能进入 Shell 历史或进程信息。更合适的交互方式是只写 -p,随后在隐藏输入中提供密码:

docker exec -it mysql-demo mysql -uroot -p

该命令省去中间 Bash,直接在容器中启动 MySQL 客户端;-p 不紧跟明文密码,客户端随后会安全地提示输入。示例密码 123456 只适合本地学习,生产环境应使用高强度秘密,并通过专门的密钥管理方案注入。

5.3 给数据库增加持久化

只使用上述命令时,MySQL 数据写入容器可写层。容器停止后数据通常仍在,但执行 docker rm 删除容器会一并删除这一层。数据库的生命周期不应与单个容器实例绑定,因此更完整的启动方式需要命名卷:

docker run -d \
  --name mysql-demo \
  -p 3307:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  -v mysql-demo-data:/var/lib/mysql \
  mysql:8.0

-v mysql-demo-data:/var/lib/mysql 把 Docker 管理的命名卷挂载到 MySQL 数据目录。这样重建容器时,只要重新挂载同一个卷,数据就不会依赖旧容器的可写层。需要强调的是,docker rm -f mysql-demo 与删除命名卷是两件事;不要在未备份、未核对名称的情况下删除数据库卷。

6. 一套可复现的完整实践流程

完成概念拆解后,可以用下面的顺序重新练习。它把“先验证上游、再启动代理、最后检查完整链路”的排错思路固化为流程。

# 1. 验证 Docker 环境
docker --version
docker run hello-world

# 2. 启动宿主机 Node.js 服务,并保持该终端运行
cd /Users/moss/study/workspace/moss_ai/backend/docker/demo
node index.js

打开第二个终端,继续执行:

# 3. 验证 Node.js 上游服务
curl http://localhost:1314

# 4. 拉取并创建 Nginx 容器
docker pull nginx
docker run --name my-nginx-demo -p 80:80 \
  -v /Users/moss/study/workspace/moss_ai/backend/docker/demo/nginx.conf:/etc/nginx/nginx.conf:ro \
  -d nginx

# 5. 检查状态、配置和代理结果
docker ps --filter name=my-nginx-demo
docker exec my-nginx-demo nginx -t
curl http://localhost

# 6. 拉取并创建 MySQL 容器
docker pull mysql:8.0
docker run -d --name mysql-demo -p 3307:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  -v mysql-demo-data:/var/lib/mysql \
  mysql:8.0

# 7. 确认 MySQL 状态并交互式登录
docker ps --filter name=mysql-demo
docker logs mysql-demo
docker exec -it mysql-demo mysql -uroot -p

上面各阶段的成功标准如下:

阶段验证命令成功标志
Docker 基础链路docker run hello-world输出 Hello from Docker!,随后正常退出
Node.js 上游curl http://localhost:1314返回 hello world
Nginx 容器docker ps --filter name=my-nginx-demo状态为 Up,显示 80->80 端口映射
Nginx 配置docker exec my-nginx-demo nginx -t配置语法检查成功
完整代理链路curl http://localhost通过 Nginx 返回 hello world
MySQL 容器docker ps --filter name=mysql-demo状态为 Up,显示 3307->3306
MySQL 连接docker exec -it mysql-demo mysql -uroot -p输入密码后出现 mysql> 提示符

清理实验环境时,应先判断数据是否需要保留。下面的命令只删除两个容器,不删除镜像,也不删除 mysql-demo-data 命名卷:

docker stop my-nginx-demo mysql-demo
docker rm my-nginx-demo mysql-demo

docker stop 依次停止两个运行中的容器,docker rm 再删除已停止容器。相比 docker stop $(docker ps -q)docker rm $(docker ps -aq) 这类批量命令,显式写出目标名称更安全,避免误操作其他项目的容器。

总结

Docker 的关键并不在于记住多少命令,而在于建立清晰的对象和网络边界:镜像是可复用模板,容器是运行实例,docker run 每次都会创建新实例,容器主进程结束则容器停止。在 Nginx 实践中,-p 处理宿主机到容器的端口映射,-v 把本机配置挂载进容器,proxy_pass 再把请求交给真正的 Node.js 服务;在 Docker Desktop 中,容器需要通过 host.docker.internal 访问 Mac 宿主机。名称冲突提示说明旧容器仍然存在,应先检查状态,再决定启动、重命名或删除。MySQL 实践则进一步说明,数据库除了能启动,还必须考虑密码安全、就绪日志和数据持久化。掌握“执行命令—检查状态—查看日志—验证服务”的闭环,才能把 Docker 从命令记忆转化为可复现的工程能力。


更多推荐