为什么需要 Docker
很多人第一次部署项目时,遇到的不是代码问题,而是环境问题:
- 本地能跑,服务器跑不起来。
- A 项目需要 Node.js 18,B 项目需要 Node.js 20。
- 换一台机器,又要重新安装运行环境、配置环境变量、调整启动脚本。
- 项目越多,服务器上的依赖、日志、进程和端口越难管理。
Docker 要解决的核心问题是:把应用和它依赖的运行环境一起打包,让它在不同机器上用尽量一致的方式运行。
可以把 Docker 理解成一种轻量的应用交付方式。它不会像传统虚拟机那样完整模拟一台操作系统,而是基于宿主机内核,把不同应用隔离在不同容器中。这样既能减少环境差异,又不会像虚拟机那样笨重。
先记住一句话:
Docker 不是为了让代码变得神奇,而是为了让代码运行环境变得可复制、可迁移、可管理。
没有 Docker 和有 Docker 的部署区别
假设服务器上要部署一个 Nuxt 应用,并且使用 Nginx 接收外部请求。
没有 Docker 时,常见结构是:
Linux 宿主机
├─ Nginx(直接安装在宿主机)
└─ Nuxt 应用(直接运行在宿主机)
这种方式当然能用,但服务器会逐渐变成一个"手工配置现场":Node 版本、项目依赖、进程管理、日志目录、环境变量都散落在宿主机上。后续迁移、排查问题或部署新项目时,很容易出现环境不一致。
使用 Docker 后,可以有两种常见做法。
第一种是 Nginx 不容器化,只把应用容器化:
Linux 宿主机
├─ Nginx(直接安装在宿主机)
└─ Docker
└─ Nuxt 容器
这种方式适合刚开始使用 Docker 的阶段。Nginx 仍然由服务器直接管理,Docker 只负责应用本身,理解成本比较低。
第二种是 Nginx 和应用都容器化:
Linux 宿主机
└─ Docker
├─ Nginx 容器
└─ Nuxt 容器
这种方式更统一,所有服务都由 Docker 管理,通常会配合 docker compose 使用。正式项目中,如果后面还要加入数据库、Redis、后端服务、AI 服务等,多容器编排会更方便。
这两种方式没有绝对优劣。刚入门时,可以先让应用容器化;当项目服务变多、部署流程变复杂时,再把 Nginx、数据库、缓存等服务逐步纳入 Compose 管理。
镜像和容器
Docker 里最重要的两个概念是:镜像 和 容器。
镜像 Image 是静态的构建产物。它描述了应用运行需要的文件、依赖、环境和默认启动方式。可以把镜像类比成"软件安装包",也可以类比成面向对象里的"类"。
容器 Container 是镜像运行后的实例。它是动态的,是一个被隔离起来的运行环境。可以把容器类比成"安装并启动后的软件",也可以类比成面向对象里的"对象实例"。
简单说:
- 镜像是构建结果。
- 容器是镜像运行后的实例。
- 一个镜像可以启动多个容器。
- 删除容器不会自动删除镜像。
- 删除镜像前,一般要先停止并删除依赖它的容器。
比如你构建出一个 my-blog:v1 镜像,就可以用它启动一个博客容器。将来升级版本时,可以重新构建 my-blog:v2,再用新镜像启动新容器。
Dockerfile 是什么
Dockerfile 是写给 Docker 的构建说明书。它告诉 Docker 如何一步一步生成镜像:使用哪个基础镜像、进入哪个工作目录、复制哪些文件、安装哪些依赖,以及容器启动时执行什么命令。
下面是一个入门版 Node.js 项目 Dockerfile:
sql
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
这里每一行都对应一个构建或运行阶段的动作:
FROM node:20-alpine:选择一个已有的基础镜像,里面已经包含 Node.js 运行环境。WORKDIR /app:指定容器内部的工作目录,后续命令默认都在这里执行。COPY package*.json ./:先复制依赖描述文件,方便 Docker 利用构建缓存。RUN npm ci --omit=dev:在构建镜像时安装生产依赖。相比npm install,npm ci更适合根据锁文件做可重复安装。COPY . .:把项目其他文件复制到镜像中。EXPOSE 3000:声明应用在容器内监听3000端口。注意它只是元信息,不会自动把端口暴露到宿主机。CMD ["npm", "start"]:容器启动后默认执行的命令。
这里有两个时间点很容易混淆:
RUN发生在构建镜像时,比如安装依赖、编译项目。CMD发生在容器启动时,比如启动 Web 服务。
真实项目中,还应该配合 .dockerignore 使用,避免把 node_modules、构建产物、日志、环境变量文件等内容复制进镜像:
bash
node_modules
dist
.nuxt
.output
.env
*.log
coverage
如果项目需要先构建再运行,例如 Nuxt、Next.js、Vue、React 等前端项目,通常还会使用多阶段构建:第一阶段负责安装依赖和打包,第二阶段只保留运行所需文件。这样可以减少最终镜像体积,也能让镜像更干净。
下面是一个多阶段构建的例子:
sql
# 阶段一:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 阶段二:运行
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
构建和运行一个镜像
假设当前目录下已经有 Dockerfile,可以执行:
erlang
docker build -t test-docker .
这条命令表示:使用当前目录的 Dockerfile 和项目文件构建一个名为 test-docker 的镜像。
构建完成后,可以启动容器:
css
docker run -d \
--name test-docker \
-p 80:3000 \
test-docker
这条命令的含义是:
docker run:根据镜像创建并启动一个容器。-d:让容器在后台运行。--name test-docker:给容器起一个明确的名字,后续可以直接用名字操作。-p 80:3000:把宿主机的80端口映射到容器内部的3000端口。test-docker:要运行的镜像名。
端口映射是初学 Docker 时最容易混淆的地方。80:3000 的左边是宿主机端口,右边是容器端口。访问服务器的 80 端口时,流量会被转发到容器内部的 3000 端口。
也可以这样理解:
markdown
浏览器访问服务器 80 端口
↓
Docker 转发到容器 3000 端口
↓
容器里的应用处理请求
需要注意:默认情况下,发布端口可能会让外部网络访问到该服务。生产环境里,不要随手把数据库、Redis、内部管理服务直接映射到公网端口。如果只是想让本机 Nginx 反向代理访问应用,可以绑定到本地地址:
css
docker run -d \
--name test-docker \
-p 127.0.0.1:3000:3000 \
test-docker
这样外部用户不能直接访问容器端口,而是由宿主机上的 Nginx 统一接收请求后再转发。
一个更完整的项目结构
如果项目里有前端、后端、AI 服务,就可以给每个服务写自己的 Dockerfile,再用 compose.yaml 或 docker-compose.yml 统一管理。
arduino
ai-drug-discovery-workbench/
├─ compose.yaml
├─ backend/
│ └─ Dockerfile
├─ ai-service/
│ └─ Dockerfile
├─ frontend/
│ └─ Dockerfile
└─ nginx/
└─ default.conf
这种结构的好处是:每个服务只关心自己的运行环境,整体启动则交给 docker compose。例如后端可以使用 Java 或 Node.js,AI 服务可以使用 Python,前端可以使用 Nuxt,它们不用把所有运行环境都堆在宿主机上。
一个简化的 Compose 文件可能长这样:
yaml
services:
frontend:
build: ./frontend
expose:
- "3000"
backend:
build: ./backend
expose:
- "8080"
environment:
NODE_ENV: production
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- frontend
- backend
在同一个 Compose 项目里,服务会加入默认网络,并且可以通过服务名互相访问。也就是说,Nginx 容器里可以把请求转发到 http://frontend:3000 或 http://backend:8080,而不是写死某个容器 IP。容器 IP 可能会变化,服务名才是更稳定的连接方式。
常用 Docker 命令
初学阶段不需要背完所有命令,先掌握"看状态、看日志、进容器、停服务、清资源"这几类就够了。
查看容器
css
docker ps
docker ps -a
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker ps 只查看运行中的容器,docker ps -a 会包含已经停止的容器。第三条命令可以用表格形式查看容器名称、状态和端口映射,更适合日常排查。
查看和删除镜像
docker images
docker images your-app
docker rmi your-app:v1
docker images 用来查看本机已有镜像。docker rmi 用来删除镜像。如果某个镜像正在被容器使用,需要先停止并删除对应容器。
启动容器
perl
docker run -d \
--name my-app \
-p 80:8080 \
-v /data:/app/data \
-e DB_HOST=db.example.com \
--restart unless-stopped \
your-app:v1.0
这是一条比较完整的启动命令:
--name my-app:给容器起一个名字,后续可以直接用名字操作它。-p 80:8080:把宿主机80端口映射到容器8080端口。-v /data:/app/data:挂载目录,用来持久化数据或提供配置文件。-e DB_HOST=db.example.com:注入环境变量。--restart unless-stopped:容器异常退出后自动重启,除非手动停止。your-app:v1.0:指定镜像和版本标签。
这里不建议把 DB_HOST 写成 localhost,除非数据库就在同一个容器里。对容器来说,localhost 通常指容器自己,不是宿主机,也不是其他容器。多容器项目中,应该优先使用 Compose 服务名,例如 db、redis、backend。
停止和重启容器
perl
docker stop my-app
docker start my-app
docker restart my-app
docker stop $(docker ps -q)
容器既可以通过名称操作,也可以通过容器 ID 操作。日常使用时,建议给重要容器都设置明确的名字。
查看日志
perl
docker logs my-app
docker logs -f my-app
docker logs --tail 100 my-app
docker logs --since 1h my-app
排查线上问题时,docker logs -f 很常用,它会持续输出日志,效果类似 tail -f。
进入容器
perl
docker exec -it my-app sh
docker exec -it my-app bash
docker exec my-app ls /app
docker exec my-app env
docker exec 可以在已经运行的容器里执行命令。基于 Alpine 的镜像通常只有 sh,Ubuntu、Debian 这类镜像通常可以使用 bash。
删除容器和镜像
bash
docker rm my-app
docker rmi your-app:v1
docker rm 删除容器,docker rmi 删除镜像。清理资源时一般先用 docker ps -a 找到停止的容器,再决定是否删除。
构建镜像时的 CPU 架构问题
构建镜像时还要注意 CPU 架构。很多开发者使用 Apple Silicon Mac,比如 M1、M2、M3、M4,这类机器通常是 linux/arm64 架构。而大多数云服务器、Intel/AMD 服务器通常是 linux/amd64 架构。
常见架构可以这样理解:
linux/arm64:ARM 64 位架构,常见于 Apple Silicon Mac、部分 ARM 服务器、树莓派等。linux/amd64:x86_64 架构,常见于大多数云服务器和普通 Intel/AMD 服务器。
如果在 M 系列 Mac 上构建镜像,然后拿到 x86_64 云服务器上运行,就可能遇到架构不匹配的问题。部署到这类服务器时,可以显式指定目标平台:
bash
docker build --platform linux/amd64 -t your-app:v1 .
如果需要同时支持多种架构,可以使用 docker buildx 构建多平台镜像,并推送到镜像仓库:
bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t yourname/your-app:v1 \
--push .
单平台构建适合个人项目或固定服务器架构;多平台构建适合要同时支持 x86_64 服务器、ARM 服务器或不同开发机器的项目。
Docker 和 Nginx 的关系
Docker 负责打包和运行应用,Nginx 更常见的角色是接收外部请求、处理域名和路径转发、统一配置 HTTPS。
没有 Nginx,服务器也可以部署多个项目,只要每个项目监听不同端口,外部就能通过 IP + 端口 访问它们。例如:
arduino
http://服务器IP:3000
http://服务器IP:8080
http://服务器IP:9000
但是这种方式不适合正式网站。用户不会希望记住端口号,HTTPS 配置也会变得分散。
加入 Nginx 后,可以让外部统一访问标准的 80 或 443 端口,再由 Nginx 根据域名、子域名或路径转发到不同项目:
rust
blog.example.com -> frontend:3000
api.example.com -> backend:8080
ai.example.com -> ai-service:9000
所以更准确的理解是:Nginx 不是 Docker 的替代品。Docker 负责运行应用,Nginx 负责把外部请求转发到正确的应用。
如果 Nginx 直接安装在宿主机上,可以把容器端口绑定到 127.0.0.1,再让 Nginx 转发到本机端口。如果 Nginx 也在 Compose 里,则可以直接用服务名转发,例如 proxy_pass http://frontend:3000;。
上传服务器的一种流程
如果不使用镜像仓库,也可以先在本地构建镜像,再把镜像文件上传到服务器。
整体流程是:
rust
构建镜像 -> 保存镜像到文件 -> scp 上传到 Linux 服务器 -> 加载镜像 -> 运行容器
对应命令大致如下:
ruby
# 本地构建镜像(注意指定目标平台)
docker build --platform linux/amd64 -t your-app:v1 .
# 保存镜像为文件
docker save your-app:v1 -o your-app-v1.tar
# 上传到服务器
scp your-app-v1.tar user@server:/tmp/
# 登录服务器
ssh user@server
# 加载镜像
docker load -i /tmp/your-app-v1.tar
# 运行容器
docker run -d --name your-app -p 80:3000 your-app:v1
这种方式适合个人项目、小项目或刚开始学习部署的时候。它的优点是直观,不需要先配置镜像仓库;缺点是手工步骤多,版本管理和回滚不够方便。
更正式的生产环境里,通常会把镜像推送到镜像仓库,例如 Docker Hub、GitHub Container Registry 或私有镜像仓库,然后让服务器直接拉取指定版本的镜像:
rust
本地或 CI 构建镜像 -> 推送到镜像仓库 -> 服务器拉取镜像 -> 重启容器或 Compose 项目
这样做更利于版本追踪、自动化部署和回滚。
入门时最容易踩的坑
-
把
localhost理解错在容器内部,
localhost通常指容器自己。容器访问宿主机、其他容器或外部数据库时,要根据网络环境使用正确地址。 -
端口方向写反
-p 80:3000是宿主机端口:容器端口。如果应用在容器里监听3000,右边就应该是3000。 -
把敏感服务暴露到公网
数据库、Redis、内部管理后台不要随便
-p到公网。能走内网或 Compose 网络的,就不要暴露到外部。 -
镜像里复制了太多无关文件
忘记写
.dockerignore会让镜像变大,也可能把.env等敏感文件打进镜像。 -
只会
docker run,不会看日志容器启动失败时,先看
docker ps -a和docker logs 容器名,通常比反复重启更有效。
总结
Docker 入门可以先抓住这几件事:
- 镜像是静态的构建产物,容器是镜像运行起来后的实例。
Dockerfile是构建镜像的说明书,FROM、WORKDIR、COPY、RUN、CMD是最常见的指令。docker build用来构建镜像,docker run用来启动容器。- 端口映射里,
宿主机端口:容器端口,例如80:3000。 docker compose适合管理多容器项目,服务之间可以通过服务名访问。- Nginx 不是 Docker 的替代品,它通常负责接收外部请求并转发到不同容器或服务。
- 正式部署时,把镜像构建、推送、拉取和重启流程标准化,比直接在服务器上手动安装依赖更可靠。
学 Docker 不需要一开始就把所有命令都背下来。先理解镜像、容器、Dockerfile、端口映射和日志查看,再拿一个真实项目从构建到部署跑通一遍,很多概念就会自然连起来。