一、为什么我用 .local 而不是 .test
在一台机器上同时跑开发、预发和生产镜像的站点,域名是最容易被忽视的一环。早年我习惯给测试站配 test.example.com 的二级域名,结果吃了两次亏:一次是泛域名证书把它也盖住了,测试站被搜索引擎抓到并建了索引;一次是本地 hosts 写错,把预发流量打到了生产。后来改成完全独立的本地域名体系,问题一次性消失。
选后缀时有两个常见选择:.local 和 .test。前者被 mDNS(Bonjour/Avahi)占用,在某些系统上会先尝试组播解析再回落,导致每个请求多几十毫秒的延迟,个别环境下甚至解析不到 IPv6 回环;后者是 IETF 保留给测试用途的顶级域,不会与任何真实注册域名冲突,也不会被 mDNS 抢。所以我现在全部用 .test:blog.test、shop.test、api.shop.test。
下面的配置假定你有一台 Linux 服务器,Nginx 作为唯一入口,站点分别在独立目录里,PHP-FPM 或 Node 在本地端口上听。
二、hosts 文件:写得短,改得勤
最直接的办法是 hosts,一条记录一行:
# /etc/hosts
127.0.0.1 blog.test www.blog.test
127.0.0.1 shop.test api.shop.test
::1 blog.test www.blog.test要点有三个:主机名要一次把带 www 和不带 www 的都写上,否则浏览器会去 DNS 查 www.blog.test,拿到 NXDOMAIN 之后报「无法访问」;IPv6 那行也别省,现代系统会优先尝试 AAAA,缺了会造成首包延迟;如果机器上有 Docker,容器的 hosts 和宿主机是分开的,需要 --add-host 或 compose 里的 extra_hosts。
hosts 的麻烦在于它只对本机有效。想给手机、给同事的电脑、给 CI 跑测试用,就得每台机器配一遍,还容易漏。这时候改用本地 DNS。
三、把解析集中到 dnsmasq
dnsmasq 只有几百 KB,一条配置文件就能把所有 .test 指向本机,同时把其余查询转发给上游:
# /etc/dnsmasq.d/local-dev.conf
address=/test/127.0.0.1
# 需要给局域网其他设备用时改成服务器的局域网地址
# address=/test/192.168.1.10
# 只监听本机,避免变成开放解析器
listen-address=127.0.0.1
bind-interfaces
no-resolv
server=223.5.5.5
server=119.29.29.29address=/test/127.0.0.1 这条规则会匹配所有以 .test 结尾的域名,包括任意深度的子域,等于一个免费的泛解析。装好之后 systemctl enable --now dnsmasq,再把系统的 /etc/resolv.conf 指过去。
这里有个容易踩的坑:/etc/resolv.conf 常被 NetworkManager 或 systemd-resolved 覆盖,改完过一会儿又变回去。两种正确做法,任选其一:用 systemd-resolved 的 drop-in 把 DNS=127.0.0.1 写进 /etc/systemd/resolved.conf.d/;或者干脆不用 dnsmasq,直接用 systemd-resolved 自带的能力:
# /etc/systemd/resolved.conf.d/test-domains.conf
[Resolve]
DNS=127.0.0.1
Domains=~testDomains=~test 的意思是「.test 这个域的路由交给本机解析器」,前面的波浪号表示路由域而不是搜索域,别写漏——写成 Domains=test 会被当成自动补全后缀,行为完全不同。之后对每个名字单独加解析记录:
resolvectl dns lo 127.0.0.1
resolvectl domain lo '~test'
# 或者直接把 .test 全部交给一个本地 DNS 实例我更推荐 dnsmasq 的写法,因为它给的是真正的泛解析,新增一个 *.test 站点不需要再加任何解析记录。
四、Nginx 侧:server_name 与默认服务器
本地开发最容易犯的错,是把新站点配好了却访问到另一个站——因为请求落到默认 server 上。Nginx 在没有匹配的 server_name 时,会用该端口上第一个 server 块当兜底,所以那个「默认兜底」必须显式声明,别靠配置顺序:
# 兜底:所有未匹配的 Host 都落到这里,方便发现写错的域名
server {
listen 80 default_server;
server_name _;
return 404;
}
server {
listen 80;
server_name blog.test www.blog.test;
root /srv/sites/blog/public;
index index.php index.html;
access_log /var/log/nginx/blog.test.access.log;
error_log /var/log/nginx/blog.test.error.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm-blog.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}配套的 PHP-FPM 池要单独一个,socket 名字带上站点名,避免两个站共用一个池导致互相拖慢:
; /etc/php/8.2/fpm/pool.d/blog.conf
[blog]
user = www-data
group = www-data
listen = /run/php/php8.2-fpm-blog.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 4
php_admin_value[error_log] = /var/log/php/blog.error.log
php_admin_flag[log_errors] = on五、HTTPS:自签证书与 mkcert
本地开发不上 HTTPS 会漏掉一整类问题:Secure cookie 拿不到、混合内容告警、第三方 SDK 拒绝加载。用自签证书又得每次点「继续访问」,手机端尤其难受。推荐用 mkcert 生成一个本地 CA,把它装进系统的信任库,之后所有 .test 域名一次性信任:
mkcert -install
mkcert blog.test www.blog.test shop.test api.shop.test
# 生成 blog.test+3.pem / blog.test+3-key.pem 两个文件Nginx 里指过去就行:
server {
listen 443 ssl;
http2 on;
server_name blog.test www.blog.test;
ssl_certificate /etc/nginx/certs/blog.test+3.pem;
ssl_certificate_key /etc/nginx/certs/blog.test+3-key.pem;
# 其余同 80 端口
}想省掉「每个站点都要列一次证书」的麻烦,可以用带 SAN 的一张泛域名自签证书:
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/nginx/certs/test.key \
-out /etc/nginx/certs/test.crt \
-subj "/CN=*.test" \
-addext "subjectAltName=DNS:*.test,DNS:test"把 test.crt 加进系统与浏览器信任库,所有 *.test 就都是绿锁。顺手把 80 端口也跳过来,复现真实的跳转链路:
server {
listen 80;
server_name blog.test www.blog.test;
return 301 https://$host$request_uri;
}注意 $host 和 $http_host 的区别:前者在没有 Host 头时回落到 server_name,后者保持原样的空值,做本地多站点跳转用 $host 更稳。
六、网站本身的三处配置最容易忘
域名能打开之后,还有三处「不配就永远对不上」的地方,通常表现为登录不了、跳回错误域名。
第一处是应用的 base URL。多数框架或 CMS 会把绝对地址写进数据库或配置文件,换域名时必须同步改,否则后台提交表单会跳到旧域名,表现为「点保存之后 404 但数据其实存进去了」。稳妥做法是让配置读环境变量,而不是硬编码。
第二处是 Cookie 域。开发环境如果写死了生产域名,本地浏览器根本不会带上 cookie:
session.cookie_domain = .blog.test
session.cookie_secure = 1
session.cookie_samesite = Lax有些项目在本地必须开 cookie_secure=0(因为走的是 http),这时要保证你测的就是 http 场景,而不是自欺欺人。
第三处是反向代理的转发头。应用判断「当前是不是 HTTPS」往往看 X-Forwarded-Proto,如果你的请求经过一层本地代理而没传这个头,应用会认为你在 http 上,于是 Secure cookie 被丢掉、跳转陷入循环:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;七、多站点、多环境,怎么不混乱
我的目录约定是「一个站一个根目录,环境靠后缀区分」,永远不在同一个目录里切分支来模拟环境:
/srv/sites/
├── blog/ # blog.test (开发)
├── blog-staging/ # staging.blog.test(预发)
├── shop/
└── shop-staging/对应的做法是:配置文件按站点拆成独立文件放进 /etc/nginx/sites-enabled/,命名带上完整域名(blog.test.conf),这样出问题时一眼能定位;PHP-FPM 每个站点一个池;日志文件名带域名,出事直接 tail -f /var/log/nginx/blog.test.error.log。
预发环境和生产环境共用一份代码时,别共用数据库。本地测试写坏数据影响线上是最难救的一类事故,而 .test 域名体系天然鼓励这种隔离——每个域名后面配一套自己的库,用 shop.test 和 staging.shop.test 分辨。
八、机器重启后失效的两个常见原因
本地环境是「半年才用一次」的东西,重启之后崩了往往要重查一遍。两个最常见原因:
/etc/hosts 被清空。某些容器镜像、VPN 客户端、系统更新会重写这个文件。处置办法是把你的记录单独放到一个 include 目录并定期校对,或者干脆只依赖 dnsmasq,不写 hosts。
自签证书过期。用 mkcert 生成的叶子证书默认有效期只有两年多一点,而本地 CA 是十年;叶子过期时的报错是「证书已过期」,不是「不被信任」,容易误判成浏览器缓存。一条命令就能查:
openssl x509 -in /etc/nginx/certs/test.crt -noout -subject -dates
echo | openssl s_client -connect blog.test:443 -servername blog.test 2>/dev/null \
| openssl x509 -noout -dates第一行看文件里的日期,第二行看线上真实握手用的证书——两者不一致就说明你改的文件不是 Nginx 实际加载的那份。
九、一份可以直接跑的验收清单
每次重建本地环境后,我会按这七条走一遍,全过才算可用:
# 1. 解析是否正确
getent hosts blog.test www.blog.test
# 2. 80 是否 301 到 443
curl -sI http://blog.test/ | head -3
# 3. 证书是否被信任(不加 -k 必须成功)
curl -sI https://blog.test/ | head -1
# 4. 证书名是否匹配
echo | openssl s_client -connect blog.test:443 -servername blog.test 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName
# 5. 是否落到正确的 server 块
curl -s https://blog.test/ | grep -o '<title>[^<]*'
# 6. 未匹配域名是否兜底 404(而不是串到别的站)
curl -sI http://nonexistent.test/ | head -1
# 7. 应用是否认得 https
curl -s https://blog.test/api/whoami第 6 条特别值得单独留一个域名来测,比如 canary.test,它不通任何站点,只用来确认兜底规则还在。之前有一次我把 default_server 那行删了,结果 canary.test 直接返回了博客首页——意味着任何写错域名的请求都会被当成博客处理,缓存和统计都会因此串数据。
十、小结
本地多站点域名体系的价值不在于「能打开」,而在于它让本地和线上足够像:同一套 Nginx 语法、同一套 HTTPS 与 cookie 语义、同一个兜底行为。用 .test 而不是 .local,用 dnsmasq 做泛解析而不是手工维护 hosts,用 mkcert 或自建 CA 让所有站点一次信任,再固定一个兜底 404 的 server 块——这四件事加起来不到一百行配置,却能省掉大多数「本地好好的、线上不对」的排查时间。剩下要做的,只是把验收清单存成脚本,每次重建环境后跑一遍。