1. 一次"本地好好的,上线就挂"引发的学习冲动
我在掘金和知乎上刷到过无数次类似的问题:"前端要不要学 Docker?"评论区永远分成两派,一派说这是运维的事,另一派说容器化是开发的基本功。我的答案很明确:要学,而且越早越好。
原因很简单,几乎所有前端都经历过那个经典场景——本地开发时一切正常,npm run dev一键启动,页面丝滑流畅。结果部署到测试服务器,要么 Node 版本不对,要么环境变量缺失,要么 Nginx 配置把路由 history 模式搞挂,页面白屏。你明明写的代码没变,可就是跑不起来。这个锅到底算谁的?很多时候是环境不一致造成的,而 Docker 就是来解决这个问题的。
还有一层现实原因:越来越多的公司要求前端具备部署能力。打开招聘软件,能看到很多 JD 里明确写着熟悉 Docker、Nginx,能独立完成项目部署。后端倒是想帮你部署,可你要是连构建产物怎么放到镜像里都不清楚,整个交付流程就会卡在你这一环。所以说,Docker 不是纯运维的玩具,它是前端从"写出页面"进阶到"交付一个可用系统"的必经之路。
这篇文章我会用一个前端最熟悉的思维路径来讲 Docker,不扯太多底层原理,直接告诉你每个概念对应到前端是"什么东西",然后带着你把一个 Vue 项目从零构建成镜像,最终部署到云服务器上,跑通一个完整闭环。
2. 把 Docker 的三大核心概念翻译成前端黑话
接触 Docker 的人第一反应就是蒙:镜像、容器、仓库、数据卷、端口映射……这些词单独看都认识,拼在一起就完全不知道它们是什么关系。其实拿前端的体系去类比,一下就通了。
2.1 镜像约等于"npm 包 + Node 运行时"的冷冻体
你可以把镜像理解成一个提前打好包、包含完整运行环境的只读模板。比如一个镜像里可能装着 Node 18 的运行时、项目所有依赖、Nginx 配置、构建后的静态文件,所有东西冻结在一起。
这就好比一个完整的node_modules再加上整个 Node 解释器,以及能把它跑起来的操作系统组件,全部存到一个压缩包里。镜像一旦构建出来就是只读的,你想直接改一个文件是不可能的,只能基于它重新构建或生成新的镜像。
经常有人说"我把镜像发给对方,他本地就能跑起来",这句话的本质是在说:通过镜像把运行环境彻底固化住,杜绝了版本差异带来的不确定性。
2.2 容器等于"你 npm install 之后开启的一次 dev server"
镜像启动时,Docker 会基于这个只读模板创建一个可写的运行层,这个运行中的实例就是容器。
类比前端项目的话,就好比依赖跟代码已经打包好了,每次启动npm run dev就是在创建一个基于这个项目的运行实例。你可以同时启动多个相同的镜像,各自的运行状态互不干扰。
这也是 Docker 最实用的一点:它本质上是一个进程隔离机制,不是虚拟机快照。每个容器里跑的进程,在操作系统层面是独立隔离的,有自己的文件系统、网络、端口空间。这种隔离保证了你在本地怎么折腾都不会污染宿主机。
2.3 仓库等于"npm registry"
镜像一般通过docker pull从仓库拉取,最常用的就是 Docker Hub。你要 Nginx 官方镜像,docker pull nginx就行,跟你npm install vue的体验几乎一样。
我们后面自己构建出的镜像,如果不推送到仓库里,那它只存在于你本机。想要在服务器上使用,可以把本机镜像导出再导入服务器,更方便的是推到 Docker Hub 或私有仓库,然后在服务器docker pull拉取,这就跟发布一个 npm 包到 registry 再安装几乎一样。
2.4 数据卷与端口映射:前端容易忽视的关键点
容器运行时的可写层是临时的——容器删除后,里面的数据就全没了。如果你在容器里存了日志、数据库文件,容器一删,数据就没了。数据卷(Volume)就可以让容器把数据持久化到宿主机的指定目录,类似于把 localStorage 存到浏览器之外,不再受单个页面刷新影响。
端口映射则是把容器内部的端口有效暴露到宿主机。比如容器里的 Nginx 监听 80 端口,宿主机也需要访问 80 端口,通过-p 80:80把两者做映射。不映射,宿主机根本访问不到容器里的服务。这也是前端面试中经常被问到的"容器网络"的基础概念。
3. 环境准备:本地先把 Docker Desktop 跑起来
不管你用 macOS 还是 Windows,本地开发环境直接用 Docker Desktop 是最省心的选择。
3.1 macOS 与 Windows 的安装差异
macOS 用户直接官网下载 Docker Desktop 的 dmg 安装,拖入 Applications 就完成了。芯片是 Apple Silicon 的话,建议选择对应 ARM 架构的版本,因为 x86 镜像在 ARM 上默认会用模拟器跑,性能会打折扣。
Windows 用户的情况要分两种说清楚。如果系统是 Windows 10/11 专业版或企业版,推荐开启 WSL 2 后端,这样可以获得更完整的 Linux 内核兼容性;如果你用的是 Windows Home 版,很多教程里推荐的 Hyper-V 压根没有,最简单的方式也是开 WSL 2。装了 WSL 2 后,在 Docker Desktop 设置里把 Use the WSL 2 based engine 打开,基本上安装过程就不会有坑了。
安装完在终端敲docker --version,能看到版本号就说明 Docker CLI 已经可用了。再敲docker run hello-world,如果能看到一段 "Hello from Docker!" 的提示,说明整套环境已经正常。
注意:Windows 如果启动 Docker Desktop 时报错 "virtualization support not detected" 或者 "WSL 2 installation is incomplete",打开 BIOS 把虚拟化(Intel VT-x 或 AMD-V)开启,并且执行
wsl --install确保 WSL 2 内核装好,这类问题基本都能解决。
3.2 常用命令先练出手感
我不推荐一上来就死记硬背一堆命令,先反复用最常见的十几个就够了。
| 命令 | 用途 | 前端类比 |
|---|---|---|
docker pull nginx:alpine | 拉取镜像 | npm install 某个依赖 |
docker run -d -p 8080:80 nginx | 创建并后台启动容器 | 启动 dev server |
docker ps | 查看运行中的容器 | 查看哪些服务还在跑 |
docker ps -a | 查看所有容器(含已退出) | 查看所有启动过但已经停止的进程 |
docker logs <容器名或ID> | 查看容器日志 | 控制台输出的 log |
docker stop <容器名或ID> | 停止容器 | 杀掉 dev server |
docker rm <容器名或ID> | 删除容器 | 清理该服务的运行实例 |
docker images | 查看本地镜像列表 | 查看当前缓存里的依赖产物 |
docker rmi <镜像名或ID> | 删除本地镜像 | 清除本地缓存 |
docker build -t 镜像名 . | 根据 Dockerfile 构建镜像 | 执行 npm run build 产出静态文件包 |
docker exec -it <容器名> sh | 进入容器内部终端 | 打开容器内的命令行窗口 |
我在本地练了几遍之后,发现最有手感的操作流程是:docker run -d -p 8080:80 nginx把 Nginx 启起来,浏览器访问 localhost:8080 看到 welcome 页面,然后docker stop关掉,docker rm删掉容器。整个过程十几秒,但对"容器是独立的、启停是自由的、操作是无痕的"这种认知,会有非常直观的感受。
4. 给一个 Vue3 项目写 Dockerfile:多阶段构建是灵魂
掌握了基础命令后,真正的实战就是从写 Dockerfile 开始的。我用一个常见的 Vue3 + Vite 项目来做示范。
4.1 两种方案,为什么推荐多阶段构建
很多初学者接触到的第一版 Dockerfile 长这样:
FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这个写法有一个致命缺陷:项目构建阶段要用到的 Node 环境和几百兆的node_modules,最终都会留在镜像里。一个构建产物可能只有几 MB,但打包出来的镜像却有 1GB 以上,体积大、传输慢、攻击面也大。
多阶段构建的思路就完全不同了:先从一个包含 Node 环境的镜像开始,完成安装依赖、构建打包,得到一个 dist 目录;然后再换一个只有 Nginx 的轻量镜像,只把 dist 目录复制进来。前一个阶段的所有中间产物都不进入最终镜像。
做一个类比:你不需要在交付给用户的成品里保留"生产这台机器的整个工厂"。你需要的是把成品本身交给用户,而不是把整个产线也一起交付。
4.2 完整的 Dockerfile 逐行注释版
这是我在项目中实际使用并跑通的一条配置。
# 第一阶段:构建阶段 FROM node:18-alpine AS build-stage # 设置工作目录,等价于 cd 到容器内的 /app WORKDIR /app # 先只复制 package 相关文件,利用 Docker 的层缓存机制 COPY package*.json ./ # 安装依赖,用 npm ci 而不是 npm install,保证按 lock 文件精准安装 RUN npm ci # 复制项目所有源码到容器 COPY . . # 执行构建命令,默认输出到 dist 目录 RUN npm run build # 第二阶段:运行阶段 FROM nginx:alpine AS production-stage # 把第一阶段构建出的 dist 目录复制到 Nginx 的 web 根目录 COPY --from=build-stage /app/dist /usr/share/nginx/html # 如果有自定义 Nginx 配置,复制到容器中 COPY nginx.conf /etc/nginx/conf.d/default.conf # 声明容器运行时对外暴露 80 端口 EXPOSE 80 # 启动 Nginx 并保持在前台运行 CMD ["nginx", "-g", "daemon off;"]这里有几个值得展开讲的关键点。
第一个是npm ci和npm install的区别。npm ci严格要求依据 package-lock.json 安装,不会去更新 lock 文件,也不会做任何版本的浮动判断,这在容器构建这种需要确定性的场景里是最正确的选择。你有多少次因为npm install意外升级了某个小版本,导致构建产物和本地不一致?npm ci直接把这个坑堵死了。
第二个是 COPY 指令拆成两份。先COPY package*.json ./,执行RUN npm ci,再把剩余代码COPY . .。这样做的好处是:只要依赖没变,Docker 就会命中"依赖安装"这一层缓存,不用每次改动代码都重新 npm ci。对于依赖几百个包的前端项目,这个缓存命中能节省大量构建时间。
第三个是nginx -g daemon off;为什么要这样写。Nginx 默认以 daemon(守护进程)模式在后台运行,但 Docker 容器的生命周期跟容器内第一个进程(PID 1)是绑定的。如果 Nginx 后台运行,PID 1 进程会立刻退出,容器随之退出。daemon off让 Nginx 以前台模式运行,进程一直挂在那里,容器也就一直活着。
我见过不少新手用错了 CMD 导致容器启动后秒退,本质就是这个前台/后台进程的问题没搞明白。
4.3 .dockerignore 里必须排除哪些东西
前端项目的 .dockerignore 和 .gitignore 长得几乎一样,但它的作用不是防提交,而是避免把无关文件复制进镜像构建上下文。重点排除这些:
node_modules dist .git .gitignore Dockerfile .dockerignore *.local .vscode .idea如果你不写 .dockerignore,COPY . .会把整个项目目录都塞进构建上下文,其中 node_modules 动辄几百 MB,不仅会让构建变慢,更糟糕的是如果 node_modules 被复制进镜像,很可能覆盖掉容器里通过npm ci安装的原生依赖,因为宿主机的 node_modules 可能是不同平台编译的,兼容性问题立刻暴露。
5. Nginx 配置:前端部署最容易踩坑的地方
很多前端学 Docker 查到教程,Dockerfile 写得很顺利,结果一访问页面就白屏。这时候十有八九是 Nginx 配置的问题。
5.1 History 路由模式的 rewrite 规则
Vue Router 默认使用 history 模式,地址栏里是/home、/about这种路径,不再是哈希模式下的/#/home。但这种模式有一个特点:当浏览器直接访问https://example.com/home时,服务器如果只按路径找静态文件,根本找不到 /home 这个文件,就返回 404。
所以 Nginx 里必须加一条 try_files 规则,把所有路径都引导到 index.html,由前端路由接管后续逻辑:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }这段配置的意思是:访问/home时,先看真实文件是否存在,不存在就尝试加斜杠的目录,再不存在,就把请求重新指向 /index.html。这样浏览器拿到了 SPA 的入口,然后 Vue Router 识别到地址是 /home,就会渲染对应的组件。
没有配置过这个规则的运维接手前端项目时经常问:为什么我把 dist 放到服务器上访问不了子路由?原因就在这里。
5.2 静态资源缓存策略
前端项目的文件名里通常带 hash(如index-a1b2c3.js),这个 hash 只有在文件内容变化时才会变。所以对于带 hash 的静态资源,可以放心让浏览器和 CDN 长缓存;对于入口的 index.html,则必须禁止缓存或使用协商缓存,保证发版后用户能及时拿到新的入口文件。
location /assets/ { expires 30d; add_header Cache-Control "public, immutable"; } location / { add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; }地名解释一下为什么要区分:带 hash 的资源相当于"永远不变的版本号",内容变了 hash 自然会变,所以长缓存收益很高;index.html 是指挥入口,如果被浏览器缓存放了一个老版本,用户可能一直加载旧的 JS 文件,页面看起来就像没更新。
如果你在部署后发现"改了代码线上不生效",先把浏览器缓存强刷掉,再检查 Nginx 有没有给 index.html 设置 no-cache,这两种情况分别排查一遍。
5.3 用 gzip 把体积再压一压
Nginx 开启 gzip 对前端体验的改善立竿见影,尤其是 JS 和 CSS 这种文本文件。一般来说,默认的gzip on开启后,再配上压缩级别和最小压缩阈值就够了:
gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1k; gzip_comp_level 6;级别是 1-9 的一个区间,级别越高压缩率越高,但 CPU 开销也越大。6 是我试下来在性能与体积之间比较均衡的值。阈值设为 1k 是为了避开太小的文件,压缩它们带来的收益很小,反而白白消耗 CPU。
5.4 反向代理:前端如何调用后端接口
前后端分离部署时,前端容器里的项目需要请求/api接口。如果前端直接写后端的 IP 和端口,会产生 CORS 问题,而且后端地址暴露在前端代码里,安全性和灵活性都差。
解决这种问题的思路就是反向代理:前端只请求同源的/api,Nginx 收到后把以/api开头的请求转发给真实的后端服务。
location /api/ { proxy_pass http://backend-service:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }注意proxy_pass结尾的斜杠有讲究:http://backend-service:3000/会替换掉/api/这段前缀,也就是说请求/api/user会被转发为http://backend-service:3000/user。如果你想去掉或者保留前缀,调整斜杠就能灵活控制。
backend-service是 Docker Compose 里的服务名,而不是 IP,这一点需要特别记住:在 Docker 内部网络中,服务名就是域名。
6. Docker Compose:前端项目一键启动整套服务
如果你只需要部署一个纯静态前端,一个容器可能就够了。但实际项目里常常是"前端 + 后端 + 数据库 + Redis"全家桶,每个都用 docker run 命令手动启,启动顺序、网络、环境变量会变得非常难维护。
Docker Compose 的定位就类似于在前端里用npm run dev来约定一套统一的启动入口:用 YAML 声明服务清单和依赖关系,一条docker compose up -d把这些服务全部拉起来。
6.1 docker-compose.yml 的核心字段
下面这个例子里有前端 Nginx、后端 Node 和 MySQL,三个服务一次拉起。
version: "3.8" services: frontend: build: context: ./frontend dockerfile: Dockerfile ports: - "80:80" depends_on: - backend backend: build: context: ./backend dockerfile: Dockerfile environment: - DB_HOST=mysql - DB_PORT=3306 - DB_USER=root - DB_PASSWORD=example ports: - "3000:3000" depends_on: mysql: condition: service_healthy mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: example MYSQL_DATABASE: app_db volumes: - mysql_data:/var/lib/mysql ports: - "3306:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s retries: 10 volumes: mysql_data:逐段说一下:build指定通过哪个目录下的 Dockerfile 构建镜像;ports做端口映射;environment设置环境变量,后端读到DB_HOST=mysql就是用服务名mysql去连接数据库;depends_on控制启动顺序,MySQL 还配了 healthcheck,后端要等 MySQL 健康检查通过后才真正启动。
volumes里声明的mysql_data是命名数据卷,MySQL 的数据文件持久化在这里,容器删了重建,数据不会丢。
6.2 常用 Compose 命令
docker compose up -d:根据配置构建并以后台方式启动所有服务,-d表示后台运行。docker compose down:停止并删除所有容器,数据卷默认不会被删除,除非加-v。docker compose ps:查看所有服务状态。docker compose logs -f <服务名>:追踪某个服务的实时日志,相当于前端控制台。
实际项目里,我通常会针对不同环境维护多份配置:docker-compose.yml放公共配置,docker-compose.dev.yml放本地开发配置,docker-compose.prod.yml放生产配置,通过-f指定多份配置文件来组合启动。这种方式灵活性确实很强,但文件多了也容易混乱,建议如果团队规模不大,先维护一份简洁的单文件配置,真的遇到差异化需求再拆分。
6.3 日志是排查问题的主要入口
启动完成后一切正常,不代表不会出问题。前端和后端一旦联调失败,我会按这个顺序排查:
docker compose ps看所有容器是否都在运行。docker compose logs backend看后端日志有没有报错。- 如果后端日志显示连不上数据库,
docker compose exec mysql mysql -uroot -p进 MySQL 容器里,直接在容器内执行 SQL 验证账号权限和数据是否存在。
记住别一上来就改代码,先看日志。日志能回答的问题,就不该靠猜。
7. 从本地到云端:云服务器部署的完整闭环
本地已经能构建镜像、能通过 Compose 一键启动整套服务,接下来就是把这个能力迁移到云服务器,实现真正的线上部署。
我以一台 Linux 云服务器(Ubuntu 22.04)为例,完整走一遍流程。
7.1 服务器初始化:安全组、SSH 登录、装 Docker
购买服务器后,先做两件事:登录云厂商控制台,在安全组规则里放行需要用到的端口(80、443 和 SSH 端口,需要自己手动加上)。不放行规则,就算容器监听端口正常,外部流量也进不来。
然后用 SSH 登录:
ssh root@你的服务器公网IP登录后先更新系统,再安装 Docker:
apt update apt install -y docker.io docker-compose-v2 systemctl enable docker systemctl start dockerUbuntu 的官方源里 Docker 版本可能不是最新的,但对绝大多数场景完全够用。如果用apt install docker-compose-v2装好后命令是docker compose,注意带横杠的docker-compose是老版独立的 Python 工具,现在官方已经推荐前者。
7.2 镜像加速:服务器拉镜像更快
国内云服务器直接访问 Docker Hub 时经常超时,配置文件里加镜像加速器是标准操作。编辑/etc/docker/daemon.json:
{ "registry-mirrors": [ "https://docker.m.daocloud.io", "https://dockerproxy.com" ] }改完执行systemctl restart docker。这一步能明显提升docker pull nginx这类操作的速度。
7.3 把代码传到服务器并构建镜像
有几种方式:最简单的是git clone项目仓库到服务器,然后在项目目录里执行docker build。这种方式在操作上最顺手,也是我最常用的做法。
假设项目已经 clone 到/opt/app/frontend:
cd /opt/app/frontend docker build -t my-frontend:latest .构建完之后,用docker images查看本地镜像,确认 my-frontend 已经存在。然后运行容器:
docker run -d -p 80:80 --name frontend-container my-frontend:latest浏览器访问http://服务器公网IP,看到你的页面就是成功。
如果是前后端整套服务,直接git clone后到项目根目录docker compose up -d,一次拉起所有服务。
7.4 更新部署:改代码之后怎么发新版本
部署不是一次性的,线上项目每周甚至每天都要迭代。手动部署的更新流程是:
cd /opt/app/frontend git pull docker build -t my-frontend:latest . docker stop frontend-container docker rm frontend-container docker run -d -p 80:80 --name frontend-container my-frontend:latest这套流程虽然原始,但每一步清晰可控,适合中小型项目和刚开始接触容器化的团队。等跑熟之后再考虑接入 CI/CD 自动化流程,让提交代码自动构建、自动部署。
7.5 用 Docker 做自动化部署的思路
前面那套手动流程,最终可以升级为一条 CI 流水线:代码 push 到仓库后,自动触发构建镜像,推送到镜像仓库,再通过 SSH 远程到服务器拉取新镜像并重启容器。GitHub Actions 里很常见的步骤大概是这样的:checkout 代码,登录镜像仓库,构建并 push 镜像,SSH 到服务器执行docker pull和docker compose up -d。
这个过程里 Docker 的价值再次体现出来:镜像在构建阶段就把环境固定住了,服务器上只需要有 Docker,不需要关心 Node 版本、Nginx 版本这些依赖是否安装正确,因为镜像本身已经包含了完整可运行环境。
8. 前端部署实战中的高频问题与我的排查习惯
最后分享一些我在实际部署中遇到很多次、也帮初学者排查过很多次的常见问题。
8.1 容器启动后立即退出
docker logs <容器ID>大概率会显示具体报错。常见原因不外乎:CMD 命令写错、Nginx 没有前台运行、端口被占用。前两个前面已经讲过,端口被占用则是在宿主机执行netstat -tlnp | grep 80检查一下哪个进程霸占了 80 端口,把冲突的进程关掉或改用其他宿主端口。
8.2 页面 404 或白屏
先确认路由模式。如果是 history 模式,Nginx 有没有配置try_files;如果是纯静态托管,要确认root指向的目录里是不是真的有index.html。我见过很多次实际组合是:Dockerfile 里COPY的源路径跟构建产物目录对不上,dist 根本没被复制进去,容器里当然找不到页面。
进容器里验证一下是最直接的方式:
docker exec -it <容器ID> sh ls /usr/share/nginx/html看到没有文件,就说明 Dockerfile 里 COPY 的路径有问题,或者构建阶段根本没有产出 dist。
8.3 前端请求接口 502
502 的本质是 Nginx 在后端地址上连不上一个有效服务。常见原因有这么几个:proxy_pass配置的地址端口不对;后端容器没有在 Compose 网络里;后端启动失败。前端能访问到 Nginx 页面,说明 Nginx 本身是好的,问题出在 Nginx 和后端之间的链路上。先docker compose ps确认后端在不在,再docker compose logs backend看启动日志,逐步缩小范围。
8.4 环境变量在构建时失效
这个坑很多前端会踩:在 Dockerfile 里写RUN npm run build,然后在构建命令里指定环境变量:
docker build --build-arg API_BASE_URL=https://api.example.com -t my-frontend .但 Dockerfile 里没有声明 ARG 或 ENV,导致变量没有传递进去。正确做法是在 Dockerfile 里增加:
ARG API_BASE_URL ENV VITE_API_BASE_URL=$API_BASE_URLVite 的环境变量默认只支持以VITE_前缀开头的变量在构建时被注入到代码中。这个细节不搞清楚,构建出来的前端可能会在运行时一直报接口地址没配置的错误。
8.5 我个人的部署习惯
经过一段时间的项目历练后,我现在养成了几个固定习惯,也变成了一套操作模板:镜像标签永远打明确版本,而不是一直用 latest,方便回滚;构建命令固定用docker compose up -d --build组合,确保代码更新时镜像同步构建;每次部署完一定在浏览器用无痕窗口访问一次线上地址,避免本地缓存干扰判断;日志别等出问题了才看,养成docker compose logs随便翻一翻的习惯,能提前发现很多隐患。
还有一点对前端特别友好的认知:Docker 和前后端分离架构天然就是配合的。前端构建出的产物本质只是静态文件,而静态文件的托管用 Docker + Nginx 本来就是最标准、最正规的组合。掌握了这套流程以后,你会发现自己对"怎么把一个前端项目安全、稳定、可更新地跑在服务器上"这件事,从模糊变成清晰,这种掌控感还挺值得拥有的。
以后不管是参加面试被问到 Docker 的底层原理,还是工作中要给团队发一个测试包,你都具备了动手做出来、而不是只说概念的能力,这种"能做出来"的底气,比背熟一堆命令更有价值。