所有系统运行正常 6 个离岸区域 无 KYC 结账
Hands-on 实战指南

VPS 服务器日志:你决定不保留的那些数据

你把机器搬到了离岸,用门罗币付了钱,还把整套东西都藏到了隧道后面。接着 nginx 写下了一行日志。你的服务器每回答一个请求,就会留下一条带时间戳的记录:谁问的,从哪儿问的,问了什么——而在一份默认镜像上,这条记录会在 /var/log 里存活两周,在 fail2ban 数据库里存活一个月,在备份里能活多久就看你备份留存设的是多久,通常比前两者都长。这篇指南要处理的是机器本身,而不是网络:决定你自己的服务器该写下什么,在这一行日志被写入之前就把地址截断,而不是事后再清理,只留下刚好够用来拦住攻击者、调试一次 500 错误的信息,不去堆出一份你并不想握在手里的访客历史。

更新于 2026-09-15 · 15 分钟阅读 · 服务器运维
本页内容
  1. 你刚刚回答的这个请求,现在成了你手里的一条记录
  2. 六份日志,两份指名道姓
  3. 在写入那一刻截断,不要等到轮转
  4. 截断会破坏什么,以及一份日志真正在做的三件事
  5. journald、auth.log,以及那条通向你自己的轨迹
  6. 你的留存政策,最终是由你的备份说了算
  7. 你控制不了的那些副本
  8. 设计阶段就做最小化,而不是接到通知才去删
  9. 分步操作
SP·01

你刚刚回答的这个请求,现在成了你手里的一条记录

这个系列里的其他每一篇,讲的都是对外的事情:藏起源站地址、给硬盘加密、挑司法辖区、不让别人替你留一份日志。这一篇讲的是机器本身,因为一台会应答请求的服务器,本身就会把是谁发的请求写下来——默认就是这样,同时写在好几个地方,留存期是谁都没选过的。

看看一份默认镜像部署两周之后手里攒了些什么。/var/log/nginx/access.log 里每个请求一行:地址、时间戳、路径、来源页、User-Agent,每天轮转一次,保留十四天。/var/log/auth.log 里是你的每一次 SSH 会话,包括你自己每一次连接的源地址。journal 里又把同样这些事件记了一遍,外加你各个服务打到 stderr 上的一切内容。如果跑着 Docker,每个容器都有自己的 JSON 日志文件,开箱状态下这个文件根本没有大小上限。如果 fail2ban 在跑,它会有一份记录着它封禁过的每一个地址的日志,外加一个说着同样内容的 SQLite 数据库,而且两边的地址都是完整的。

这些东西没有一样是出于恶意,而且大部分在出问题之后的一个小时里,确实真的有用。问题出在形态上:保真度拉满,存得很久,而这不是一次决定的结果,只是没人管而已。关于这些文件,真正值得问的问题不是"这个敏感吗",而是"三周前写下的那一行,我到底会拿它来干什么"。对绝大多数服务器上的绝大多数行来说,答案都是什么也不会干,而一条你永远不会去读的记录,纯粹只是负债——面对入侵是负债,面对一份比机器本身活得还久的备份是负债,面对以后随便谁伸手来要它也是负债。

这个修法有个正式名字,叫数据最小化,它也是数据保护领域里争议最小的一个理念:收集这份工作需要的东西,在这份工作还需要它的时候留着它,然后就停手。接下来的内容,把这套理念用到一台你真正在运行的机器上,前提是你已经做完了部署后的第一小时加固,而且这台机器上确实有值得保护的东西。

SP·02

六份日志,两份指名道姓

动手改任何东西之前,先把家底摸清楚。一台跑着 Web 服务的小型 Debian 或 Ubuntu VPS,通常会同时写六路日志,而且它们之间的重叠程度比大多数人以为的要高得多——同一个事件经常会落进三个文件里,带着三种不同的留存期。

nginx 的访问日志是那份指名道姓记录你访客的日志。每个请求一行,带着客户端地址、精确路径、来源页,还有一串详细到单靠它自己就能当一个弱指纹用的 User-Agent 字符串。nginx 的错误日志则是大家容易忘掉的那份:它会在每一次上游超时、每一个 403、每一个畸形请求上记下 client: 203.0.113.9——而且跟访问日志不一样,它的格式是固定死的,没法用模板改。

认证日志——Debian 系上是 /var/log/auth.log,RHEL 系上是 /var/log/secure——记的名字是。每一条公钥认证成功的记录,都带着你的源地址和打开这次会话所用密钥的指纹。谁要是读上一个月的记录,就能摸清你平时从哪些网络管理这台机器、在什么时间段活动、手里握着多少把不同的密钥。在一台匿名持有的机器上,这份文件往往比你访客产生的任何东西都更能说明问题。

journal 里保存着上面这些内容里的大部分,外加每个 unit 打到 stdout 和 stderr 上的一切,而且默认安装状态下,它可以一直长到占满文件系统的 10%,上限封在 4 GB,超出才开始丢最老的条目。在任何 40 GB 以上的磁盘上,这个上限就是完整的 4 GB,以一台小型服务器的产出量来算,这够存好几个月。

应用日志是那个不确定因素。一个开着调试模式的框架,会把完整 URL(包括查询字符串)都记下来,而查询字符串里经常带着会话令牌、重置密码的链接和搜索关键词。PHP-FPM 可以被配置成写自己的访问日志,这会把 nginx 已经写过的那行请求重复记一遍,而且是记在一个你调整 nginx 设置时根本碰不到的文件里。

容器日志是最不声不响的那个。Docker 默认的 json-file 驱动除非你专门配置,否则根本不会轮转,所以 /var/lib/docker/containers/*/*-json.log 会把这个容器从创建那天起说过的每一句话都留着。人们经常就是这样才发现,一份写着"十四天"的留存政策,实际留着的是十一个月,这跟Docker 绕开 ufw 写下的防火墙规则是同一类的意外。

这六份日志里,有两份带着值得专门去做最小化处理的、关于真人的标识信息:nginx 的访问日志(你的访客)和认证日志(你自己)。剩下的那几份,大多只需要一个大小上限和一个更短的时钟周期。

SP·03

在写入那一刻截断,不要等到轮转

本能的做法是照常记日志,事后再清理——比如每晚跑一个 cron,把前一天的文件重写一遍,抹掉里面的地址。别去搭这种东西。一个事后清洗的任务,意味着原始地址确确实实在磁盘上存在过长达一天的时间,而在这一天里,它们已经被你的备份任务拍进了快照,可能也被你主机商的块级快照复制走了,还会以文件系统处理旧数据块的方式,留下各种痕迹。更糟的是,这个任务本身是一个会出故障的活动部件:磁盘写满的那一周,它会悄无声息地失败,而没有任何东西会告诉你,昨天那份文件其实还是完整的。

你真正能依靠的削减方式,只有一种:在这一行被写入之前就完成。在 nginx 里,这就是一个在记日志那一刻被求值的 map 块,它把地址改写进一个新变量;再加上一个 log_format,让它使用这个新变量,而不是 $remote_addr。完整地址从来不会被序列化下来。事后没什么要清理的,不用排定日程,也不存在你没盯着的那天会出岔子的可能。

# /etc/nginx/conf.d/00-privacy-log.conf  --  http context, loaded before the sites
map $remote_addr $ip_trunc {
    # IPv4: keep the /24, zero the host part
    ~(?<v4>\d+\.\d+\.\d+)\.     "${v4}.0";
    # IPv6: keep the first two groups, drop the rest
    ~(?<v6>[^:]+:[^:]+):          "${v6}::";
    # anything the two patterns cannot parse -- including compressed forms
    # like ::1 -- falls through here, i.e. fails closed rather than open
    default                       "0.0.0.0";
}

log_format privacy '$ip_trunc - - [$time_local] "$request" $status '
                   '$body_bytes_sent "$http_referer" "$http_user_agent" '
                   'rid=$request_id rt=$request_time';

这里有三个细节,决定它到底管不管用。第一,这个 map 必须放在 http 上下文里——放进 server 块里,nginx 会直接拒绝启动。把它丢进 conf.d/ 目录、取一个排序靠前的文件名,是确保这一点最简单的办法。

第二,如果你架在 Cloudflare 或者别的什么边缘节点后面,去查一下 $remote_addr 里装的是什么。realip 模块会在请求处理的早期,把它替换成真实客户端地址,并把发起连接的那个地址留在 $realip_remote_addr 里。这个先后顺序对你有利:map 是在记日志那一刻才运行的,所以它截断的是真正的访客,而不是边缘节点。但这也意味着,如果你没有配置 realip,你截断的就是 Cloudflare 的地址,等于什么都没学到,而真实地址还老老实实待在某个请求头里。

第三,把格式字符串里剩下的部分也查一遍。截断 $remote_addr 这件事,如果这一行结尾依然带着 "$http_x_forwarded_for",或者还带着 $http_cf_connecting_ip,就完全等于白做——相当多默认格式和面板自带的格式,都会带上这两者之一。完整地址就在请求头里;只有把每一个携带着它的请求头都从格式里去掉,它才不会留在文件里。

注意是什么顶替了它的位置:$request_id,一串 nginx 给每个请求随机生成的 32 位十六进制字符串。这就是通向下一节的那个转折点。

SP·04

截断会破坏什么,以及一份日志真正在做的三件事

总有人反对说,匿名化之后的日志没什么用,这话说对了一半——因为"日志"这个说法,其实是三份互不相干的工作共用了一个文件名,而这三件事里只有一件真正需要那个地址。

第一件事:拦住现在正在猛敲你机器的那个人。这件事需要完整地址,而且要在几秒钟之内拿到,不需要留到明天。常用的工具是 fail2ban,而它确确实实没法在一份截断过的文件上干活——封禁 203.0.113.0 只会封掉一台无辜主机,攻击者本人还连着呢。但这件事,一份只存活一天的文件就能满足,甚至根本不需要文件:nginx 自己的 limit_reqlimit_conn 把状态放在共享内存里,反应以微秒计,而不是按 fail2ban 的轮询间隔来,而且什么都不写进磁盘。

第二件事:搞清楚那个请求为什么返回了 500。这件事需要的是关联,不是身份。一个从 nginx 一路带进应用里的请求 ID,能把访问记录里的那一行、上游报的错误和应用的堆栈跟踪,全部拴到同一个事件上——而这其实才是你当初伸手去拿地址时真正想做的事。实际用起来,这个 ID 还更好用:一个在移动网络上、会话中途换了地址的客户端,它照样能追踪到底;四个访客共用一个 CGNAT 地址时,它也不会因此就失效。

第三件事:看懂一段时间里的流量情况。总量、状态码的构成、哪些路径最热门、爬虫是不是已经失控。这件事上,截断过的地址完全够用,截断之后剩下的那个 /24,就足以看出某一个网络占了你 40% 的请求量。

所以这套设计的思路不是"少记点日志",而是按时钟拆分:一路保真度拉满、只活一天、专供拦截工具使用;另一路截断过,想留多久做统计就留多久。两路都是 nginx 在同一时刻写下的,中间没有任何处理步骤,也就不存在错误的东西在磁盘上多待一秒这种窗口期。

# inside the server block, or in a snippet included by it
access_log  /var/log/nginx/access.log       privacy;   # truncated, keep for weeks
access_log  /var/log/nginx/security.log     secip;     # full address, keep for a day

# static assets are pure noise in a privacy log -- drop them entirely
location ~* \.(?:css|js|svg|png|jpe?g|webp|woff2?|ico)$ {
    access_log off;
    expires 30d;
}

secip 这个格式,跟另一个比起来就是简简单单一行:$remote_addr、时间戳、请求内容和状态码,仅此而已。它是这台机器上唯一一个允许完整访客地址存在的文件,这就让它的留存期变成了一个地方、一次就能定下来的决定,而不是一个你得在六份文件里来回推敲的属性。

SP·05

journald、auth.log,以及那条通向你自己的轨迹

访客隐私是被人写来写去的那部分。而在一台整个存在意义就是不让基础设施上留下你的名字的机器上,真正要紧的是管理员自己留下的轨迹,而这条轨迹几乎全都集中在两个地方。

/var/log/auth.log 会给你打开的每一次会话都记下一行 Accepted publickey,带着你的源地址和密钥指纹。攒上一个月,这就是一份你工作习惯的时间表,外加一份你都用过哪些网络的清单。如果你总是走同一条隧道连进来,那就只是一个反复出现的地址,相当乏味。如果你是随便在哪儿都连一下,那这就是一份行踪记录。

journal 里保存着同样这些事件,外加你各个 unit 打印出来的一切内容,而且它的默认值相当宽松:SystemMaxUse= 是文件系统的 10%,MaxRetentionSec= 则根本没设,也就是完全没有时间上限——只有大小上限。在一台安静的服务器上,这个组合能把东西留上好几个月。

这里有一笔真实的取舍,值得说清楚,而不是含糊带过。描述你自己的那些日志,跟能告诉你别人是怎么进来的那些日志,是同一批日志。Storage=volatile 设上,journal 就只活在内存里,重启就消失——这是真正的隐私,但也是真正的没用:等你哪天早上发现一个自己没启动过的进程时,攻击者的第一次重启早就把证据抹掉了。对大多数人来说,理智的中间地带是持久化存储,配上一个硬性大小上限和一个短时钟:长到够你调查一周内发现的事故,短到这份文件不至于变成一本日记。

有两个容易让人栽跟头的实现细节。Debian 和 Ubuntu 的镜像在是否预装 rsyslog 这件事上并不一致;如果你机器上存在 /var/log/auth.log,那就是 rsyslog 在写它,journald 的那些上限根本管不到这份文件——它归 logrotate 管。另外,ForwardToSyslog= 才是把内容从 journal 喂给 rsyslog 的那个开关,所以在一台两者都有的机器上把它关掉,就能避免同一份东西按两套不同的留存政策存上两份。

SP·06

你的留存政策,最终是由你的备份说了算

这一条能把上面所有的细心安排全部推翻,而且除非你专门去查,否则根本看不见它。

比如说,logrotate 把 nginx 日志留了十四天,你对此也挺满意。现在再加上你当初就该设好的异地加密备份:每天跑一次 Borg 或者 restic,留存策略是七份日备份、四份周备份、六份月备份。这些归档里的每一份,都装着它运行那天原封不动的 /var/log。最老的那份月备份已经六个月大了,里面装的,是当时还算最新的那十四天日志。你实际的日志留存期不是十四天,而是六个月——存在一个不还原就没法 grep 的加密仓库里,在第二台机器上,在另一个国家。

说得过去的解法正好有两种。要么把日志目录从备份里排除掉——这几乎是一台服务器上唯一一种你总能重建、或者干脆没有也无所谓的东西,一次不包含 /var/log 的还原,并不会因此就变成一次更差的还原。要么就刻意把它们包含进去,然后接受一个事实:你真正的留存期,是备份仓库说了算,这种情况下,你公开的政策里也该这么写清楚,因为另一种选择,就是让自己写下的政策被自己的基础设施当场打脸。

# exclude logs from the backup, and prove it took
borg create --stats                       \
    --exclude '/var/log'                  \
    --exclude '/var/lib/docker/containers' \
    ::'{hostname}-{now:%Y-%m-%d}' /

# the check that matters: can you still find an address in the newest archive?
borg list ::"$(borg list --short --last 1)" | grep -c '^var/log/' || echo "clean"

既然想到这一层了,顺带说两个同类的问题。你主机商拍的快照,不属于你。虚拟机管理程序层面的快照,拍下的是磁盘当时的样子,包括你后来已经轮转掉的那些日志,而它存在服务商自己的存储上,按服务商自己的留存期走——这又是一个地址从一开始就不该被完整写下来的理由,而不是一个事后该耍什么小聪明的理由。

而且别在 VPS 上指望用 shred 这类工具。在一块精简置备的虚拟磁盘上,架在一个写时复制的文件系统上,又架在一块为了损耗均衡而不断重新映射数据块的 SSD 上,覆写一个文件并不能可靠地覆写到当初真正存放过它的那些物理存储单元。在租来的、虚拟化的存储上做安全删除,纯粹是做样子。真正管用的削减,是你在写入那一刻就做好的那一次;在那之后的一切,都只是一次没法验证的尽力而为。给这个卷加密会改变这个局面——但它改变的时机是数据被写入之前,而不是之后,这还是同一个教训。

SP·07

你控制不了的那些副本

在你自己这台机器上做最小化,只是好几层里的一层,把其他几层也想清楚,才能避免这变成一种虚假的、以为万事俱备的安全感。

你的托管网络看得到流量记录。源地址、目的地址、端口、字节数、时间——机器每一次进出的连接都是如此,不管你自己有没有记日志。服务器上的任何配置都改变不了这一点。这也是为什么这台机器所在的司法辖区是一个真实变量、而不是一句营销话术的重要原因:网络在法律上到底被要求做到什么,因国家而异,差别巨大。

你的 CDN 或者边缘节点,会在边缘这一层记日志。如果是 Cloudflare 替你终结 TLS,那么在你的服务器介入之前,它就已经拿到了请求行和客户端地址,按它自己的留存计划保存,服从它自己的法律程序。截断你源站的日志,没法反过来影响到它。运行一个你自己掌控的边缘节点,才是这件事里你真正能配置的那个版本——而且本指南里的这些记日志规则,得先套用在这台边缘机器上,因为未截断的地址正是先落在那里的。

错误追踪工具和分析工具,会替你把这些数据送出这台机器。Sentry 和它大多数同类产品,默认都会把客户端 IP 挂在每一个事件上;这个设置项通常叫 send_default_pii 之类的名字,值得专门去查一下,而不是想当然。任何托管式的分析工具,从构造上讲,都是你刚花了一下午去截断的那份访问日志的第三方副本。

邮件是所有这些里漏得最厉害的一个。只要这台机器上有什么在发邮件,邮件头里就带着发送主机和地址,而路径上的每一跳中继,都会留一份带时间戳的信封副本。一个会给你发邮件的联系表单,其实就是一份不归你管的日志。

这些都不意味着你在本机上做的这些功夫毫无意义——本机上的这份副本,恰恰是那份会随着机器一起被查扣、在一次入侵里被偷走、或者由你亲手交出去的副本。它只是众多层里你能完全掌控的那一层,把它当成全部,才是真正的错误。

SP·08

设计阶段就做最小化,而不是接到通知才去删

这里值得说得精确一点,因为这两件事经常被混为一谈,而它们之间的差别,恰恰就是标准工程实践和一件你不该做的事之间的全部差别。

提前决定好你的服务要收集什么、留多久,是一种平常的、有文档记录的、受到鼓励的做法。在 GDPR 之下,这对应着两条核心原则——数据最小化和存储限制——而更短的日志留存期,是审计人员会主动要求的一项控制措施,不是他们会反对的东西。欧盟境内的网站运营者或者托管服务的客户,并没有一项通用义务要求留存流量日志;曾经暗示相反结论的那份统一留存指令,已经在 2014 年被欧盟法院推翻,而幸存下来的那些国内法律,约束的主要是电信运营商,不是运营一台 Web 服务器的普通人。美国那边,对网站运营者同样没有通用的留存强制要求。

在接到关于某些具体记录的通知之后再去销毁它们,则是完全另一回事。一份保全请求、一项诉讼保全令、一道法院命令,或者一次警方问询,都会改变你对那一刻已经存在的数据能做什么,而如果你是因为收到了通知才加速删除的,那"是我的留存政策把它删了"这个说法,站不住脚。本指南讲的完全不是这件事。一份留存政策,是你在一个平平无奇的星期二定下来、然后就不再去碰的东西;如果它只在出事的时候才被缩短,那它从一开始就不是一份政策。

同一条界线贯穿了这个网站上的其他内容:隐私工程,和那种把自己包装成毫无破绽的"防弹"姿态之间,有一条实实在在的分界线。设计一个从不积累访客历史的服务,稳稳地站在这条线正确的那一侧,跟给硬盘加密、不去向用户索要你根本用不上的邮箱地址,是同一类事情。

这会带来两个实际后果。如果你公开发布了一份隐私政策,就要让上面写的留存期数字,跟磁盘上实际发生的情况对得上——写着十四天,备份仓库里却实实在在存着六个月,这种落差,足以在纸面上把一个出于善意运营的人,变成一个看起来心怀不轨的人。另外,把这个决定给自己记下来,哪怕只是写在 logrotate 文件开头的一行注释里,因为十八个月之后要为这些数字负责解释的人是你自己,而到时候你不会记得当初为什么定的是七天。以上都不构成法律建议;如果你所在的行业有专门的留存义务,在缩短任何东西之前,先拿你自己的情况去对照检查一遍。

SP·09

分步操作

  1. 01

    先搞清楚这台机器现在已经留了什么

    动手配置任何东西之前,先测量清楚现状。这一步的意义在于找出那份你早就忘了的文件,在大多数机器上,那不是一份容器日志,就是一份部署完之后再没人看过的应用日志。

    # biggest log files anywhere on the box, largest last
    sudo du -ah /var/log /var/lib/docker/containers 2>/dev/null \
      | sort -h | tail -20
    
    # how much disk the journal holds, and how far back it goes
    journalctl --disk-usage
    journalctl --output=short-iso | head -1
    
    # how old is the oldest nginx line still on disk?
    zcat -f /var/log/nginx/access.log* 2>/dev/null | head -1

    然后从每份文件里挑一行出来,问问自己它标识出了什么。下面这条命令统计的是,你的 Web 日志里现在还能取出多少个不重复的完整地址——这通常就是让本指南接下来的内容显得有必要的那个数字。

    zcat -f /var/log/nginx/access.log* 2>/dev/null \
      | grep -oE '^([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u | wc -l

    把你查到的结果记下来。到最后你会再跑一遍同样这些命令,用来证明改动确实生效了。

  2. 02

    在 nginx 写下这一行之前,先截断客户端地址

    把这个 map 和两种格式,写进一个会被加载进 http 上下文的文件里。在 Debian 和 Ubuntu 上,/etc/nginx/conf.d/ 会在各个站点配置之前,被 nginx.conf 引入进来,这正是这份文件该待的地方。

    sudo tee /etc/nginx/conf.d/00-privacy-log.conf > /dev/null <<'EOF'
    # The client address is reduced here, at log time, and never written in full
    # to the long-retention file. $realip_remote_addr still holds the connecting
    # address if you need it while debugging a proxy problem.
    map $remote_addr $ip_trunc {
        ~(?<v4>\d+\.\d+\.\d+)\.     "${v4}.0";
        ~(?<v6>[^:]+:[^:]+):          "${v6}::";
        # anything the two patterns cannot parse -- including compressed forms
        # like ::1 -- falls through here, i.e. fails closed rather than open
        default                       "0.0.0.0";
    }
    
    # Long retention: no full address, no forwarded-for header, request id instead.
    log_format privacy '$ip_trunc - - [$time_local] "$request" $status '
                       '$body_bytes_sent "$http_referer" "$http_user_agent" '
                       'rid=$request_id rt=$request_time';
    
    # Short retention: the one file allowed to hold a complete address.
    log_format secip   '$remote_addr [$time_local] "$request" $status';
    EOF
    
    sudo nginx -t && sudo systemctl reload nginx

    现在让这个站点用上它们。在你的 server 块里,把现有的 access_log 那一行换成这一对,顺手把静态资源的日志记录也关掉。

    access_log  /var/log/nginx/access.log    privacy;
    access_log  /var/log/nginx/security.log  secip;

    重新加载,访问一个页面,看看结果。第一个字段应该以 .0 结尾,而且这一行应该带着一个 rid= 值。

    sudo nginx -t && sudo systemctl reload nginx
    curl -s -o /dev/null https://your-domain.example/
    sudo tail -1 /var/log/nginx/access.log

    如果地址依然是完整的,说明 server 块在更下面某处覆盖掉了这个格式——用 grep -rn access_log /etc/nginx/ 能找到最终生效的是哪一行。

  3. 03

    只在真正会用到地址的地方,才保留完整地址

    如果 fail2ban 正在运行,它目前读的正是你刚刚截断过的那份文件。把它改指向安全日志,并且给这份日志设一天的寿命,让完整地址自己过期。

    sudo tee /etc/fail2ban/jail.d/nginx-privacy.local > /dev/null <<'EOF'
    [nginx-http-auth]
    enabled  = true
    logpath  = /var/log/nginx/error.log
    
    [nginx-botsearch]
    enabled  = true
    logpath  = /var/log/nginx/security.log
    maxretry = 6
    findtime = 10m
    bantime  = 1h
    EOF
    
    sudo fail2ban-client reload

    接下来处理 fail2ban 自己的记忆,这是大多数人从来不会去碰的地方:它自己有一份封禁日志,默认按 rotate 4 weekly 轮转,还有一个记着它发出过的每一次封禁的 SQLite 数据库。dbpurgeage 就是让这个数据库里的行过期的那个设置——把它设成接近你最长封禁时长的一个值,而不是留着默认值不管。

    sudo tee /etc/fail2ban/fail2ban.d/retention.local > /dev/null <<'EOF'
    [Definition]
    dbpurgeage = 2d
    loglevel   = NOTICE
    EOF
    
    sudo systemctl restart fail2ban

    更好的办法是,对付单纯的流量洪水,直接在 nginx 里把活干了,什么都不用写下来。limit_req 把计数器放在共享内存里,以微秒级的速度响应,而不是靠轮询间隔,而且不会留下任何谁被限速过的记录——具体该怎么给这些 zone 定容量、应付真实负载,参见DDoS 攻击第一小时应对手册

    # http context: keyed on the truncated address, so nothing complete is
    # held in memory either. 10m of shared state is plenty for a small site.
    limit_req_zone $ip_trunc zone=perip:10m rate=20r/s;
    
    # in the location you want protected
    limit_req zone=perip burst=40 nodelay;

    $ip_trunc 而不是 $binary_remote_addr 来作为这个 zone 的键,是一次刻意的取舍:现在这个限制是整个 /24 网段共用的,所以一间繁忙的、共用一个地址段的办公室会共享同一份额度。对小网站来说,这通常没什么问题,有时候还是个改进;如果不是,就改用完整地址作为键——限速器的状态本来就存在共享内存里,从来不会写进磁盘,所以它不属于你在这里要做最小化的那部分东西。

  4. 04

    给 journal 设一个上限,别再留第二份副本

    journald 支持 drop-in 文件,这种方式能在软件包升级后依然保留下来,而直接编辑 journald.conf 做不到这一点。下面这些数值大约能留一周——够你调查一件周一才注意到、但其实周五就开始的事——同时封死在 200 MB 的硬上限里。

    sudo mkdir -p /etc/systemd/journald.conf.d
    sudo tee /etc/systemd/journald.conf.d/retention.conf > /dev/null <<'EOF'
    [Journal]
    Storage=persistent
    SystemMaxUse=200M
    SystemMaxFileSize=20M
    MaxRetentionSec=7day
    MaxFileSec=1day
    ForwardToSyslog=no
    EOF
    
    sudo systemctl restart systemd-journald
    journalctl --disk-usage

    重启会立刻让这个大小上限生效;而留存期这条时钟,是在新文件被轮转出来的时候才会被真正执行的,所以一份已经超标的旧 journal,要等到下一次轮转才会缩小,而不是立刻缩小。如果你想今天就把磁盘空间要回来,journalctl --vacuum-time=7d 能立刻强制执行一次。

    ForwardToSyslog=no 在任何预装了 rsyslog 的镜像上都很关键:不设这个,每一条 journal 记录还会被再追加写进一份 /var/log/syslog,按 logrotate 的时间表走,而不是 journald 的,于是你就有了两份副本,带着两个不同的过期时间。动手假设之前,先查清楚自己是哪种情况:

    systemctl is-active rsyslog 2>/dev/null || echo "rsyslog not running"
    ls -la /var/log/auth.log /var/log/syslog 2>/dev/null

    如果这些文件存在,那就是 rsyslog 在管它们,它们的留存期要在第六步里去设。如果不存在,journal 就是唯一的副本,而你刚刚已经给它封了顶。

  5. 05

    别让应用把你刚删掉的东西又重新记一遍

    以上这些都没有碰到你的应用,而一个模式开错了的应用,会很乐意把完整地址、完整 URL 和会话令牌,写进它自己的文件里。有三件事要检查。

    PHP-FPM 在它的进程池配置里带着一条 access.log 指令,默认是注释掉的,但相当多控制面板会把它打开。如果它是开着的,那就是每一行请求记录的第二份副本,写在一份你调整 nginx 时根本碰不到的文件里。

    grep -rn '^access.log' /etc/php/*/fpm/pool.d/ || echo "fpm access log off"

    你框架的日志级别,决定了查询字符串和请求体会不会落到磁盘上。大多数框架的调试模式都会记录完整 URL,而一个重置密码的链接就是一个完整 URL。把级别设成生产环境用的那一档,并确认真正加载生效的就是这一档,而不是你以为正在被读取的那份文件里写的那一档。

    Docker 默认的驱动从来不会轮转。在守护进程这一级把它修好,这样以后每一个新建的容器都会继承这个上限。注意这只对重启之后新建的容器生效——已经存在的容器,会一直保留着它们当前那份没有上限的文件,直到被重建为止。

    sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    EOF
    
    sudo systemctl restart docker
    docker inspect --format '{{.HostConfig.LogConfig}}' $(docker ps -q) 2>/dev/null

    如果某个容器已经攒下了一份很大的文件,用 docker compose up -d --force-recreate 重建它,才能真正截断这段历史——只是重启的话,用的还是同一份日志文件。

  6. 06

    刻意去设定留存期,每份文件只在一个地方设

    rsyslog 和 nginx 写的所有东西,它们的时钟都归 logrotate 管。去改它自带的那个配置段,而不是另外再加一段:两个配置段指向同一个路径,会让 logrotate 报一个重复条目的错误,然后干脆彻底不再轮转那份文件,这是一次留存期改动,在悄无声息中变成永久保留的最常见原因。

    # check for duplicates BEFORE editing, then again after
    sudo logrotate --debug /etc/logrotate.conf 2>&1 | grep -i 'duplicate\|error'

    对 nginx 来说,这两份文件需要不同的时钟:截断过的那份可以活上几个星期,装着完整地址的那份,不该活过当天。给安全日志单独加一段——路径不一样,所以不算重复——再把自带的那一段缩短。

    sudo tee /etc/logrotate.d/nginx-security > /dev/null <<'EOF'
    # The only file on this box that holds complete client addresses.
    # One day, uncompressed so fail2ban can read it. Do not lengthen without
    # a reason you would be happy to write down here.
    /var/log/nginx/security.log {
        daily
        rotate 1
        maxage 1
        missingok
        notifempty
        nocompress
        create 0640 www-data adm
        sharedscripts
        postrotate
            [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
        endscript
    }
    EOF
    
    sudo sed -i 's/^\trotate 14$/\trotate 7/' /etc/logrotate.d/nginx
    sudo logrotate --debug /etc/logrotate.d/nginx-security

    如果装了 rsyslog,对 /etc/logrotate.d/rsyslog 做同样的盘算——它的默认值给 auth.log 留了四个星期,那就是你自己四个星期的 SSH 会话记录。最后再跑一遍调试模式:它会精确打印出它打算轮转和删除哪些文件,这是确认你刚敲进去的数字就是实际生效的数字的唯一办法。

  7. 07

    验证效果,包括查一下备份里的情况

    把第一步做过的测量重新跑一遍。长留存期那份日志里,不重复的完整地址计数,现在应该已经不再增长,等过完一个轮转周期之后,应该会归零。

    # should print 0 once the pre-change files have rotated out
    zcat -f /var/log/nginx/access.log* 2>/dev/null \
      | grep -oE '^([0-9]{1,3}\.){3}[0-9]{1,3}' \
      | grep -v '\.0$' | sort -u | wc -l
    
    # and the journal should now be bounded
    journalctl --disk-usage

    然后是几乎没人会做的那项检查:看看备份里面装的是什么。一个还装着 /var/log 的备份仓库,保留着你刚花了一下午才去掉的那份保真度和留存期,而且只要你最老的那份归档还在,它就会一直保留下去。

    borg list | head -3          # borg lists oldest first
    borg list ::"$(borg list --short | head -1)" 2>/dev/null \
      | grep -c '^var/log/' || echo "no logs in oldest archive"

    如果这个数字不是零,那就要么加上前面说的那条排除规则,让旧归档自然过期,要么就主动去清理它们。在这两件事发生之前,你真正的留存期,是备份仓库说了算——这是本指南里最有用的一句话,也是最容易被忘掉的一句话。

    最后,把这些数字写在下一个接手的人能找到的地方:你选的留存期、理由,还有日期。写在 logrotate 文件开头的一条注释里就够了。上面这些配置代表的是一个决定,而一个一年后谁都没法还原出理由的决定,会重新变回一个默认值。

SP·10 — 常见问题

快速解答

把 IP 地址最后一段归零,真的能做到匿名化吗?

不能,而这件事值得说得精确一点,而不是说得让人安心一点。截断成一个 /24,做到的是假名化:它把一个访客的范围缩小到最多 256 个地址组成的一个地址段,而不是缩小到唯一一个地址——这在住宅宽带的 ISP 那里大概是一整片街区,在一个小企业的网络里,却可能依然就是同一家公司。把这个 /24 跟一个精确的时间戳、一串详细的 User-Agent 字符串结合起来,对一个铁了心要查的人来说,重新识别出具体是谁,往往还是做得到的。

它真正做到的,是去掉了那个能让一份日志文件轻易跟世界上其他任何数据集拼接起来的单一字段,而且是在这一行被写入之前就去掉的,这意味着那个精确的值从来没有在你的磁盘上存在过,也就没法被查扣、泄露或者被传票调走。这是一次巨大的、真实的风险削减。但这不是匿名,任何把它叫做匿名的工具,都是在夸大其词。

如果我的访问日志被截断了,fail2ban 还能正常工作吗?

光靠那份截断过的文件不行——<HOST> 这个捕获组会匹配到 203.0.113.0,然后封禁一个从来没连接过你的地址,真正的源头却毫发无损。谁要是把日志匿名化了却没注意到这一点,就等于在不知不觉中把 fail2ban 给关掉了。

解决办法就是前面说的那个拆分:nginx 另写一份短命的文件,装着完整地址,fail2ban 改读这一份。注意 /var/log/nginx/error.log 里同样带着完整地址,而且没法重新定制格式,所以那些以它为对象的 jail(通常是处理认证失败的那些)不受影响,照常工作。对付单纯的流量滥用而不是撞库攻击,nginx 的 limit_req 比这两者都更好:它在进程内动作,以微秒计,而且什么都不写下来。

没有客户端地址,我要怎么调试生产环境里的 500 错误?

靠一个请求 ID,而且这个办法通常比地址原来能做到的还要好用。nginx 会给每个请求生成一个 $request_id;把它放进你的日志格式里,用 proxy_set_header X-Request-ID $request_id; 把它转发给上游,再让应用把它和堆栈跟踪一起记下来。这样一个字符串,就能把访问记录里的那一行、上游报的错误和异常本身,全都拴在一起。

它在三个方面都比地址更好:它是按请求唯一的,而不是按客户端唯一的,所以两个同时发生的故障不会被混在一起;它经得住一个会话中途换了地址的移动端客户端;而且把它显示在错误页面上给用户看也是安全的,这样一份错误报告送过来的时候,正好带着你需要的那个精确标识符。对于那种确实需要用到地址的罕见情况——比如一次针对性的攻击——那份短留存期的安全日志会把它留上一天。

我在法律上有义务保留服务器日志吗?

对欧盟境内的网站运营者或者托管服务客户来说,一般没有。大家似懂非懂记着的那套统一留存制度,源自 2006 年的《数据留存指令》,而这份指令已经在 2014 年被欧盟法院宣布无效;幸存下来的那些国内法律,约束的主要是电信运营商这类电子通信服务提供方,而不是运营一台 Web 服务器的人。GDPR 推的方向正好相反:留得更少、留得更短。美国那边,对网站运营者同样没有通用的留存强制要求,不过一份保全请求依然可能找上门来,改变你对那一刻已经存在的数据能做什么。

真正因具体情况而异的,是行业和角色:支付处理、受监管的金融业、医疗行业,还有真正意义上的 ISP,各自都背着自己的一套义务,一些司法辖区还会对公共 Wi-Fi 或者平台运营方施加额外义务。机器放在哪个国家,会实实在在地改变这个答案。这不构成法律建议——如果你属于上面这些类别之一,在缩短任何东西之前,先对照自己的实际情况查清楚。

Cloudflare 或者我用的 CDN 留的那些日志,该怎么办?

不管你在源站这边做了什么,这些日志都照样存在,截断你自己的文件,没法反过来影响到边缘那一层。如果是 Cloudflare 替你终结 TLS,那么在你的服务器介入之前,它就已经看到了完整的请求和真实的客户端地址,按它自己的日程留存,服从它自己的法律程序。任何托管式的 WAF 或者 DDoS 流量清洗服务,情况都一样。

你手上有两个杠杆。一是去查清楚你的边缘服务商留存什么、留多久,能调低的都调低。二是运行一个你自己掌控的边缘节点——用一台小 VPS 来做 TLS 终结,再通过隧道代理回源——这种情况下,本指南里的一切规则,都要先套用在那台机器上,因为完整地址真正抵达的,就是那台机器。

我是不是应该干脆彻底关掉访问日志?

通常不该,而理由是实际操作层面的,不是什么理念问题。没有访问日志,你就没法回应一份滥用投诉,分不清是爬虫还是攻击,没法给机器的规格做参考,也看不出一次部署一小时前就已经开始返回 404 了。如果有人诬称你的服务器做过什么它其实没做过的事,你也没法证明当时到底发生了什么。

本指南搭建的正是这个中间地带:保留这一行,去掉标识信息,加上一个关联 ID,再让唯一那份装着完整地址的文件一天之后就过期。唯一一个可以干脆利落用上 access_log off 的地方,是静态资源——图片、CSS、字体——这些东西纯粹只是走量,告诉不了你任何在它们之前那次 HTML 请求里得不到的信息。记住,不管怎样,错误日志在失败的时候都还是会继续记录客户端地址,这也是该给它一个短留存期、而不是干脆关掉它的又一个理由。

付诸实践

VPS 在 15 min 内上线,独立服务器在 2–12 h 内交付。从 $30.00 起用加密货币充值——不绑定任何身份。

部署一台 VPS