Web 服务器
二十行 Shell,浏览器就肯跟它说话。
Web 服务器不过是一个等待请求、送出响应的程序。为了证明这一点,我们只用 bash、netcat 和一个命名管道做一台出来 —— 每一行你都读得懂。之后再走向 Caddy,那是连 HTTPS 一起带上、真正跑线上站点的服务器。
Web 服务器是一个会回答的程序
撇开机柜和厂商的标志,Web 就是一个反复做同一件事的程序:等人连上来,读他要什么,把文字送回去,然后接着等。
「服务器」这个词指两样东西,而人们总把它们混着说。机房里那台机器是服务器,在某个上侧耳倾听的那个程序也是服务器。这里说的是程序,而它在你面前这台笔记本上跑得一样好。
一次访问,四个步骤
- 监听程序占住一个端口等着 —— 例子里是 8084。端口就是机器上编了号的门,所以两个程序不可能守着同一扇。
- 读取请求连上来,送出几行文字。第一行说明它用哪种方法要哪个路径,比如「GET 路径 /」。
- 做出判断服务器决定送什么:从磁盘读文件、跑一段代码、去查。我们的例子永远只送同一个文件,这也正是它能塞进二十行的唯一原因。
- 回应并关闭写下状态行、几个头部、一个空行和内容,然后关掉连接,回到开头等下一位访客。
你听说过的每一台服务器 —— Apache、Nginx、Caddy,包括撑着这个站点的 Node 进程 —— 做的都是上面这四步。区别只在于它们要同时替上千人做,还要做得安全、走 HTTPS。骨架完全一样。
一份 Shell 脚本做出的 Web 服务器
下面是一台用 bash 写成的完整 Web 。它打开、读取请求行、留下日志,并把 index.html 作为一份规规矩矩的 响应送回去。二十行,不用任何,而分辨不出差别。
重活由两个命令承担: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 命令线上真正流过去的东西
把连接打开一看,流过去的是你自己也打得出来的普通文字。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 —— 没有路由,也没有第二个页面。
- 什么都当 送。图片、样式表、下载文件各自需要自己的内容类型,而这台只认识一种。
- 没有 。一切都是裸着走的,沿途任何一段网络都能读,也能改。
- 完全没有防护。它不检查请求大小,没有超时,你要是随手扩展一下,它会老老实实去读别人指定的文件路径。
在自己机器上跑它,用眼睛看清 HTTP;凡是别人会来访问的,就用真正的服务器。下一节就是那台真正的服务器,而启动它并不比这个难多少。
Caddy:一台仍然读得懂的真服务器
Caddy 是一个只有单个可执行文件的现代 Web 。它值得紧接着 Shell 例子来学,因为它保留了同样的手感 —— 几行读得懂的配置就跑起来 —— 却把 Shell 版做不到的事全部解决了。
它最拿得出手的是自动 。给它一个真实,它就会申请证书、装好、在过期前续期,并把 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 --browse
Caddyfile
要长期放着的,就写一份 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 申请免费证书,这要求域名的 指向你的机器,而且 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 —— 不用重启,也不会有第二个程序来抢端口。
Docker:把 Caddy 装进盒子,再把真正的站点放上互联网
把一个程序连同它运行所需的一切 —— 库、配置,甚至一个小小的操作系统 —— 打包成一个 ,再把这个镜像作为运行。容器在你的笔记本上和在租来的服务器上表现完全一样,于是那句老借口「可在我电脑上是好的啊」就不复存在了。
Caddy 本来就只是一个文件,何必多此一举?因为真正的很快就要同时跑好几样东西 —— 一个站点、一个应用、一个。Docker 给每一样东西一个自己的盒子,用同样几条命令就能启动、停止、更新和删除,既不用在服务器上另装别的,也不会打扰左邻右舍。
先认识四个词
- 镜像把一个程序和它所需的一切冻结起来的包。可以直接下载(caddy:2),也可以用一份叫 Dockerfile 的配方文件自己构建。运行期间它从不改变。
- 容器正在运行的镜像。一个镜像可以启动许多个容器,删掉容器也碰不到镜像 —— 下次启动又是全新的。
- 端口映射容器有自己的端口,外面谁也看不见。-p 8080:80 把你电脑的 8080 端口接到容器里的 80 端口,浏览器才能连得上。
- 数据卷容器写在自己内部的文件,会随着容器被删而消失。数据卷,或者挂载进去的你自己的文件夹,才是能留下来的存储 —— 你的页面、配置和 Caddy 的证书都住在那里。
安装 Docker
在 Mac 或 Windows 电脑上,从 docker.com 安装 Docker Desktop 并启动一次,之后终端里的 docker 命令就能用了。在 Linux 服务器上,Docker 官方脚本一行就能装好一切。继续往下之前,两条检查命令都应该有回应。
# macOS · Windows: install Docker Desktop from docker.com and start it once
# Ubuntu / Debian server: Docker's official install script
curl -fsSL https://get.docker.com | sh
# check that both commands answer
docker --version
docker compose version
一条命令跑起 Caddy
进到放着 index.html 的文件夹,运行下面这一行。第一次 Docker 会下载 Caddy 镜像,之后大约一秒就能启动。打开 http://localhost:8080,你的页面就在那儿。
# serve this folder at http://localhost:8080 (Control-C stops it)
docker run --rm -p 8080:80 -v "$PWD":/usr/share/caddy caddy:2
docker run用镜像创建一个容器并启动它。--rm容器一停就删掉它,免得停下的容器越积越多。镜像还在,所以下次运行一样快。-p 8080:80把你电脑的 8080 端口接到容器里 Caddy 监听的 80 端口。左边的数字随你挑,右边的数字由镜像定好。-v "$PWD":/usr/share/caddy让当前文件夹($PWD)在容器里显示为 /usr/share/caddy —— 官方镜像默认对外提供的就是这个文件夹。在这里改一个文件,下次刷新就能看到变化。caddy:2要运行的镜像:官方 Caddy 镜像,任意 2.x 版本。不带 :2 标签的话,Docker 会取 latest,而它哪天可能就在你脚下跳到一个新的大版本。
按 Control-C 停止。临时看一眼再合适不过,但命令很长,终端一关就忘了。凡是要长期放着的,就把它写进文件 —— 这就是下一个项目。
项目 1:一个 HTML 主页,从文件夹到互联网
整个项目就是三个小文件。index.html 是页面,Caddyfile 告诉 Caddy 要提供什么,compose.yaml 告诉 Docker 如何运行 Caddy。Docker Compose 会读这个文件,所以你再也不用重新敲一遍长长的 docker run 命令。
my-site/
├── compose.yaml
├── conf/
│ └── Caddyfile
└── site/
└── index.html
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./conf:/etc/caddy
- ./site:/srv
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
逐行看 compose.yaml
image: caddy:2要运行的镜像 —— 和前面一样的官方 Caddy 镜像。restart: unless-stopped容器崩溃或服务器重启时,Docker 会自动把它重新启动,直到你有意把它停掉。"80:80" · "443:443"把机器的 80(http)和 443(https)端口开给 Caddy。Caddy 要拿到证书、把访客从 http 转到 https,两个都需要。"443:443/udp"还是 443 端口,不过走 UDP。它让浏览器能用上 HTTP 最新的版本 HTTP/3。省掉也无妨,站点就只用 HTTP/2。./conf:/etc/caddy你的 conf 文件夹变成 /etc/caddy,Caddy 会在那里找它的 Caddyfile。Caddy 的文档挂载的是整个文件夹而不是单个文件,因为很多编辑器保存时会用新文件替换旧文件,只挂载单个文件的话会一直看到旧内容。./site:/srv你的 site 文件夹变成 /srv —— Caddyfile 对外提供的文件夹。在磁盘上改一个页面,Caddy 立刻就提供新版本,不用重启。caddy_data:/data由 Docker 替你保管的具名数据卷。Caddy 把 HTTPS 证书和密钥存在这里,所以容器被删掉再重建时它们依然还在。旁边的 caddy_config 也以同样的方式保住 Caddy 的自动配置。
先在自己电脑上试
在 Caddyfile 的地址位置写上 :80。意思是「不管问的是哪个名字,都在 80 端口回答」 —— 不带证书的普通 http,对一台没有域名的电脑来说正合适。然后启动它,打开 http://localhost。
:80 {
root * /srv
file_server
}
cd my-site
docker compose up -d # start Caddy in the background
open http://localhost # or type the address into a browser
docker compose logs -f # watch requests arrive (Control-C stops watching)
放上互联网
- 租一台服务器任何带公网 IP 地址的 VPS 都行 —— 小而便宜的就足够。用上面的脚本在上面装好 Docker,并确认它的防火墙放行 80 和 443 端口。
- 让域名指向它在你买的地方,添加一条 A 记录:名称填 example.com,值填服务器的 IP 地址。要等整个互联网都看到这个变化,可能需要几分钟到几个小时。
- 写上真正的地址在 Caddyfile 里把 :80 换成你的域名。正是这一处改动打开了自动 HTTPS:Caddy 会申请证书、安装、续期,并把 http 访客转到 https。
- 复制并启动用 rsync 把文件夹复制到服务器,用 ssh 登录,再用 docker compose up -d 启动。盯着日志看:出现一行说证书已经拿到,你的站点就上线了。
example.com {
root * /srv
file_server
encode gzip zstd
}
下面的 203.0.113.10 是保留给示例用的地址,请换成你自己服务器的 IP。以后要改页面,就改 index.html,再运行一次同样的 rsync。site 文件夹是挂载进容器的,所以不用重启任何东西,下次刷新就是新页面。
# on your computer: copy the project folder to the server
rsync -av my-site/ root@203.0.113.10:~/my-site/
# on the server: start it — Caddy fetches the HTTPS certificate by itself
ssh root@203.0.113.10
cd ~/my-site
docker compose up -d
docker compose logs -f caddy
项目 2:一个 React 主页
是一个 ,用可以重复使用的小组件来搭建页面。对发布来说,要紧的只有一点:React 项目不能原样拿去提供。构建步骤会把它变成 dist 文件夹里普通的 HTML、CSS 和 JavaScript 文件 —— 从那以后,就和项目 1 是同一件事了。
下面的命令需要你电脑上装有 Node.js。Vite 会创建一个马上就能跑的 React 项目,npm run dev 在你工作时显示它,npm run build 写出完成的站点。
# needs Node.js (nodejs.org) — create a React project with Vite
npm create vite@latest my-react -- --template react
cd my-react
npm install
npm run dev # development server at http://localhost:5173
npm run build # the finished site is written to dist/
方法 A:在自己电脑上构建,只复制结果
最简单的路线是原样复用项目 1。先构建,然后只把 dist 文件夹复制到服务器的 site 文件夹。--delete 会删掉上一次构建留下、这次已经不存在的文件,旧的残留就不会越堆越多。
# way A: build here, copy only the result into Project 1's site folder
npm run build
rsync -av --delete dist/ root@203.0.113.10:~/my-site/site/
React 应用从一个 index.html 出发,自己画出每一个页面。像 /about 这样的地址并不是磁盘上的文件,所以如果没人帮忙,一旦有人刷新那个页面或者打开分享给他的链接,Caddy 就会回答 404。try_files 告诉 Caddy:文件存在就给那个文件,否则交出 index.html,让 React 去画出正确的页面。
example.com {
root * /srv
encode gzip zstd
try_files {path} /index.html
file_server
}
方法 B:用两阶段 Dockerfile,让 Docker 来构建
更稳妥的路线是在 Docker 里构建。这样无论是服务器还是同事的电脑,每次都以同样的方式构建,也没人需要在服务器上安装 Node。Dockerfile 分两个阶段:第一阶段用 Node 构建,第二阶段只留下结果。
# stage 1: build the React app with Node
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# stage 2: only the finished files go into the Caddy image
FROM caddy:2
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
逐行看 Dockerfile
FROM node:24-alpine AS build第一阶段从一个带 Node.js 的镜像开始,并给这个阶段起名叫 build,好让下一阶段能从这里拿文件。COPY package*.json ./ · RUN npm ci先只复制软件包清单,然后严格按锁文件里的版本安装。只要清单不变,Docker 就会复用这一步,之后的构建便能跳过缓慢的安装。COPY . . · RUN npm run build复制其余的源代码并构建。完成的站点现在位于 build 阶段里的 /app/dist。FROM caddy:2第二阶段从一个干净的 Caddy 镜像重新开始。除非你复制过来,第一阶段的东西一样也不会跟过来。COPY --from=build /app/dist /srv只从 build 阶段取出构建好的文件,放到 Caddyfile 对外提供的位置。Node 和 node_modules 都留在后面,所以最终的镜像一直很小。
把加了 try_files 的 Caddyfile 放在 Dockerfile 旁边,并使用下面的 compose.yaml。build: . 的意思是「用这个文件夹里的 Dockerfile 来制作镜像」,而不是去下载一个。在服务器上 clone 代码后,一条命令就能构建并启动;以后每次修改,先 pull,再运行同一条命令。
services:
web:
build: .
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
# keep node_modules and old builds out of the image
printf 'node_modules\ndist\n' > .dockerignore
# on the server: get the code, then build and start in one step
git clone https://github.com/you/my-react.git
cd my-react
docker compose up -d --build
# after you push a change: pull it and rebuild
git pull && docker compose up -d --build
你会用到的命令
| 命令 | 作用 |
|---|---|
docker compose up -d | 在后台启动 compose.yaml 里的一切。改了 compose.yaml 之后再运行一次,只有改动过的部分会被重新创建。 |
docker compose ps | 列出本项目的容器以及它们是否在运行。站点没有回应时,第一个要查的就是它。 |
docker compose logs -f | 实时跟踪日志。证书问题、路径写错、程序崩溃,都会最先在这里露面。Control-C 停下的只是观看,不是服务器。 |
docker compose exec -w /etc/caddy caddy caddy reload | 让 Caddy 在运行中的容器里重新加载 Caddyfile,一条连接也不中断。改完 Caddyfile 后用它。 |
docker compose down | 停止并删除容器。具名数据卷会保留下来,所以证书也还在。加上 -v 会把它们一并删掉 —— 别这么做。 |
docker compose pull | 下载更新版的 Caddy 镜像。接着运行 docker compose up -d 才会切换过去。 |
你的证书住在 caddy_data 数据卷里。如果它被删掉 —— 用了 docker compose down -v,或者在 compose.yaml 里漏写了这个卷 —— Caddy 每次启动都得重新申请证书。证书颁发机构会限制同一个域名一周内能拿到的证书数量,所以几次粗心的重启,就可能让你的站点好几天都没有 HTTPS。
这一栏由你自己标记,这里不打分。
要把这一阶段标记为已理解吗?
目前还不能取消,请在真正理解后再标记。
已标记为理解
这一栏由你自己标记,这里不打分。