Docker 与容器化
问题
什么是 Docker?前端项目如何使用 Docker 进行容器化部署?如何编写高效的 Dockerfile、配置 Nginx、使用 Docker Compose 编排多服务?
答案
Docker 是现代前端工程化中不可或缺的工具。它通过容器技术将应用及其依赖打包为一个标准化的运行单元,实现了「一次构建、到处运行」的目标。对于前端开发者来说,掌握 Docker 不仅能提升部署效率,也是高级前端岗位面试中的高频考点。
Docker 核心概念
镜像(Image)
镜像是一个只读模板,包含了运行应用所需的所有文件系统、库、环境变量和配置。镜像由多层(Layer)叠加而成,每一层对应 Dockerfile 中的一条指令。层与层之间共享和复用,这使得镜像的存储和传输非常高效。
可以把镜像理解为「类(Class)」,容器理解为「实例(Instance)」。一个镜像可以创建多个容器,每个容器互相隔离。
容器(Container)
容器是镜像的运行实例。容器在镜像的只读层之上添加了一个可写层,所有运行时的修改都发生在这个可写层中。容器具有自己独立的文件系统、进程空间、网络栈。
# 从镜像创建并启动容器
docker run -d -p 80:80 --name my-app nginx:alpine
# 查看运行中的容器
docker ps
# 进入容器内部
docker exec -it my-app sh
# 查看容器日志
docker logs -f my-app
# 停止并删除容器
docker stop my-app && docker rm my-app
仓库(Registry)
仓库是存放镜像的服务,类似于 npm registry 之于 npm 包。Docker Hub 是官方公共仓库,企业通常会搭建私有仓库(如 Harbor、阿里云容器镜像服务等)。
Dockerfile
Dockerfile 是构建镜像的脚本文件,包含一系列指令,每条指令创建镜像的一层。
| 指令 | 作用 | 示例 |
|---|---|---|
FROM | 指定基础镜像 | FROM node:20-alpine |
WORKDIR | 设置工作目录 | WORKDIR /app |
COPY | 复制文件到镜像 | COPY package.json . |
RUN | 执行命令 | RUN npm install |
EXPOSE | 声明端口 | EXPOSE 3000 |
CMD | 容器启动命令 | CMD ["node", "server.js"] |
ENTRYPOINT | 容器入口点 | ENTRYPOINT ["nginx"] |
ENV | 设置环境变量 | ENV NODE_ENV=production |
ARG | 构建时变量 | ARG API_URL |
VOLUME | 挂载数据卷 | VOLUME ["/data"] |
CMD提供默认命令,可被docker run的参数覆盖ENTRYPOINT定义容器的入口程序,不会被覆盖(除非使用--entrypoint)- 最佳实践:用
ENTRYPOINT指定主进程,用CMD提供默认参数
前端项目 Dockerfile 编写
基础版本(单阶段)
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "preview"]
这种方式虽然简单,但最终镜像中包含了 Node.js 运行时、node_modules、源代码等构建依赖,导致镜像体积非常大(通常 > 1GB)。
多阶段构建(推荐)
多阶段构建(Multi-stage Build)是前端项目 Docker 化的最佳实践。核心思路是:用 Node.js 镜像构建,用 Nginx 镜像部署。
# ========== 阶段一:构建 ==========
FROM node:20-alpine AS builder
WORKDIR /app
# 先复制依赖文件,利用缓存分层
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# 再复制源代码并构建
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN pnpm build
# ========== 阶段二:部署 ==========
FROM nginx:1.25-alpine AS production
# 复制构建产物到 Nginx 目录
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制自定义 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
- 镜像体积极小:最终镜像只有 Nginx + 静态文件,约 25MB(对比单阶段的 1GB+)
- 安全性高:生产镜像不包含源代码、node_modules、构建工具
- 构建缓存:分层策略让依赖变更和代码变更互不影响
构建镜像并运行
# 构建镜像,传入构建参数
docker build \
--build-arg VITE_API_URL=https://api.example.com \
-t my-frontend:1.0.0 .
# 运行容器
docker run -d -p 80:80 --name frontend my-frontend:1.0.0
.dockerignore 配置
.dockerignore 的作用类似 .gitignore,它告诉 Docker 在构建上下文中排除哪些文件。合理配置 .dockerignore 可以:
- 减小构建上下文大小,加快
docker build速度 - 避免将敏感文件(如
.env、密钥)打包进镜像 - 防止缓存失效:排除频繁变更的无关文件
# 依赖目录
node_modules
# 构建产物(容器内重新构建)
dist
build
# 版本控制
.git
.gitignore
# IDE 配置
.vscode
.idea
*.swp
# 环境变量(敏感信息)
.env
.env.local
.env.*.local
# Docker 自身配置
Dockerfile
docker-compose*.yml
.dockerignore
# 测试与文档
coverage
__tests__
*.test.ts
*.spec.ts
docs
README.md
# 操作系统文件
.DS_Store
Thumbs.db
永远不要将 node_modules 放进构建上下文。即使 COPY . . 会复制它,也应该在容器内重新 npm install,以确保依赖与容器环境兼容(例如 node-gyp 编译的原生模块)。
Nginx 配置
对于前端 SPA 应用,Nginx 的配置至关重要。以下是一份生产级配置,包含反向代理、gzip 压缩、缓存策略和 SPA history 模式支持。
server {
listen 80;
server_name localhost;
# 根目录指向构建产物
root /usr/share/nginx/html;
index index.html;
# ========== Gzip 压缩 ==========
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml
application/rss+xml image/svg+xml;
# ========== 静态资源缓存 ==========
# 带 hash 的静态资源:强缓存 1 年
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 图片、字体等资源
location ~* \.(?:png|jpg|jpeg|gif|ico|svg|webp|avif|woff2?|ttf|eot)$ {
expires 6M;
add_header Cache-Control "public";
access_log off;
}
# ========== API 反向代理 ==========
location /api/ {
proxy_pass http://backend: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_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ========== SPA History 模式 ==========
# 所有路由都回退到 index.html,交由前端路由处理
location / {
try_files $uri $uri/ /index.html;
# index.html 不缓存,确保用户始终获取最新版本
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
# ========== 安全 Headers ==========
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
- index.html:绝对不缓存(
no-cache),确保每次都获取最新版本,从而加载到最新的 JS/CSS 文件 - 带 hash 的 JS/CSS:强缓存 1 年(
immutable),因为内容变更时文件 hash 会改变,用户一定会获取新文件 - 图片/字体:缓存 6 个月,兼顾性能与更新
Docker Compose 多服务编排
Docker Compose 是 Docker 的多容器编排工具,通过一个 YAML 文件定义和管理多个服务。这在全栈开发和本地联调中非常常见。
前端 + 后端 + 数据库
version: '3.8'
services:
# ========== 前端服务 ==========
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: /api
ports:
- "80:80"
depends_on:
- backend
networks:
- app-network
restart: unless-stopped
# ========== 后端服务 ==========
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=database
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=myapp
depends_on:
database:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
volumes:
- backend-logs:/app/logs
# ========== 数据库服务 ==========
database:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ========== 网络定义 ==========
networks:
app-network:
driver: bridge
# ========== 数据卷定义 ==========
volumes:
postgres-data:
backend-logs:
配合环境变量文件
# Docker Compose 会自动读取同目录下的 .env 文件
DB_PASSWORD=your_secure_password_here
NODE_ENV=production
Docker Compose 常用命令
# 构建并启动所有服务(后台运行)
docker compose up -d --build
# 查看服务状态
docker compose ps
# 查看某个服务的日志
docker compose logs -f frontend
# 停止并删除所有服务
docker compose down
# 停止并删除所有服务(包括数据卷)
docker compose down -v
# 仅重建某个服务
docker compose up -d --build frontend
# 进入某个服务的容器
docker compose exec backend sh
# 扩容某个服务
docker compose up -d --scale backend=3
镜像优化
镜像体积直接影响拉取速度、存储成本和安全攻击面。前端项目应该追求尽可能小的镜像。
优化策略对比
| 策略 | 效果 | 说明 |
|---|---|---|
| 多阶段构建 | 体积减少 90%+ | 只保留构建产物 |
| Alpine 基础镜像 | 体积减少 80%+ | node:20-alpine 仅 ~180MB vs node:20 ~1GB |
.dockerignore | 加快构建速度 | 排除无关文件 |
| 合并 RUN 指令 | 减少层数 | RUN cmd1 && cmd2 |
| 缓存分层 | 加快重复构建 | 先 COPY 依赖文件再 COPY 源代码 |
| 清理缓存 | 减少层体积 | npm cache clean --force |
缓存分层详解
Docker 的缓存机制基于层(Layer)。当某一层的内容未变化时,Docker 会复用缓存。因此,Dockerfile 中指令的顺序至关重要。
FROM node:20-alpine AS builder
WORKDIR /app
# 第一步:只复制依赖相关文件
# 只要 package.json 和 lockfile 不变,这一层就会被缓存
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# 第二步:复制源代码(频繁变更)
# 源代码变更不会影响上面依赖安装层的缓存
COPY . .
RUN pnpm build
# 错误示范:先 COPY 全部文件
COPY . . # 任何文件变更都会导致缓存失效
RUN pnpm install # 每次都要重新安装依赖!
RUN pnpm build
如果把 COPY . . 放在 pnpm install 之前,即使只修改了一行代码,pnpm install 的缓存也会失效,导致每次构建都要重新安装所有依赖。
进一步压缩镜像体积
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# 只安装生产依赖时不需要 devDependencies
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# 使用更小的 Nginx 镜像
FROM nginx:1.25-alpine AS production
# 删除默认配置
RUN rm -rf /etc/nginx/conf.d/*
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 以非 root 用户运行(安全最佳实践)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Docker 网络与数据卷
网络(Network)
Docker 提供多种网络驱动,容器之间可以通过网络进行通信。
| 网络模式 | 说明 | 适用场景 |
|---|---|---|
bridge | 默认模式,创建虚拟网桥 | 同一主机的容器互通 |
host | 共享宿主机网络 | 高性能、无需端口映射 |
overlay | 跨主机通信 | Docker Swarm / K8s |
none | 无网络 | 安全隔离 |
# 创建自定义网络
docker network create my-network
# 运行容器时指定网络
docker run -d --network my-network --name frontend nginx:alpine
docker run -d --network my-network --name backend node:20-alpine
# 同一网络内的容器可以通过容器名互相访问
# frontend 容器中可以通过 http://backend:3000 访问后端
在同一个 Docker Compose 文件中定义的服务默认处于同一网络。服务之间可以直接用服务名作为主机名互相访问,例如 Nginx 配置中的 proxy_pass http://backend:3000/ 就是通过服务名访问后端容器。
数据卷(Volume)
数据卷用于持久化容器中的数据。容器本身是临时的,重建后数据会丢失,因此需要数据卷来保存数据库数据、日志等。
# 创建命名卷
docker volume create my-data
# 挂载命名卷
docker run -d -v my-data:/var/lib/postgresql/data postgres:16-alpine
# 挂载宿主机目录(bind mount)
docker run -d -v $(pwd)/logs:/app/logs my-backend
# 查看所有数据卷
docker volume ls
# 清理未使用的数据卷
docker volume prune
| 类型 | 语法 | 特点 |
|---|---|---|
| 命名卷(Named Volume) | -v my-data:/data | Docker 管理,适合数据库等持久化 |
| 绑定挂载(Bind Mount) | -v /host/path:/container/path | 直接映射宿主机目录,适合开发调试 |
| tmpfs 挂载 | --tmpfs /tmp | 内存中存储,不持久化,适合临时文件 |
CI/CD 中的 Docker 使用
Docker 在 CI/CD 流水线中扮演着核心角色,确保构建环境的一致性和部署的可重复性。
GitHub Actions 示例
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/my-frontend:latest
${{ secrets.DOCKER_USERNAME }}/my-frontend:${{ github.sha }}
build-args: |
VITE_API_URL=${{ vars.API_URL }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/my-frontend:latest
docker stop frontend || true
docker rm frontend || true
docker run -d \
-p 80:80 \
--name frontend \
--restart unless-stopped \
${{ secrets.DOCKER_USERNAME }}/my-frontend:latest
使用 TypeScript 脚本管理 Docker 部署
在大型项目中,可以用 TypeScript 编写部署脚本,实现更灵活的部署逻辑:
import { execSync } from 'node:child_process';
interface DeployConfig {
imageName: string;
tag: string;
registry: string;
port: number;
envVars: Record<string, string>;
}
const config: DeployConfig = {
imageName: 'my-frontend',
tag: process.env.GIT_SHA ?? 'latest',
registry: process.env.DOCKER_REGISTRY ?? 'docker.io',
port: 80,
envVars: {
VITE_API_URL: process.env.VITE_API_URL ?? 'https://api.example.com',
},
};
function runCommand(cmd: string): string {
console.log(`> ${cmd}`);
return execSync(cmd, { encoding: 'utf-8', stdio: 'inherit' }) as unknown as string;
}
function buildImage(cfg: DeployConfig): void {
const buildArgs = Object.entries(cfg.envVars)
.map(([key, value]) => `--build-arg ${key}=${value}`)
.join(' ');
runCommand(
`docker build ${buildArgs} -t ${cfg.registry}/${cfg.imageName}:${cfg.tag} .`
);
}
function pushImage(cfg: DeployConfig): void {
runCommand(`docker push ${cfg.registry}/${cfg.imageName}:${cfg.tag}`);
}
function deploy(cfg: DeployConfig): void {
const fullImage = `${cfg.registry}/${cfg.imageName}:${cfg.tag}`;
// 停止旧容器
try {
runCommand(`docker stop ${cfg.imageName}`);
runCommand(`docker rm ${cfg.imageName}`);
} catch {
console.log('No existing container to remove');
}
// 启动新容器
runCommand(
`docker run -d -p ${cfg.port}:80 --name ${cfg.imageName} --restart unless-stopped ${fullImage}`
);
console.log(`Deployed ${fullImage} on port ${cfg.port}`);
}
// 执行部署流程
buildImage(config);
pushImage(config);
deploy(config);
- 镜像标签:使用 Git commit SHA 作为镜像标签,而非仅用
latest,便于回滚 - 构建缓存:利用 Docker Layer Cache(如 GitHub Actions 的
cache-from: type=gha)加速构建 - 多环境配置:通过
ARG/--build-arg注入不同环境的变量 - 健康检查:配合
HEALTHCHECK指令确保容器正常运行
常见面试问题
Q1: Docker 镜像和容器的区别是什么?Dockerfile 中 CMD 和 ENTRYPOINT 有什么区别?
答案:
镜像与容器的区别:
| 对比项 | 镜像(Image) | 容器(Container) |
|---|---|---|
| 本质 | 只读模板(多层文件系统) | 镜像的运行实例 |
| 状态 | 静态,不可修改 | 动态,可写层 |
| 存储 | 磁盘上的分层文件 | 运行在内存中的进程 |
| 生命周期 | 持久存在,可复用 | 创建、运行、停止、删除 |
| 类比 | 类(Class) | 实例(Instance) |
| 创建方式 | docker build | docker run |
CMD 与 ENTRYPOINT 的区别:
FROM node:20-alpine
CMD ["node", "server.js"]
# docker run myapp -> 执行 node server.js
# docker run myapp node test.js -> CMD 被覆盖,执行 node test.js
FROM node:20-alpine
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run myapp -> 执行 node server.js
# docker run myapp test.js -> 执行 node test.js(ENTRYPOINT 不变,CMD 被覆盖)
| 对比项 | CMD | ENTRYPOINT |
|---|---|---|
| 是否可被覆盖 | docker run 参数会覆盖 | 不会被覆盖(除非 --entrypoint) |
| 使用场景 | 提供默认命令或默认参数 | 定义容器的主进程 |
| 组合使用 | 作为 ENTRYPOINT 的默认参数 | 与 CMD 搭配使用 |
Q2: 前端项目如何优化 Docker 镜像体积?为什么要使用多阶段构建?
答案:
前端 Docker 镜像优化的核心思路是减少不必要的文件和层。
1. 使用多阶段构建(最重要)
# 单阶段:最终镜像包含 Node.js + node_modules + 源代码 ≈ 1.2GB
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npx", "serve", "dist"]
# ---
# 多阶段:最终镜像仅有 Nginx + 静态文件 ≈ 25MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
2. 优化缓存分层
将不常变动的操作(安装依赖)放在前面,频繁变动的操作(复制源代码)放在后面,最大化利用 Docker 缓存:
COPY package.json pnpm-lock.yaml ./ # 依赖文件变动少 -> 缓存命中率高
RUN pnpm install --frozen-lockfile # 依赖未变时完全复用缓存
COPY . . # 源代码经常变 -> 仅从此层开始重建
RUN pnpm build
3. 完整优化清单
| 优化手段 | 效果 | 优先级 |
|---|---|---|
| 多阶段构建 | 1.2GB -> 25MB | 必须 |
| Alpine 基础镜像 | 减少 80% 基础体积 | 必须 |
.dockerignore | 减少构建上下文 | 必须 |
| 缓存分层优化 | 重复构建加速 50%+ | 推荐 |
| 合并 RUN 指令 | 减少中间层 | 推荐 |
| 非 root 用户 | 提升安全性 | 推荐 |
Q3: Docker Compose 中如何实现前端和后端的联调?服务之间如何通信?
答案:
Docker Compose 中的服务通信基于自定义网络和服务发现机制。
核心原理:同一个 docker-compose.yml 中定义的服务默认处于同一个 bridge 网络,容器之间可以用服务名(即 services 下的键名)作为主机名互相访问。
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- app-net
# Nginx 中配置 proxy_pass http://backend:3000
backend:
build: ./backend
# 注意:不需要暴露 3000 端口到宿主机
# 只在内部网络中通信即可
expose:
- "3000"
environment:
- DB_HOST=database
- DB_PORT=5432
depends_on:
- database
networks:
- app-net
database:
image: postgres:16-alpine
expose:
- "5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- app-net
networks:
app-net:
driver: bridge
volumes:
pgdata:
通信链路:
关键要点:
depends_on:控制启动顺序(但不保证服务就绪,需配合healthcheck)exposevsports:expose仅在容器网络内开放端口,ports映射到宿主机- 服务名即主机名:
proxy_pass http://backend:3000中的backend就是服务名 - 环境变量注入:后端通过
DB_HOST=database连接数据库,database也是服务名
# 前端 Nginx 将 /api 开头的请求转发给后端
location /api/ {
proxy_pass http://backend:3000/; # backend 是 Docker Compose 中的服务名
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
在本地开发时,可以用 docker compose 启动后端和数据库,前端使用 vite dev 本地开发,通过 Vite 的 proxy 配置代理请求到 Docker 中的后端服务:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // Docker 映射到宿主机的端口
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
});