Web 服务器
二十行 Shell,浏览器就肯跟它说话。
Web 服务器不过是一个等待请求、送出响应的程序。为了证明这一点,我们只用 bash、netcat 和一个命名管道做一台出来 —— 每一行你都读得懂。之后再走向 Caddy,那是连 HTTPS 一起带上、真正跑线上站点的服务器。
Web 服务器是一个会回答的程序
撇开机柜和云厂商的标志,Web 服务器就是一个反复做同一件事的程序:等人连上来,读他要什么,把文字送回去,然后接着等。
「服务器」这个词指两样东西,而人们总把它们混着说。机房里那台机器是服务器,在某个端口上侧耳倾听的那个程序也是服务器。这里说的是程序,而它在你面前这台笔记本上跑得一样好。
一次访问,四个步骤
- 监听 程序占住一个端口等着 —— 例子里是 8084。端口就是机器上编了号的门,所以两个程序不可能守着同一扇。
- 读取请求 浏览器连上来,送出几行文字。第一行说明它用哪种方法要哪个路径,比如「GET 路径 /」。
- 做出判断 服务器决定送什么:从磁盘读文件、跑一段代码、去查数据库。我们的例子永远只送同一个文件,这也正是它能塞进二十行的唯一原因。
- 回应并关闭 写下状态行、几个头部、一个空行和内容,然后关掉连接,回到开头等下一位访客。
你听说过的每一台服务器 —— Apache、Nginx、Caddy,包括撑着这个站点的 Node 进程 —— 做的都是上面这四步。区别只在于它们要同时替上千人做,还要做得安全、走 HTTPS。骨架完全一样。
一份 Shell 脚本做出的 Web 服务器
下面是一台用 bash 写成的完整 Web 服务器。它打开端口、读取请求行、留下日志,并把 index.html 作为一份规规矩矩的 HTTP 响应送回去。二十行,不用任何库,而浏览器分辨不出差别。
重活由两个命令承担:netcat(nc)负责说网络,mkfifo 造出一个命名管道,好让我们写出的答案回到 nc 正握着的那条连接上。
#!/bin/bash
PORT=8084
PIPE=simple-web-server-pipe
rm -f "$PIPE"
mkfifo "$PIPE"
response() {
read -r method path _
echo "[$(date)] $method $path" >&2
index=$(cat index.html)
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n%s' \
"$index"
}
while true; do
response < "$PIPE" | nc -l "$PORT" > "$PIPE"
done逐行来看
PORT=8084要监听的端口。大于 1024 的号码不需要管理员权限,8084 也不太会和你已经在跑的东西撞上。PIPE=simple-web-server-pipe管道文件的名字。它在文件夹里看着像普通文件,但里面从不存东西 —— 它只是让数据在两个命令之间穿过去。mkfifo "$PIPE"创建命名管道(FIFO)。上一行之所以先删掉上次残留的文件,是因为 mkfifo 拒绝覆盖已经存在的文件。read -r method path _读取请求的第一行,拆成方法、路径和其余部分。之后浏览器送来的那些头部,这台服务器一概不看。echo "[$(date)] $method $path" >&2打印一行日志。重定向把它送去标准错误,所以它出现在你的终端上,而不会混进送给浏览器的页面里。index=$(cat index.html)把当前文件夹里的 index.html 读进变量。这正是必须在那个文件所在的目录里启动服务器的原因。printf 'HTTP/1.1 200 OK\r\n…'写出响应:状态行、内容类型、一个说明连接到此为止的头部,然后是空行和页面。分隔头部与内容的正是那个空行,而 HTTP 要求每一行都以回车加换行结束。response < "$PIPE" | nc -l "$PORT" > "$PIPE"让它跑得起来的诀窍。nc 从管道里读出我们的响应,又把浏览器的请求写回管道,两头互相喂给对方。while true; do … done循环。nc 处理完一条连接就退出,没有这一行,服务器接待完一位访客就停了。
请求要先到达函数,函数的答案才能到达 nc —— 这是一个圈。可是 Shell 的管道只朝一个方向流。命名管道把这个圈闭合了:响应顺着管道流向 nc,请求则顺着 FIFO 绕回来。这是让两个命令双向交谈的最小办法。
自己跑一遍
把脚本存成 server.sh,旁边放一份 index.html,然后启动它。让那个终端一直开着,再开第二个终端去发请求。
# 1. create an index.html in the same folder
echo '<h1>Hello</h1>' > index.html
# 2. save the script above as server.sh and start it
bash server.sh
# 3. open it in a browser (or check from a second terminal)
open http://localhost:8084
curl http://localhost:8084一边刷新浏览器一边看第一个终端:每来一次请求就多一行日志。用 Control-C 停下,之后如果管道文件还在就删掉它。Linux 上的 nc 可能需要在端口前多加一个 -p;macOS 自带的版本按写好的样子就能跑。
线上真正流过去的东西
把连接打开一看,流过去的是你自己也打得出来的普通文字。Shell 服务器之所以成立就靠这一点,而你能靠「读」而不是「猜」来排查 Web 故障,靠的也是这一点。
浏览器送出的请求
GET / HTTP/1.1
Host: localhost:8084
User-Agent: Mozilla/5.0
Accept: text/html第一行是我们这台服务器唯一读的部分:方法(GET 是取回,POST 是送出)、路径,以及协议版本。 Host 说明想要哪个站点。一台服务器可以在同一个地址上放几百个域名,靠的就是这个头部来分辨。 其余是浏览器的自我介绍:自己是什么软件、能读哪些格式、偏好哪种语言。服务器可以采用,也可以无视。
服务器送出的响应
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Connection: close
<h1>Hello</h1>状态行:协议版本,加上一个说明结果如何的数字。200 表示请求成功。 Content-Type 告诉浏览器该怎么读接下来的内容。把 HTML 标成错误的类型,浏览器就会把你的标签当文字显示,而不是渲染成页面。 charset 那一段在实践中不是可选项。少了它,中文很可能到达时已经是乱码。 然后是一个空行,它之后的一切都是内容。头部与页面之间的全部边界,就是那一个空行。
值得记住的状态码
| 状态码 | 含义 |
|---|---|
| 200 OK | 成功。要的东西就在响应里。200 开头的都属于这一类成功。 |
| 301 Moved Permanently | 地址永久搬走了。新地址放在 Location 头部里,浏览器会自动跟过去 —— 站点把 http 访客送去 https 用的就是它。 |
| 404 Not Found | 服务器好好的,但那个路径不存在。400 开头表示问题出在请求这一侧。 |
| 500 Internal Server Error | 请求本身没问题,是服务器在处理时倒下了。500 开头意味着该修的人是你,细节都在服务器日志里。 |
这台服务器做不到的事
这个例子是诚实的教材,也是糟糕的服务。把它究竟差在哪里弄清楚,就等于弄清楚了真正的服务器替你解决了什么。
一次只接待一个人。nc 只处理一条连接,它忙着的时候其他人只能排队。 它无视路径。你要什么它都只给 index.html —— 没有路由,也没有第二个页面。 什么都当 HTML 送。图片、样式表、下载文件各自需要自己的内容类型,而这台服务器只认识一种。 没有 HTTPS。一切都是裸着走的,沿途任何一段网络都能读,也能改。 完全没有防护。它不检查请求大小,没有超时,你要是随手扩展一下,它会老老实实去读别人指定的文件路径。
在自己机器上跑它,用眼睛看清 HTTP;凡是别人会来访问的,就用真正的服务器。下一节就是那台真正的服务器,而启动它并不比这个难多少。
Caddy:一台仍然读得懂的真服务器
Caddy 是一个只有单个可执行文件的现代 Web 服务器。它值得紧接着 Shell 例子来学,因为它保留了同样的手感 —— 几行读得懂的配置就跑起来 —— 却把 Shell 版做不到的事全部解决了。
它最拿得出手的是自动 HTTPS。给它一个真实域名,它就会申请证书、装好、在过期前续期,并把 http 访客转去 https。在别的服务器上,这是每隔几个月就得重来一次的杂活。
安装
# macOS
brew install caddy
# Ubuntu / Debian
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
sudo apt update && sudo apt install caddy
# Docker alone is enough
docker run -p 8080:80 -v "$PWD":/usr/share/caddy caddy一行命令把文件夹送出去
不需要配置文件。进到放着 index.html 的文件夹,敲一行命令就好 —— 这是把页面给同一网络里的人看的最快办法。
# serve this folder's index.html on port 8080
caddy file-server --listen :8080
# add a directory listing for folders without an index.html
caddy file-server --listen :8080 --browseCaddyfile
要长期放着的,就写一份 Caddyfile。它就是纯文本,一个完整站点四行就够。
example.com {
root * /var/www/html
file_server
encode gzip zstd
}example.com {站点地址。这里写真实域名,Caddy 会自己张罗 HTTPS 证书;写 localhost,它就为本地开发提供普通的 http。root * /var/www/html文件在磁盘上的位置。星号表示这条规则适用于本站点的所有路径。file_server真正把那些文件送出去的一行。少了它,Caddy 会对请求什么都不给。encode gzip zstd送出前先压缩响应。就一个词,慢速网络下页面到达得明显更快。
Caddy 向 Let's Encrypt 申请免费证书,这要求域名的 DNS 指向你的机器,而且 80 和 443 端口从外面能连上。如果前面另有一层代理替它接下了这两个端口,验证就会失败,证书永远拿不到 —— 这是第一次配 Caddy 时锁不变绿最常见的原因。
反向代理:把请求转交出去
大多数站点不是磁盘上的文件,而是某个在本地端口上倾听的应用程序。反向代理就是接下外部请求、转交给那个应用、再把答案送回去的服务器。一行就够。
getpes.com {
reverse_proxy pes-web:3000
}
dl.getpes.com {
root * /srv/files
file_server browse
}一台机器承载多个站点靠的也是它。443 端口只能由一个程序占住,于是让 Caddy 占着,它读出访客要的是哪个域名,再把每一个转给背后不同的应用。
需要掌握的命令
| 命令 | 作用 |
|---|---|
| caddy run | 在前台运行,把日志打在终端上。配置还没调好的时候,你要的就是这个。 |
| caddy start | 转到后台运行并把提示符还给你。临时演示够用,正式服务器则应当作为系统服务来跑。 |
| caddy reload | 不中断任何一条连接就加载新配置。改了 Caddyfile 并不需要重启。 |
| caddy fmt --overwrite | 就地把文件格式整理好。缩进比看上去重要,这条命令一次了结所有争论。 |
| caddy validate | 什么都不启动,只检查配置有没有错。在给线上服务器 reload 之前先跑一遍。 |
在 PES 的服务器上,一个 Caddy 占住 80 和 443,按域名分流:主站送往应用容器,下载用的子域名直接从磁盘提供。新增一个站点,只是多放一份上面那样的文件再 reload —— 不用重启,也不会有第二个程序来抢端口。
这一栏由你自己标记,这里不打分。