Docker 容器化实战:从零搭建 Web 服务与反向代理

为什么需要容器化?

想象一下这个场景:你第一天到新公司报到,接手了一个五年前用 Vue2 写的老项目。项目文档上写着"请使用 Node 16 + npm 8 运行"。你打开自己的电脑,发现装的是 Node 22 ------ 你尝试直接运行,项目报错;你尝试降级 Node,但电脑上还有其他项目依赖新版本。你陷入了两难。

这就是软件开发中经常遇到的"环境不一致"问题。每个项目都有自己依赖的运行环境:特定的 Node 版本、特定的数据库版本、特定的系统库。如果每次切换项目都要重新配置环境,不仅效率低下,还容易产生各种意想不到的问题。

Docker 就是为了解决这个问题而生的工具。它能把你的应用程序和它所需要的所有依赖环境打包在一起,形成一个独立的"容器"。这个容器可以在任何安装了 Docker 的设备上运行,而且运行结果完全一致。就像海运中使用标准集装箱来运输货物一样,不管里面装的是什么,都可以用同样的方式装载、运输和卸载。

Docker 的核心概念

理解 Docker 需要掌握两个基本概念:镜像(Image)容器(Container)

镜像可以理解为一个"模板"或"快照"。它包含了运行某个应用所需要的全部内容:代码、运行环境、系统工具、库文件等。镜像是只读的,你可以在任何地方下载和分享它。

容器则是镜像的一个"运行实例"。当你基于一个镜像启动容器时,Docker 会在镜像之上添加一个可写的文件层,让你可以在容器内进行操作和修改。你可以随时启动、停止、删除容器,而不会影响原始的镜像文件。

打个比方:镜像就像是游戏光盘,里面刻录了完整的游戏内容;容器就像是把光盘放入游戏机运行起来,你可以操作游戏,但光盘本身的内容不会改变。你可以用同一张光盘在多个游戏机上运行出同样的游戏体验。

第一个 Docker 容器:Nginx 反向代理

让我们通过一个实际案例来理解 Docker 的使用。假设你有一个 Node.js 写的 Web 服务,运行在 1314 端口。但用户访问网站时通常使用 80 端口(HTTP 默认端口)或 443 端口(HTTPS 默认端口)。你需要在用户和你的 Node 服务之间加一个"中间人",负责把 80 端口的请求转发给 1314 端口。

这个"中间人"就是反向代理服务器,我们选用 Nginx 来实现。下面一步步来看如何用 Docker 完成这个任务。

第一步:编写 Node.js 服务

首先,我们创建一个最简单的 Node.js HTTP 服务:

javascript

javascript 复制代码
// index.js
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello world');
});

server.listen(1314, '0.0.0.0', () => {
  console.log('node service run on 1314');
});

这段代码启动了一个 HTTP 服务器,监听 1314 端口。0.0.0.0 表示监听所有网络接口,这样容器内外都能访问到这个服务。

第二步:配置 Nginx 反向代理

接下来,我们需要写一个 Nginx 配置文件,让它监听 80 端口并把请求转发给我们的 Node 服务:

nginx

ini 复制代码
events {}

http {
    server {
        listen 80;
        location / {
            proxy_pass http://host.docker.internal:1314;
            proxy_set_header Host $host;
        }
    }
}

这里有几个关键点需要解释:

  • listen 80 表示 Nginx 监听容器内的 80 端口
  • location / 表示匹配所有路径的请求
  • proxy_pass 指定了转发目标地址
  • host.docker.internal 是一个特殊的域名,在容器内部可以用来访问宿主机(就是运行 Docker 的那台电脑)

为什么要用 host.docker.internal?因为我们的 Node 服务是运行在宿主机上的,而 Nginx 运行在容器内。容器有自己独立的网络空间,不能直接通过 localhost 访问到宿主机上的服务。Docker 提供了 host.docker.internal 这个特殊的 DNS 名称来解决这个问题。

第三步:用 Docker 启动 Nginx

准备工作就绪后,我们通过以下命令启动 Nginx 容器:

bash

bash 复制代码
docker run \
  --name my-nginx-demo \
  -p 80:80 \
  -v /path/to/nginx.conf:/etc/nginx/nginx.conf \
  -d nginx

让我们逐行拆解这个命令做了什么:

  • docker run:基于一个镜像启动新容器
  • --name my-nginx-demo:给容器起个名字,方便后续管理
  • -p 80:80:端口映射。将宿主机的 80 端口映射到容器的 80 端口。当用户在浏览器访问 http://localhost 时,实际上访问的是宿主机的 80 端口,然后 Docker 把这个请求转发给容器内的 80 端口
  • -v /path/to/nginx.conf:/etc/nginx/nginx.conf:卷挂载。把宿主机上的 Nginx 配置文件映射到容器内的对应位置。这样你修改配置文件后重启容器就能生效,不需要重新构建镜像
  • -d nginx-d 表示后台运行,nginx 是镜像名称。如果本地没有这个镜像,Docker 会自动从 Docker Hub 下载

启动成功后,访问 http://localhost,你会看到浏览器显示 "hello world"。请求流程是这样的:

用户浏览器 → 宿主机 80 端口 → Docker 端口映射 → 容器 80 端口 → Nginx 监听 → 反向代理转发 → 宿主机 1314 端口 → Node 服务响应

这就是一个完整的反向代理流程。反向代理的好处是:用户不需要知道后端服务具体运行在哪个端口,甚至不需要知道后端有多少台服务器在提供服务。

第四步:管理 Docker 容器的常用命令

随着你启动的容器越来越多,学会管理容器变得很重要。这里列出几个常用命令:

查看运行中的容器:

bash

复制代码
docker ps

查看所有容器(包括已停止的):

bash

css 复制代码
docker ps -a

停止一个运行中的容器:

bash

arduino 复制代码
docker stop 容器名或容器ID

删除一个已停止的容器:

bash

bash 复制代码
docker rm 容器名或容器ID

停止并删除所有容器:

bash

javascript 复制代码
docker stop $(docker ps -q)
docker rm $(docker ps -aq)

删除一个镜像:

bash

复制代码
docker rmi 镜像名

MySQL 容器化部署

除了 Web 服务器,数据库也是常见的容器化对象。我们来看如何用 Docker 运行 MySQL:

bash

css 复制代码
docker run -d \
  --name mysql-demo \
  -p 3307:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  mysql:8.0

这个命令和启动 Nginx 类似,但多了几个新参数:

  • -p 3307:3306:把宿主机的 3307 端口映射到容器的 3306 端口(MySQL 默认端口)。这样做是为了避免和宿主机上已有的 MySQL 服务冲突
  • -e MYSQL_ROOT_PASSWORD=123456:设置环境变量。MySQL 镜像会读取这个变量来初始化 root 用户的密码
  • mysql:8.0:指定了镜像名称和标签(版本号)

容器启动后,你可以通过 localhost:3307 来连接这个 MySQL 服务。

进入容器内部

有时候你需要进入容器内部进行调试或执行命令。比如你想在 MySQL 容器中执行 SQL 命令:

bash

bash 复制代码
# 进入容器的终端
docker exec -it mysql-demo /bin/bash

# 进入容器后,连接 MySQL
mysql -uroot -p123456

docker exec 命令可以在运行中的容器内执行新命令。-it 表示交互式终端,/bin/bash 是要执行的 shell。进入容器后,你就像登录了一台 Linux 服务器一样,可以使用各种系统命令。

运维视角:Docker 在部署中的价值

从运维的角度来看,Docker 带来了几个显著的好处:

环境一致性:开发、测试、生产环境使用相同的镜像,避免了"在我电脑上能跑"的尴尬。开发人员本地验证通过的镜像,直接推到生产环境使用。

快速部署 :以前部署一个应用需要安装各种依赖、配置环境变量、调整系统参数,可能需要几个小时。现在只需要 docker pull 拉取镜像,docker run 启动容器,几分钟就能完成。

资源隔离:容器之间共享宿主机内核,但拥有独立的文件系统、网络栈和进程空间。一个容器不会影响另一个容器的运行。

版本管理 :每个镜像都带有版本标签,可以随时回滚到旧版本。mysql:8.0mysql:5.7 可以在同一台机器上和平共存。

TypeScript 高级类型:让代码更安全

聊完 Docker,我们来谈谈 TypeScript 中的一些高级类型工具。这些工具在实际项目中非常实用,可以帮助你写出更安全的代码。

什么是工具类型?

工具类型是 TypeScript 内置的一些泛型类型,它们可以对已有类型进行转换,生成新的类型。常见的有 PickOmitPartialRecordExclude 等。

Pick:从类型中挑选部分属性

假设你有一个用户信息类型:

typescript

typescript 复制代码
interface User {
  id: number;
  name: string;
  age: number;
  email: string;
}

在用户列表页面,你可能只需要展示 id 和 name;在详情页面,你可能需要全部字段;在修改资料页面,你又不希望暴露 id 和 email。这时候就可以用 Pick 来创建一个只包含特定属性的新类型:

typescript

ini 复制代码
type UserPreview = Pick<User, 'id' | 'name'>;

const user: UserPreview = {
  id: 1,
  name: '张三'
};

Pick<T, K> 的含义是:从类型 T 中挑选出键名为 K 的属性,组成一个新的类型。K 必须是 T 的键名之一。

Omit:排除部分属性

Pick 相反,Omit 用于排除某些属性:

typescript

ini 复制代码
type UserSafe = Omit<User, 'email'>;

const safeUser: UserSafe = {
  id: 2,
  name: '李四',
  age: 30
};

这里的 UserSafe 去掉了 email 字段,适合在不需要敏感信息的场景下使用。

Omit 的内部实现很有意思,它等价于:

typescript

r 复制代码
type MyOmit<T, K> = Pick<T, Exclude<keyof T, K>>;

我们一步步拆解这个过程:

  1. keyof T 获取类型 T 的所有键名,得到 'id' | 'name' | 'age' | 'email'
  2. Exclude<U, K> 从联合类型 U 中剔除 K,得到 'id' | 'name' | 'age'
  3. Pick<T, ...> 从 T 中选取剩下的这些键,得到排除 email 后的类型

所以 Omit<User, 'email'> 就是先从 User 的键名中去掉 'email',再挑选剩余的键构建新类型。

Partial:将所有属性变为可选

当你需要更新一个对象的某些字段时,Partial 非常有用:

typescript

ini 复制代码
type PartialUser = Partial<User>;

// 可以只传部分字段
const patchUser: PartialUser = {
  name: '王五',
  age: 25
};

// 甚至可以传空对象
const emptyUser: PartialUser = {};

Partial 会把类型中所有属性都变成可选的,这在 API 的 PATCH 请求中很常见------客户端只需要发送要修改的字段即可。

Record:构建键值对类型

Record 用于定义键值对的结构:

typescript

ini 复制代码
type Dict = Record<string, number>;
const scores: Dict = { math: 90, english: 85 };

Record<K, V> 表示键的类型为 K,值的类型为 V 的对象。这在定义映射关系时非常有用,比如 HTTP 状态码与错误信息的映射:

typescript

typescript 复制代码
type ErrorMsgMap = Record<number, string>;

const errorMessages: ErrorMsgMap = {
  400: '请求参数错误',
  401: '未登录,请重新登录',
  403: '权限不足',
  404: '资源未找到',
  500: '服务器内部错误'
};

function getErrorMsg(code: number): string {
  return errorMessages[code] ?? '未知错误';
}

Exclude:处理联合类型

Exclude 用于从联合类型中排除某些成员:

typescript

ini 复制代码
type AllFields = 'id' | 'name' | 'age' | 'email';
type WithoutEmail = Exclude<AllFields, 'email'>;
// 结果:'id' | 'name' | 'age'

注意 ExcludeOmit 的区别:Exclude 处理的是联合类型Omit 处理的是对象类型

更多工具类型

  • ReturnType:获取函数的返回值类型

    typescript

    bash 复制代码
    function getUser() { return { id: 1, name: '赵六' } }
    type UserType = ReturnType<typeof getUser>;
    // 结果:{ id: number; name: string }
  • Readonly:将所有属性变为只读

  • Required:将所有可选属性变为必选

总结

本文从实际问题出发,介绍了 Docker 容器化的核心概念和实践操作。我们通过 Nginx 反向代理和 MySQL 部署两个实例,学习了镜像、容器、端口映射、卷挂载等关键概念。容器化技术让应用部署变得更加标准化和可预测,是现代软件开发中不可或缺的工具。

同时,我们也学习了 TypeScript 中的几个高级类型工具。这些工具可以帮助你在开发过程中更好地约束数据类型,提前发现潜在的错误。类型系统越完善,运行时出现类型相关 bug 的可能性就越小。

希望这篇文章能帮你建立对 Docker 和 TypeScript 工具类型的初步认识。动手实践是最好的学习方式,建议你按照本文的步骤在自己的电脑上操作一遍,遇到问题时多查阅官方文档。

相关推荐
王中阳Go1 小时前
做了 3 年 Spring Boot,老板让我一周搭个 AI Agent,上线当天被安全叫停:Java 团队落地 AI 的三道坎,我替你踩完了
后端·面试
Liora_Yvonne1 小时前
前端项目为什么需要一个配置单一真相源?
前端
禁止摆烂_才浅1 小时前
Vite 面试题
前端·面试·vite
GISer_Jing1 小时前
全栈AI实战:基于 TypeScript + LangChain + MCP 的企业级智能研发助手
前端·后端·ai·langchain·前端框架
禁止摆烂_才浅1 小时前
微信小程序高频面试题
前端·面试·微信小程序
禁止摆烂_才浅1 小时前
Vue2 高频面试题
前端·vue.js·面试
乒乓狂魔14786739970001 小时前
Grafana 的全家桶,Tempo、Loki 看起来过时了
后端
Java内核笔记1 小时前
万字长文剖析 Spring Boot 4 自动配置机制源码:从 @EnableAutoConfiguration 到条件装配
java·后端
禁止摆烂_才浅1 小时前
前端性能优化面试题
前端·面试·性能优化