Docker入门实战:从镜像与容器到 Nginx 反向代理和 MySQL 部署
Docker入门实战:从镜像与容器到 Nginx 反向代理和 MySQL 部署
前言
“代码在我的电脑上明明能运行,换一台电脑却报错”,通常不是代码突然失效,而是两台机器的 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:latest、nginx:latest、mysql:8.0 |
容器 Container | 镜像的一个运行实例 | my-nginx-demo、mysql-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_pike 和 elated_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 指定镜像;COMMAND 和 ARG 可选,用于覆盖镜像默认启动命令及其参数。
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.js | proxy_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 world。server.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 | 指定容器名 | 后续可以使用名称执行 logs、exec 等操作 |
-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 从命令记忆转化为可复现的工程能力。
更多推荐
所有评论(0)