首页
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
搜索
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
登录
搜索
23-4.CN
累计撰写
59
篇文章
累计收到
3
条评论
首页
分类
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
页面
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
注册丨登录
搜索到
15
篇
分类为
自建服务
的文章
2026-10-24
SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO
这篇是自建服务笔记。站内的加密 DNS 服务在dns.23-4.cn。用 SQLite 做博客数据库,最爽的是「整个数据库就是一个文件」——备份应该很简单吧?cp blog.db backup.db,完事。这个动作在大多数时候是对的,在少数时候会给你一个打不开的副本。而且更麻烦的是:SQLite 有几个不同的备份接口,.backup、VACUUM INTO、.dump、cp,网上教程混着讲,但它们的一致性保证、产物大小、适用场景完全不同。这篇把三种可用方式都实测一遍,给出选择依据。所有数字都是我在一台 Ubuntu 20.04(SQLite 3.31.1)上实际跑出来的。一、先看你的库现在是什么状态先查四个关键 PRAGMA:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) for k in ('journal_mode', 'page_size', 'page_count', 'freelist_count', 'auto_vacuum', 'synchronous', 'encoding'): print('%-16s = %s' % (k, c.execute('pragma ' + k).fetchone()[0])) EOF实测这台机器:journal_mode = delete page_size = 4096 page_count = 151 freelist_count = 2 auto_vacuum = 0 synchronous = 2 encoding = UTF-8逐个解释:PRAGMA值含义journal_modedelete回滚日志模式。有写入时会有 -journal 临时文件page_size4096每页 4 KB,SQLite 默认值page_count151共 151 页 → 151 × 4096 = 618,496 Bfreelist_count2有 2 页是空闲的(删过数据留下的洞)auto_vacuum0关闭自动整理,文件只增不减synchronous2FULL,最安全的写入模式(性能最低)encodingUTF-8文本编码验算文件大小:python3 - <<'EOF' import sqlite3, os DB = '/var/lib/typecho/6abdb5f537ed4.db' c = sqlite3.connect('file:%s?mode=ro' % DB, uri=True) pc = c.execute('pragma page_count').fetchone()[0] ps = c.execute('pragma page_size').fetchone()[0] sz = os.path.getsize(DB) print('page_count × page_size = %d B' % (pc * ps)) print('实际文件大小 = %d B' % sz) print('差值 = %d B' % (sz - pc * ps)) EOF实测:page_count × page_size = 618496 B 实际文件大小 = 618496 B 差值 = 0 B差值为 0,说明文件里没有浪费的空间(除了那 2 个 freelist 页,它们也算在 page_count 里)。关键:看有没有 -wal / -shm / -journalls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件"实测这台机器:-wal 不存在 -shm 不存在 -journal 不存在三个都没有。但这只代表"此刻没有",不代表 cp 的那一瞬间没有。二、为什么 cp 在多数时候能用,在少数时候会坏journal_mode=delete 时写入流程是:把要被修改的原始页复制到 xxx.db-journal;修改 xxx.db 里的页;提交成功 → 删除 -journal;如果中途崩溃 → 下次打开时用 -journal 回滚。如果你在第 2 步和第 3 步之间 cp,得到的副本可能是「一半新一半旧」的状态——文件头的页数、freelist 链表、索引都可能是矛盾的。后果有两种:打开时立刻报 database disk image is malformed(好的情况,你知道坏了);打开成功,但某些表的数据不对/缺行(坏的情况,你可能几个月后才发现)。journal_mode=WAL 时WAL 模式下就更危险了:最近的写入根本不在 xxx.db 里,而在 xxx.db-wal 里。只 cp 主文件,等于丢掉了所有还没 checkpoint 的写入。# 如果你的库是 WAL,必须先看有没有 -wal ls -la /var/lib/typecho/*.db-wal 2>/dev/null判断你的库能不能安全 cp 的完整条件:journal_mode = delete 且 此刻没有 -journal 文件 且 cp 的那一瞬间没有事务在写第三个条件你无法在 shell 里保证。所以严谨的结论是:cp 适合"停机后备份"。只要服务还在跑,就该用 .backup() 或 VACUUM INTO。三、三种方式的实测对比我在这台机器上把三种都跑了一遍(服务仍在运行,未停机):方式 1:cpcp /var/lib/typecho/6abdb5f537ed4.db /tmp/bak_cp.db方式 2:Connection.backup()python3 - <<'EOF' import sqlite3 src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) dst = sqlite3.connect('/tmp/bak_backup.db') src.backup(dst) dst.close(); src.close() EOF方式 3:VACUUM INTOpython3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/tmp/bak_vacuum.db'") c.close() EOF实测结果方式产物大小耗时一致性保证cp618,496 B1.8 ms❌ 无(依赖时机).backup()618,496 B13.1 ms✅ 有(遇写入自动重试)VACUUM INTO585,728 B6.2 ms✅ 有(事务快照)三种产物我都验证过:python3 - <<'EOF' import sqlite3 for name, f in (('cp', '/tmp/bak_cp.db'), ('.backup()', '/tmp/bak_backup.db'), ('VACUUM INTO', '/tmp/bak_vacuum.db')): c = sqlite3.connect('file:%s?mode=ro' % f, uri=True) n = c.execute("select count(*) from sqlite_master where type='table'").fetchone()[0] rows = c.execute("select count(*) from typecho_contents").fetchone()[0] ok = c.execute('pragma integrity_check').fetchone()[0] print('%-14s tables=%-3d rows=%-4d integrity=%s' % (name, n, rows, ok)) c.close() EOF实测:cp tables=7 rows=55 integrity=ok .backup() tables=7 rows=55 integrity=ok VACUUM INTO tables=7 rows=55 integrity=ok这一次 cp 也没坏——因为跑测试的时候没有并发写入。这正是问题所在:cp 的失败是概率性的,你没法靠"试了几次都好"来判断它安全。注意 VACUUM INTO 的产物小了 32,768 B(正好 8 个页)。因为它把 freelist_count=2 的空闲页和页内碎片整理掉了。四、该选哪个日常备份(服务不停)→ .backup()#!/bin/bash # /root/backup-blog.sh DB=/var/lib/typecho/6abdb5f537ed4.db DEST=/root/backups mkdir -p "$DEST" TS=$(date +%Y%m%d-%H%M%S) python3 - "$DB" "$DEST/blog-$TS.db" <<'EOF' import sqlite3, sys src, dst = sys.argv[1], sys.argv[2] s = sqlite3.connect('file:%s?mode=ro' % src, uri=True) d = sqlite3.connect(dst) s.backup(d) d.close(); s.close() print('备份完成:', dst) EOF # 只保留最近 14 份 ls -1t "$DEST"/blog-*.db | tail -n +15 | xargs -r rm -f优点:安全、产物和原库同大小、恢复后行为和原库完全一致。缺点:碎片不整理,文件大小只增不减。长期归档 / 想压缩体积 → VACUUM INTOpython3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/root/backups/archive-2026-10.db'") c.close() EOF优点:产物小、顺带整理碎片、天然是一致快照。缺点:需要 SQLite ≥ 3.27(先查版本)、耗时略长、需要额外磁盘空间(原库 + 产物同时存在)。需要跨版本迁移 / 人工查看 → .dump# 注意:需要 sqlite3 命令行 sqlite3 /var/lib/typecho/6abdb5f537ed4.db .dump > backup.sql但先确认它存在:command -v sqlite3 || echo "sqlite3 CLI NOT FOUND"实测这台机器:sqlite3 CLI NOT FOUND没有。 这是精简系统的常态(sqlite3 命令行不在默认安装里)。替代方案——用 Python 导出 SQL:python3 - <<'EOF' import sqlite3 src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) with open('/root/backup.sql', 'w', encoding='utf-8') as f: for line in src.iterdump(): f.write(line + '\n') src.close() print('导出完成') EOFiterdump() 是 sqlite3 模块的标准方法,和命令行的 .dump 等价。⛔ 别用 .dump 做日常备份:它导出的是 SQL 文本,恢复要重新执行所有语句,慢得多;而且大库的 SQL 文件可能比 db 文件大好几倍。五、恢复怎么做从 .backup() 或 VACUUM INTO 的产物恢复直接替换文件就行——它们就是完整的数据库文件:# 1. 停服务(避免写到旧库) systemctl stop php8.3-fpm # 2. 备份当前(坏掉的)库,别直接删 mv /var/lib/typecho/6abdb5f537ed4.db /root/broken-$(date +%s).db # 3. 复制回去 cp /root/backups/blog-20261024-010000.db /var/lib/typecho/6abdb5f537ed4.db # 4. 修权限(这一步最容易漏) chown www-data:www-data /var/lib/typecho/6abdb5f537ed4.db chmod 640 /var/lib/typecho/6abdb5f537ed4.db # 5. 验证 python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print(c.execute('pragma integrity_check').fetchone()[0])" # 6. 起服务 systemctl start php8.3-fpm踩坑点:权限。cp 出来的文件属主是执行 cp 的用户(通常是 root),而 PHP-FPM 以 www-data 运行。不改权限的话,站点会报 unable to open database file——而这个报错看起来像"数据库损坏",很容易往错误方向排查。原库的权限供参考:ls -la /var/lib/typecho/实测:-rw-r----- 1 www-data www-data 606208 Oct 24 00:32 6abdb5f537ed4.db640 + www-data:www-data。从 .dump 的 SQL 恢复python3 - <<'EOF' import sqlite3 conn = sqlite3.connect('/var/lib/typecho/6abdb5f537ed4.db') with open('/root/backup.sql', encoding='utf-8') as f: conn.executescript(f.read()) conn.commit(); conn.close() print('恢复完成') EOF六、怎么验证备份真的可用最容易被忽略的一步。 备份文件存在 ≠ 备份可用。# 1. 完整性检查(每次都做) python3 -c " import sqlite3 c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True) print('integrity:', c.execute('pragma integrity_check').fetchone()[0]) print('quick: ', c.execute('pragma quick_check').fetchone()[0]) " # 2. 对比行数(备份必须和源库一致) python3 - <<'EOF' import sqlite3 def counts(path): c = sqlite3.connect('file:%s?mode=ro' % path, uri=True) r = {} for (t,) in c.execute("select name from sqlite_master where type='table'"): r[t] = c.execute('select count(*) from "%s"' % t).fetchone()[0] c.close() return r src = counts('/var/lib/typecho/6abdb5f537ed4.db') bak = counts('/root/backups/blog-latest.db') same = True for t in src: if src[t] != bak.get(t): print('!! %s 行数不一致: 源=%d 备份=%s' % (t, src[t], bak.get(t))) same = False print('所有表行数一致' if same else '存在差异') EOF第 3 步最重要:真的从备份启动一次。# 在测试端口上起一个临时实例,指向备份文件 # 或者更简单:临时把站点指向备份,看能不能打开如果嫌麻烦,至少做这一条——用备份文件跑一次 Typecho 的查询:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True) print('已发布文章数 =', c.execute( "select count(*) from typecho_contents where type='post' and status='publish'").fetchone()[0]) print('最新一篇 =', c.execute( "select cid, substr(title,1,30) from typecho_contents where type='post' order by cid desc limit 1").fetchone()) EOF能查出正确的结果,才算备份可用。七、自动化:加进 crontab 并验证它在跑# 每天凌晨 3:17 备份(避开整点) crontab -e17 3 * * * /bin/bash /root/backup-blog.sh >> /root/backup.log 2>&1验证它在跑(不是"配置存在"):# 看日志有没有新记录 tail -5 /root/backup.log # 看产物有没有按日期增加 ls -la /root/backups/ | tail -5踩坑点:cron 里的环境变量和交互式 shell 不同。PATH 通常只有 /usr/bin:/bin,你的 python3 如果在 /usr/local/bin 就找不到;HOME 可能是空的。所以脚本里一律写绝对路径:#!/bin/bash PY=/usr/bin/python3 # ← 写死,别依赖 PATH DB=/var/lib/typecho/6abdb5f537ed4.db查一下你的实际路径:command -v python3八、一页速查需求用什么命令服务不停,日常备份.backup()src.backup(dst)想压缩 + 一致快照VACUUM INTOc.execute("vacuum into '...'")停机后备份cp 也行cp db db.bak跨版本迁移.dump / iterdump()for line in src.iterdump()验证备份可用integrity_check + 行数对比 + 真查一次见第六节一句话结论:服务在跑就用 .backup(),别用 cp。 那 11 毫秒的额外耗时,换的是「备份一定可恢复」。相关阅读把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite
2026年10月24日
31 阅读
0 评论
0 点赞
2026-10-24
把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏
这篇是自建服务笔记。站内的加密 DNS 服务在dns.23-4.cn。给自己的博客换服务器,看起来很简单的四步:装环境、传文件、导数据库、改配置。但真正做过的人都知道,最容易漏的是第四步之后的东西:附件目录权限不对,图片全裂;伪静态规则没带过去,除了首页全是 404;定时任务没迁,备份悄悄停了两个月;数据库导入了但 auto_increment 丢了,新文章 ID 冲突。这篇是按「漏一次就疼一次」的顺序整理的迁移清单,每一节都有可复制的命令和验证方法。以 Typecho + nginx + PHP + SQLite 为例,MySQL 的差异会单独标出。一、先记录旧机器的完整状态⛔ 别急着动手传文件。 先在旧机器上把状态记录下来,后面每一步都要拿它对照。# 1. 系统与版本 cat /etc/os-release | head -3 uname -r # 2. PHP 版本与模块 php -v | head -1 php -m | tr '\n' ' ' # 3. nginx 版本与配置结构 nginx -v nginx -T 2>/dev/null | grep -c "^# configuration file" # 4. 数据库类型与位置(Typecho 的配置文件里就有) grep -E "Db\(|'file'|'database'|'host'" /var/www/typecho/config.inc.php # 5. 站点根目录大小与文件数 du -sh /var/www/typecho find /var/www/typecho -type f | wc -l # 6. 定时任务 crontab -l ls -la /etc/cron.d/ 2>/dev/null # 7. 所有 systemd 服务单元(非系统自带的) systemctl list-unit-files --state=enabled --no-pager | grep -vE "^(systemd|dbus|getty|cron|ssh|rsyslog|networkd|resolved|udev|apparmor|unattended|snap)" | head -20把这份输出存成文件,这是你的迁移基线:{ echo "=== OS ==="; cat /etc/os-release | head -3; uname -r echo "=== PHP ==="; php -v | head -1 echo "=== nginx ==="; nginx -v 2>&1 echo "=== files ==="; find /var/www/typecho -type f | wc -l echo "=== cron ==="; crontab -l 2>/dev/null } > /root/migrate-baseline.txt 2>&1二、备份数据库:别用 cp这一步是全文最重要的技术点。为什么不能直接 cp 数据库文件SQLite 的默认日志模式是 journal_mode=delete(回滚日志)。在有写入进行中的时候,数据可能分散在三个文件里:xxx.db:主文件;xxx.db-journal:回滚日志(delete 模式);xxx.db-wal + xxx.db-shm:WAL 模式(如果开了)。先看你的库是什么模式:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) for k in ('journal_mode', 'page_size', 'page_count', 'freelist_count'): print(k, '=', c.execute('pragma ' + k).fetchone()[0]) EOF实测我这台机器(注意 SQLite 3.31.1 的 3.31.1 版本号被 python 报出来):journal_mode = delete page_size = 4096 page_count = 151 freelist_count = 2delete 模式下没有 -wal 文件,所以 cp 看起来"没问题"。但如果备份的那一瞬间正好有事务在写,cp 出来的副本可能包含半提交的页——打开时可能报 database disk image is malformed,也可能静默丢掉最后几次写入。判断风险的方法:看有没有 -wal / -journal 文件在存在。ls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件(但仍不能保证 cp 安全)"实测这台机器:三个都没有。但这只是"此刻没有",不代表 cp 的那一瞬间没有。三种备份方式的实测对比我在这台机器上把三种方式都跑了一遍,产物大小和耗时:方式产物大小耗时能否在线做cp618496 B1.8 ms⚠️ 有写入时不安全.backup() API618496 B13.1 ms✅ 安全VACUUM INTO585728 B6.2 ms✅ 安全,且会整理碎片三种产物我都验证了:tables=7 rows=55 integrity_check=ok,完全一致。推荐 .backup():python3 - <<'EOF' import sqlite3, time src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) dst = sqlite3.connect('/root/backup-%s.db' % time.strftime('%Y%m%d-%H%M%S')) src.backup(dst) # 内部按页复制,并且会重试被写入的页 dst.close(); src.close() print('备份完成') EOFConnection.backup() 的好处:它在复制过程中如果遇到正在被修改的页,会自动重试,所以能拿到一致快照。VACUUM INTO 是另一个好选择(SQLite ≥ 3.27):python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/root/backup-vacuum.db'") c.close() print('VACUUM INTO 完成') EOF它的额外好处:产物是整理过的(585728 B vs 618496 B,小了 32 KB,因为去掉了 freelist_count=2 的空闲页)。适合长期归档。⛔ 不要用 sqlite3 .dump——不是不好,而是这台机器根本没装 sqlite3 命令行:command -v sqlite3 || echo "sqlite3 CLI NOT FOUND"实测:sqlite3 CLI NOT FOUND这是很多精简系统的常态。用 Python 的 sqlite3 模块代替,功能完全够,而且不依赖额外安装。MySQL 的差异如果你用的是 MySQL 而不是 SQLite:# 备份(--single-transaction 保证一致性,不停机) mysqldump --single-transaction --routines --triggers \ -u root -p typecho > /root/typecho-$(date +%F).sql # 恢复 mysql -u root -p typecho < /root/typecho-2026-10-24.sql--single-transaction 是关键——它在一个事务里导出,保证数据一致,不需要锁表。三、传文件:用 rsync,别用 scp# 在旧机器上执行,推到新机器 rsync -avz --progress \ --exclude 'usr/uploads/cache/*' \ --exclude '*.log' \ /var/www/typecho/ root@NEW_IP:/var/www/typecho/rsync 相比 scp 的价值:可断点续传、可增量、可排除。几百 MB 的附件目录用 scp 传到一半断了要重来,rsync 直接续。踩坑点 1:附件目录权限传完之后,附件目录的属主往往是错的(rsync 默认保留 uid/gid,但新机器上可能没有同样的用户)。# 新机器上 chown -R www-data:www-data /var/www/typecho find /var/www/typecho -type d -exec chmod 755 {} \; find /var/www/typecho -type f -exec chmod 644 {} \; # 只有这个目录需要写权限 chmod -R 775 /var/www/typecho/usr/uploads别偷懒 chmod -R 777。那会让 PHP 能写任何文件,包括 config.inc.php——一个上传漏洞就能改你的数据库配置。踩坑点 2:config.inc.php 不要覆盖如果新机器上已经装过一份 Typecho(哪怕是为了拿目录结构),rsync 会把它的 config.inc.php 覆盖掉。而那个文件里的数据库路径、表前缀是新机器的,不该被旧机器的覆盖。# 正确做法:排除它,单独处理 rsync -avz --exclude 'config.inc.php' /var/www/typecho/ root@NEW:/var/www/typecho/ # 然后在新机器上手工确认 config.inc.php 指向正确的库踩坑点 3:验证文件数一致# 旧机器 find /var/www/typecho -type f | wc -l # 新机器 find /var/www/typecho -type f | wc -l两个数字必须一样。不一样就说明有文件没传过去,或者被 exclude 掉了不该 exclude 的东西。四、伪静态:这是 404 的头号原因文件传完、数据库导完,打开首页正常,点进文章 404——九成是伪静态规则没迁。nginx 的 try_filesTypecho 需要把不存在的路径交给 index.php:server { listen 443 ssl http2; server_name www.example.com; root /var/www/typecho; index index.php; location / { try_files $uri $uri/ /index.php$uri?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php83-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # 静态文件直接返回,不经过 PHP location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; access_log off; } }注意 /index.php$uri?$query_string 里的 $uri——这个不能漏。对比两个写法:try_files $uri $uri/ /index.php?$query_string; # ⚠️ 丢了路径 try_files $uri $uri/ /index.php$uri?$query_string; # ✅ 带上路径第一种把请求全部改写成「index.php 加查询串」,路径信息丢了。Typecho 靠 PATH_INFO 风格的路由来判断「你在访问哪篇文章」,路径没了就等于告诉它「你在访问首页」——于是所有文章页都返回首页内容,状态码还是 200。这种故障比 404 更难查:状态码是 200,页面能打开,只是内容不对。验证伪静态的三条命令# 1. 首页 curl -sS -o /dev/null -w "home=%{http_code}\n" https://www.example.com/ # 2. 文章页(这条最容易出问题) curl -sS -o /dev/null -w "post=%{http_code}\n" https://www.example.com/archives/1/ # 3. 分类页 curl -sS -o /dev/null -w "cat=%{http_code}\n" https://www.example.com/category/default/三条全 200 才算伪静态通了。只测首页是不够的——首页是 index.php 直接命中,根本用不到 try_files。而且光看状态码也不够,要确认返回的是正确的文章,不是首页顶替的:# 用标题做区分:文章页的 <title> 应该包含文章标题,不是站点名 curl -sS https://www.example.com/archives/1/ | grep -o "<title>[^<]*</title>"如果文章页返回的 <title> 是站点名而不是文章名,就是 $uri 丢了。踩坑点 4:fastcgi_pass 的 socket 路径Ubuntu 上 PHP-FPM 的 socket 路径带版本号。实测这台机器:/etc/nginx/conf.d/typecho.conf:895: fastcgi_pass unix:/run/php/php83-fpm.sock;而实际监听的 socket:srw-rw---- 1 www-data www-data 0 Oct 24 00:20 /run/php/php83-fpm.sock lrwxrwxrwx 1 root root 30 Oct 11 10:58 /run/php/php-fpm.sock -> /etc/alternatives/php-fpm.sock注意有个 php-fpm.sock 是软链接(指向 alternatives)。所以两个路径可能都能用——但如果 alternatives 指向了别的版本,软链接就会指错。从 PHP 8.1 换到 8.3 时,这个路径会变,而 nginx 配置里写死了旧路径:# 看实际在监听的 socket ls -la /run/php/*.sock如果 nginx 日志里有 connect() failed (2: No such file or directory) while connecting to upstream,就是这个原因。五、定时任务:最容易被忘的一步# 旧机器 crontab -l > /root/crontab-backup.txt cat /root/crontab-backup.txt新机器上恢复:crontab /root/crontab-backup.txt crontab -l # 确认踩坑点 5:cron.d 里的任务不会跟着 crontab -l 走ls -la /etc/cron.d/ /etc/cron.daily/ 2>/dev/null这些目录里的任务要单独复制。很多人只迁了用户 crontab,忘了系统级的。验证定时任务真的在跑(不只是"配置存在"):# 方式 1:看 cron 日志 grep CRON /var/log/syslog | tail -10 # 方式 2:让任务写个时间戳,等它执行后检查 # 在 crontab 里临时加一行: # * * * * * date >> /tmp/cron-alive.txt sleep 70 && cat /tmp/cron-alive.txt配置存在 ≠ 任务在跑。 上面第二种方式才能真正验证。六、迁移后必做的 8 项验证SITE=https://www.example.com echo "--- 1. 首页 ---" curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" $SITE echo "--- 2. 文章页(伪静态)---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/archives/1/ echo "--- 3. 后台登录页 ---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/admin/login.php echo "--- 4. RSS ---" curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" $SITE/feed/ echo "--- 5. sitemap ---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/sitemap.xml echo "--- 6. 随便一个附件 ---" curl -sS -o /dev/null -w "%{http_code} %{size_download}\n" $SITE/usr/uploads/$(ls /var/www/typecho/usr/uploads | head -1) echo "--- 7. 数据库能读 ---" python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print('posts=',c.execute(\"select count(*) from typecho_contents where type='post' and status='publish'\").fetchone()[0])" echo "--- 8. 写入能落库(发一条测试评论或改一篇文章)---" # 这条必须真做一次,只读测试证明不了写权限第 8 项特别重要。只读验证(前 7 项)全部通过,不代表写入正常。 数据库文件权限、目录权限、SQLite 的临时目录,这些只在写的时候才暴露。最简单的写入测试:# 用后台改一篇文章的标题,点保存,然后看数据库 python3 -c " import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) print(c.execute('select cid,modified from typecho_contents order by modified desc limit 1').fetchone()) "modified 应该是刚才那一刻。七、切换 DNS 之前的最后一件事用 --resolve 先在新机器上验证,别直接改 DNS。# 让 curl 连到新 IP,但用原来的域名(SNI 和 Host 头都不变) curl -sS -o /dev/null -w "new-server=%{http_code}\n" \ --resolve www.example.com:443:NEW_IP \ https://www.example.com/返回 200 就说明新机器能正常响应。返回证书错误,说明新机器上的证书没装好;返回 301/404,说明 server_name 或 try_files 有问题。这一条能避免一次线上故障:改 DNS 是有缓存和传播时间的,如果新机器其实没准备好就切了,你会有一段「部分用户访问失败」的窗口,而且回滚也要等传播。八、迁移清单(打印出来照做)□ 旧机器:记录 OS / PHP / nginx 版本 □ 旧机器:备份数据库(用 .backup() 或 VACUUM INTO,不用 cp) □ 旧机器:导出 crontab 和 /etc/cron.d/ □ 传输:rsync(排除 config.inc.php / *.log / cache) □ 新机器:chown www-data + 正确的目录权限(不是 777) □ 新机器:确认 config.inc.php 的数据库路径 □ 新机器:伪静态规则(try_files) □ 新机器:确认 fastcgi_pass 的 socket 路径 □ 新机器:恢复 crontab □ 验证:首页 / 文章页 / 后台 / RSS / sitemap / 附件 / 读库 / 写库 □ 切换:先用 --resolve 验证,再改 DNS □ 切换后:观察 1 小时,确认错误日志无新增相关阅读SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLitenginx error.log 里那 5 类 warn 到底要不要管
2026年10月24日
24 阅读
0 评论
0 点赞
2026-10-24
nginx error.log 里那 5 类 warn 到底要不要管
这篇是服务器运维笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。/var/log/nginx/error.log 里堆着几万行 warn,你打开一看,第一条是「could not build optimal map_hash」——看不懂,也不敢动,就那么放着。这篇的做法是:先按级别和类型统计,再逐个判定。不看统计直接读日志,会在同一个警告上浪费半小时。下面所有数字都是我在一台跑了几个月的 Ubuntu 20.04 + nginx 1.18 上实际统计出来的。一、先按级别数一遍nginx 的日志级别从高到低:emerg > alert > crit > error > warn > notice > info。for lv in emerg alert crit error warn notice info; do printf "%-8s " "$lv" grep -c "\[$lv\]" /var/log/nginx/error.log 2>/dev/null || echo 0 done实测输出(有点反直觉):emerg 0 alert 0 crit 1 error 504 warn 2 notice 1 info 0warn 只有 2 条,error 有 504 条。这就是第一个结论:别只盯着 warn。 很多人一搜「nginx error.log warn」就以为 warn 是主要噪音,实际上在这台机器上,真正值得看的是那 504 条 error。二、按类型聚合(这一步最关键)同一类错误会重复几万次,先把它们归并:grep -E "\[error\]" /var/log/nginx/error.log \ | sed -E 's/^.*\[error\] [0-9#]+: //; s/, client.*//; s/, server.*//' \ | sort | uniq -c | sort -rn | head -12实测这台机器的前几名: 158 *9417356 access forbidden by rule 5 *9439453 connect() failed (111: Connection refused) while connecting to upstream 5 *9431818 connect() failed (111: Connection refused) while connecting to upstream 5 *9364629 access forbidden by rule 4 *9433151 connect() failed (111: Connection refused) while connecting to upstream注意 *9417356 这种是 nginx 的连接序号,每次都不一样——所以同一个错误会被拆成很多行,uniq -c 数出来的次数是被稀释的。想真正聚合,得把这个序号去掉:grep -E "\[error\]" /var/log/nginx/error.log \ | sed -E 's/^.*\[error\] [0-9#]+: \*[0-9]+ //; s/, client.*//; s/, server.*//' \ | sort | uniq -c | sort -rn | head -10实测这台机器(去掉序号后): 190 access forbidden by rule 23 connect() failed (111: Connection refused) while connecting to upstream 3 recv() failed (104: Connection reset by peer) while reading response header from upstream 2 upstream timed out (110: Connection timed out) while SSL handshaking to upstream 2 FastCGI sent in stderr: "PHP message: PHP Warning: Undefined variable $index_result ...对比一下:不去序号时 access forbidden 被拆成 158 + 5 = 163 行,去序号后是 190。 同一个错误被拆散了 27 份,你就很难判断它到底占多少比例。这一步是整篇文章的核心——不看分布就去逐条读,等于在噪音里找信号。三、五类常见 warn,逐个判定类型 1:could not build optimal map_hash(可以忽略)完整原文:could not build optimal map_hash, you should increase either map_hash_max_size: 8192 or map_hash_bucket_size: 64; ignoring map_hash_bucket_size判定:可以忽略,但值得顺手改掉。它的意思很明确:map 指令建出来的哈希表不够优,nginx 用了备用方案。功能完全正常,只是查找时多比较几次。为什么会有这个警告?因为你用了 map,而 map_hash_max_size 相对 map 的条目数偏小。看你有多少个 map 块:grep -c "^ *map " /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf实测:/etc/nginx/nginx.conf:0 /etc/nginx/conf.d/00-dns-cors-map.conf:3 /etc/nginx/conf.d/typecho.conf:0 /etc/nginx/conf.d/zz-agh-doh.conf:1 /etc/nginx/conf.d/zz-sy-tls.conf:15 个 map 块。而配置里其实已经设过 map_hash_max_size:/etc/nginx/nginx.conf:14: # nginx -t 报 "could not build optimal map_hash"。加大到 8192。 /etc/nginx/nginx.conf:15: map_hash_max_size 8192;但警告还在——因为这条指令写在 http 块里,而某些 map 块可能在 include 之后才被解析。正确做法是把它提到 http 块的最前面:http { # 必须放在任何 map 之前 map_hash_max_size 8192; map_hash_bucket_size 128; include /etc/nginx/conf.d/*.conf; }顺便说一个判据:这条警告会出现在 nginx -t 的输出里,所以不用等运行时才发现。nginx -t实测这台机器:nginx: [warn] could not build optimal map_hash, you should increase either map_hash_max_size: 8192 or map_hash_bucket_size: 64; ignoring map_hash_bucket_size nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful注意:nginx -t 成功了,但 warn 还在。 这说明它确实是「配置能跑、只是不最优」,不是错误。踩坑点:map_hash_max_size 和 map_hash_bucket_size 是两条不同的指令,调整哪个取决于 map 的条目数还是键长:条目多(几百上千)→ 加 map_hash_max_size;键很长(URL 路径做 key)→ 加 map_hash_bucket_size。警告里说「increase either ... or ...」就是因为它不确定你该调哪个。盲目两个都加到很大只是浪费内存。类型 2:an upstream response is buffered to a temporary file(要处理)[warn] an upstream response is buffered to a temporary file /var/lib/nginx/tmp/fastcgi/1/98/0000002981 while reading upstream判定:要处理,但优先级低于它引发的磁盘问题。它的意思是:上游(这里是 PHP-FPM)返回的响应大于 nginx 的缓冲区,nginx 把它写到了磁盘临时文件。功能上没问题,响应还是会完整返回。但两个副作用:慢——多了一次磁盘写读;占空间——/var/lib/nginx/tmp 会持续增长,小根分区上可能撑爆。处理方式(按响应大小调):location ~ \.php$ { # 让缓冲区能装下正常响应 fastcgi_buffers 16 16k; fastcgi_buffer_size 32k; fastcgi_busy_buffers_size 64k; # 给临时文件设上限,避免无限增长 fastcgi_max_temp_file_size 0; }fastcgi_max_temp_file_size 0 表示完全禁用临时文件——如果响应超了缓冲区,直接报错而不是写盘。要不要这么激进取决于你的业务:响应通常不大 → 设 0,宁可报错也不占盘;有正常的大响应(比如导出文件)→ 保留一个上限,比如 64m。实测这台机器:0 条这类 warn。 所以虽然它是最常被讨论的一类,但不一定是你的问题。类型 3:access forbidden by rule(不是故障)[error] *9417356 access forbidden by rule, client: 2409:8a5c:894:8170:..., server: www.23-4.cn, request: "GET /dns-status/p2p/p2p/status HTTP/2.0"判定:不是故障,是你在拦人。这条是 deny all 或 location ~ /\. 之类的规则命中了。上面那条是有人在探测 /dns-status/p2p/p2p/status(一个不存在的路径),被规则挡了。它记在 [error] 级别,但语义是「正常工作」——这正是为什么不能只看级别。如果你不想让这类噪音占满日志,可以把那个 location 的日志关掉:location ~ /\. { deny all; access_log off; error_log off; }⚠️ 这里有个版本陷阱,我实际踩到了。 网上流传的另一种写法是用 map 动态调日志级别:# ⛔ 这段在 nginx 1.18 上不生效 map $request_uri $log_level { default warn; ~^/dns-status/ crit; } error_log /var/log/nginx/error.log $log_level;error_log 的第二个参数支持变量,是 nginx 1.19.5 之后才有的能力。先查你的版本:nginx -v实测这台机器:nginx version: nginx/1.18.0 (Ubuntu)1.18 —— 不支持。写了这段,nginx 会忽略变量(或者直接报配置错误),你以为降级了其实没有。Ubuntu 20.04 的官方源就是 1.18,要这个特性得升到 1.20+ 或者用 nginx 官方源。在升级之前,老老实实用 error_log off 按 location 关。别直接 error_log /dev/null——那会把整个 server 块(甚至整个 http 块)的真正问题也一起吞掉,包括上面那些 111 和 110。类型 4:connect() failed (111: Connection refused) while connecting to upstream[error] *9439453 connect() failed (111: Connection refused) while connecting to upstream判定:要查,但通常是「后端没起来」而不是「nginx 配错了」。111 是 ECONNREFUSED:连接被拒绝。含义是「目标端口上没有进程在监听」。排查顺序:# 1. 上游地址是什么?看 nginx 配置 grep -rn "proxy_pass\|fastcgi_pass" /etc/nginx/conf.d/*.conf | head -5 # 2. 那个端口有没有在监听 ss -tlnp | grep ':3000 ' # 3. 直接连一下试试 curl -sS -m 5 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/如果第 2 步是空的,就是后端没起来。别去改 nginx——它没错。这台机器上的实例很有说服力。把 23 条 Connection refused 按上游地址聚合:grep "Connection refused" /var/log/nginx/error.log \ | grep -oE 'upstream: "[^"]+"' | sort | uniq -c | sort -rn | head -5实测: 14 upstream: "http://127.0.0.1:9100/ingest" 4 upstream: "http://127.0.0.1:9100/chat_poll?since=3498" 4 upstream: "http://127.0.0.1:9100/chat_poll?since=3497" 1 upstream: "http://127.0.0.1:9100/chat_poll?since=3499"全部指向 127.0.0.1:9100 的同一个后端。 结论很清楚:那个服务在某个时段挂了,nginx 正常地记录了连接失败。修法是让那个服务起来(或者加 systemd 自动重启),跟 nginx 配置一点关系都没有。这条聚合命令的价值就在这里:它把「23 条 error」直接压缩成「1 个后端有问题」。类型 5:SSL_do_handshake() failed ... bad key share[crit] SSL_do_handshake() failed (SSL: error:141CF06C:SSL routines: tls_parse_ctos_key_share:bad key share) while SSL handshaking, client: 203.75.213.2, server: 0.0.0.0:443判定:忽略。这是扫描器/老旧客户端。bad key share 表示客户端在 TLS 1.3 握手里发了一个无效的 key share。正常浏览器不会这样,通常是漏洞扫描器或者不完整的 TLS 实现。它记在 [crit] 级别——最高的实际级别(emerg/alert 都是 0)。看到 crit 很容易紧张,但这一条完全可以忽略。怎么确认是扫描器:grep "bad key share" /var/log/nginx/error.log | grep -oP 'client: \K[0-9.]+' | sort | uniq -c | sort -rn | head -5如果同一个 IP 短时间内出现几十次,基本就是扫描。四、判定流程(照着走)LOG=/var/log/nginx/error.log echo "=== 1. 级别分布(先看有没有 crit/alert/emerg)===" for lv in emerg alert crit error warn; do printf "%-8s " "$lv"; grep -c "\[$lv\]" $LOG done echo "=== 2. 类型分布(去掉连接序号才能真正聚合)===" grep -E "\[(error|crit|warn)\]" $LOG \ | sed -E 's/^.*\[[a-z]+\] [0-9#]+: \*[0-9]+ //; s/, client.*//; s/, server.*//' \ | sort | uniq -c | sort -rn | head -10 echo "=== 3. 有没有真影响业务的 ===" grep -c "buffered to a temporary file" $LOG grep -c "Connection refused" $LOG grep -c "upstream timed out" $LOG echo "=== 4. 临时目录有没有撑大 ===" du -sh /var/lib/nginx/tmp 2>/dev/null || echo "(无此目录)"验证做对了没有:改完配置后 nginx -t 必须通过;nginx -s reload 之后,新产生的日志里目标类型的条目不再增加(wc -l 前后对比);站点本身 curl -o /dev/null -w '%{http_code}' 仍然是 200。# 记录基线 before=$(wc -l < $LOG) # 做一次 reload nginx -t && nginx -s reload # 等 60 秒,制造一些流量,再看增量 sleep 60 after=$(wc -l < $LOG) echo "新增 $((after - before)) 行"五、一张判定表warn / error 内容要管吗一句话could not build optimal map_hash可忽略功能正常,只是哈希表不最优;顺手把 map_hash_max_size 提到 include 之前an upstream response is buffered to a temporary file要管慢 + 占盘;调 fastcgi_buffers 或设 max_temp_file_sizeaccess forbidden by rule不用管你在拦探测;嫌吵就单独关那个 location 的日志connect() failed (111)要查后端没起来,不是 nginx 的问题SSL_do_handshake() failed ... bad key share可忽略扫描器;看 client IP 分布确认upstream timed out要查后端慢,先看后端自己的日志worker_connections are not enough要管并发不够,调 worker_connections 或 worker_processes六、结论三件事,按顺序:先数级别,确认有没有 emerg/alert/crit——这三个才是「真的出事」;再聚合类型,一定要把 *连接序号 去掉,否则同一类错误会被拆成几千行;按表判定,access forbidden 和 bad key share 看着吓人但没事,buffered to a temporary file 看着温和但会撑爆磁盘。日志里的级别和严重程度不是一回事。[error] 可能只是有人在扫你的站,[warn] 可能正在慢慢写满你的根分区。相关阅读服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序进程被 OOM Killer 杀掉之后:dmesg 怎么读、怎么防止再发生谁在占着这个端口:ss / lsof / /proc 三条路各自的盲区
2026年10月24日
1 阅读
0 评论
0 点赞
2026-10-24
谁在占着这个端口:ss / lsof / /proc 三条路各自的盲区
这篇是服务器运维笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。启动服务报 bind: Address already in use,或者 EADDRINUSE——你要查「谁占了这个端口」。答案通常给三个:ss、lsof、netstat。但它们各自有盲区,而且在特定的情况下会给出「端口没被占用」这种完全错误的结论,让你去改代码。这篇把三条路都走一遍,重点是什么时候它们会骗你。下面所有输出都是我在一台 Ubuntu 20.04 上实际跑出来的。一、先看工具在不在for t in ss netstat lsof fuser; do printf "%-10s " $t command -v $t >/dev/null && echo yes || echo NO done实测这台机器:ss yes netstat NO lsof yes fuser yesnetstat 已经不在默认安装里了(net-tools 包)。这不是意外——ss 是它的替代品,而且更快。所以下面主要讲 ss。二、ss:默认选择,但有三个盲区最常用的一条:ss -tlnp参数含义:-t TCP、-l 只听 LISTEN、-n 不做名字解析、-p 显示进程。实测输出(截前几行):State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 511 0.0.0.0:2096 0.0.0.0:* users:(("nginx",pid=1253931,fd=27),("nginx",pid=1253930,fd=27),...) LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1253931,fd=6),...) LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=895990,fd=13)) LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=886753,fd=4))注意 0.0.0.0:2096 那一行有五个 nginx pid——master + 4 个 worker 都继承了同一个 fd。这很正常,不是重复占用。盲区 1:-p 需要权限不加 sudo 时,别人进程的 Process 列是空的:LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=886753,fd=4)) ← root 跑的,能看 LISTEN 0 100 0.0.0.0:9000 0.0.0.0:* ← 别的用户跑的,pid 看不到你看到端口被占了,但看不到是谁。先加 sudo 再下结论。盲区 2:UDP 和 TCP 是两个独立命名空间ss -tlnp 只查 TCP。如果你要查的端口是 UDP 的(DNS、QUIC、syslog 都可能是),这条命令会告诉你「没被占用」,而实际上是占着的。# UDP 要单独查 ss -ulnp这是最常见的误判来源:一个服务同时监听 TCP 和 UDP 的 53 端口,你只查了 TCP,以为端口空着,然后 bind UDP 失败。盲区 3:IPv4 和 IPv6 是两个独立 socket(除非开了 dual-stack)ss -tlnp | grep ':443 '可能只返回 0.0.0.0:443(纯 IPv4),而 [::]:443(IPv6)是另一个 socket。如果你 bind 的时候没指定地址族,可能碰到「IPv4 空着但 IPv6 被占」。ss -4 / ss -6 可以分开看:ss -4 -tlnp | grep ':443 ' ss -6 -tlnp | grep ':443 '三、lsof:信息更全,但更慢lsof -iTCP -sTCP:LISTEN -P -n-iTCP 只看 TCP;-sTCP:LISTEN 只看监听状态;-P 不把端口号转成服务名(必须加,否则 443 会显示成 https,grep 不到);-n 不做 IP 反解(不加会很慢,因为要对每个 IP 查 DNS)。实测:COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME nginx 2715 root 6u IPv4 38108 0t0 TCP *:80 (LISTEN) nginx 2715 root 7u IPv6 38109 0t0 TCP *:80 (LISTEN) nginx 2715 root 11u IPv4 10248389 0t0 TCP *:8443 (LISTEN)lsof 的优势:能看到同一个进程占的所有端口和文件,而且 IPv4/IPv6 分得清清楚楚(上面 *:80 出现了两次,一次 IPv4 一次 IPv6)。它的劣势:慢。lsof 会遍历 /proc 下所有进程的所有 fd,机器上进程多的时候要几秒。踩坑点:不加 -P 是新手最常见的错误。lsof -iTCP -sTCP:LISTEN -n | grep 443 # ← 可能一条都匹配不到 lsof -iTCP -sTCP:LISTEN -P -n | grep 443 # ← 正确因为默认会把端口 443 显示成 https,你 grep 443 自然匹配不上。四、/proc:不用任何工具,也不受权限限制(部分)这一条最值得学,因为它在容器里、精简系统里、ss 和 lsof 都没有的时候仍然可用。原理:/proc/net/tcp 里记录了所有 TCP socket,其中有一列是 inode。而每个进程的 /proc/PID/fd/* 是符号链接,指向 socket:[inode]。两边一对,就知道谁持有这个 socket。第 1 步:从 /proc/net/tcp 找到端口的 inode/proc/net/tcp 里端口是十六进制的。443 = 0x01BB,80 = 0x0050。换算命令(注意别踩下面的坑):for p in 80 443 853 53; do printf "%-5s -> %s\n" "$p" "$(awk -v p="$p" 'BEGIN{printf "%04X", p}')" done实测:80 -> 0050 443 -> 01BB 853 -> 0355 53 -> 0035踩坑点(我实际踩到的):不要写 printf "%04X" $p。$ printf "%04X" 443 0001 ← 错!sh 的 printf 把 443 当成十进制解析后没有按 %X 处理 $ awk 'BEGIN{printf "%04X", 443}' 01BB ← 对sh(dash)的内置 printf 处理 %X 时行为和 bash 不一致,会把数字截断成奇怪的结果(443 变成 0001,80 变成 0028)。用 awk 算十六进制最稳。反方向验证:$ echo $((0x01BB)) 443 $ echo $((0x0050)) 80第 2 步:反查谁持有这个 inodeinode=$(awk 'NR>1 && $2 ~ /:01BB$/ {print $10; exit}' /proc/net/tcp) echo "inode=$inode"inode=142089然后拿它去反查:for p in /proc/[0-9]*; do for fd in $p/fd/*; do [ -L "$fd" ] && [ "$(readlink $fd)" = "socket:[$inode]" ] && \ echo "持有者: $p $(cat $p/comm 2>/dev/null)" done done 2>/dev/null实测输出:持有者: /proc/1253928 nginx 持有者: /proc/1253929 nginx 持有者: /proc/1253930 nginx 持有者: /proc/1253931 nginx 持有者: /proc/2715 nginx五个 nginx 进程——master 加四个 worker,和 ss 的输出完全对得上。这条路的盲区/proc/PID/fd 里,别人进程的 fd 你读不到(需要 root)。所以非 root 跑这段,只会看到自己的进程。但和 ss -p 不同的是:你能确定「端口确实被占着」(inode 存在),只是不知道是谁。这已经足够区分「端口没被占」和「端口被占但我没权限看」——而这正是排查里最关键的一步。五、还有两种「端口被占」的假象假象 1:TIME_WAIT 让你以为端口还在用ss -tan | grep ':443 ' | awk '{print $1}' | sort | uniq -c如果输出里大量 TIME_WAIT,说明连接刚关闭,内核还在等 2MSL。这通常不影响 bind(除非你用了 SO_REUSEADDR 之外的选项),但会让 ss 的输出看起来很吓人。TIME_WAIT 不是「被占用」,是「等超时」。区别很重要:前者要杀进程,后者等 60 秒自己就没了。假象 2:SO_REUSEPORT 允许多个进程监听同一端口ss -tlnp | grep ':80 '如果看到多个不同的进程名都监听 :80,那不是异常,是用了 SO_REUSEPORT(nginx 多进程、某些负载均衡器会这么干)。内核会把新连接分给其中一个。这时「谁占着端口」这个问题的答案就是「不止一个,它们是设计好的」。六、对照表:什么时候用哪个场景用哪个理由快速看一眼ss -tlnp最快,默认就有看不到 pid加 sudo-p 需要权限查 UDPss -ulnp-tlnp 完全不覆盖 UDP查 IPv6ss -6 -tlnpv4/v6 是独立 socket要完整上下文lsof -iTCP -sTCP:LISTEN -P -n能看进程的所有资源精简系统 / 容器/proc/net/tcp + /proc/*/fd不依赖任何工具只想知道端口通不通nc -z host port不关心是谁,只关心能不能连七、一页排查脚本PORT=443 HEX=$(awk -v p="$PORT" 'BEGIN{printf "%04X", p}') echo "PORT=$PORT HEX=$HEX" echo "=== TCP 监听 ===" ss -tlnp | grep ":$PORT " echo "=== UDP 监听(别漏)===" ss -ulnp | grep ":$PORT " || echo "(UDP 无)" echo "=== IPv4 / IPv6 分开看 ===" echo -n "v4: "; ss -4 -tlnp | grep -c ":$PORT " echo -n "v6: "; ss -6 -tlnp | grep -c ":$PORT " echo "=== /proc 反查(不依赖工具)===" for inode in $(awk -v p=":$HEX" 'NR>1 && index($2,p) {print $10}' /proc/net/tcp); do for pr in /proc/[0-9]*; do for fd in $pr/fd/*; do [ -L "$fd" ] && [ "$(readlink "$fd")" = "socket:[$inode]" ] && \ echo " inode=$inode -> $pr $(cat $pr/comm 2>/dev/null)" done done 2>/dev/null done echo "=== 状态分布(TIME_WAIT 不是占用)===" ss -tan | grep ":$PORT " | awk '{print $1}' | sort | uniq -c实测这台机器对 443 跑完整脚本的输出:PORT=443 HEX=01BB === TCP 监听 === LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1253931,fd=17),...,("nginx",pid=2715,fd=17)) LISTEN 0 511 [::]:443 [::]:* users:(("nginx",pid=1253931,fd=18),...,("nginx",pid=2715,fd=18)) === UDP 监听(别漏)=== (UDP 无) === IPv4 / IPv6 分开看 === v4: 1 v6: 1 === 状态分布 === 43 ESTAB 2 FIN-WAIT-2 2 LISTEN 7 TIME-WAIT注意 v4: 1 和 v6: 1——同一个 443 端口有两个独立的 LISTEN socket(0.0.0.0:443 和 [::]:443)。如果只查 IPv4,你会以为「只有一个」,而实际 bind 失败的可能是 IPv6 那一个。验证做对了没有:ss 和 /proc 两条路给出的进程列表应该完全一致(这里都是那 5 个 nginx pid)。如果不一致,说明你漏了 UDP 或者漏了 IPv6——那才是真正需要继续查的地方。八、一句话总结ss -tlnp 是起点,但它只覆盖 TCP + IPv4 + 你自己有权限看的进程。这三个限制之外的每一种情况,都会让它给你一个「端口空着」的错误答案。加 sudo、加 -u、加 -6,或者退回 /proc 自己查——这四步能覆盖几乎所有场景。最后补一个容易忽略的点:别拿 netstat 的肌肉记忆直接套 ss。两者参数名看着一样(netstat -tulnp / ss -tulnp),但 ss 对权限更敏感,而且参数的语义有细微差别。如果你在网上抄来的命令跑出来是空的,先 ss --help 确认这个参数在你的版本里存在——老版本 ss 遇到不认识的参数时,有些情况是静默返回空结果而不是报错。这又是一个「看起来端口没被占用、其实是工具骗了你」的来源。相关阅读进程被 OOM Killer 杀掉之后:dmesg 怎么读、怎么防止再发生服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序nginx error.log 里那 5 类 warn 到底要不要管
2026年10月24日
2 阅读
0 评论
0 点赞
2026-10-24
进程被 OOM Killer 杀掉之后:dmesg 怎么读、怎么防止再发生
这篇是服务器运维笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。进程突然消失,日志里没有任何异常,systemctl status 显示 killed 或者干脆 inactive (dead)——这时候最容易被误导的方向是「程序有 bug 崩了」。实际上很可能是内核把它杀了。我在一台 3.8 GB 内存的小机器上排查过好几次。最近一次的真实记录:[529021.651374] Out of memory: Killed process 1461869 (python3) total-vm:2542300kB, anon-rss:2529504kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:5000kB oom_score_adj:0一个 python3 进程吃到了 2.5 GB anon-rss,然后被内核杀掉。这台机器总共 3835 MB。这篇讲三件事:怎么确认是 OOM、那行日志每个字段什么意思、以及怎么让它别再发生。一、先确认到底是不是 OOM第一件事永远是查内核日志,不是查应用日志。dmesg | grep -i "killed process"实测这台机器的输出(最近 3 条):[529021.651374] Out of memory: Killed process 1461869 (python3) total-vm:2542300kB, anon-rss:2529504kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:5000kB oom_score_adj:0 [529099.143488] Out of memory: Killed process 1462964 (python3) total-vm:2285420kB, anon-rss:2272596kB, file-rss:0kB, UID:0 pgtables:4504kB oom_score_adj:0 [960123.301888] Out of memory: Killed process 281498 (python3) total-vm:2296232kB, anon-rss:2132456kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:4232kB oom_score_adj:0数一下次数:dmesg | grep -ci "out of memory"4四次。 全是 python3,全是 2.1~2.5 GB。踩坑点:dmesg 在某些发行版上普通用户读不到,会输出空。这时要么 sudo dmesg,要么读持久化日志:# systemd 系统 journalctl -k | grep -i "killed process" # 或按时间窗口 journalctl -k --since "1 day ago" | grep -i "out of memory"dmesg 是环形缓冲区,重启就没了。如果机器刚重启过,dmesg 里查不到,但 journalctl -k 可能还在(取决于 journald 的持久化配置)。二、那行日志每个字段什么意思以这条为例:Out of memory: Killed process 1461869 (python3) total-vm:2542300kB, anon-rss:2529504kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:5000kB oom_score_adj:0字段含义怎么用1461869PID已经不存在了,只能用来对日志时间(python3)进程名先确认是不是你的业务进程total-vm虚拟地址空间总量通常没用,虚拟内存可以远超物理内存anon-rss匿名页常驻内存这才是真实占用,看这个file-rss文件映射的常驻页一般很小,代码段和 mmap 的文件shmem-rss共享内存常驻页用了 shm/大页的会很大pgtables页表本身占的内存内存碎片化严重时会异常大oom_score_adj优先级调整值-1000 到 1000,越大越容易被杀关键判断:anon-rss 接近 total-vm,说明是真实的内存分配(堆上的数据),不是 mmap 映射出来的虚拟空间。上面三条都是这样(2.5 GB vs 2.54 GB),说明程序真的把 2.5 GB 数据读进内存了。如果反过来,total-vm 很大但 anon-rss 很小,那是正常的——很多运行时(JVM、Go、Python)会预留大量虚拟地址空间。pgtables:5000kB 也值得注意。5 MB 页表意味着进程有大量分散的内存页,通常是内存碎片。这种进程往往不是「用得多」,而是「用得散」。三、内核是依据什么挑中它的oom_score 决定了谁被杀。每个进程都有:for f in /proc/self/oom_score /proc/self/oom_score_adj /proc/self/oom_adj; do printf "%-28s " $f; cat $f done实测:/proc/self/oom_score 666 /proc/self/oom_score_adj 0 /proc/self/oom_adj 0oom_score(0~1000)是内核算出来的,主要由内存占用比例决定;oom_score_adj(-1000~1000)是你可以调的偏移量;oom_adj 是老的、已废弃的接口,别用。找当前最容易被杀的进程:for p in /proc/[0-9]*; do [ -r "$p/oom_score" ] || continue printf "%5s %8s %s\n" "$(cat $p/oom_score 2>/dev/null)" "$(cat $p/oom_score_adj 2>/dev/null)" "$(cat $p/comm 2>/dev/null)" done | sort -rn | head -10这个排序就是内核的候选名单顺序。排第一的基本就是下次会被杀的。四、让它别再发生:四种手段,按性价比排手段 1:给关键进程设 oom_score_adj = -1000(最省事)# 临时生效 echo -1000 > /proc/$(pidof sshd)/oom_score_adj # systemd 服务里永久生效 systemctl edit myservice在打开的编辑器里写:[Service] OOMScoreAdjust=-1000注意:-1000 表示「永不杀」。⛔ 别给业务进程设 -1000——那只是把被杀的对象换成了别人(可能是 sshd 或数据库)。这个值应该留给 sshd、systemd 本身这类「死了就登不上机器」的进程。手段 2:用 cgroup 给服务设内存上限(最推荐)systemd 245 支持 MemoryMax,超了只杀这个 cgroup 里的进程,不影响整机。先看当前值:systemctl show -p MemoryMax -p MemoryHigh -p OOMPolicy nginx实测(默认全是无限):OOMPolicy=stop MemoryHigh=infinity MemoryMax=infinity设置上限:systemctl edit myservice[Service] MemoryMax=512M MemoryHigh=400M OOMPolicy=stopMemoryHigh 是软限制:超过后内核会强力回收(让进程变慢),但不杀;MemoryMax 是硬限制:超过直接杀,且只杀这个 cgroup 内的;OOMPolicy=stop 表示 cgroup 内 OOM 时把整个服务停掉(而不是只杀一个进程,留下半死不活的状态)。这是最该用的手段,因为它把「整机 OOM」变成了「单个服务重启」,故障范围小了一个数量级。手段 3:加 swap(有争议,但对小机器有用)free -m实测这台机器: total used free shared buff/cache available Mem: 3835 1283 260 9 2291 2277 Swap: 0 0 0Swap 是 0。 没有 swap 意味着内存一旦用满,内核只有一条路:杀进程。加一个 2 GB 的 swap 文件:fallocate -l 2G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab验证:swapon --show free -m踩坑点:swap 不是万能药。如果进程是持续增长的(内存泄漏),加 swap 只是把「立刻被杀」变成「慢慢变卡,最后连 swap 也用满还是被杀」。swap 的正确用途是吸收突发峰值,不是掩盖泄漏。另外 vm.swappiness 默认 60,对小内存机器建议调到 10~20,让内核尽量不把热数据换出去:sysctl -w vm.swappiness=10手段 4:找根因(唯一真正的解法)上面三条都是「控制损失」,不解决「为什么它要吃 2.5 GB」。Python 进程吃到 2.5 GB,最常见的四个原因:原因特征怎么查一次性读入大文件anon-rss 突然涨到文件大小量级查代码里有没有 read() / readlines()无界缓存/字典缓慢单调增长,永不回落加 len(cache) 日志生成器误用成 listlist(gen) 把整个结果集物化搜 list( 包生成器的地方子进程/线程泄漏进程数或线程数持续涨`ls /proc/PID/task \wc -l`看进程内存的时间序列(不用装任何工具):PID=$(pidof myservice) while true; do echo "$(date +%H:%M:%S) $(awk '/VmRSS/{print $2, $3}' /proc/$PID/status)" sleep 5 doneVmRSS 单调上涨且不回落 = 泄漏。锯齿形(涨到某个值又掉下来)= 正常的批量处理峰值。五、怎么验证做对了改完配置,做一次受控压测,别等生产环境再出问题。# 1. 确认上限已生效 systemctl show -p MemoryMax -p MemoryHigh -p OOMPolicy myservice # 2. 确认 swap 已启用 swapon --show free -m # 3. 观察 PSI(内存压力),这是比 free 更准的指标 cat /proc/pressure/memory实测这台机器的 PSI:some avg10=0.00 avg60=0.00 avg300=0.00 total=138645986 full avg10=0.00 avg60=0.00 avg300=0.00 total=59592289some = 至少有一个任务因为等内存而卡住的时间占比;full = 所有任务都卡住的时间占比。full 长期大于 0 就说明内存真的不够了。这是比 free 好用得多的指标:free 里的 available 再大,只要 PSI 的 full 在涨,就说明实际有压力;反过来 free 看着很少但 buff/cache 可回收、PSI 为 0,那就是健康的。第 4 步,故意触发一次,确认隔离有效:# 在一个 cgroup 里跑一个吃内存的程序,确认只有它被杀 systemd-run --scope -p MemoryMax=100M python3 -c "a=bytearray(200*1024*1024)"实测输出:Running scope as unit: run-r258b23d3d64041bc9f1ce1255cf99392.scope Killed一个 200 MB 的分配,被 100 MB 的硬上限立刻截断,只杀这一个 scope——机器上其它服务完全不受影响。这就算验证通过了。对比一下不设上限时会发生什么:同样的分配会真的吃满 200 MB,如果整机剩余内存不够,内核就要在全局挑一个进程杀——可能就是你的数据库或 sshd。另外看一眼这台机器当前的候选名单(oom_score 降序前 5):for p in /proc/[0-9]*; do [ -r "$p/oom_score" ] || continue printf "%5s %8s %s\n" "$(cat $p/oom_score 2>/dev/null)" "$(cat $p/oom_score_adj 2>/dev/null)" "$(cat $p/comm 2>/dev/null)" done | sort -rn | head -5实测: 734 0 python3 708 0 AdGuardHome 706 0 python3 671 0 php-fpm 671 0 php-fpm排第一的 python3 就是下一次最可能被杀的。这份名单比任何监控面板都直接——它告诉你「如果再 OOM 一次,谁先死」。六、完整流程回顾# 1. 确认是不是 OOM(不是查应用日志) dmesg | grep -i "killed process" journalctl -k --since "1 day ago" | grep -i "out of memory" # 2. 读那行日志:重点看 anon-rss,不是 total-vm # anon-rss ≈ total-vm ⇒ 真实内存分配,不是虚拟空间 # 3. 看谁最容易被杀 for p in /proc/[0-9]*; do printf "%5s %s\n" "$(cat $p/oom_score 2>/dev/null)" "$(cat $p/comm 2>/dev/null)"; done | sort -rn | head # 4. 保护关键进程(只给 sshd 这种) echo -1000 > /proc/$(pidof sshd)/oom_score_adj # 5. 给业务服务设硬上限(最推荐) systemctl edit myservice # MemoryMax=512M / OOMPolicy=stop # 6. 有 swap 吗?没有就加,但别拿它掩盖泄漏 free -m; swapon --show # 7. 用 PSI 判断真实压力 cat /proc/pressure/memory # 8. 找根因:VmRSS 是否单调上涨 awk '/VmRSS/{print $2, $3}' /proc/$(pidof myservice)/statusOOM 不是一个「需要修 bug」的故障,它是一个容量与隔离问题。绝大多数「进程莫名消失」都能靠第 5 步(cgroup 硬上限)从「整机雪崩」降级成「单服务重启」——这是投入产出比最高的一步。相关阅读服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序谁在占着这个端口:ss / lsof / /proc 三条路各自的盲区nginx error.log 里那 5 类 warn 到底要不要管SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO
2026年10月24日
1 阅读
0 评论
0 点赞
1
2
3