跳到主要内容

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 vs ENTRYPOINT
  • CMD 提供默认命令,可被 docker run 的参数覆盖
  • ENTRYPOINT 定义容器的入口程序,不会被覆盖(除非使用 --entrypoint
  • 最佳实践:用 ENTRYPOINT 指定主进程,用 CMD 提供默认参数

前端项目 Dockerfile 编写

基础版本(单阶段)

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 镜像部署

Dockerfile
# ========== 阶段一:构建 ==========
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;"]
多阶段构建的优势
  1. 镜像体积极小:最终镜像只有 Nginx + 静态文件,约 25MB(对比单阶段的 1GB+)
  2. 安全性高:生产镜像不包含源代码、node_modules、构建工具
  3. 构建缓存:分层策略让依赖变更和代码变更互不影响

构建镜像并运行

构建与运行
# 构建镜像,传入构建参数
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、密钥)打包进镜像
  • 防止缓存失效:排除频繁变更的无关文件
.dockerignore
# 依赖目录
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 模式支持。

nginx.conf
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;
}
}
Nginx 缓存策略要点
  • index.html:绝对不缓存(no-cache),确保每次都获取最新版本,从而加载到最新的 JS/CSS 文件
  • 带 hash 的 JS/CSS:强缓存 1 年(immutable),因为内容变更时文件 hash 会改变,用户一定会获取新文件
  • 图片/字体:缓存 6 个月,兼顾性能与更新

Docker Compose 多服务编排

Docker Compose 是 Docker 的多容器编排工具,通过一个 YAML 文件定义和管理多个服务。这在全栈开发和本地联调中非常常见。

前端 + 后端 + 数据库

docker-compose.yml
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:

配合环境变量文件

.env
# Docker Compose 会自动读取同目录下的 .env 文件
DB_PASSWORD=your_secure_password_here
NODE_ENV=production

Docker Compose 常用命令

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 中指令的顺序至关重要。

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 的缓存也会失效,导致每次构建都要重新安装所有依赖。

进一步压缩镜像体积

Dockerfile(极致优化)
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:/dataDocker 管理,适合数据库等持久化
绑定挂载(Bind Mount)-v /host/path:/container/path直接映射宿主机目录,适合开发调试
tmpfs 挂载--tmpfs /tmp内存中存储,不持久化,适合临时文件

CI/CD 中的 Docker 使用

Docker 在 CI/CD 流水线中扮演着核心角色,确保构建环境的一致性和部署的可重复性。

GitHub Actions 示例

.github/workflows/deploy.yml
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 编写部署脚本,实现更灵活的部署逻辑:

scripts/deploy.ts
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);
CI/CD 最佳实践
  1. 镜像标签:使用 Git commit SHA 作为镜像标签,而非仅用 latest,便于回滚
  2. 构建缓存:利用 Docker Layer Cache(如 GitHub Actions 的 cache-from: type=gha)加速构建
  3. 多环境配置:通过 ARG / --build-arg 注入不同环境的变量
  4. 健康检查:配合 HEALTHCHECK 指令确保容器正常运行

常见面试问题

Q1: Docker 镜像和容器的区别是什么?Dockerfile 中 CMD 和 ENTRYPOINT 有什么区别?

答案

镜像与容器的区别:

对比项镜像(Image)容器(Container)
本质只读模板(多层文件系统)镜像的运行实例
状态静态,不可修改动态,可写层
存储磁盘上的分层文件运行在内存中的进程
生命周期持久存在,可复用创建、运行、停止、删除
类比类(Class)实例(Instance)
创建方式docker builddocker run

CMD 与 ENTRYPOINT 的区别:

CMD 示例
FROM node:20-alpine
CMD ["node", "server.js"]

# docker run myapp -> 执行 node server.js
# docker run myapp node test.js -> CMD 被覆盖,执行 node test.js
ENTRYPOINT 示例
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 被覆盖)
对比项CMDENTRYPOINT
是否可被覆盖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 下的键名)作为主机名互相访问。

docker-compose.yml
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:

通信链路:

关键要点:

  1. depends_on:控制启动顺序(但不保证服务就绪,需配合 healthcheck
  2. expose vs portsexpose 仅在容器网络内开放端口,ports 映射到宿主机
  3. 服务名即主机名proxy_pass http://backend:3000 中的 backend 就是服务名
  4. 环境变量注入:后端通过 DB_HOST=database 连接数据库,database 也是服务名
nginx.conf 中的反向代理
# 前端 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 中的后端服务:

vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // Docker 映射到宿主机的端口
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
});

相关链接