# nginx-lite **Repository Path**: yang_fan_81/nginx-lite ## Basic Information - **Project Name**: nginx-lite - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-30 - **Last Updated**: 2026-09-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # nginx-lite 一个用 C 写的**精简版 Nginx 风格静态文件服务器**:Linux 原生 socket 编程 + epoll 边沿触发多路复用 + 多线程 SO_REUSEPORT + sendfile 零拷贝。头文件与实现合计约 3400 行(含大量中文注释), 另配 570 行单元测试与 36 项端到端断言,专门为「逐步读源码学 Linux 网络编程」而设计。 > 目标平台:**Linux**(使用 epoll / accept4 / sendfile / SO_REUSEPORT / TCP_NODELAY 等 Linux 接口)。 > 在 macOS / Windows 上可以通过 Docker 或虚拟机运行,见下文「编译与运行」。 --- ## 1. 它能做什么 | 能力 | 实现位置 | 关键系统调用 / 知识点 | |---|---|---| | 监听、接受连接 | `net.c` / `server.c` | `socket` `bind` `listen` `accept4` `SO_REUSEPORT` `SO_REUSEADDR` | | 多路复用 I/O | `server.c` | `epoll_create1` `epoll_ctl` `epoll_wait`,**边沿触发 EPOLLET** | | 非阻塞读写 | `conn.c` | `O_NONBLOCK`、循环 `recv`/`send` 到 `EAGAIN`、`EPOLLOUT` 续写 | | 静态文件响应 | `response.c` + `conn.c` | `open` `fstat` `sendfile`(零拷贝)、`Range` 断点续传 | | HTTP/1.x 解析 | `http.c` | 请求行 / 头部字段 / query 拆分 / keep-alive 协商 / 长度上限 | | 并发连接处理 | `main.c` + `server.c` | 多线程,每线程独立 epoll + 独立监听 socket(无锁 accept) | | 长连接超时回收 | `conn.c` | LRU 双向链表 + `epoll_wait` 超时充当秒级定时器 | | 安全防护 | `path_util.c` | 百分号解码 → 词法归一化 → realpath → 前缀校验(防目录穿越/软链接逃逸) | | 访问日志 | `log.c` | `O_APPEND` 原子追加、`write(2)` 直写、互斥锁保护多线程 | | 配置系统 | `config.c` | `key value;` 极简配置 + 命令行覆盖 + 启动期校验 | | 优雅退出 | `main.c` | `sigaction` 不设 `SA_RESTART`,`volatile sig_atomic_t` 标志位 | 状态码支持:`200 206 301 304 400 403 404 405 416 431 500 501 503 505`。 **刻意不做的**(想清楚"为什么不做"比"全都做"更能体现工程判断): - 不解析请求体、不支持 chunked → 明确回 405 / 501,并强制关闭连接(避免请求走私) - 不支持 HTTP 流水线 → 检测到残留字节直接关连接(保守但安全) - 不做 TLS、不做动态内容、不做负载均衡上游 → 生产环境建议前面挂 nginx/caddy 做 TLS 与限流 --- ## 2. 目录结构与阅读顺序 ``` nginx-lite/ ├── README.md ← 你正在读的文件 ├── nginx-lite-源码导读.html ← ★ 配套深度长文(单文件离线可打开,含架构图/状态机图/零拷贝对比图) ├── Makefile ← make / debug / check / unit / test / install / clean ├── conf/nginx-lite.conf ← 配置文件(nginx 风格的 key value;) ├── www/ ← 示例站点(首页 + 样式 + 数据目录 + 子目录首页) ├── include/ ← 头文件(接口与数据结构契约) │ ├── common.h 全局常量、上限、错误日志宏、事件源标记 │ ├── util.h 动态字符串缓冲 nl_sbuf_t、HTTP 日期、HTML 转义 │ ├── net.h socket / epoll 薄封装 │ ├── conn.h 连接结构与状态机声明 │ ├── http.h 请求结构与解析接口 │ ├── response.h 由请求生成响应的接口 │ ├── path_util.h URI 解码 / 路径归一化 / 越权校验 │ ├── config.h / log.h / mime.h / server.h ├── src/ ← 实现 │ ├── main.c 进程入口:参数 -> 配置 -> 信号 -> 建监听 -> 起线程 │ ├── server.c 事件循环 + accept 循环 + daemonize │ ├── conn.c ★ 核心 连接状态机、读写驱动、sendfile 续传、超时回收 │ ├── net.c socket/epoll 封装的系统调用细节 │ ├── http.c HTTP 报文解析与语义处理 │ ├── response.c 状态码决策、响应头构造、目录列表、Range/304 │ ├── path_util.c ★ 核心 安全命门:路径归一化与前缀校验 │ ├── util.c / log.c / mime.c / config.c ├── tests/ │ ├── unit_test.c 逻辑层单元测试(任意 POSIX 平台可跑,无需开端口) │ └── smoke_test.sh 端到端冒烟测试(Linux,需要 curl) ├── deploy/ │ ├── nginx-lite.service systemd 单元文件 │ └── Dockerfile 多阶段构建,一行命令在 Mac 上跑起来 └── tools/syntax-check/ 非 Linux 平台用的 epoll/sendfile 桩头文件(仅供 make check) ``` **推荐的 4 遍阅读法**(从"能跑"到"懂原理",每遍只关心一条主线): | 第几遍 | 只看这些文件 | 要回答的问题 | |---|---|---| | 第 1 遍 | `main.c` → `server.c` → `net.c` | 服务器进程是怎么起来的?监听 socket 在哪创建?`while + epoll_wait` 长什么样? | | 第 2 遍 | `conn.c`(重点 `conn_on_readable` / `conn_try_write`) | 一次可读事件之后发生了什么?为什么必须循环读到 `EAGAIN`? | | 第 3 遍 | `http.c` → `response.c` | 一堆字节怎么变成"方法和路径"?状态码和响应头是怎么决定的? | | 第 4 遍 | `path_util.c` → `util.c` / `log.c` / `config.c` | 攻击者能怎么绕过?为什么 `realpath` 之后还要比较前缀? | `conn.c` 里的三个 `conn_*` 函数是整棵树的枢纽,建议边读边在纸上画出「状态迁移 + 事件触发」图。 --- ## 3. 编译与运行 ### 3.1 在 Linux 上(正式用法) ```bash # 依赖:gcc/clang + make + pthread(pthread 在 glibc 2.34+ 已并入 libc) sudo apt install -y build-essential # Debian/Ubuntu # sudo yum install -y gcc make glibc-devel # RHEL/CentOS make # 产出 build/nginx-lite ./build/nginx-lite -c conf/nginx-lite.conf # 前台启动,Ctrl-C 退出 ``` `make` 之后可以立刻验证(需要 curl): ```bash make test # 跑 tests/smoke_test.sh:36 项端到端断言 open http://127.0.0.1:8080/ # 或用 curl 打开示例站点首页 ``` ### 3.2 在 macOS / Windows 上(三种可选方式) 代码依赖 Linux 内核接口,所以本机**不能直接编译运行**。三种做法: **方式 A:Docker(最省事)** ```bash docker build -f deploy/Dockerfile -t nginx-lite . docker run --rm -p 8080:8080 nginx-lite # 挂载自己的站点: docker run --rm -p 8080:8080 -v "$PWD/www:/srv/www:ro" nginx-lite ``` **方式 B:本机只做静态检查(零依赖,跑不了但能查错)** ```bash make check # 用 tools/syntax-check 的桩头文件做 -fsyntax-only -Werror 全量检查 make unit # 逻辑层单元测试,真机执行 133 项断言(HTTP 解析/路径安全/响应构造) ``` **方式 C:起一台 Linux(UTM / Multipass / 云服务器)** 再按 3.1 操作。 > `make unit` 在 macOS 上是**真实执行**的:网络层之外的模块(HTTP 解析、路径归一化、 > 响应头计算、MIME、配置解析)都不依赖 Linux 接口,所以最容易出漏洞的那部分逻辑 > 可以在本地被完整验证。 ### 3.3 调试版本 ```bash make debug # -O1 -g + AddressSanitizer + UBSan ./build/nginx-lite-dbg -p 8080 -r ./www ``` > 注意:Apple clang 11 自带的 ASan 运行时在新版 macOS 上可能无法启动(会直接 abort)。 > `make debug` 请在 Linux 上使用;macOS 上可先用 `-fsanitize=undefined` 单跑。 --- ## 4. 配置与命令行 命令行参数(都会覆盖配置文件): ``` -c, --config FILE 配置文件路径 -l, --listen ADDR:PORT 监听地址,如 0.0.0.0:8080 或 127.0.0.1:8080 -p, --port PORT 端口 -r, --root DIR 站点根目录 -w, --workers N 工作线程数(建议 = CPU 核心数) -k, --keepalive SEC 长连接空闲超时秒数,0 = 禁用 keep-alive -a, --autoindex on|off 目录无首页时是否生成列表页 -D, --daemon 后台守护进程运行 -h, --help / -v, --version ``` 配置文件(`conf/nginx-lite.conf`): ``` listen 0.0.0.0:8080; backlog 512; root ./www; index index.html; autoindex on; worker_threads 4; max_connections 4096; tcp_nodelay on; keepalive_timeout 15; access_log logs/access.log; ``` **注意**:用 `-D` 后台运行时进程会 `chdir("/")`,`root` 与 `access_log` 请写成**绝对路径** (程序启动时会先对 `root` 做 `realpath`,日志文件也在 daemonize 之前打开,所以相对路径仅在 **前台**运行时安全)。 --- ## 5. 测试情况说明 | 命令 | 覆盖范围 | 是否需要 Linux | |---|---|---| | `make check` | 全部 11 个 .c 文件的语法/类型检查(`-Werror`,零告警) | 否 | | `make unit` | 133 项断言:URI 解码 / 路径归一化 / 越权校验 / HTTP 解析(含畸形报文)/ 请求→响应全链路 / 配置解析 | 否 | | `make test` | 36 项端到端断言:真实 TCP + curl + 原生 `/dev/tcp` 报文、Range 分段比对、1MB 文件 sha256、keep-alive 复用、200 并发、访问日志核对 | **是** | `make unit` 是"假连接"测试:手工构造 `nl_conn_t` 与 `nl_worker_t`(不创建任何 socket), 把原始请求字节喂给 `nl_http_parse`,再调用 `nl_response_prepare`,最后检查生成的响应头与 `file_off/file_end` 是否与预期一致。网络层不参与,但 HTTP 语义与安全逻辑 100% 被执行。 开发过程中这套测试抓到了一个真实缺陷,值得记录: > 最初 `nl_path_normalize` 会把路径末尾的 `/` 吃掉,于是 `GET /sub/` 被归一化成 `/sub`, > 服务器按"目录缺斜杠"回 301 指向 `/sub/`,客户端跟随后又拿到 301 —— **无限重定向**。 > 修复方式是在归一化时保留输入末尾的斜杠(末尾斜杠在 HTTP 里是有语义的: > 它表示"这是一个目录")。这个 bug 用肉眼 review 很难发现,但一条断言就暴露了。 --- ## 6. 部署到 Linux 服务器 ### 6.1 直接跑 / 后台跑 ```bash # 前台(调试用,日志直接打屏) ./build/nginx-lite -c conf/nginx-lite.conf # 后台守护进程(推荐配合日志文件) mkdir -p logs ./build/nginx-lite -c conf/nginx-lite.conf -D 2>>logs/error.log # 关闭 pkill -f nginx-lite # 或 kill $(pgrep nginx-lite) ``` ### 6.2 systemd(推荐) ```bash sudo mkdir -p /opt/nginx-lite && sudo cp -r . /opt/nginx-lite/ cd /opt/nginx-lite && make sudo cp deploy/nginx-lite.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now nginx-lite systemctl status nginx-lite journalctl -u nginx-lite -f # 看错误日志 tail -f /opt/nginx-lite/logs/access.log ``` ### 6.3 上线前必须调的三个内核/资源参数 ```bash # 1) 打开文件数上限:必须 ≥ worker_threads × max_connections ulimit -n 65535 # 临时;systemd 里用 LimitNOFILE=65535 # 2) 连接队列与端口复用(写入 /etc/sysctl.conf 后 sysctl -p) net.core.somaxconn = 65535 # 上限受 listen() 的 backlog 参数约束 net.ipv4.tcp_tw_reuse = 1 # 允许复用 TIME_WAIT 连接(客户端场景) net.ipv4.ip_local_port_range = 1024 65535 # 3) 大量小文件场景可考虑 net.ipv4.tcp_fastopen = 3 # 需要内核与应用同时支持 ``` ### 6.4 生产环境建议前置一层反向代理 本项目的定位是**教学**,请把它放在 nginx / Caddy / 云负载均衡后面: - TLS 终止、HTTP/2、gzip/brotli 压缩交给前置代理 - 限流、WAF、IP 黑名单交给前置代理 - 缓存交给前置代理(本项目**没有实现协商缓存之外的任何缓存**) --- ## 7. 压测与观察 ```bash # 安装压测工具:sudo apt install -y apache2-utils 或 wrk ab -k -c 100 -n 200000 http://127.0.0.1:8080/data/range-demo.txt wrk -t4 -c200 -d30s --latency http://127.0.0.1:8080/data/big.bin ``` 观察要点: - `-k` 开启 keep-alive:长连接下 QPS 会明显高于短连接(省掉建立/关闭连接的开销) - 换成 `data/big.bin`(1MB)压测:能看到 sendfile 零拷贝的吞吐优势 - 用 `ss -ltnp | grep 8080` 与 `ss -s` 观察队列;`netstat -s | head` 看丢包/重传 - 调 `worker_threads` 与 CPU 核心数对比:单线程也能压满一个核,多线程才能吃满多核 --- ## 8. 常见问题排查 | 现象 | 原因与处理 | |---|---| | `bind: Address already in use` | 端口被占用:`ss -ltnp \| grep 8080`;已设 `SO_REUSEADDR`,若仍失败说明有别的进程真的在监听 | | 启动后立刻退出、打印 `worker fd 耗尽` | `ulimit -n` 太小,见 6.3 | | 大量请求返回 503 | 达到 `max_connections` 上限:调大配置或提升 `ulimit -n` | | 访问目录返回 403 | 该目录没有 `index.html` 且 `autoindex off`(这是刻意设计:不列目录更安全) | | 请求某文件返回 403 而不是 404 | 文件是软链接且解析后落在站点根目录之外(安全策略拒绝),或权限不足(EACCES) | | `GET /sub` 返回 301 | 正常行为:目录 URI 补斜杠;不是死循环(服务器不会把带斜杠的 URI 再重定向) | | Ctrl-C 后要等约 1 秒才退出 | 事件循环的 `epoll_wait` 超时是 1000ms,用于超时扫描;信号已把标志位设好,最多等一个节拍 | | 访问日志为空 | `access_log` 是相对路径但用了 `-D`(daemonize 后 `chdir("/")`):改用绝对路径 | --- ## 9. 想再深入一点?这 10 个练习按难度排序 1. **日志切分**:SIGUSR1 触发关闭并重新打开访问日志(`fopen`→`reopen` 模式) 2. **IPv6 双栈**:当前 `getaddrinfo` 只取了第一个结果,改成同时监听 `::` 与 `0.0.0.0` 3. **sendfile 与 TLS 的冲突**:思考为什么 HTTPS 场景下 sendfile 会退化,尝试换成 `read/write` 对比吞吐 4. **etag 协商缓存**:补 `If-None-Match`,实现 `304` 的另一半 5. **多区间 Range**:支持 `bytes=0-9,20-29`(`multipart/byteranges`,需要自己算 boundary) 6. **定时器升级**:把"1 秒扫描 LRU 链表"换成时间轮或最小堆,支持毫秒级超时 7. **进程模型**:改成 nginx 那样的多进程 + `SO_REUSEPORT`,用 `fork` 共享监听 socket 8. **零拷贝再进一步**:研究 `TCP_CORK` / `MSG_MORE`,把响应头与正文合并成一个 TCP 段(少一个小包) 9. **热重载配置**:SIGHUP 触发重新读配置并平滑替换站点根目录(学习 `execve` 或 double-buffer 配置) 10. **压测对比**:写一份 read/write 版实现,和 sendfile 版做同机对比,量化零拷贝的收益 --- ## 10. 已知限制(诚实清单) - 只支持 `GET` / `HEAD`;无请求体、无 chunked、无 HTTP/2、无 TLS - 不支持 HTTP 流水线(检测到多余字节会关闭连接) - 单一 `root` 目录,不支持 `location` 路由、别名、重写规则 - 没有 gzip、没有缓存、没有限流、没有 upstream 负载均衡 - 定时器精度为 1 秒(`epoll_wait` 超时),没有实现 nginx 的红黑树定时器 - `index` 只支持一个文件名(不支持 `index index.html index.htm`) - 访问日志固定为近似 combined 格式,字段不可配 --- ## 11. 许可证 仅用于学习与研究,可自由修改。生产环境请使用 nginx、Caddy 等成熟实现。