[{"content":"信任危机 各大平台的账号密码越来越多，有些乱设的记不住，设成一样的又怕撞库。存在浏览器自带的密码夹里也不放心。 看上了大名鼎鼎的 Bitwarden，但官方镜像太重了，最终选择了基于 Rust 语言轻量级重写的 Vaultwarden。\n极其轻量的部署 因为是 Rust 写的，内存占用仅需十几兆。配合 Docker 部署极其简单：\n1version: \u0026#39;3\u0026#39; 2services: 3 vaultwarden: 4 image: vaultwarden/server:latest 5 container_name: vaultwarden 6 restart: always 7 environment: 8 # 禁用别人注册，这是我的私人领地 9 - SIGNUPS_ALLOWED=false 10 volumes: 11 - ./vw-data:/data 12 ports: 13 - 8080:80 安全性强化配置 这玩意儿毕竟存着我的全部身家性命，我给它上了三重锁：\n前端 Nginx 强制开启 HTTPS，否则 Vaultwarden 拒绝工作（防止明文抓包）。 在后台开启了两步验证（2FA），登录必须输入我手机 Authenticator 上的动态口令。 每天半夜，系统定时把 ./vw-data 目录下的 sqlite3 数据库文件打包加密，推送到云盘。 现在电脑浏览器装上插件，手机装上 App，随时随地自动填充高强度随机密码。数据全握在自己手里，极其安心。\n","permalink":"http://www.moxuan.xin/posts/self-host-vaultwarden-password-manager/","summary":"\u003cp\u003e\u003cstrong\u003e信任危机\u003c/strong\u003e\n各大平台的账号密码越来越多，有些乱设的记不住，设成一样的又怕撞库。存在浏览器自带的密码夹里也不放心。\n看上了大名鼎鼎的 Bitwarden，但官方镜像太重了，最终选择了基于 Rust 语言轻量级重写的 \u003ccode\u003eVaultwarden\u003c/code\u003e。\u003c/p\u003e","title":"受够了密码满天飞，用 Vaultwarden 搭建私人密码管家服务器"},{"content":"没有公网 IP 的悲哀 最近在自己电脑上开发了一个小面板，想发给朋友测试。但现在的家用宽带全是层层 NAT，根本拿不到公网 IP，就算拿到也封了 80 和 443 端口。\n优雅的降维方案：Cloudflare Tunnels 以前玩 FRP 还要自己准备一台带带宽的云服务器，现在 Cloudflare 直接把这项企业级技术免费开放了。它的原理是让你的本地机器主动向 CF 边缘节点发起一条加密隧道，不需要暴露任何本地端口。\n三步极速打洞记录\n在服务器/本地机器装上 CF 的守护进程 cloudflared： 1wget [https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb](https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb) 2dpkg -i cloudflared-linux-amd64.deb 终端里执行登录验证 cloudflared tunnel login，浏览器会弹出一个页面绑定我的域名。 创建隧道并绑定内部端口： 直接用零信任仪表盘（Zero Trust Dashboard）可视化配置，把本地的 http://localhost:8080 映射到了我公网域名 test.moxuan.de 上。 不仅完美穿透了内网，流量还自动带上了 CF 的全球 CDN 加速和 WAF 防火墙保护。简直是做私有云盘和挂内网服务的终极神器。\n","permalink":"http://www.moxuan.xin/posts/cloudflare-tunnels-expose-local-service/","summary":"\u003cp\u003e\u003cstrong\u003e没有公网 IP 的悲哀\u003c/strong\u003e\n最近在自己电脑上开发了一个小面板，想发给朋友测试。但现在的家用宽带全是层层 NAT，根本拿不到公网 IP，就算拿到也封了 80 和 443 端口。\u003c/p\u003e","title":"连公网 IP 都不需要，用 Cloudflare Tunnels 安全发布内网服务"},{"content":"运维的痛 以前跑一个要编译半小时的内核模块或者爬虫脚本，必须小心翼翼地把 SSH 窗口开着。有次网线不小心碰了一下断网了，几十分钟的进度直接化为乌有。直到我用上了终端复用神器：Tmux。\n会话持久化 装上 Tmux 后，在服务器里敲下 tmux。它在后台开辟了一个独立的会话进程。我在里面跑脚本，就算我把电脑合上、直接关掉 SSH 工具，它在服务器上的进度依然在跑。 第二天重新连上服务器，输入 tmux attach，昨天的画面和进度条原封不动地出现在眼前。\n分屏与神级配置 Tmux 的分屏逻辑比很多多窗口工具好用一万倍。我习惯把屏幕切成左边看日志（tail -f），右上写代码（vim），右下执行命令。\n分享一段我缝合的极简 ~/.tmux.conf 配置，把反人类的快捷键改成了贴合人体工程学的：\n1# 把前缀键从 Ctrl+b 改成 Ctrl+a，手小的救星 2set -g prefix C-a 3unbind C-b 4bind C-a send-prefix 5 6# 开启鼠标支持（可以点来点去切窗口了） 7set-option -g mouse on 8 9# 垂直和水平分屏快捷键改成 | 和 - 10bind | split-window -h 11bind - split-window -v 配置好这套环境后，看着那花花绿绿的终端界面，我觉得自己敲键盘的速度都快了一倍。\n","permalink":"http://www.moxuan.xin/posts/tmux-zsh-productivity-setup/","summary":"\u003cp\u003e\u003cstrong\u003e运维的痛\u003c/strong\u003e\n以前跑一个要编译半小时的内核模块或者爬虫脚本，必须小心翼翼地把 SSH 窗口开着。有次网线不小心碰了一下断网了，几十分钟的进度直接化为乌有。直到我用上了终端复用神器：\u003cstrong\u003eTmux\u003c/strong\u003e。\u003c/p\u003e","title":"告别断网掉线的恐惧，Tmux 与 Zsh 打造终极服务器终端环境"},{"content":"告别 FTP 时代 以前修改博客的前端代码或者自己的小项目，总是在本地改完，用 FTP 软件拖到服务器，极其繁琐，且版本经常搞混。 稍微研究了一下 Git 底层的钩子逻辑（Git Hooks），花半小时搞定了一套极简的自动化部署流水线。\n原理与配置 在我的云服务器上，我没有选择庞大的 Jenkins，而是直接初始化了一个“裸仓库”（Bare Repository）：\n1git init --bare /home/git/myproject.git 然后跑到仓库的 hooks 目录下，新建了一个 post-receive 脚本。这个脚本会在我每次把代码 push 到服务器后自动触发。\n核心同步脚本\n1#!/bin/bash 2# 定义工作区目录（前端访问的 Nginx 目录） 3TARGET=\u0026#34;/var/www/html/myproject\u0026#34; 4GIT_DIR=\u0026#34;/home/git/myproject.git\u0026#34; 5 6echo \u0026#34;==== 正在接收推送并自动部署 ====\u0026#34; 7# 强制检出最新的代码到 Nginx 目录下 8git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f 9echo \u0026#34;==== 部署完成 ====\u0026#34; 赋予脚本执行权限 chmod +x post-receive。\n最后在本地电脑把服务器加为远程分支： git remote add prod root@我的IP:/home/git/myproject.git。 以后只要在本地敲下 git push prod master，服务器立刻自己更新页面。效率提升 1000%。\n","permalink":"http://www.moxuan.xin/posts/git-hooks-auto-deploy/","summary":"\u003cp\u003e\u003cstrong\u003e告别 FTP 时代\u003c/strong\u003e\n以前修改博客的前端代码或者自己的小项目，总是在本地改完，用 FTP 软件拖到服务器，极其繁琐，且版本经常搞混。\n稍微研究了一下 Git 底层的钩子逻辑（Git Hooks），花半小时搞定了一套极简的自动化部署流水线。\u003c/p\u003e","title":"告别手动传文件，利用 Git Hooks 实现代码推送到服务器的全自动部署"},{"content":"把域名托管给 Cloudflare 之后，发现首页加载还是差那么点意思。打开浏览器 F12 看了一下 Network，很多图片每次还要回源站去请求。\n我的网站是 Hugo 生成的静态博客，图片、CSS、JS 这些文件基本不会变，非常适合长期缓存。Cloudflare 默认只会缓存某些静态扩展名，但图片目录的缓存策略还不够激进。\nCloudflare 缓存层级 Cloudflare 有几种缓存相关功能：\nCaching -\u0026gt; Configuration：全局缓存设置，比如缓存级别、浏览器缓存 TTL。 Caching -\u0026gt; Rules / Page Rules：自定义缓存规则，针对特定 URL 模式。 Speed -\u0026gt; Optimization：自动压缩、图片优化等。 我主要用的是 Page Rules，现在新版界面叫 Caching Rules。\n配置页面规则 进入 Cloudflare 控制台，找到 Rules -\u0026gt; Page Rules，添加一条规则：\nURL：*moxuan.de/images/* Settings： Cache Level: Cache Everything Edge Cache TTL: 1 month Browser Cache TTL: 1 month Cache Everything 表示不管是什么类型的文件，都缓存到 Cloudflare 边缘节点。默认情况下 Cloudflare 不会缓存 HTML，只对静态文件缓存。但对纯静态博客来说，HTML 也可以缓存。\nEdge Cache TTL 控制 CDN 边缘节点的缓存时间，Browser Cache TTL 控制浏览器本地缓存时间。\n验证缓存是否命中 配置完后等几分钟生效。然后打开浏览器访问一张图片，看响应头：\n1CF-Cache-Status: HIT 这个 Header 表示资源是从 Cloudflare 缓存直接返回的，没有回源。如果是 MISS，表示这次没命中，下次访问可能就会命中。\n我配置之前图片的 CF-Cache-Status 大部分是 MISS 或者 DYNAMIC，加载时间几百毫秒。配置之后变成 HIT，加载时间降到了十几毫秒。\n缓存 HTML 的注意事项 对于纯静态博客，可以把整个站点都设置 Cache Everything：\nURL：*moxuan.de/* Settings： Cache Level: Cache Everything Edge Cache TTL: 2 hours Browser Cache TTL: 30 minutes 但这样有个问题：你更新了博客内容，访问者可能还是看到旧的缓存页面。解决方法有两个：\n发布新文章后手动清除缓存：Cloudflare 控制台有 Purge Cache 功能。 缩短 HTML 缓存时间：比如只缓存 30 分钟，牺牲一点性能换取实时性。 我的博客更新不频繁，所以用了 2 小时缓存。每次发新文章后手动 Purge 一下。\n使用 Cache Rules 替代 Page Rules Cloudflare 现在更推荐用新的 Cache Rules，功能更强大，表达式也更灵活。\n比如只缓存图片和静态文件：\n1(http.request.uri.path contains \u0026#34;/images/\u0026#34;) or (http.request.uri.path contains \u0026#34;/static/\u0026#34;) Action 选择：\nCache eligibility: Eligible for cache Edge TTL: 1 month Browser TTL: 1 month Cache Rules 比 Page Rules 的优先级更直观，新站建议直接用 Cache Rules。\n配合 Origin Cache Control 如果源站 Nginx 已经设置了 Cache-Control 响应头，Cloudflare 默认会尊重源站的设置。比如 Nginx 里：\n1location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ { 2 expires 6M; 3 add_header Cache-Control \u0026#34;public, immutable\u0026#34;; 4} 这样 Cloudflare 会自动按 6 个月缓存这些文件，不需要额外配置 Page Rules。\n但如果你用的是第三方托管或者不方便改源站配置，Page Rules 就很有用了。\n监控缓存命中率 Cloudflare 控制台 Caching -\u0026gt; Analytics 里可以看到缓存命中率。我优化前大概是 40% 多，优化后稳定在 85% 以上。\n缓存命中率越高，回源请求越少，源站压力越小，访问者加载也越快。\n总结 这次 Cloudflare 缓存优化主要做了几件事：\n给图片目录配置 Cache Everything。 设置较长的 Edge Cache TTL 和 Browser Cache TTL。 对纯静态页面也适度缓存。 发布新内容后手动 Purge 缓存。 边缘节点的物理距离优势确实明显。配置好之后，国内访问者打开我的博客，静态资源基本都是从最近的 Cloudflare 节点加载，速度提升很明显。\n如果你的网站也是静态内容为主，强烈推荐花时间研究一下 Cloudflare 的缓存规则，这是性价比最高的性能优化之一。\n","permalink":"http://www.moxuan.xin/posts/cloudflare-cache-rules/","summary":"\u003cp\u003e把域名托管给 Cloudflare 之后，发现首页加载还是差那么点意思。打开浏览器 F12 看了一下 Network，很多图片每次还要回源站去请求。\u003c/p\u003e\n\u003cp\u003e我的网站是 Hugo 生成的静态博客，图片、CSS、JS 这些文件基本不会变，非常适合长期缓存。Cloudflare 默认只会缓存某些静态扩展名，但图片目录的缓存策略还不够激进。\u003c/p\u003e","title":"研究了一下 Cloudflare 的缓存规则，静态资源直接起飞"},{"content":"机房里有一台装了 Linux 的旧工控机，局域网内可以互相 Ping 通，但就是死活无法访问外网的 API 接口。路由器的防火墙查了没拦截，工控机网关也没配错。\n面对这种灵异事件，单纯看配置已经没用了，只能深入协议栈去抓包看底层数据。\nTCPdump 基础用法 Linux 自带的 tcpdump 是抓包神器。它的基本语法是：\n1tcpdump [选项] [过滤表达式] 常用选项：\n-i eth0：指定网卡 -c 100：抓取 100 个包后停止 -w file.pcap：把抓包结果保存到文件 -n：不解析主机名，显示 IP -v / -vv / -vvv：显示更详细的信息 我的抓包操作 连上工控机的 SSH，针对外网接口的 80 和 443 端口抓包：\n1# 抓取网卡 eth0 上，目标端口为 80 或 443 的 1000 个包 2tcpdump -i eth0 port 80 or port 443 -c 1000 -w /tmp/offline_capture.pcap -w 参数会把数据包保存为 .pcap 格式，方便后续用 Wireshark 分析。\n如果你只是想在终端快速看一下，可以不加 -w：\n1tcpdump -i eth0 port 443 -n 常用过滤表达式 抓包的时候通常会加过滤条件，避免抓到太多无关数据。\n按 IP 过滤：\n1tcpdump host 192.168.1.1 按端口过滤：\n1tcpdump port 443 按协议过滤：\n1tcpdump icmp 2tcpdump tcp 3tcpdump udp 组合条件：\n1tcpdump -i eth0 \u0026#39;tcp port 443 and host 8.8.8.8\u0026#39; 用 Wireshark 离线分析 把 .pcap 文件下载到自己的电脑上，用 Wireshark 打开。\nWireshark 的界面虽然很复杂，但常用的功能就几个：\n过滤栏：输入 ip.addr == 192.168.1.1 只显示某个 IP 的包。 Follow TCP Stream：右键一个 TCP 包，选择 \u0026ldquo;Follow -\u0026gt; TCP Stream\u0026rdquo;，可以看到完整的会话内容。 Statistics -\u0026gt; Conversations：看哪些 IP 之间通信最多。 Expert Info：自动标记一些异常，比如重传、乱序等。 定位问题 我打开抓到的包，用 \u0026ldquo;Follow TCP Stream\u0026rdquo; 跟踪一个外网 API 请求的数据流。\n正常情况下，TCP 三次握手应该是：\n客户端发 SYN 服务端回 SYN, ACK 客户端回 ACK 但我看到的是：\n工控机发 SYN 外部服务器回 SYN, ACK 工控机没有回 ACK，而是直接发了 RST（重置连接） 这说明连接在工控机这边被主动中断了。\n进一步看包头的参数，发现这台工控机的 MTU 被改成了 900。正常的以太网 MTU 是 1500，900 这个值非常奇怪。\nMTU 太小会导致大包被分片。如果某些网络设备或者目标服务器不支持分片，或者分片后的包无法正确重组，连接就会失败。\n修复 找到问题后，修复就很简单了。把 MTU 改回 1500：\n1ip link set eth0 mtu 1500 验证：\n1ip addr show eth0 如果要永久生效，可以写入网络配置文件。Debian/Ubuntu 在 /etc/network/interfaces 或者 netplan 配置里加 mtu 1500。\n改完之后，外网 API 立刻通了。\n另一个实用场景：排查 DNS 问题 TCPdump 不仅可以抓 TCP，抓 UDP DNS 包也很有用：\n1tcpdump -i eth0 port 53 -n 如果你发现 DNS 请求发出去但没收到响应，或者收到响应但解析结果不对，这个命令能帮你快速定位。\n排查 HTTP 接口问题 对于 HTTP/HTTPS 接口，如果你需要看应用层内容，可以用 tcpdump -A 以 ASCII 形式打印数据：\n1tcpdump -i eth0 port 80 -A -s 0 -s 0 表示不截断数据包，抓完整内容。\n不过 HTTPS 是加密的，抓到的内容看不到明文。这种情况下可以改用 Web 服务器日志或者浏览器开发者工具。\n抓包注意事项 抓包会产生大量数据，注意磁盘空间。尤其是高流量服务器，别一不小心把磁盘撑满。 生产环境抓包要谨慎，可能会影响性能。 注意隐私合规，抓包可能抓到敏感信息，分析完及时删除。 总结 这次故障让我深刻体会到：在网络世界里，数据包永远不会说谎。当配置看起来都对但就是不工作的时候，抓包往往能直接定位问题。\n我的排查流程一般是：\n先看基础网络配置（IP、网关、DNS、防火墙）。 用 ping 和 traceroute 确认连通性。 用 tcpdump 抓包，保存为 pcap。 用 Wireshark 离线分析协议细节。 根据数据包行为定位根因。 掌握 tcpdump + Wireshark 这对组合，网络排障能力会有质的飞跃。\n","permalink":"http://www.moxuan.xin/posts/tcpdump-wireshark-network-analysis/","summary":"\u003cp\u003e机房里有一台装了 Linux 的旧工控机，局域网内可以互相 Ping 通，但就是死活无法访问外网的 API 接口。路由器的防火墙查了没拦截，工控机网关也没配错。\u003c/p\u003e\n\u003cp\u003e面对这种灵异事件，单纯看配置已经没用了，只能深入协议栈去抓包看底层数据。\u003c/p\u003e","title":"设备连不上外网？用 TCPdump 和 Wireshark 抓包排查"},{"content":"早上起来发现博客打不开了，登进终端一看，Load Average 飙到了 15。top 命令一看，Nginx 进程占了 99% 的 CPU。\n用 tail -n 100 /var/log/nginx/access.log 一看，某个段的海外 IP 正在以每秒几百次的频率狂刷我的查询接口，典型的 CC 攻击。虽然带宽没被打满，但后端查询接口扛不住这么高的并发，服务器直接卡死了。\n以前这种时候我都是手动写脚本看日志然后加 iptables 规则，容易误杀还麻烦。这次直接上 Fail2ban，让它自动监听日志并封锁 IP。\n什么是 Fail2ban Fail2ban 是一个入侵防御工具，它会监控指定的日志文件，用正则表达式匹配恶意行为，然后自动把对应的 IP 加入防火墙黑名单。\n常见的使用场景：\nSSH 暴力破解 Nginx 恶意爬虫 WordPress 登录爆破 邮件服务器 spam 攻击 安装 Fail2ban Debian/Ubuntu 直接 apt 安装：\n1apt update 2apt install fail2ban -y 安装完成后默认会启动一个 fail2ban 服务，但默认配置不会启用任何规则。\n配置 SSH 防护 虽然这篇文章主要讲 Nginx，但 SSH 防护是必开的。创建 /etc/fail2ban/jail.local：\n1[sshd] 2enabled = true 3port = 22 4filter = sshd 5logpath = /var/log/auth.log 6maxretry = 3 7bantime = 3600 8findtime = 600 如果你改了 SSH 端口，port 这里要对应修改。\n配置 Nginx CC 攻击防护 针对 Nginx 的 CC 攻击，我需要自己写一个 jail 和 filter。\n1. 创建 jail 在 /etc/fail2ban/jail.local 里加上：\n1[nginx-cc] 2enabled = true 3port = http,https 4filter = nginx-cc 5logpath = /var/log/nginx/access.log 6findtime = 10 7maxretry = 50 8bantime = 86400 9action = iptables-multiport[name=NoCC, port=\u0026#34;http,https\u0026#34;] 参数说明：\nfindtime = 10：10 秒时间窗口。 maxretry = 50：10 秒内同一个 IP 访问超过 50 次就封禁。 bantime = 86400：封禁 24 小时。 这些阈值要根据自己网站的正常流量调整。如果正常用户刷新比较多，可以把 maxretry 调大一点。\n2. 创建 filter 在 /etc/fail2ban/filter.d/nginx-cc.conf 里写匹配规则：\n1[Definition] 2failregex = ^\u0026lt;HOST\u0026gt; -.*\u0026#34;(GET|POST) /api/search.*HTTP.*\u0026#34; 3ignoreregex = 这个规则匹配访问 /api/search 接口的请求。\u0026lt;HOST\u0026gt; 是 Fail2ban 内置的变量，表示 IP 地址。\n如果你的攻击目标是其他路径，可以修改正则。比如匹配所有请求：\n1failregex = ^\u0026lt;HOST\u0026gt; -.*\u0026#34;(GET|POST) .*HTTP.*\u0026#34; 但这样可能误伤正常用户和高并发爬虫，建议针对具体被攻击的接口写规则。\n3. 重启服务 1systemctl restart fail2ban 查看状态：\n1fail2ban-client status nginx-cc 查看被封禁的 IP：\n1fail2ban-client status nginx-cc banned 效果验证 重启 Fail2ban 后，我观察了十几分钟。一开始日志里还是疯狂的请求，但大概一分钟后，请求量骤降。\n用 fail2ban-client status nginx-cc 一看，已经封禁了 30 多个 IP。整个服务器负载很快从 15 降到了 1 以下。\n更精细的规则 只匹配一个接口有时候不够。后来我加了几条规则：\n匹配 WordPress 登录爆破\n1[wordpress-login] 2enabled = true 3port = http,https 4filter = wordpress-login 5logpath = /var/log/nginx/access.log 6maxretry = 5 7bantime = 86400 filter：\n1[Definition] 2failregex = ^\u0026lt;HOST\u0026gt; -.*\u0026#34;POST /wp-login.php.*HTTP.*\u0026#34; 匹配 404 扫描\n有些扫描器会批量请求不存在的路径，也可以用 Fail2ban 封禁：\n1[nginx-404] 2enabled = true 3port = http,https 4filter = nginx-404 5logpath = /var/log/nginx/access.log 6maxretry = 20 7findtime = 60 8bantime = 3600 filter：\n1[Definition] 2failregex = ^\u0026lt;HOST\u0026gt; -.*\u0026#34;(GET|POST) .*HTTP.*\u0026#34; 404 白名单设置 如果你有自己的固定 IP，建议加到白名单，防止被误封。在 /etc/fail2ban/jail.local 的 [DEFAULT] 段加：\n1[DEFAULT] 2ignoreip = 127.0.0.1/8 你的公网IP 查看和手动解封 如果不小心封错了 IP，可以手动解封：\n1fail2ban-client set nginx-cc unbanip IP地址 总结 Fail2ban 是个非常实用的安全工具，特别适合我这种小站长。它的核心优势是：\n自动分析日志，不用自己写复杂脚本。 自动封禁 IP，响应速度快。 规则灵活，可以针对各种攻击模式定制。 这次 CC 攻击让我深刻体会到，小网站也需要基本的安全防护。fail2ban 配置一次，长期受益，强烈建议大家至少把 SSH 和 Nginx 的防护规则开起来。\n","permalink":"http://www.moxuan.xin/posts/fail2ban-protect-nginx-ssh/","summary":"\u003cp\u003e早上起来发现博客打不开了，登进终端一看，Load Average 飙到了 15。\u003ccode\u003etop\u003c/code\u003e 命令一看，Nginx 进程占了 99% 的 CPU。\u003c/p\u003e\n\u003cp\u003e用 \u003ccode\u003etail -n 100 /var/log/nginx/access.log\u003c/code\u003e 一看，某个段的海外 IP 正在以每秒几百次的频率狂刷我的查询接口，典型的 CC 攻击。虽然带宽没被打满，但后端查询接口扛不住这么高的并发，服务器直接卡死了。\u003c/p\u003e","title":"服务器遭遇 CC 攻击，用 Fail2ban 自动封禁恶意 IP"},{"content":"数据库备份这件事，说起来容易，真要做好还挺考验耐心的。我之前就吃过亏：有一次手滑删错表，结果发现最近的备份是一周前的，中间的数据全丢了。从那以后，我对数据库备份就特别上心。\n这篇文章分享一下我目前给小网站设计的数据库备份策略，从最简单的 mysqldump 开始，到自动化脚本和异地存储。\n为什么 mysqldump 够用了 数据库备份工具有很多，比如 Percona XtraBackup、mydumper、二进制日志备份等。但对于小网站和个人项目来说，mysqldump 已经够用了。\n它的优点：\n简单易用，几乎所有 MySQL/MariaDB 都自带。 备份结果是 SQL 文本，方便查看和部分恢复。 恢复简单，一条命令就能导入。 缺点也很明显：\n大数据库备份慢。 备份过程中会锁表（如果不加参数）。 全量备份占空间大。 对于几十 MB 到几个 GB 的数据库，mysqldump 完全没问题。\n基础全量备份 最简单的备份命令：\n1mysqldump -u root -p 数据库名 \u0026gt; backup.sql 输入密码后就会生成一个 SQL 文件。\n如果是备份所有数据库：\n1mysqldump -u root -p --all-databases \u0026gt; all_databases.sql 更 production 一点的写法，加上一些参数：\n1mysqldump -u root -p \\ 2 --single-transaction \\ 3 --routines \\ 4 --triggers \\ 5 --events \\ 6 --all-databases \u0026gt; backup_$(date +%Y%m%d).sql 参数说明：\n--single-transaction：在导出前开启一个事务，保证数据一致性，对 InnoDB 表不锁表。 --routines：备份存储过程和函数。 --triggers：备份触发器。 --events：备份定时事件。 压缩备份文件 SQL 文本压缩率通常很高，我习惯备份完直接压缩：\n1mysqldump -u root -p --all-databases | gzip \u0026gt; backup_$(date +%Y%m%d).sql.gz 一个 500MB 的 SQL 文件，压缩后可能只有 50MB。\n恢复的时候这样解压导入：\n1gunzip \u0026lt; backup_20240925.sql.gz | mysql -u root -p 加密备份 如果数据库里有敏感信息，建议对备份文件加密。我用的是 GPG：\n1# 生成密钥（只需要做一次） 2gpg --gen-key 3 4# 加密备份文件 5gpg --encrypt --recipient \u0026#34;你的名字\u0026#34; backup_20240925.sql.gz 加密后的文件后缀是 .gpg。解密：\n1gpg --decrypt backup_20240925.sql.gz.gpg \u0026gt; backup_20240925.sql.gz 自动化备份脚本 手动备份容易忘，我把它写成了脚本，每天凌晨自动跑。\n1#!/bin/bash 2 3BACKUP_DIR=\u0026#34;/data/backup/mysql\u0026#34; 4DATE=$(date +%Y%m%d) 5RETENTION_DAYS=30 6DB_USER=\u0026#34;backup\u0026#34; 7DB_PASS=\u0026#34;your_password\u0026#34; 8 9mkdir -p \u0026#34;$BACKUP_DIR\u0026#34; 10 11# 全量备份并压缩 12mysqldump -u \u0026#34;$DB_USER\u0026#34; -p\u0026#34;$DB_PASS\u0026#34; \\ 13 --single-transaction \\ 14 --all-databases | gzip \u0026gt; \u0026#34;$BACKUP_DIR/all_$DATE.sql.gz\u0026#34; 15 16# 删除过期备份 17find \u0026#34;$BACKUP_DIR\u0026#34; -name \u0026#34;all_*.sql.gz\u0026#34; -type f -mtime +$RETENTION_DAYS -delete 然后用 crontab 定时执行：\n10 2 * * * /root/scripts/backup_mysql.sh \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 增量备份思路 对于数据量比较大的情况，只做全量备份不够经济。可以结合二进制日志（binlog）做增量备份。\nMySQL 的二进制日志记录了所有数据变更操作。开启 binlog 后，可以通过 mysqlbinlog 工具把增量变更导出：\n1mysqlbinlog /var/lib/mysql/binlog.000001 \u0026gt; increment.sql 恢复的时候，先恢复最近一次全量备份，再按顺序应用 binlog：\n1mysql -u root -p \u0026lt; full_backup.sql 2mysql -u root -p \u0026lt; increment.sql 不过 binlog 管理比较复杂，要定期清理，还要考虑日志轮转。小网站如果没有特别大的数据量，每天全量备份其实更省心。\n异地存储 备份文件如果只存在服务器本地，服务器挂了照样没。所以一定要有异地存储。\n我的方案是：备份完成后用 rsync 同步到另一台便宜的备份服务器：\n1rsync -avz --delete \u0026#34;$BACKUP_DIR/\u0026#34; backup@backup-server:/backups/mysql/ 也可以用对象存储，比如阿里云 OSS、腾讯云 COS。用对应厂商的 CLI 工具上传：\n1aliyun oss cp \u0026#34;$BACKUP_DIR/all_$DATE.sql.gz\u0026#34; oss://my-backup-bucket/mysql/ 定期恢复演练 备份不是终点，能恢复才有意义。我每隔一两个月会做一次恢复演练：\n找一台测试机器。 把备份文件传过去。 导入数据库。 检查数据是否完整，应用是否能正常连接。 恢复命令：\n1# 解压并导入 2gunzip \u0026lt; all_20240925.sql.gz | mysql -u root -p 3 4# 如果是单个数据库 5gunzip \u0026lt; db_20240925.sql.gz | mysql -u root -p 数据库名 如果从来不演练，真到用的时候才发现备份文件损坏或者命令记错了，那就完蛋了。\n总结 一个基本可用的数据库备份策略包括：\n每天全量备份，用 mysqldump + gzip 压缩。 保留 30 天历史备份，过期自动删除。 备份文件加密，保护敏感数据。 同步到异地存储，防止单点故障。 定期恢复演练，确保备份可用。 这套方案对个人博客和小型网站来说已经足够。数据无价，希望大家都能重视备份，不要等到丢了才后悔。\n","permalink":"http://www.moxuan.xin/posts/mysql-backup-strategy/","summary":"\u003cp\u003e数据库备份这件事，说起来容易，真要做好还挺考验耐心的。我之前就吃过亏：有一次手滑删错表，结果发现最近的备份是一周前的，中间的数据全丢了。从那以后，我对数据库备份就特别上心。\u003c/p\u003e","title":"数据库备份策略：从 mysqldump 到自动化脚本"},{"content":"手里有一台极其廉价的 1核 1G 内存的 VPS，跑了一个 WordPress。最近只要稍微有点访问量，系统就会因为内存耗尽触发 OOM，直接把数据库进程杀掉，导致网页报 \u0026ldquo;Error establishing a database connection\u0026rdquo;。\n加钱换大内存是不可能加钱的，这辈子都不可能加钱的。既然没钱升级，就只能靠改配置文件来极限压榨了。\n先搞清楚内存被谁吃了 优化之前，先用 free -h 和 ps aux --sort=-%mem | head 看看内存占用情况。\n我当时的状况大概是：\n系统本身占用 200MB 左右 MariaDB 启动后直接吃掉 400-500MB Nginx + PHP-FPM 占用 200MB 左右 留给业务缓存和突发请求的空间几乎没有 所以优化的核心目标就是把 MariaDB 的内存占用压下来。\n调整 MariaDB 内存参数 MySQL/MariaDB 默认是为企业级大内存设计的，默认参数对小内存机器非常不友好。我的配置文件在 /etc/mysql/mariadb.conf.d/50-server.cnf，不同发行版路径可能略有不同。\n主要改了这几个参数：\n1[mysqld] 2# 最大连接数减小，小破站用不到那么多 3max_connections = 50 4 5# 核心大户：InnoDB 缓冲池 6# 默认一般是 128M，我直接降到 32M 7innodb_buffer_pool_size = 32M 8 9# 关闭 performance_schema，省一点内存 10performance_schema = off 11 12# MyISAM 索引缓存 13key_buffer_size = 8M 14 15# 查询缓存，MySQL 8.0 已经移除了，MariaDB 还可以开 16# 但我这里干脆关掉，因为查询模式多变，缓存命中率不高 17query_cache_size = 0 18query_cache_type = 0 19 20# 排序和读缓存适当缩小 21sort_buffer_size = 256K 22read_buffer_size = 128K 23read_rnd_buffer_size = 256K 24 25# 连接线程栈大小 26thread_stack = 192K 27 28# InnoDB 日志缓冲区 29innodb_log_buffer_size = 1M 30 31# InnoDB 每表一个文件，方便管理 32innodb_file_per_table = 1 改完之后重启 MariaDB：\n1systemctl restart mariadb 再看内存占用，MariaDB 从 500MB 降到了 250MB 左右，效果明显。\n各个参数的作用 max_connections\n默认可能是 151，对小站点来说太多了。每个连接都会消耗内存，连接数越少，总体内存占用越低。50 个连接对我的小站足够用了。\ninnodb_buffer_pool_size\n这是 InnoDB 最重要的内存参数，用来缓存表数据和索引。默认 128M 对 1G 内存的机器来说太大了。降到 32M 后，查询性能确实会下降一些，因为缓存命中率低了，但总比 OOM 被杀掉强。\nperformance_schema\n这是一个性能监控特性，会收集很多运行时统计信息。对调试有帮助，但很耗内存。小机器建议关掉。\nquery_cache\n查询缓存会把相同的查询结果缓存起来。理论上能加速，但如果表更新频繁，缓存失效会很频繁，反而可能成为瓶颈。而且 query cache 本身也要占内存，小机器关掉更省心。\n加个双保险：Swap 小内存机器千万不能关 Swap。虽然 Swap 速度比内存慢很多，但在内存紧张的时候，它能让系统不至于直接 OOM 杀进程。\n我创建了一个 2G 的 Swap 文件：\n1# 创建 2G 的空文件 2fallocate -l 2G /swapfile 3 4# 设置权限 5chmod 600 /swapfile 6 7# 格式化为 swap 8mkswap /swapfile 9 10# 启用 11swapon /swapfile 12 13# 写入 fstab 永久生效 14echo \u0026#39;/swapfile none swap sw 0 0\u0026#39; \u0026gt;\u0026gt; /etc/fstab 然后用 swapon --show 确认已经启用。\n关于 Swap 有个常见误区：很多人觉得用了 Swap 就说明内存不够，性能会很差。其实 Linux 内核会尽量把不常用的内存页换到 Swap，把物理内存留给活跃的进程。轻度使用 Swap 是正常的，只要不是频繁大量换入换出，性能影响没有想象中大。\n监控内存使用 优化完之后，我用几个命令持续观察了一段时间：\n1# 看整体内存和 Swap 使用 2free -h 3 4# 看 MariaDB 实际占用 5ps aux | grep mariadb 6 7# 看 Swap 使用是否剧烈 8vmstat 1 10 vmstat 里的 si 和 so 表示每秒 Swap 换入换出的量。如果长期大于 0，说明内存是真的不够用了，这时候就该考虑升级配置了。\n其他能做的优化 除了数据库参数，还有一些配套的优化：\n减少 PHP-FPM 进程数：WordPress 这种 PHP 应用，FPM 进程太多也会吃光内存。 开启 OPcache：用内存缓存 PHP 字节码，虽然多占一点内存，但能显著降低 CPU 负载和响应时间。 用对象缓存：WordPress 可以用 Redis 或 Memcached 做对象缓存，减少数据库查询次数。 不过这些就超出数据库优化的范围了，以后有机会再写。\n总结 小内存 VPS 跑数据库，核心思路就是\u0026quot;能关的关，能小的调小\u0026quot;：\n降低 max_connections，够用就行 减小 innodb_buffer_pool_size，这是内存大户 关闭 performance_schema 和 query_cache 合理配置 Swap 作为保险 持续监控内存和 Swap 使用情况 优化完之后，我的 MariaDB 平时内存占用被死死按在了 250MB 左右，加上系统和其他服务，总体占用在 700MB 以内，基本不再触发 OOM。\n穷人版服务器的调优乐趣就在于此。虽然配置寒酸，但通过合理调整，依然能让它稳定运行。当然，如果业务量真的上来了，该升级还是要升级，调优永远替代不了硬件。\n","permalink":"http://www.moxuan.xin/posts/mysql-mariadb-memory-optimization/","summary":"\u003cp\u003e手里有一台极其廉价的 1核 1G 内存的 VPS，跑了一个 WordPress。最近只要稍微有点访问量，系统就会因为内存耗尽触发 OOM，直接把数据库进程杀掉，导致网页报 \u0026ldquo;Error establishing a database connection\u0026rdquo;。\u003c/p\u003e","title":"1核1G的小鸡怎么跑 MySQL？记一次 MariaDB 的极限内存压榨"},{"content":"我的博客是用 Hugo 生成的静态站点，生成之后就是一堆 HTML、CSS、JS 和图片文件。这种静态站点最适合用 Nginx 直接托管，速度快、配置简单、资源占用低。\n这篇文章分享一下我目前用的 Nginx 配置，算是给静态博客的一个参考模板。\n基础配置 最简单的静态博客配置只需要指定根目录和 index 文件：\n1server { 2 listen 80; 3 server_name blog.moxuan.xin; 4 root /var/www/blog/public; 5 index index.html; 6 7 location / { 8 try_files $uri $uri/ /index.html; 9 } 10} try_files $uri $uri/ /index.html 这个配置很重要，因为 Hugo 很多页面是干净的 URL，比如 /about/ 而不是 /about.html。如果直接访问目录找不到文件，就回退到 index.html。\n强制 HTTPS 现在 HTTP 基本已经被淘汰了，我直接把所有 80 端口的请求 301 跳转到 HTTPS：\n1server { 2 listen 80; 3 server_name blog.moxuan.xin; 4 return 301 https://$server_name$request_uri; 5} 6 7server { 8 listen 443 ssl http2; 9 server_name blog.moxuan.xin; 10 root /var/www/blog/public; 11 index index.html; 12 13 ssl_certificate /etc/letsencrypt/live/blog.moxuan.xin/fullchain.pem; 14 ssl_certificate_key /etc/letsencrypt/live/blog.moxuan.xin/privkey.pem; 15 16 location / { 17 try_files $uri $uri/ /index.html; 18 } 19} http2 建议加上，能提升多资源并发加载的效率。\n启用 Gzip 压缩 静态博客的 HTML、CSS、JS 都是文本，开启 Gzip 能大幅减少传输体积。在 http 块里加：\n1gzip on; 2gzip_vary on; 3gzip_proxied any; 4gzip_comp_level 6; 5gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; gzip_comp_level 是压缩级别，1 最快但压缩率低，9 最慢但压缩率高。一般 6 是个不错的平衡点。\n静态资源缓存 对于 CSS、JS、图片这些不常变化的文件，可以让浏览器和 CDN 缓存久一点：\n1location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ { 2 expires 6M; 3 access_log off; 4 add_header Cache-Control \u0026#34;public, immutable\u0026#34;; 5} expires 6M 表示缓存 6 个月。如果以后更新了这些文件，可以通过改文件名（比如加 hash）来强制刷新。\n图片懒加载配合 Nginx 本身不负责图片懒加载，这是前端的事情。但 Nginx 可以优化图片的传输方式。比如开启 sendfile 和 tcp_nopush：\n1sendfile on; 2tcp_nopush on; 3tcp_nodelay on; 这些参数可以提升静态文件的发送效率。\n安全相关 Header 顺手加一些安全响应头：\n1add_header X-Frame-Options \u0026#34;SAMEORIGIN\u0026#34; always; 2add_header X-Content-Type-Options \u0026#34;nosniff\u0026#34; always; 3add_header Referrer-Policy \u0026#34;strict-origin-when-cross-origin\u0026#34; always; 这些不会直接影响性能，但能提升安全性。\n配合 CDN 使用 如果博客流量比较大，建议前面套一个 CDN。我目前用的是 Cloudflare，配置好之后：\nDNS 解析到 Cloudflare。 Cloudflare 回源到我的源站 Nginx。 静态资源被缓存到 Cloudflare 的边缘节点。 CDN 配置我单独写了一篇文章，这里就不展开了。\n完整的配置示例 把上面的内容整合起来，我的完整配置大概是这样：\n1http { 2 sendfile on; 3 tcp_nopush on; 4 tcp_nodelay on; 5 keepalive_timeout 65; 6 7 gzip on; 8 gzip_vary on; 9 gzip_proxied any; 10 gzip_comp_level 6; 11 gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; 12 13 server { 14 listen 80; 15 server_name blog.moxuan.xin; 16 return 301 https://$server_name$request_uri; 17 } 18 19 server { 20 listen 443 ssl http2; 21 server_name blog.moxuan.xin; 22 root /var/www/blog/public; 23 index index.html; 24 25 ssl_certificate /etc/letsencrypt/live/blog.moxuan.xin/fullchain.pem; 26 ssl_certificate_key /etc/letsencrypt/live/blog.moxuan.xin/privkey.pem; 27 28 add_header X-Frame-Options \u0026#34;SAMEORIGIN\u0026#34; always; 29 add_header X-Content-Type-Options \u0026#34;nosniff\u0026#34; always; 30 31 location / { 32 try_files $uri $uri/ /index.html; 33 } 34 35 location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ { 36 expires 6M; 37 access_log off; 38 add_header Cache-Control \u0026#34;public, immutable\u0026#34;; 39 } 40 } 41} 验证配置 每次改完 Nginx 配置，一定要先用 nginx -t 检查语法：\n1nginx -t 没有报错再重载：\n1nginx -s reload 总结 静态博客的 Nginx 配置不复杂，但有几个点做好了能明显提升体验：\n强制 HTTPS，启用 HTTP/2。 开启 Gzip 压缩，减少传输体积。 静态资源长期缓存，配合 CDN 效果更佳。 加安全响应头，提升安全性。 如果你也用 Hugo、Hexo、Jekyll 这类静态生成器，这个配置基本可以直接拿去用，改改域名和路径就行。\n","permalink":"http://www.moxuan.xin/posts/nginx-static-blog-acceleration/","summary":"\u003cp\u003e我的博客是用 Hugo 生成的静态站点，生成之后就是一堆 HTML、CSS、JS 和图片文件。这种静态站点最适合用 Nginx 直接托管，速度快、配置简单、资源占用低。\u003c/p\u003e","title":"用 Nginx 给静态博客加速的完整配置"},{"content":"本来在查资料，结果发现自己博客图片刷不出来了。看了一下账单，好家伙，CDN 流量半小时跑了 5G。\n日志拉下来一分析，某个爬虫正在丧心病狂地遍历下载我博客所有的附件目录，而且 User-Agent 伪装成了正常的 Chrome 浏览器，靠封 UA 根本封不掉。\n既然分不清你是人是爬虫，那就直接按 IP 限制并发频率。这篇文章记录我用 Nginx 漏桶算法限流的过程。\n什么是漏桶算法 漏桶算法（Leaky Bucket）是一种流量整形算法。想象一个底部有洞的桶，水（请求）以任意速度倒入桶中，但流出的速度是固定的。如果倒入的速度太快，桶就会满，多出来的水就溢出了。\n在 Nginx 里，这个\u0026quot;桶\u0026quot;就是内存中的一块区域，用来记录每个 IP 的请求频率。请求进来先放到桶里，如果频率超过设定值，就直接拒绝服务。\nNginx 通过 limit_req_zone 和 limit_req 两个指令实现漏桶限流。\n配置 limit_req_zone 首先在 Nginx 配置文件的 http 块里定义限流区域：\n1http { 2 # 定义一个名为 anti_spider 的限流区域 3 # $binary_remote_addr 表示用客户端 IP 作为 key 4 # 10m 表示分配 10MB 内存存储状态 5 # rate=2r/s 表示每个 IP 每秒最多 2 个请求 6 limit_req_zone $binary_remote_addr zone=anti_spider:10m rate=2r/s; 7 8 # 其他配置... 9} 参数说明：\n$binary_remote_addr：客户端 IP 的二进制形式，比字符串形式更省内存。 zone=anti_spider:10m：区域名称叫 anti_spider，分配 10MB 内存。 rate=2r/s：每个 key（这里是每个 IP）每秒允许 2 个请求。 10MB 内存大概能存 16 万个 IP 的状态，对个人博客来说完全够用。\n在 location 里应用限流 定义好区域后，在需要限流的 location 里使用 limit_req：\n1server { 2 listen 80; 3 server_name blog.example.com; 4 5 location /images/ { 6 limit_req zone=anti_spider burst=5 nodelay; 7 alias /var/www/blog/images/; 8 expires 30d; 9 } 10} zone=anti_spider：使用刚才定义的限流区域。 burst=5：允许突发 5 个请求。也就是说，前 5 个请求可以先进桶里排队。 nodelay：不延迟处理，超过速率的请求直接返回 503，而不是排队等待。 对于图片、附件这些静态资源，用 nodelay 比较合理。爬虫每秒请求几百次，让它排队没意义，直接拒绝最干脆。\n实际效果 重新加载配置后，我自己用脚本并发请求了一波图片做测试：\n1ab -n 100 -c 10 https://blog.example.com/images/test.jpg 结果前几个请求返回 200，后面的全部返回 503。这说明限流生效了。\n再看监控，那个疯狂的爬虫依然在尝试抓取，但全被 Nginx 拒之门外，带宽占用瞬间回落到正常水平。\n全局限流 vs 局部限流 上面是按 location 限流，只针对 /images/ 目录。如果你发现全站都被爬，可以在 server 块里全局应用：\n1server { 2 limit_req zone=anti_spider burst=10 nodelay; 3 4 location / { 5 proxy_pass http://localhost:8080; 6 } 7} 但全局限流要慎重，因为正常用户如果点得快，也可能触发限制。建议先针对静态资源目录限流，观察效果再决定是否扩大范围。\n配合日志分析 限流之后，建议把被拒绝的请求记录到单独日志里，方便分析：\n1limit_req_zone $binary_remote_addr zone=anti_spider:10m rate=2r/s; 2 3server { 4 location /images/ { 5 limit_req zone=anti_spider burst=5 nodelay; 6 error_page 503 = @rate_limited; 7 } 8 9 location @rate_limited { 10 access_log /var/log/nginx/rate_limit.log main; 11 return 503; 12 } 13} 通过分析 rate_limit.log，可以看到哪些 IP 在大量请求，必要时再用防火墙封禁。\n另一个维度：限制连接数 除了限制请求频率，Nginx 还可以限制并发连接数，用的是 limit_conn_zone 和 limit_conn：\n1http { 2 limit_conn_zone $binary_remote_addr zone=addr:10m; 3 4 server { 5 location / { 6 limit_conn addr 10; 7 } 8 } 9} 这表示每个 IP 同时最多只能建立 10 个连接。对于防止某些下载工具开大量线程很有用。\n总结 这次被爬虫偷带宽的经历让我学到了几件事：\n不要只依赖 User-Agent 判断爬虫，现在很多爬虫会伪装 UA。 按 IP 限流是比较通用的防御手段，对人影响小，对爬虫效果明显。 limit_req_zone 配置简单，但效果很好。 限流后要监控日志，看看是哪些 IP 在搞事情。 如果你也运营一个小网站，建议提前把 Nginx 限流配置好。等真正被爬的时候再临时应对，可能已经损失了不少流量费用。\n","permalink":"http://www.moxuan.xin/posts/nginx-limit-req-zone-anti-crawler/","summary":"\u003cp\u003e本来在查资料，结果发现自己博客图片刷不出来了。看了一下账单，好家伙，CDN 流量半小时跑了 5G。\u003c/p\u003e\n\u003cp\u003e日志拉下来一分析，某个爬虫正在丧心病狂地遍历下载我博客所有的附件目录，而且 User-Agent 伪装成了正常的 Chrome 浏览器，靠封 UA 根本封不掉。\u003c/p\u003e","title":"谁在偷我的带宽？用 Nginx 漏桶算法限流防恶意爬虫"},{"content":"今天在给一个前后端分离的项目配置 Nginx 反代时，前端控制台一片红，全都是 CORS Error。前端的请求发到了 api.example.com，而后端服务跑在 localhost:8080，域名不一样，浏览器的同源策略就把请求拦截了。\n其实 CORS 问题算是前后端分离项目里的老面孔了。解决思路一般有两种：要么让后端加上 CORS Header，要么在 Nginx 这一层统一处理。我这次选择在 Nginx 里解决，因为后端服务可能不止一个，统一在网关层管理跨域更方便。\n什么是 CORS CORS 全称 Cross-Origin Resource Sharing，跨域资源共享。浏览器出于安全考虑，会限制从一个域名加载的网页去请求另一个域名的资源。这种限制叫做\u0026quot;同源策略\u0026quot;。\n两个 URL 同源需要满足三个条件都相同：\n协议相同（http / https） 域名相同 端口相同 只要有一个不同，就是跨域。\n比如前端跑在 https://www.example.com，后端 API 在 https://api.example.com，虽然主域名一样，但子域名不同，也是跨域。\nNginx 里加 CORS Header 解决思路很简单：在 Nginx 返回的响应里加上允许跨域的 HTTP Header。修改 Nginx 配置文件，在对应的 location 块里添加：\n1location /api/ { 2 proxy_pass http://127.0.0.1:8080; 3 4 # 允许跨域的域名，* 表示允许所有 5 add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;*\u0026#39;; 6 7 # 允许的 HTTP 方法 8 add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39;; 9 10 # 允许的请求头 11 add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization\u0026#39;; 12 13 # 是否允许携带 Cookie 14 add_header \u0026#39;Access-Control-Allow-Credentials\u0026#39; \u0026#39;true\u0026#39;; 15} 改完重新加载 Nginx：\n1nginx -s reload 搞定收工。配置服务器就是这样，有时候卡了一小时的问题，其实就是少写了两行配置项。\n处理 OPTIONS 预检请求 上面的配置在简单请求里没问题，但如果请求带了自定义 Header 或者 Content-Type 是 application/json，浏览器会先发送一个 OPTIONS 预检请求。这个请求不会带 Cookie，也不会带请求体。\n如果 Nginx 直接把 OPTIONS 请求转发给后端，而后端没有正确处理，就会报 405 或者 403。\n更好的做法是在 Nginx 层直接响应 OPTIONS 请求：\n1location /api/ { 2 if ($request_method = \u0026#39;OPTIONS\u0026#39;) { 3 add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;*\u0026#39;; 4 add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39;; 5 add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization\u0026#39;; 6 add_header \u0026#39;Access-Control-Max-Age\u0026#39; 86400; 7 return 204; 8 } 9 10 proxy_pass http://127.0.0.1:8080; 11 add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;*\u0026#39;; 12 add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39;; 13 add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization\u0026#39;; 14} 这样 OPTIONS 预检请求就不会打到后端，响应速度也会更快。\n生产环境不要写 * 上面的例子为了演示方便，我写的是 Access-Control-Allow-Origin: *，表示允许所有域名。这在开发环境没问题，但生产环境强烈建议改成具体的域名：\n1add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;https://www.example.com\u0026#39;; 如果允许多个域名，可以配合 map 使用：\n1map $http_origin $cors_origin { 2 default \u0026#34;\u0026#34;; 3 \u0026#34;~^https://www\\.example\\.com$\u0026#34; $http_origin; 4 \u0026#34;~^https://admin\\.example\\.com$\u0026#34; $http_origin; 5} 6 7server { 8 location /api/ { 9 add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; $cors_origin; 10 proxy_pass http://127.0.0.1:8080; 11 } 12} 这样只有白名单里的域名才能跨域访问，安全性更高。\n我踩过的一个坑 有一次我加了 CORS Header 之后，前端还是报跨域错误。排查了很久才发现，Nginx 返回了 502，而 502 的响应里不会带上我配置的 add_header。浏览器看到的是没有 CORS Header 的 502 响应，于是报了跨域错误。\n实际上真正的错误是后端服务没启动，跟 CORS 没关系。但浏览器的报错信息误导了我。\n所以遇到 CORS 报错，建议先用 curl -v 直接请求后端，确认后端服务本身是通的，然后再排查 CORS 配置。\n总结 Nginx 处理 CORS 的核心就是加几个响应头：\nAccess-Control-Allow-Origin：允许的源 Access-Control-Allow-Methods：允许的方法 Access-Control-Allow-Headers：允许的请求头 Access-Control-Allow-Credentials：是否允许携带凭证 Access-Control-Max-Age：预检结果缓存时间 对于简单请求，直接加 Header 就行。对于复杂请求，最好单独处理 OPTIONS 预检。\n不过最根本的解决办法还是前后端统一域名，比如都用 www.example.com，API 走 /api/ 路径，这样就不存在跨域问题了。\n","permalink":"http://www.moxuan.xin/posts/ginx-reverse-proxy-cors/","summary":"\u003cp\u003e今天在给一个前后端分离的项目配置 Nginx 反代时，前端控制台一片红，全都是 \u003ccode\u003eCORS Error\u003c/code\u003e。前端的请求发到了 \u003ccode\u003eapi.example.com\u003c/code\u003e，而后端服务跑在 \u003ccode\u003elocalhost:8080\u003c/code\u003e，域名不一样，浏览器的同源策略就把请求拦截了。\u003c/p\u003e","title":"Nginx 反向代理踩坑：跨域资源共享 (CORS) 报错怎么破"},{"content":"刚开始用 Docker 的时候，我写了一大串 docker run 命令，参数又多又乱，后来想改个端口或者加个环境变量，得翻半天历史记录。自从换成 Docker Compose 之后，部署和管理容器变得舒服多了。\nDocker Compose 的核心思想是把一个应用的多个服务写在一个 YAML 文件里，一条命令就能启动整个应用栈。这篇文章总结我用 Compose 部署过的一些常用服务，给想入门的人做个参考。\n为什么用 Docker Compose 相比 docker run，Compose 有几个明显优势：\n配置集中管理：所有服务的镜像、端口、环境变量、挂载都写在一个文件里。 一键启停：docker-compose up -d 启动所有服务，docker-compose down 停止。 服务自动组网：同一个 compose 项目里的服务自动在同一个网络里，可以通过服务名互相访问。 便于迁移：换服务器的时候把 docker-compose.yml 和数据目录复制过去就行。 基础写法 一个最简单的 docker-compose.yml 长这样：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 web: 5 image: nginx:alpine 6 ports: 7 - \u0026#34;80:80\u0026#34; 8 volumes: 9 - ./html:/usr/share/nginx/html 10 restart: unless-stopped 启动：\n1docker-compose up -d -d 表示后台运行。\n部署一个静态博客 我的博客前端是 Hugo 生成的静态文件，后端用 Nginx 提供服务。Compose 文件很简单：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 blog: 5 image: nginx:alpine 6 container_name: blog 7 ports: 8 - \u0026#34;80:80\u0026#34; 9 - \u0026#34;443:443\u0026#34; 10 volumes: 11 - ./public:/usr/share/nginx/html 12 - ./nginx.conf:/etc/nginx/conf.d/default.conf 13 - ./ssl:/etc/nginx/ssl 14 restart: unless-stopped ./public 是 Hugo 构建出来的静态文件目录。每次更新博客后，重新构建并替换这个目录里的文件即可。\n部署 WordPress + MySQL 很多老项目还是 WordPress，用 Compose 部署 WordPress 和数据库非常方便：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 db: 5 image: mysql:8.0 6 container_name: wp_db 7 environment: 8 MYSQL_ROOT_PASSWORD: root_password 9 MYSQL_DATABASE: wordpress 10 MYSQL_USER: wordpress 11 MYSQL_PASSWORD: wordpress_password 12 volumes: 13 - db_data:/var/lib/mysql 14 restart: unless-stopped 15 16 wordpress: 17 image: wordpress:latest 18 container_name: wp_app 19 ports: 20 - \u0026#34;8080:80\u0026#34; 21 environment: 22 WORDPRESS_DB_HOST: db:3306 23 WORDPRESS_DB_USER: wordpress 24 WORDPRESS_DB_PASSWORD: wordpress_password 25 WORDPRESS_DB_NAME: wordpress 26 volumes: 27 - wp_data:/var/www/html 28 depends_on: 29 - db 30 restart: unless-stopped 31 32volumes: 33 db_data: 34 wp_data: 注意 WORDPRESS_DB_HOST 直接写 db:3306，因为 Compose 会自动把服务名解析成 IP。\n部署 Uptime Kuma 监控 Uptime Kuma 是个很好用的监控工具，我自己也在用：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 uptime-kuma: 5 image: louislam/uptime-kuma:1 6 container_name: uptime-kuma 7 ports: 8 - \u0026#34;3001:3001\u0026#34; 9 volumes: 10 - ./data:/app/data 11 restart: unless-stopped 启动后访问 http://服务器IP:3001 进行初始化配置。\n部署 Vaultwarden 密码管理器 Vaultwarden 是 Bitwarden 的 Rust 轻量实现，适合个人或小团队自建：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 vaultwarden: 5 image: vaultwarden/server:latest 6 container_name: vaultwarden 7 environment: 8 WEBSOCKET_ENABLED: \u0026#34;true\u0026#34; 9 SIGNUPS_ALLOWED: \u0026#34;false\u0026#34; 10 volumes: 11 - ./vw-data:/data 12 ports: 13 - \u0026#34;8080:80\u0026#34; 14 restart: unless-stopped SIGNUPS_ALLOWED: \u0026quot;false\u0026quot; 表示关闭公开注册，只允许管理员手动邀请用户。自建密码管理器一定要注意安全。\n部署 Nginx 反向代理 如果一台服务器上跑了多个服务，前面加一个 Nginx 做反向代理是标准做法：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 nginx: 5 image: nginx:alpine 6 container_name: nginx 7 ports: 8 - \u0026#34;80:80\u0026#34; 9 - \u0026#34;443:443\u0026#34; 10 volumes: 11 - ./nginx.conf:/etc/nginx/nginx.conf 12 - ./ssl:/etc/nginx/ssl 13 restart: unless-stopped 14 15 app1: 16 image: myapp1:latest 17 container_name: app1 18 restart: unless-stopped 19 20 app2: 21 image: myapp2:latest 22 container_name: app2 23 restart: unless-stopped 对应的 nginx.conf 里可以用服务名做 upstream：\n1server { 2 listen 80; 3 server_name app1.example.com; 4 5 location / { 6 proxy_pass http://app1:8080; 7 } 8} 9 10server { 11 listen 80; 12 server_name app2.example.com; 13 14 location / { 15 proxy_pass http://app2:8080; 16 } 17} 几个使用技巧 1. 使用 .env 文件管理敏感信息 不要把密码直接写在 docker-compose.yml 里，可以用 .env 文件：\n1MYSQL_ROOT_PASSWORD=your_password 然后在 compose 文件里引用：\n1environment: 2 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} 2. 合理设置 restart 策略 一般服务用 unless-stopped，表示除非手动停止，否则 always 重启。测试环境可以用 no。\n3. 限制容器资源 生产环境建议限制 CPU 和内存，防止某个容器把资源吃光：\n1deploy: 2 resources: 3 limits: 4 cpus: \u0026#39;0.5\u0026#39; 5 memory: 512M 6 reservations: 7 cpus: \u0026#39;0.25\u0026#39; 8 memory: 256M 4. 使用 networks 隔离不同项目 如果一台服务器上跑了多个项目，最好给每个项目单独的网络，避免服务名冲突：\n1networks: 2 project_a: 3 driver: bridge 常用命令 1# 启动所有服务（后台） 2docker-compose up -d 3 4# 查看日志 5docker-compose logs -f 6 7# 重启某个服务 8docker-compose restart 服务名 9 10# 停止并删除容器 11docker-compose down 12 13# 停止并删除容器和数据卷（慎用） 14docker-compose down -v 15 16# 拉取最新镜像 17docker-compose pull 18 19# 重新构建 20docker-compose up -d --build 总结 Docker Compose 是我目前最喜欢的小型项目部署方式。它不像 Kubernetes 那么重，又比纯 docker run 好管理得多。\n这篇文章给的几个模板都是我自己实际在用的，你可以根据需求改一改端口、路径和密码就能跑起来。如果刚开始接触 Docker，建议先从一个简单的静态博客或者 Uptime Kuma 入手，熟练之后再挑战多服务组合。\n","permalink":"http://www.moxuan.xin/posts/docker-compose-guide/","summary":"\u003cp\u003e刚开始用 Docker 的时候，我写了一大串 \u003ccode\u003edocker run\u003c/code\u003e 命令，参数又多又乱，后来想改个端口或者加个环境变量，得翻半天历史记录。自从换成 Docker Compose 之后，部署和管理容器变得舒服多了。\u003c/p\u003e","title":"Docker Compose 部署常用服务总结"},{"content":"我手起刀落用 docker run 跑了一个 PHP 应用的容器，又用 docker run 跑了一个 MySQL 的容器。在配置 PHP 连数据库时，我填了 MySQL 容器的名称 db_mysql，结果死活报错：Unknown MySQL server host 'db_mysql'。\n一开始我以为是 MySQL 容器没启动成功，进去看了下服务是正常的。又以为是防火墙问题，检查了一下端口也是通的。最后才意识到问题出在 Docker 的网络上。\nDocker 默认 bridge 网络的限制 以前用 docker-compose 的时候都是直接写服务名就能互相访问，为啥单跑 docker run 就不行？\n去查了 Docker 官方文档才发现：所有直接 docker run 起来的容器，默认会被丢进一个叫 bridge 的默认桥接网络里。而这个默认桥接网络有一个很重要的限制：不支持内建的 DNS 解析服务。\n也就是说，在默认 bridge 网络里，容器之间不能通过容器名互相访问，只能写死内网 IP，比如 172.17.0.2。但容器 IP 每次重启可能都会变，写死 IP 显然不现实。\n验证默认网络的行为 为了确认这一点，我做了个小实验。\n启动两个容器：\n1docker run -d --name container_a alpine sleep 3600 2docker run -d --name container_b alpine sleep 3600 进入 container_a 尝试 ping container_b：\n1docker exec -it container_a ping container_b 结果确实是 bad address 'container_b'。\n但 ping IP 是可以的：\n1docker exec -it container_a ping 172.17.0.3 这就验证了默认 bridge 网络确实不支持 DNS。\n解决方案一：创建自定义网络 正确的做法是创建一个自定义的 bridge 网络。自定义网络默认就支持 DNS 解析。\n1docker network create my_net 创建容器的时候直接指定网络：\n1docker run -d --name php_app --network my_net nginx:alpine 2docker run -d --name db_mysql --network my_net mysql:8 这样 php_app 里直接 ping db_mysql 就能解析到内部 IP。\n如果你已经启动了容器，也可以事后把容器加入网络：\n1docker network connect my_net php_app 2docker network connect my_net db_mysql 解决方案二：用 Docker Compose 其实最简单的方法还是直接用 Docker Compose。Compose 会自动给项目里的所有服务创建一个默认网络，并且服务名就是 DNS 名。\n一个简单的 docker-compose.yml：\n1version: \u0026#39;3.8\u0026#39; 2 3services: 4 web: 5 image: nginx:alpine 6 networks: 7 - my_network 8 9 db: 10 image: mysql:8 11 environment: 12 MYSQL_ROOT_PASSWORD: example 13 networks: 14 - my_network 15 16networks: 17 my_network: 18 driver: bridge 在这个例子里，web 容器里可以直接通过 db 访问 MySQL。\n这也是我之前用 Compose 没遇到过这个问题的原因，它帮我隐藏了网络配置的复杂性。\n解决方案三：使用 \u0026ndash;link（不推荐） Docker 早期有一个 --link 参数可以实现容器名解析，比如：\n1docker run -d --name php_app --link db_mysql:db mysql:8 但这个参数已经被官方标记为废弃了，不建议在新项目中使用。未来可能会被移除。\n排查 DNS 问题的通用思路 遇到容器间网络不通的问题，我一般会按这个顺序排查：\n确认容器都在同一个网络里： 1docker network inspect my_net 确认 DNS 是否能解析： 1docker exec -it 容器名 nslookup 目标容器名 确认端口是否监听： 1docker exec -it 容器名 netstat -tlnp 检查防火墙规则：宿主机或者容器内的 iptables 规则可能拦截了流量。 一个相关的坑：自定义 DNS 有时候容器里的 DNS 解析会慢或者解析不到某些域名。这可能是因为 Docker 默认使用宿主机的 DNS 配置。\n可以在 docker run 时指定 DNS：\n1docker run -d --dns 223.5.5.5 --dns 8.8.8.8 nginx:alpine 或者在 daemon 配置里全局设置：\n1{ 2 \u0026#34;dns\u0026#34;: [\u0026#34;223.5.5.5\u0026#34;, \u0026#34;8.8.8.8\u0026#34;] 3} 国内服务器我一般用阿里云的公共 DNS 223.5.5.5，解析国内域名会快一些。\n总结 这次的问题虽然不大，但让我对 Docker 网络的理解更深了一层。核心结论：\n默认 bridge 网络不支持容器名 DNS 解析。 自定义 bridge 网络支持 DNS，推荐用这种方式。 Docker Compose 最省心，适合多容器项目。 --link 已废弃，不要再用了。 搞懂了 Docker 的底层网络隔离机制，以后部署就不会再抓瞎了。\n","permalink":"http://www.moxuan.xin/posts/docker-network-dns-troubleshooting/","summary":"\u003cp\u003e我手起刀落用 \u003ccode\u003edocker run\u003c/code\u003e 跑了一个 PHP 应用的容器，又用 \u003ccode\u003edocker run\u003c/code\u003e 跑了一个 MySQL 的容器。在配置 PHP 连数据库时，我填了 MySQL 容器的名称 \u003ccode\u003edb_mysql\u003c/code\u003e，结果死活报错：\u003ccode\u003eUnknown MySQL server host 'db_mysql'\u003c/code\u003e。\u003c/p\u003e","title":"容器间连不上？深入剖析 Docker Bridge 网络的 DNS 坑"},{"content":"前段时间我打算把主站迁到一台新的 VPS 上。旧机器用了两年多，配置有点跟不上了，而且同价位的新机器性能更好一些。迁移前最头疼的就是数据怎么搬：网站文件、用户上传的图片、数据库备份，零零散散加起来有 100G 左右，而且文件数量特别多，小文件占了大头。\n如果用 scp 或者 sftp 慢慢拖，按照我这破线路的速度，估计得传好几天，中间断一次还得从头来。所以这次迁移我用的工具是 rsync，整个过程比预想中顺利很多。\n为什么选 rsync rsync 有几个特点特别符合这次迁移的需求：\n增量同步：只传输发生变化的部分，已经传过的文件不会再传。 断点续传：大文件传到一半断了，下次会从断点继续，不用重来。 保留权限：可以保留文件的权限、时间戳、所有者这些信息，对 Web 目录特别重要。 压缩传输：-z 参数可以在传输过程中压缩，适合小文件多的场景。 迁移前的准备 迁移之前我先列了一个清单：\n在新服务器上装好同样的环境：Nginx、PHP、MySQL、Node.js 这些。 确认新服务器的磁盘空间足够，留有一定余量。 把旧服务器的 SSH 密钥加到新服务器，方便 rsync 直连。 通知用户或者设置维护页面（如果有的话）。 数据迁移我分了两步走：先迁静态文件，再迁数据库。因为数据库在迁移过程中可能还在变化，所以放在最后一步。\n第一次全量同步 第一次同步把旧服务器上的所有数据拉到新服务器。我直接在新服务器上执行拉取命令：\n1rsync -avz --progress \\ 2 -e \u0026#34;ssh -p 22\u0026#34; \\ 3 root@旧服务器IP:/var/www/myblog/ /var/www/myblog/ 参数解释：\n-a：归档模式，保留权限、符号链接、时间戳等，相当于 -rlptgoD。 -v：显示详细进度，方便看传输了什么。 -z：传输时压缩，适合文本和小文件。 --progress：显示每个文件的进度。 -e \u0026quot;ssh -p 22\u0026quot;：指定 SSH 端口，如果改了端口这里要对应修改。 第一次全量同步花了我大概三四个小时。主要是小文件太多，rsync 要逐个比对，带宽反而没用满。\n遇到的第一个坑：大量小文件导致内存不足 同步到一半的时候，连接突然断开，报错 connection unexpectedly closed。\n排查了一下，发现是因为我有一个目录里存了几百万张缩略图，每张只有几 KB。rsync 在构建文件列表时要把所有文件信息加载到内存，而那台旧机器只有 512M 内存，直接被 OOM 杀了。\n解决办法是分目录同步。我写了一个简单的脚本，把大目录拆成多个小批次：\n1#!/bin/bash 2 3SRC=\u0026#34;root@旧服务器IP:/var/www/myblog/uploads/\u0026#34; 4DST=\u0026#34;/var/www/myblog/uploads/\u0026#34; 5 6# 按年份子目录分别同步 7for dir in 2022 2023 2024; do 8 echo \u0026#34;正在同步 $dir ...\u0026#34; 9 rsync -avz --delete -e \u0026#34;ssh -p 22\u0026#34; \u0026#34;$SRC$dir/\u0026#34; \u0026#34;$DST$dir/\u0026#34; 10done 这样每次同步的文件列表就小多了，不会再把内存吃光。\n第二次增量同步 全量同步完成后，旧服务器上的数据可能又有更新了。在切换域名解析之前，我再跑一次增量同步：\n1rsync -avz --delete \\ 2 -e \u0026#34;ssh -p 22\u0026#34; \\ 3 root@旧服务器IP:/var/www/myblog/ /var/www/myblog/ 这次加上 --delete 参数，保证两边完全一致。如果新服务器上多出了旧服务器没有的文件，会被删除。\n因为大部分文件已经在第一次同步过了，第二次只花了十几分钟。\n数据库迁移 静态文件同步完之后，开始迁数据库。我先在旧服务器上做一个完整的 dump：\n1mysqldump -u root -p --all-databases --single-transaction \u0026gt; /tmp/all_databases.sql --single-transaction 参数可以在不锁表的情况下导出 InnoDB 表，对线上服务影响比较小。\n然后把 dump 文件传到新服务器：\n1rsync -avz --progress -e \u0026#34;ssh -p 22\u0026#34; root@旧服务器IP:/tmp/all_databases.sql /tmp/ 在新服务器上导入：\n1mysql -u root -p \u0026lt; /tmp/all_databases.sql 导入完成后，我检查了几个关键表的数据量和最近更新时间，确认没有遗漏。\n切换前的最终同步 真正切换流量之前，我还做了一次最终同步。这次要尽量减少停机时间。\n步骤大概是这样的：\n在旧服务器上暂停写入操作，比如暂时关闭用户上传功能，或者把应用设为只读模式。 跑最后一次 rsync 增量同步。 跑最后一次数据库 dump 和导入。 更新域名解析到新服务器 IP。 等新服务器开始承接流量后，再打开写入功能。 整个切换过程实际停机时间大概 10 分钟左右，主要花在数据库导入上。\n验证和收尾 切换之后，我重点检查了这些东西：\n网站首页、文章页、搜索功能是否正常。 图片、CSS、JS 等静态资源是否能正常加载。 数据库读写是否正常。 Nginx 日志有没有报错。 SSL 证书是否生效。 全部确认没问题后，我在旧服务器上保留了一个星期的数据作为保险，然后才清理掉。\n总结 这次迁移让我对 rsync 的理解更深了一层。几个关键点：\n全量 + 增量 + 最终同步 是减少停机时间的标准做法。 大量小文件要分目录同步，避免内存问题。 --delete 要谨慎使用，确保目标目录是对的。 数据库和静态文件分开迁移，数据库放在最后。 迁移前备份，迁移后验证，不要嫌麻烦。 rsync 真的算得上运维人员的神器之一。只要网络通，它就能把数据稳稳当当地搬过去。下次如果你也要迁移服务器，不妨试试这个方案。\n","permalink":"http://www.moxuan.xin/posts/rsync-server-migration-guide/","summary":"\u003cp\u003e前段时间我打算把主站迁到一台新的 VPS 上。旧机器用了两年多，配置有点跟不上了，而且同价位的新机器性能更好一些。迁移前最头疼的就是数据怎么搬：网站文件、用户上传的图片、数据库备份，零零散散加起来有 100G 左右，而且文件数量特别多，小文件占了大头。\u003c/p\u003e","title":"用 rsync 无感迁移 100G 网站数据到新的服务器"},{"content":"早上服务器的监控脚本发来预警，说这台 20G 硬盘的小机器可用空间不足 5% 了。\n登上去用 df -h 一看，确实快满了。顺着用 du -sh /* 往下扒，发现罪魁祸首是 Docker 的目录 /var/lib/docker，占了 15G 多。平时瞎折腾拉了一堆镜像，旧容器删了但悬空镜像还在占空间，还有各种构建缓存和数据卷。\n这篇文章记录一下我常用的 Docker 清理命令，以及它们分别会删掉什么，避免误删重要数据。\n先看磁盘占用分布 清理之前，先搞清楚空间到底被什么占了：\n1du -sh /var/lib/docker/* 更详细一点：\n1docker system df 这个命令会显示镜像、容器、数据卷、构建缓存各自占了多少空间。我当时的输出大概是这样：\n1TYPE TOTAL ACTIVE SIZE RECLAIMABLE 2Images 45 12 8.2GB 6.1GB (74%) 3Containers 8 3 1.2GB 800MB 4Local Volumes 23 5 3.5GB 2.8GB 5Build Cache 156 0 2.1GB 2.1GB 可以看到可清理的空间非常多。\n清理悬空镜像 什么是悬空镜像？就是那些没有标签、也没有被任何容器引用的镜像。通常出现在反复构建同一个镜像，或者 docker build 失败后留下中间层的时候。\n清理命令：\n1docker image prune 这个命令非常安全，只会删除 dangling 镜像，不会影响正在运行的容器。\n如果你想连没有被任何容器使用的镜像一起清理，可以加 -a：\n1docker image prune -a 注意：-a 会删掉所有没有容器在用的镜像。如果你有一些镜像只是暂时没跑，但之后还要用，就别加 -a。\n清理停止的容器 有时候我们跑完一个容器就忘了删，用 docker ps -a 能看到一堆 Exited 状态的容器。这些容器也会占用磁盘空间，尤其是如果它们写了很多日志或者临时文件的话。\n清理所有停止的容器：\n1docker container prune 执行之前建议先看一下有哪些会被清理：\n1docker ps -a --filter status=exited 如果你确定这些停止的容器都没用了，再执行 prune。\n清理废弃数据卷 数据卷是 Docker 里最容易被遗忘的空间杀手。很多容器删除的时候没有用 -v 参数，对应的数据卷就留在了系统里，越积越多。\n查看所有数据卷：\n1docker volume ls 清理没有被任何容器使用的数据卷：\n1docker volume prune 重要提示：数据卷里可能存着你重要的数据库文件、配置文件。执行之前一定要确认这些数据卷确实没用了。\n我一般会先 inspect 一下数据卷：\n1docker volume inspect 卷名 看看挂载点和标签，确认不是重要数据再删。\n清理构建缓存 如果你经常用 docker build，构建缓存会占用不少空间。Docker 18.09 之后引入了 BuildKit，缓存机制更复杂，但也更容易堆积。\n清理所有构建缓存：\n1docker builder prune 或者更狠一点，全部清理：\n1docker buildx prune -f 清理构建缓存一般比较安全，下次构建会重新拉取基础镜像和生成中间层，只是会多花点时间。\n一键清理所有 如果你已经很清楚自己在干什么，可以用 docker system prune 一键清理：\n1docker system prune 这个命令会清理：\n所有停止的容器 所有悬空网络 所有悬空镜像 所有构建缓存 如果想连没有使用的镜像和数据卷也一起清理：\n1docker system prune -a --volumes 警告：这个命令杀伤力很大，执行前一定要想清楚。-a 会删掉未使用的镜像，--volumes 会删掉未使用的数据卷。\n我那次就是直接用了 docker system prune -a --volumes，一下腾出 8G 空间，舒服了。\n限制日志大小 清理完空间之后，还要防止以后再次快速占满。Docker 容器的日志默认是无限制增长的，一个跑久了的容器日志文件可能有几个 G。\n可以在 daemon 配置里限制日志大小。创建或编辑 /etc/docker/daemon.json：\n1{ 2 \u0026#34;log-driver\u0026#34;: \u0026#34;json-file\u0026#34;, 3 \u0026#34;log-opts\u0026#34;: { 4 \u0026#34;max-size\u0026#34;: \u0026#34;10m\u0026#34;, 5 \u0026#34;max-file\u0026#34;: \u0026#34;3\u0026#34; 6 } 7} 然后重启 Docker：\n1systemctl restart docker 这样每个容器的日志最多保留 3 个 10M 的文件，超过就自动轮转。\n定期自动清理 为了避免手动清理，我加了一个每周自动清理的 cron 任务：\n10 3 * * 0 /usr/bin/docker system prune -f \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 -f 表示不需要确认。注意我这里没有加 -a 和 --volumes，因为自动任务里用太激进的参数有风险。每周清理一下停止的容器和悬空镜像就够了。\n总结 Docker 清理命令从温和到激进：\ndocker image prune：清理悬空镜像，最安全 docker container prune：清理停止的容器 docker volume prune：清理未使用的数据卷，要小心 docker builder prune：清理构建缓存 docker system prune -a --volumes：一键全清，杀伤力最大 建议大家先用 docker system df 看看空间分布，然后按需清理。不要一上来就 --volumes，万一删掉重要数据就亏大了。\n最后提醒一句：清理之前最好做个快照备份，尤其是小 VPS 上跑了数据库等重要服务的时候。\n","permalink":"http://www.moxuan.xin/posts/docker-disk-space-clean/","summary":"\u003cp\u003e早上服务器的监控脚本发来预警，说这台 20G 硬盘的小机器可用空间不足 5% 了。\u003c/p\u003e\n\u003cp\u003e登上去用 \u003ccode\u003edf -h\u003c/code\u003e 一看，确实快满了。顺着用 \u003ccode\u003edu -sh /*\u003c/code\u003e 往下扒，发现罪魁祸首是 Docker 的目录 \u003ccode\u003e/var/lib/docker\u003c/code\u003e，占了 15G 多。平时瞎折腾拉了一堆镜像，旧容器删了但悬空镜像还在占空间，还有各种构建缓存和数据卷。\u003c/p\u003e","title":"VPS 磁盘爆满报警，记录几个极其好用的 Docker 清理命令"},{"content":"经历了之前几次瞎搞把环境弄崩的惨痛教训后，我深刻体会到了\u0026quot;数据备份\u0026quot;的重要性。不能总指望云服务商不跑路，自己手里有备份才踏实。\n最开始我是手动打包，每次改版之前 tar 一下，特别原始。后来项目多了，手动备份又麻烦又容易忘。干脆写了个 Shell 脚本，每天凌晨自动跑，本地存一份，远端再同步一份，保留最近 30 天的历史版本。\n这篇文章就分享一下我的备份脚本是怎么写的，以及一些我认为重要的细节。\n备份需求分析 在写脚本之前，我先想清楚了自己需要什么：\n备份对象：博客源码、数据库、Nginx 配置、SSL 证书。 备份频率：每天一次，凌晨 2 点执行。 保留策略：本地保留 7 天，远端保留 30 天。 异地存储：不能只存在服务器本地，万一服务器挂了照样没。 失败告警：备份失败要通知我，不然出问题了我都不知道没备份。 脚本整体结构 我把脚本分成几个部分：\n定义变量和路径 创建临时目录 分别备份源码、数据库、配置 打包并压缩 上传到远端 清理过期备份 发送通知 下面是完整脚本：\n1#!/bin/bash 2 3# ======================================== 4# 自动备份脚本 5# ======================================== 6 7# 基础配置 8BACKUP_DIR=\u0026#34;/data/backup\u0026#34; 9TEMP_DIR=\u0026#34;/tmp/backup_$(date +%Y%m%d_%H%M%S)\u0026#34; 10DATE=$(date +%Y%m%d) 11DATETIME=$(date +\u0026#34;%Y-%m-%d %H:%M:%S\u0026#34;) 12LOG_FILE=\u0026#34;$BACKUP_DIR/backup.log\u0026#34; 13 14# 要备份的内容 15BLOG_DIR=\u0026#34;/var/www/myblog\u0026#34; 16NGINX_DIR=\u0026#34;/etc/nginx\u0026#34; 17SSL_DIR=\u0026#34;/etc/letsencrypt\u0026#34; 18DB_NAME=\u0026#34;myblog\u0026#34; 19DB_USER=\u0026#34;dbuser\u0026#34; 20DB_PASS=\u0026#34;your_password\u0026#34; 21 22# 远端存储配置 23REMOTE_USER=\u0026#34;backup\u0026#34; 24REMOTE_HOST=\u0026#34;backup.example.com\u0026#34; 25REMOTE_DIR=\u0026#34;/backups/myblog\u0026#34; 26 27# 通知配置（可选） 28WEBHOOK_URL=\u0026#34;https://your-webhook-url\u0026#34; 29 30# 创建目录 31mkdir -p \u0026#34;$BACKUP_DIR\u0026#34; 32mkdir -p \u0026#34;$TEMP_DIR\u0026#34; 33 34# 记录开始 35echo \u0026#34;[$DATETIME] 开始备份...\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 36 37# 1. 备份博客源码 38if [ -d \u0026#34;$BLOG_DIR\u0026#34; ]; then 39 cp -r \u0026#34;$BLOG_DIR\u0026#34; \u0026#34;$TEMP_DIR/blog\u0026#34; 40 echo \u0026#34;[$DATETIME] 博客源码备份完成\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 41else 42 echo \u0026#34;[$DATETIME] 错误：博客目录不存在\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 43 exit 1 44fi 45 46# 2. 备份数据库 47mysqldump -u\u0026#34;$DB_USER\u0026#34; -p\u0026#34;$DB_PASS\u0026#34; \u0026#34;$DB_NAME\u0026#34; \u0026gt; \u0026#34;$TEMP_DIR/database.sql\u0026#34; 48if [ $? -eq 0 ]; then 49 echo \u0026#34;[$DATETIME] 数据库备份完成\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 50else 51 echo \u0026#34;[$DATETIME] 错误：数据库备份失败\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 52 exit 1 53fi 54 55# 3. 备份配置文件 56cp -r \u0026#34;$NGINX_DIR\u0026#34; \u0026#34;$TEMP_DIR/nginx\u0026#34; 57cp -r \u0026#34;$SSL_DIR\u0026#34; \u0026#34;$TEMP_DIR/ssl\u0026#34; 58 59# 4. 打包压缩 60BACKUP_FILE=\u0026#34;blog_backup_$DATE.tar.gz\u0026#34; 61tar -czf \u0026#34;$BACKUP_DIR/$BACKUP_FILE\u0026#34; -C \u0026#34;$TEMP_DIR\u0026#34; . 62 63if [ $? -eq 0 ]; then 64 echo \u0026#34;[$DATETIME] 打包完成：$BACKUP_FILE\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 65else 66 echo \u0026#34;[$DATETIME] 错误：打包失败\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 67 exit 1 68fi 69 70# 5. 同步到远端 71rsync -avz --delete \u0026#34;$BACKUP_DIR/\u0026#34; \u0026#34;$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/\u0026#34; 72 73if [ $? -eq 0 ]; then 74 echo \u0026#34;[$DATETIME] 远端同步完成\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 75else 76 echo \u0026#34;[$DATETIME] 错误：远端同步失败\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 77 # 同步失败不一定要退出，但至少要知道 78fi 79 80# 6. 清理本地过期备份（保留 7 天） 81find \u0026#34;$BACKUP_DIR\u0026#34; -name \u0026#34;blog_backup_*.tar.gz\u0026#34; -type f -mtime +7 -delete 82 83# 7. 清理远端过期备份（保留 30 天） 84ssh \u0026#34;$REMOTE_USER@$REMOTE_HOST\u0026#34; \u0026#34;find $REMOTE_DIR -name \u0026#39;blog_backup_*.tar.gz\u0026#39; -type f -mtime +30 -delete\u0026#34; 85 86# 8. 清理临时目录 87rm -rf \u0026#34;$TEMP_DIR\u0026#34; 88 89# 9. 发送成功通知 90curl -s -X POST \u0026#34;$WEBHOOK_URL\u0026#34; \\ 91 -H \u0026#34;Content-Type: application/json\u0026#34; \\ 92 -d \u0026#34;{\\\u0026#34;msg\\\u0026#34;:\\\u0026#34;博客备份完成：$BACKUP_FILE\\\u0026#34;}\u0026#34; 93 94echo \u0026#34;[$DATETIME] 备份流程结束\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 几个重要的设计细节 临时目录用时间戳命名 每次备份用一个带时间戳的临时目录，避免并行执行或者上次没清理完导致文件混乱。结尾 rm -rf \u0026quot;$TEMP_DIR\u0026quot; 确保每次都清干净。\n错误处理要到位 虽然 Shell 脚本的 set -e 可以让出错时自动退出，但我更喜欢在每个关键步骤后面手动判断 $?。这样我可以把具体错误写到日志里，而不是莫名其妙地中断。\n数据库密码不要直接写脚本里 我上面为了演示把 DB_PASS=\u0026quot;your_password\u0026quot; 直接写进去了，实际生产环境不建议这么做。更好的做法是：\n用 .my.cnf 配置文件存数据库凭证： 1[mysqldump] 2user=dbuser 3password=your_password 或者通过环境变量传入。\n也可以给数据库用户单独授权，只给 SELECT 和 LOCK TABLES 权限，最小权限原则。\nrsync 的 --delete 要慎用 我用 rsync --delete 保证远端和本地一致，但这也意味着如果本地误删了备份文件，远端也会被删。所以保留策略我分了两套：本地 7 天，远端 30 天。而且远端清理是通过 ssh 单独执行的 find 命令，不会受 --delete 影响。\n远端服务器要独立 我专门用了一台便宜的存储型 VPS 当备份机，和主站不在同一个服务商。这样就算主站服务商出问题，备份也还在。这种异地备份的思想很重要，不要所有鸡蛋放一个篮子。\n设置定时任务 脚本写好后，用 crontab 每天凌晨 2 点执行：\n1crontab -e 添加：\n10 2 * * * /root/scripts/backup.sh \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 或者把输出重定向到日志文件：\n10 2 * * * /root/scripts/backup.sh \u0026gt;\u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1 注意脚本本身已经写了日志，这里可以简单一点。\n定期恢复演练 备份不是终点，能恢复才有意义。我每隔一两个月会从备份包里解压一次，检查一下文件是否完整，数据库能不能正常导入。\n测试数据库恢复的命令：\n1mysql -u dbuser -p test_db \u0026lt; database.sql 如果从来不测试恢复，真到用的时候才发现备份文件损坏，那就完了。\n总结 这个备份脚本虽然看起来不复杂，但已经能满足我个人博客和小型项目的备份需求。核心思路就是：\n本地打包 + 异地同步 分级保留策略 关键步骤报错处理 定期验证恢复能力 数据无价，备份不是可选项，而是必修课。希望大家都能养成备份的好习惯，不要等到数据丢了才后悔。\n","permalink":"http://www.moxuan.xin/posts/shell-script-auto-backup/","summary":"\u003cp\u003e经历了之前几次瞎搞把环境弄崩的惨痛教训后，我深刻体会到了\u0026quot;数据备份\u0026quot;的重要性。不能总指望云服务商不跑路，自己手里有备份才踏实。\u003c/p\u003e","title":"数据无价！写个 Shell 脚本把博客定时打包备份"},{"content":"我的 VPS 刚买回家的时候，每天打开 auth.log 都能看到一堆来自世界各地的登录尝试。用户名大多是 root、admin、test、oracle 这些，一看就是暴力破解脚本在扫全网。\n虽然密码我设得挺复杂的，但看着这些日志还是心里发毛。后来花了一个下午把 SSH 好好加固了一下，登录尝试量直接断崖式下降。这篇记录一下我做的几件事。\n第一件事：创建普通用户，禁用 root 直接登录 很多 VPS 默认只允许 root 登录，这其实挺危险的。root 权限太大，一旦密码被猜出来，对方可以为所欲为。\n我的做法是创建一个普通用户，日常操作都用它。需要 root 权限的时候再用 sudo。\n1# 添加用户 2useradd -m -s /bin/bash moxuan 3 4# 设置密码 5passwd moxuan 6 7# 添加到 sudo 组 8usermod -aG sudo moxuan 然后修改 SSH 配置，禁止 root 直接登录：\n1nano /etc/ssh/sshd_config 找到这一行并修改：\n1PermitRootLogin no 改完之后不要直接重启 SSH，先开一个新的终端窗口测试普通用户能不能登录。确认没问题再重启：\n1systemctl restart sshd 这里强烈建议保留一个 root 的会话窗口做保险，防止配置错了把自己锁在外面。\n第二件事：用密钥登录，禁用密码登录 密码再复杂，也比不上密钥安全。密钥认证的原理是非对称加密，私钥留在本地，公钥放到服务器上。没有私钥的人即使知道密码也登不上来。\n在本地生成密钥对 如果你还没有 SSH 密钥，先在本地生成：\n1ssh-keygen -t ed25519 -C \u0026#34;your_email@example.com\u0026#34; ed25519 是目前比较推荐的算法，安全性高，密钥也短。如果你的客户端太老不支持，可以用 rsa -b 4096。\n生成过程中会提示你输入密钥保存路径和密码。密码可以留空，这样登录就不用输密码。但我建议还是设一个，安全性更高。\n把公钥复制到服务器 最方便的方式是用 ssh-copy-id：\n1ssh-copy-id -i ~/.ssh/id_ed25519.pub moxuan@服务器IP 如果没有这个命令，也可以手动复制。在本地看公钥内容：\n1cat ~/.ssh/id_ed25519.pub 然后到服务器的 ~/.ssh/authorized_keys 文件里粘贴：\n1mkdir -p ~/.ssh 2chmod 700 ~/.ssh 3nano ~/.ssh/authorized_keys 4chmod 600 ~/.ssh/authorized_keys 注意 .ssh 目录权限是 700，authorized_keys 是 600，权限不对 SSH 会拒绝读取。\n测试密钥登录 新开一个终端，用普通用户和密钥登录：\n1ssh -i ~/.ssh/id_ed25519 moxuan@服务器IP 如果不需要输入密码就登录成功了，说明密钥配置好了。\n禁用密码登录 确认密钥登录没问题之后，就可以彻底关闭密码登录了：\n1PasswordAuthentication no 2ChallengeResponseAuthentication no 3UsePAM no 全部改成 no，然后重启 sshd。\n第三件事：修改默认端口 SSH 默认 22 端口是扫描器的重点照顾对象。改成高位端口，比如 2222、8022 这些，能减少 90% 以上的无聊扫描。\n修改 sshd_config：\n1Port 2222 注意防火墙也要放行新端口。如果你用 ufw：\n1ufw allow 2222/tcp 2ufw delete allow 22/tcp 重启 sshd 后，登录命令要加上 -p：\n1ssh -p 2222 -i ~/.ssh/id_ed25519 moxuan@服务器IP 为了省事，我在本地 ~/.ssh/config 里配置了别名：\n1Host myvps 2 HostName 服务器IP 3 Port 2222 4 User moxuan 5 IdentityFile ~/.ssh/id_ed25519 之后直接 ssh myvps 就能登录。\n第四件事：fail2ban 联动防御 改端口能减少大部分扫描，但总有一些执着的扫描器会继续尝试。这时候就需要 fail2ban 出场了。\nfail2ban 的原理是监控日志，发现某个 IP 短时间内多次失败登录，就自动把它封禁一段时间。\n安装：\n1apt update 2apt install fail2ban -y 创建自定义配置文件：\n1cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local 2nano /etc/fail2ban/jail.local 修改 SSH 相关部分：\n1[sshd] 2enabled = true 3port = 2222 4filter = sshd 5logpath = /var/log/auth.log 6maxretry = 3 7bantime = 3600 8findtime = 600 port：改成你实际的 SSH 端口。 maxretry：允许失败次数。 bantime：封禁时间，单位秒，3600 就是 1 小时。 findtime：在这个时间内累计失败次数超过 maxretry 就封禁。 启动并设置开机自启：\n1systemctl start fail2ban 2systemctl enable fail2ban 查看封禁状态：\n1fail2ban-client status sshd 2fail2ban-client status sshd banned 第五件事：其他可选加固 除了上面这些，还有一些锦上添花的配置：\n禁用空密码\n1PermitEmptyPasswords no 限制登录用户\n1AllowUsers moxuan 缩短认证超时\n1LoginGraceTime 60 2MaxAuthTries 3 3ClientAliveInterval 300 4ClientAliveCountMax 2 禁用 X11 转发\n1X11Forwarding no 这些改不改看个人需求，但 PermitRootLogin、PasswordAuthentication、Port 这三项强烈建议改。\n最后验证 全部改完之后，用 sshd -t 检查配置文件语法：\n1sshd -t 没有输出就是没问题。然后重启 sshd：\n1systemctl restart sshd 再用另一台机器或者新窗口测试登录，确认一切正常。\n如果你发现登录不上了，大概率是防火墙规则或者 SSH 配置写错了。这时候只能通过 VPS 控制台的 VNC 救援模式进去修复。所以再次强调，改 SSH 之前一定要做好备份，或者保留一个已登录的会话窗口。\n总结 我的 SSH 加固思路就是三道防线：\n禁用 root 登录，使用普通用户 + sudo 关闭密码认证，改用密钥登录 修改默认端口 + fail2ban 自动封禁 做完这三件事之后，我的服务器每天收到的暴力破解尝试从几千次降到了几乎为零。其实安全措施很多时候不需要多复杂，把基础做好就已经超过绝大多数服务器了。\n","permalink":"http://www.moxuan.xin/posts/vps-ssh-key-login/","summary":"\u003cp\u003e我的 VPS 刚买回家的时候，每天打开 auth.log 都能看到一堆来自世界各地的登录尝试。用户名大多是 \u003ccode\u003eroot\u003c/code\u003e、\u003ccode\u003eadmin\u003c/code\u003e、\u003ccode\u003etest\u003c/code\u003e、\u003ccode\u003eoracle\u003c/code\u003e 这些，一看就是暴力破解脚本在扫全网。\u003c/p\u003e","title":"服务器 SSH 安全配置：密钥登录与 fail2ban 联动"},{"content":"上个月我的 VPS 突然报警，说根分区使用率超过 90%。登上去一看，/ 目录只剩几百兆了，连 apt 更新都跑不动。这台机器装了很多 Docker 容器和数据库，空间消耗比我想象的快得多。\n如果是普通分区，扩容可能还要折腾数据迁移。但好在当时装机的时候用了 LVM，扩容相对来说简单很多。这篇文章就记录一下我那次 LVM 扩容的完整过程。\n什么是 LVM LVM 全称 Logical Volume Manager，逻辑卷管理。它把物理磁盘、分区抽象成物理卷（PV）、卷组（VG）、逻辑卷（LV）三层。\n简单理解：\n物理卷 PV：就是实际的磁盘或分区。 卷组 VG：把多个物理卷合并成一个\u0026quot;池子\u0026quot;。 逻辑卷 LV：从卷组里划分出来的逻辑分区，最后格式化文件系统挂载使用。 好处是扩容特别灵活。物理磁盘不够了，可以加一块新盘，把它加入卷组，然后直接扩展逻辑卷，不需要重新分区或者迁移数据。\n先确认当前磁盘布局 扩容之前一定要先搞清楚现在的结构，不然很容易扩错地方。\n查看物理卷：\n1pvdisplay 查看卷组：\n1vgdisplay 查看逻辑卷：\n1lvdisplay 我当时的结构大概是这样：\n物理卷 /dev/vda2，大小 30G 卷组 ubuntu-vg，包含 /dev/vda2 逻辑卷 /dev/ubuntu-vg/ubuntu-lv，挂载到 /，已用 28G 也就是说，整台机器实际上只有一个根分区，所有东西都在里面。\n方案选择 当时有两种方案：\n在线扩容虚拟磁盘：我的 VPS 服务商支持直接扩容云盘，扩容后在系统里扩展 LVM 即可。 加一块新数据盘：再买一块数据盘挂上去，把 /var/lib/docker 这种大目录迁移过去。 我选了第一种，因为比较省事，而且 / 下数据分散，单独迁某个目录后面维护起来麻烦。\n在 VPS 控制台把磁盘从 30G 扩容到 60G 之后，登录系统开始操作。\n第一步：确认磁盘已经变大 1lsblk 或者：\n1fdisk -l 应该能看到 /dev/vda 总容量变成了 60G，但分区 /dev/vda2 还是 30G。因为分区表没有更新。\n第二步：扩展物理分区 这一步比较关键，也最容易出错。因为 /dev/vda2 是正在使用的根分区，普通情况下没法直接调整。但现在很多工具支持在线扩展。\n我用的 growpart，它是 cloud-utils 包里的工具：\n1apt update 2apt install cloud-guest-utils -y 3growpart /dev/vda 2 注意 /dev/vda 2 中间有空格，表示第 2 个分区。执行完之后，再用 lsblk 看，/dev/vda2 应该已经变成 60G 了。\n如果你的系统没有 growpart，也可以用 parted 或者重启后用 live CD 调整。但 growpart 最方便，推荐先试试这个。\n第三步：扩展物理卷 PV 现在物理分区变大了，但 PV 还没有意识到。要让 LVM 识别新的空间：\n1pvresize /dev/vda2 然后 pvdisplay 看一下，Free PE 应该多了 30G 的可用空间。\n第四步：扩展逻辑卷 LV 把卷组里的空闲空间分配给逻辑卷。我的逻辑卷路径是 /dev/ubuntu-vg/ubuntu-lv：\n1lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv -l +100%FREE 表示把卷组里所有剩余空间都给它。如果你想指定大小，也可以用 -L +30G。\n第五步：扩展文件系统 LVM 扩展完之后，文件系统还没有变大，最后一步要扩容文件系统。\n如果文件系统是 ext4：\n1resize2fs /dev/ubuntu-vg/ubuntu-lv 如果文件系统是 xfs：\n1xfs_growfs / 我的系统是 ext4，所以用 resize2fs。这个命令可以在线执行，不需要卸载分区。\n第六步：验证 最后用 df -h 看一下：\n1df -h / 根分区应该已经从 30G 变成 60G 了，而且数据都还在，不需要重启。\n一个容易忽略的点 扩容的时候一定要先确认文件系统类型。我有一次想当然地用了 resize2fs，结果文件系统是 xfs，命令直接报错。虽然没造成损失，但浪费了不少时间。\n查看文件系统类型：\n1df -Th 或者：\n1lsblk -f 如果没有用 LVM 怎么办 如果你的根分区不是 LVM，而是普通分区，扩容会麻烦一些。一般来说需要：\n备份数据。 用 live CD 或者救援模式启动。 调整分区大小。 扩容文件系统。 这也是为什么我现在装机尽量用 LVM 的原因。虽然一开始看起来多了一层抽象，但后续扩容真的省很多事。\n总结 LVM 扩容的完整流程就是：\ngrowpart 扩展物理分区 pvresize 扩展物理卷 lvextend 扩展逻辑卷 resize2fs 或 xfs_growfs 扩展文件系统 整个过程不需要停机，数据也不会丢失。但还是要提醒一句：扩容前一定要先快照备份。虽然我这次很顺利，但磁盘操作毕竟有风险，养成备份习惯不吃亏。\n另外，扩容只是治标。如果磁盘空间持续快速增长，还是要找到真正占空间的元凶，比如日志失控、Docker 镜像堆积、数据库膨胀这些。不然扩到 100G 也很快又满了。\n","permalink":"http://www.moxuan.xin/posts/linux-lvm-logical-volume-expansion/","summary":"\u003cp\u003e上个月我的 VPS 突然报警，说根分区使用率超过 90%。登上去一看，\u003ccode\u003e/\u003c/code\u003e 目录只剩几百兆了，连 apt 更新都跑不动。这台机器装了很多 Docker 容器和数据库，空间消耗比我想象的快得多。\u003c/p\u003e","title":"Linux 磁盘满了怎么救？LVM 逻辑卷扩容完整流程"},{"content":"之前我查日志基本都是直接去 /var/log/ 下面翻文件，比如 syslog、nginx/error.log、auth.log 这些。后来系统升级之后发现有些日志找不到了，一查才知道现在很多发行版都用 systemd 的 journal 来统一管理日志了。\njournalctl 就是查看这些日志的命令。一开始我觉得挺麻烦的，因为参数太多了，记都记不住。但用熟了之后发现，它比传统日志文件方便很多，尤其是过滤和查询历史日志的时候。\n这篇文章记录一下我常用的 journalctl 命令和一些踩坑经验。\njournalctl 是什么 systemd 接管了很多系统服务，它把这些服务产生的日志统一收集到一个二进制格式的日志数据库里，也就是 journal。journalctl 就是查询这个数据库的工具。\n相比文本日志，journal 的优势是：\n结构化存储，查询效率高。 可以按服务、时间、优先级等多种维度过滤。 支持持久化配置，重启后日志不会丢。 缺点是它不是纯文本，不能直接 cat 或者 grep，必须用 journalctl 来读。\n最基础的用法 不加参数直接运行：\n1journalctl 会按时间顺序输出所有日志，最新的在最后。按 Shift+G 或者 End 键跳到最下面看最新日志。\n这个命令默认用 less 风格的分页，如果你只想看最近的几十行，可以用 -n：\n1journalctl -n 50 类似 tail -n 50。\n查看某个服务的日志 这个是我最常用的。比如看 Nginx 的日志：\n1journalctl -u nginx 看 SSH 服务：\n1journalctl -u sshd 如果服务名不确定，可以用 systemctl list-units 查看。\n还可以加 -f 实时跟踪，类似 tail -f：\n1journalctl -u nginx -f 调试服务启动问题的时候特别有用。\n按时间过滤 有时候日志太多，只想看某个时间段的。journalctl 支持 --since 和 --until。\n看今天凌晨 2 点到 4 点的 Nginx 日志：\n1journalctl -u nginx --since \u0026#34;2024-05-08 02:00:00\u0026#34; --until \u0026#34;2024-05-08 04:00:00\u0026#34; 也支持相对时间：\n1journalctl --since \u0026#34;1 hour ago\u0026#34; 2journalctl --since \u0026#34;30 minutes ago\u0026#34; 3journalctl --since today 我排查故障的时候经常先用 --since \u0026quot;1 hour ago\u0026quot; 缩小范围。\n按优先级过滤 journal 给日志分了 0 到 7 共 8 个优先级，从 emerg 到 debug。只看错误级别及以上的日志：\n1journalctl -p err 只看警告和错误：\n1journalctl -p warning..err 级别名称对应：\nemerg (0)：系统不可用 alert (1)：必须立即处理 crit (2)：严重错误 err (3)：错误 warning (4)：警告 notice (5)：正常但重要 info (6)：普通信息 debug (7)：调试信息 查看内核日志 内核相关的日志用 -k：\n1journalctl -k 相当于原来的 dmesg。排查硬件驱动、内核模块问题的时候很有用。\n查看指定进程的日志 如果你知道进程的 PID，可以这样查：\n1journalctl _PID=1234 这个在排查某个具体程序的时候很方便，不用去翻整个服务日志。\n持久化配置 journal 默认可能是内存存储，重启后日志就丢了。如果你想保留历史日志，需要开启持久化。\n创建目录并重启服务：\n1sudo mkdir -p /var/log/journal 2sudo systemd-tmpfiles --create --prefix /var/log/journal 3sudo systemctl restart systemd-journald 然后日志就会写到 /var/log/journal/ 目录下，重启不会丢失。\n你还可以限制日志占用的最大空间。编辑 /etc/systemd/journald.conf：\n1[Journal] 2Storage=persistent 3SystemMaxUse=500M 4MaxFileSec=7day SystemMaxUse：journal 最多占用多少磁盘空间。 MaxFileSec：单个日志文件保留多久。 改完重启 journald：\n1systemctl restart systemd-journald 我那个小硬盘 VPS 就设了 500M，不然日志能吃掉好几 G。\n清理旧日志 如果磁盘空间突然满了，可以手动清理 journal：\n保留最近 100M：\n1journalctl --vacuum-size=100M 保留最近 7 天：\n1journalctl --vacuum-time=7d 这两个命令很常用，建议记一下。\n输出格式调整 默认输出比较长，如果你只想快速浏览，可以用 --no-pager 不分页，或者 --output=short 简化格式。\n我常用的组合：\n1journalctl -u nginx --since \u0026#34;1 hour ago\u0026#34; --no-pager -o short 如果要把日志导出到文件，可以重定向：\n1journalctl -u nginx --since today \u0026gt; /tmp/nginx-today.log 我踩过的一个坑 有一次我改完 journald.conf 之后没重启 systemd-journald，结果配置一直没生效，日志还是存在内存里。重启机器后发现日志全丢了，排查问题少了很多线索。\n从那以后我养成了一个习惯：每次改完 journald 配置，一定要执行 systemctl restart systemd-journald，然后 journalctl --disk-usage 看一下实际占用了多少空间，确认配置生效了。\n总结 journalctl 看起来参数多，但日常用得顺手的就那几个：\njournalctl -u 服务名：看某个服务的日志 journalctl -f：实时跟踪 journalctl --since \u0026quot;时间\u0026quot;：按时间过滤 journalctl -p err：按错误级别过滤 journalctl -n 数字：看最近 N 行 journalctl --vacuum-size=100M：清理日志 如果你还在用传统方式查日志，建议花半小时熟悉一下 journalctl，真的能省不少时间。\n","permalink":"http://www.moxuan.xin/posts/linux-journalctl-debug/","summary":"\u003cp\u003e之前我查日志基本都是直接去 \u003ccode\u003e/var/log/\u003c/code\u003e 下面翻文件，比如 \u003ccode\u003esyslog\u003c/code\u003e、\u003ccode\u003enginx/error.log\u003c/code\u003e、\u003ccode\u003eauth.log\u003c/code\u003e 这些。后来系统升级之后发现有些日志找不到了，一查才知道现在很多发行版都用 systemd 的 journal 来统一管理日志了。\u003c/p\u003e","title":"Linux 日志排查：journalctl 实战与常见问题"},{"content":"前两天半夜，我的手机突然开始疯狂震动，原来是监控机器人在报警，说主站打不开了。我迷迷糊糊爬起来打开浏览器，果然看到硕大的 502 Bad Gateway。\n当时第一个反应是：完了，不会是服务器被攻击了吧？赶紧登录 VPS 查看情况。\n第一步：先确认服务器状态 登录进去之后，我先用 top 看了下整体负载。结果发现 CPU 和内存都挺正常的，负载也不高，不像是被 DDoS 或者程序跑死的样子。\n然后去看 Nginx 的错误日志，路径一般在 /var/log/nginx/error.log：\n1tail -n 100 /var/log/nginx/error.log 日志里密密麻麻全是这种报错：\n12024/04/22 22:48:12 [crit] 1234#1234: *56789 accept4() failed (24: Too many open files) Too many open files，这个错误我太熟了。Linux 里\u0026quot;一切皆文件\u0026quot;，每个 TCP 连接、每个打开的文件、每个 socket，背后都要占用一个文件描述符（File Descriptor，简称 FD）。系统为了防止单个进程把资源吃光，默认给每个进程设了一个上限，通常是 1024。\n我的 Nginx 加上后端服务，并发一上来，文件描述符瞬间就满了，新的连接自然进不来，外面看到的就是 502。\n第二步：确认当前限制 先不要急着改配置，先看一下当前的限制到底是多少。\n查看系统级限制：\n1cat /proc/sys/fs/file-max 这个数字通常很大，比如几十万，系统级一般不是瓶颈。\n查看用户级和进程级限制：\n1ulimit -n 我返回的是 1024，果然是默认值。\n再看一下具体是哪些进程占用了大量 FD。先找到 Nginx worker 的 PID：\n1ps aux | grep nginx 然后看某个 worker 打开了多少文件：\n1lsof -p PID号 | wc -l 数字接近 1024，实锤了。\n第三步：临时提升限制 为了先让服务恢复，我先临时把限制改大：\n1ulimit -n 65535 然后重载 Nginx：\n1systemctl reload nginx 网站立刻恢复正常。但这只是临时的，重启服务器或者重新登录 shell 之后又会变回 1024。\n第四步：永久修改系统限制 要让修改永久生效，需要改 /etc/security/limits.conf。在文件末尾加上：\n1* soft nofile 65535 2* hard nofile 65535 这两行的意思是：所有用户（*）的软限制和硬限制都设为 65535。\n不过这里有个坑，如果你是用 systemd 启动的服务，limits.conf 可能不生效。systemd 有自己的资源限制机制。\n针对 systemd 管理的服务，比如 Nginx，建议直接修改服务的 service 文件。创建覆盖目录和文件：\n1mkdir -p /etc/systemd/system/nginx.service.d/ 2nano /etc/systemd/system/nginx.service.d/override.conf 内容写上：\n1[Service] 2LimitNOFILE=65535 然后重载 systemd 配置并重启 Nginx：\n1systemctl daemon-reload 2systemctl restart nginx 这样 Nginx 进程就能拿到 65535 的文件描述符限制了。\n第五步：调整 Nginx 自身配置 光是改系统限制还不够，Nginx 自己也有连接数相关的配置。找到 nginx.conf，修改这两个地方：\n1worker_rlimit_nofile 65535; 2 3events { 4 worker_connections 20480; 5 use epoll; 6 multi_accept on; 7} worker_rlimit_nofile：每个 worker 进程能打开的最大文件数。 worker_connections：每个 worker 能同时处理的最大连接数。 use epoll：Linux 下推荐的事件模型。 multi_accept on：一次 accept 多个连接，提高高并发下的效率。 改完之后重载：\n1nginx -t 2systemctl reload nginx 第六步：后端应用也要检查 如果你的后端是 Node.js、Python 或者其他自己写的服务，也要检查它们的文件描述符限制。很多时候 Nginx 没问题了，但后端程序也会报同样的错。\n比如一个 Python 服务用 systemd 管理，同样创建一个 override：\n1mkdir -p /etc/systemd/system/myapp.service.d/ 2nano /etc/systemd/system/myapp.service.d/override.conf 1[Service] 2LimitNOFILE=65535 然后 daemon-reload 加 restart。\n第七步：验证和压测 改完配置之后，不能就这么算了，要验证一下是否真的生效了。\n先看 Nginx worker 当前的限制：\n1cat /proc/NGINX_PID/limits | grep \u0026#34;Max open files\u0026#34; 数字应该是 65535。\n然后用 ab 或者 wrk 压测一下。我本地装了 wrk，简单跑一下：\n1wrk -t4 -c10000 -d30s https://blog.moxuan.xin/ 压测过程中一直盯着 error.log，确认没有再出现 Too many open files。同时用 lsof -p PID | wc -l 看 FD 使用量，稳定在一万左右，离 65535 还很远。\n这次踩坑的总结 遇到 502 不要慌，先看日志，日志比猜测重要一百倍。 Linux 一切皆文件，socket 也是文件，连接数本质上受 FD 限制。 ulimit 是用户级限制，对 systemd 服务不一定生效， service override 更靠谱。 Nginx 要同时调系统限制和自身 worker_connections，只调一边没用。 改完要验证，压测一下比自我感觉更可靠。 这次故障从发现到彻底解决大概花了一个多小时。虽然熬夜很狼狈，但学到了不少东西。服务器运维就是这样，踩过的坑才是真金白银的经验。\n","permalink":"http://www.moxuan.xin/posts/linux-too-many-open-files-fix/","summary":"\u003cp\u003e前两天半夜，我的手机突然开始疯狂震动，原来是监控机器人在报警，说主站打不开了。我迷迷糊糊爬起来打开浏览器，果然看到硕大的 502 Bad Gateway。\u003c/p\u003e\n\u003cp\u003e当时第一个反应是：完了，不会是服务器被攻击了吧？赶紧登录 VPS 查看情况。\u003c/p\u003e","title":"服务突然 502？排查 Linux 'Too many open files' 报错踩坑记录"},{"content":"PaperMod 这个主题用的人挺多的，但默认配置出来的页面，说实话有点\u0026quot;千篇一律\u0026quot;。我看了几个同样用这个主题的博客，首页长得都差不多，要不是域名不同，差点以为是同一个站。\n所以我就想着给主题做一些小改造，让它看起来更像\u0026quot;我的博客\u0026quot;，而不是\u0026quot;PaperMod 默认皮肤\u0026quot;。这篇记录一下我改过的几个地方，都不复杂，复制粘贴就能用。\n改造前的准备 PaperMod 主题默认是通过 git submodule 引入的，直接改 themes/PaperMod 里的文件不太合适，因为下次更新主题会冲突。正确做法是使用 Hugo 的模板覆盖机制：\n只要在项目根目录的 layouts/ 下创建和主题里同名的文件，Hugo 会优先使用你项目里的版本。这样主题更新了也不会影响你的修改。\n比如我想覆盖主题的 layouts/partials/footer.html，就在项目里新建 layouts/partials/footer.html，Hugo 会自动用我项目里的这个文件。\n1. 自定义首页 profile PaperMod 默认首页会显示一个标题和一段介绍文字，通过 params.homeInfoParams 配置。但默认样式比较朴素，我改了一下让它更紧凑：\n1params: 2 homeInfoParams: 3 Title: \u0026#34;Moxuan\u0026#39;s Blog\u0026#34; 4 Content: \u0026#34;这里记录我折腾服务器、写代码和踩坑的日常。文章不一定对，但一定真实。\u0026#34; 如果你想更进一步，可以创建 layouts/partials/home_info.html 完全自定义首页的这块区域。我当时加了一个简单的个人介绍和最近文章列表，看起来更像个真正的首页。\n2. 页脚加备案号 国内网站底部加备案号几乎是必须的，PaperMod 默认页脚只有版权信息。我通过覆盖 layouts/partials/footer.html 来加上备案号。\n我的页脚文件大致内容：\n1\u0026lt;footer class=\u0026#34;footer\u0026#34;\u0026gt; 2 \u0026lt;span\u0026gt;\u0026amp;copy; 2024 \u0026lt;a href=\u0026#34;{{ \u0026#34;\u0026#34; | absURL }}\u0026#34;\u0026gt;Moxuan\u0026lt;/a\u0026gt;\u0026lt;/span\u0026gt; 3 \u0026lt;span\u0026gt; 4 Powered by 5 \u0026lt;a href=\u0026#34;https://gohugo.io/\u0026#34; rel=\u0026#34;noopener noreferrer\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;Hugo\u0026lt;/a\u0026gt; 6 \u0026amp; 7 \u0026lt;a href=\u0026#34;https://github.com/adityatelange/hugo-PaperMod/\u0026#34; rel=\u0026#34;noopener\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;PaperMod\u0026lt;/a\u0026gt; 8 \u0026lt;/span\u0026gt; 9 \u0026lt;span\u0026gt; 10 \u0026lt;a href=\u0026#34;https://beian.miit.gov.cn/\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;苏ICP备XXXXXXXX号-1\u0026lt;/a\u0026gt; 11 \u0026lt;/span\u0026gt; 12\u0026lt;/footer\u0026gt; 然后用一点 CSS 把三行居中对齐：\n1.footer { 2 text-align: center; 3 font-size: 14px; 4 color: var(--secondary); 5} 6.footer span { 7 display: block; 8 margin: 4px 0; 9} 备案号记得换成你自己的，不然被查到很麻烦。\n3. 调整代码块的复制按钮 PaperMod 自带代码复制按钮，但默认位置我有点不习惯。我想让它更明显一点，就去改了 CSS。\n创建 assets/css/extended/blank.css（PaperMod 会自动加载这个目录下的 CSS），然后写：\n1.copy-code { 2 background: var(--code-bg); 3 border: 1px solid var(--border); 4 border-radius: 4px; 5 padding: 4px 8px; 6 font-size: 12px; 7 cursor: pointer; 8} 9 10.copy-code:hover { 11 background: var(--primary); 12 color: var(--theme); 13} 这里用到了 PaperMod 定义的 CSS 变量，比如 --code-bg 和 --primary，所以能自动适配暗色模式，不用写两套样式。\n4. 让暗色模式默认开启 PaperMod 默认是亮色模式，需要用户手动点切换按钮才能变成暗色。我观察了一下，现在很多技术博客默认就是暗色，看起来更有\u0026quot;技术感\u0026quot;一点。\n把默认主题改成暗色很简单，在 hugo.yaml 里改：\n1params: 2 defaultTheme: dark 如果你想尊重用户的系统设置，可以改成 auto，这样会根据系统主题自动切换。\n不过要注意，改成 dark 之后，有些自己加的自定义元素可能颜色会不对劲，最好在暗色和亮色下都检查一遍。\n5. 给文章加阅读进度条 这个改动稍微麻烦一点，但效果还不错。我在文章页面顶部加了一个细长的进度条，随着滚动慢慢变长。\n实现思路是用 JavaScript 监听滚动事件，计算滚动百分比，然后调整一个 div 的宽度。\n在 layouts/partials/extend_head.html 里加样式：\n1\u0026lt;style\u0026gt; 2#reading-progress { 3 position: fixed; 4 top: 0; 5 left: 0; 6 height: 3px; 7 background: var(--primary); 8 width: 0%; 9 z-index: 1000; 10 transition: width 0.1s; 11} 12\u0026lt;/style\u0026gt; 在 layouts/partials/extend_footer.html 里加脚本：\n1\u0026lt;script\u0026gt; 2document.addEventListener(\u0026#39;scroll\u0026#39;, function() { 3 var scrollTop = document.documentElement.scrollTop || document.body.scrollTop; 4 var scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; 5 var progress = (scrollTop / scrollHeight) * 100; 6 document.getElementById(\u0026#39;reading-progress\u0026#39;).style.width = progress + \u0026#39;%\u0026#39;; 7}); 8\u0026lt;/script\u0026gt; 然后在 layouts/partials/extend_head.html 里再把这个进度条 div 加进去：\n1\u0026lt;div id=\u0026#34;reading-progress\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; 这样每篇文章顶部都会有一个细细的进度条。虽然不是什么刚需，但能增加一点精致感。\n6. 自定义 404 页面 PaperMod 的 404 页面比较简陋，我换成了一个带搜索框的 404 页面。读者访问到不存在的页面时，可以直接搜索想找的内容。\n创建 layouts/404.html：\n1{{ define \u0026#34;main\u0026#34; }} 2\u0026lt;div class=\u0026#34;not-found\u0026#34;\u0026gt; 3 \u0026lt;h1\u0026gt;404\u0026lt;/h1\u0026gt; 4 \u0026lt;p\u0026gt;这个页面不存在，可能被我不小心删掉了。\u0026lt;/p\u0026gt; 5 \u0026lt;p\u0026gt;你可以试试搜索：\u0026lt;/p\u0026gt; 6 \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;search-input\u0026#34; placeholder=\u0026#34;输入关键词...\u0026#34; autofocus\u0026gt; 7 \u0026lt;div id=\u0026#34;search-results\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; 8\u0026lt;/div\u0026gt; 9{{ end }} 具体搜索功能要配合 Fuse.js 实现，代码比较长，这里就不展开了。核心思路是用主题自带的搜索 JSON 数据，在 404 页面复用搜索逻辑。\n7. 修改文章列表的摘要长度 PaperMod 首页文章列表会显示摘要，但默认长度有点短，我觉得显示 150 个字符左右比较合适。\n在 hugo.yaml 里配置：\n1params: 2 ShowFullTextinRSS: false 如果你想更精细地控制摘要，可以在文章 front matter 里加 summary 字段，或者使用 `\n` 手动截取摘要。\n我比较喜欢手动控制，所以在每篇文章合适的位置插入 \u0026lt;!--more--\u0026gt;，这样首页显示的摘要就是我想让读者看到的那部分。\n8. 加一个小改动：文章最后显示版权声明 这个看个人喜好，我在每篇文章底部加了一个简单的版权声明，防止别人直接全文复制走。\n创建 layouts/partials/post_copyright.html：\n1\u0026lt;div class=\u0026#34;post-copyright\u0026#34;\u0026gt; 2 \u0026lt;p\u0026gt;本文作者：Moxuan\u0026lt;/p\u0026gt; 3 \u0026lt;p\u0026gt;本文链接：\u0026lt;a href=\u0026#34;{{ .Permalink }}\u0026#34;\u0026gt;{{ .Permalink }}\u0026lt;/a\u0026gt;\u0026lt;/p\u0026gt; 4 \u0026lt;p\u0026gt;转载请注明出处。\u0026lt;/p\u0026gt; 5\u0026lt;/div\u0026gt; 然后在 layouts/_default/single.html 里合适位置引用：\n1{{ partial \u0026#34;post_copyright.html\u0026#34; . }} 改造完的感受 经过这么一通小改动，博客看起来已经不太像\u0026quot;PaperMod 默认皮肤\u0026quot;了。虽然改动都不大，但累积起来的效果还是挺明显的。\n最重要的是，这些改造都是基于模板覆盖和自定义 CSS，主题以后更新不会影响我的修改。维护起来很轻松。\n如果你也用 PaperMod，建议不要一上来就大改。先从小地方开始，慢慢调整到自己喜欢的样子。主题只是壳，内容才是内核。\n","permalink":"http://www.moxuan.xin/posts/papermod-theme-customization/","summary":"\u003cp\u003ePaperMod 这个主题用的人挺多的，但默认配置出来的页面，说实话有点\u0026quot;千篇一律\u0026quot;。我看了几个同样用这个主题的博客，首页长得都差不多，要不是域名不同，差点以为是同一个站。\u003c/p\u003e\n\u003cp\u003e所以我就想着给主题做一些小改造，让它看起来更像\u0026quot;我的博客\u0026quot;，而不是\u0026quot;PaperMod 默认皮肤\u0026quot;。这篇记录一下我改过的几个地方，都不复杂，复制粘贴就能用。\u003c/p\u003e\n\u003ch2 id=\"改造前的准备\"\u003e改造前的准备\u003c/h2\u003e\n\u003cp\u003ePaperMod 主题默认是通过 git submodule 引入的，直接改 \u003ccode\u003ethemes/PaperMod\u003c/code\u003e 里的文件不太合适，因为下次更新主题会冲突。正确做法是使用 Hugo 的模板覆盖机制：\u003c/p\u003e\n\u003cp\u003e只要在项目根目录的 \u003ccode\u003elayouts/\u003c/code\u003e 下创建和主题里同名的文件，Hugo 会优先使用你项目里的版本。这样主题更新了也不会影响你的修改。\u003c/p\u003e\n\u003cp\u003e比如我想覆盖主题的 \u003ccode\u003elayouts/partials/footer.html\u003c/code\u003e，就在项目里新建 \u003ccode\u003elayouts/partials/footer.html\u003c/code\u003e，Hugo 会自动用我项目里的这个文件。\u003c/p\u003e\n\u003ch2 id=\"1-自定义首页-profile\"\u003e1. 自定义首页 profile\u003c/h2\u003e\n\u003cp\u003ePaperMod 默认首页会显示一个标题和一段介绍文字，通过 \u003ccode\u003eparams.homeInfoParams\u003c/code\u003e 配置。但默认样式比较朴素，我改了一下让它更紧凑：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003ehomeInfoParams\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003eTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;Moxuan\u0026#39;s Blog\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003eContent\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;这里记录我折腾服务器、写代码和踩坑的日常。文章不一定对，但一定真实。\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果你想更进一步，可以创建 \u003ccode\u003elayouts/partials/home_info.html\u003c/code\u003e 完全自定义首页的这块区域。我当时加了一个简单的个人介绍和最近文章列表，看起来更像个真正的首页。\u003c/p\u003e\n\u003ch2 id=\"2-页脚加备案号\"\u003e2. 页脚加备案号\u003c/h2\u003e\n\u003cp\u003e国内网站底部加备案号几乎是必须的，PaperMod 默认页脚只有版权信息。我通过覆盖 \u003ccode\u003elayouts/partials/footer.html\u003c/code\u003e 来加上备案号。\u003c/p\u003e\n\u003cp\u003e我的页脚文件大致内容：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003efooter\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;footer\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"ni\"\u003e\u0026amp;copy;\u003c/span\u003e 2024 \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e \u003cspan class=\"na\"\u003ehref\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;{{ \u0026#34;\u003c/span\u003e\u003cspan class=\"err\"\u003e\u0026#34;\u003c/span\u003e \u003cspan class=\"err\"\u003e|\u003c/span\u003e \u003cspan class=\"na\"\u003eabsURL\u003c/span\u003e \u003cspan class=\"err\"\u003e}}\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003eMoxuan\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e        Powered by\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e \u003cspan class=\"na\"\u003ehref\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;https://gohugo.io/\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003erel\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;noopener noreferrer\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003etarget\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;_blank\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003eHugo\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"err\"\u003e\u0026amp;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e \u003cspan class=\"na\"\u003ehref\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;https://github.com/adityatelange/hugo-PaperMod/\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003erel\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;noopener\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003etarget\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;_blank\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003ePaperMod\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e \u003cspan class=\"na\"\u003ehref\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;https://beian.miit.gov.cn/\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003etarget\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;_blank\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e苏ICP备XXXXXXXX号-1\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003espan\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003efooter\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e然后用一点 CSS 把三行居中对齐：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nc\"\u003efooter\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003etext-align\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003ecenter\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efont-size\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e14\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ecolor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003esecondary\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nc\"\u003efooter\u003c/span\u003e \u003cspan class=\"nt\"\u003espan\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003edisplay\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003eblock\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e8\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003emargin\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e4\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e9\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e备案号记得换成你自己的，不然被查到很麻烦。\u003c/p\u003e\n\u003ch2 id=\"3-调整代码块的复制按钮\"\u003e3. 调整代码块的复制按钮\u003c/h2\u003e\n\u003cp\u003ePaperMod 自带代码复制按钮，但默认位置我有点不习惯。我想让它更明显一点，就去改了 CSS。\u003c/p\u003e\n\u003cp\u003e创建 \u003ccode\u003eassets/css/extended/blank.css\u003c/code\u003e（PaperMod 会自动加载这个目录下的 CSS），然后写：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nc\"\u003ecopy-code\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ebackground\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003ecode\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"n\"\u003ebg\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eborder\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e \u003cspan class=\"kc\"\u003esolid\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003eborder\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eborder-radius\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e4\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epadding\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e4\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e \u003cspan class=\"mi\"\u003e8\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efont-size\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e12\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ecursor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003epointer\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nc\"\u003ecopy-code\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"nd\"\u003ehover\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ebackground\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003eprimary\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ecolor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003etheme\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e13\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里用到了 PaperMod 定义的 CSS 变量，比如 \u003ccode\u003e--code-bg\u003c/code\u003e 和 \u003ccode\u003e--primary\u003c/code\u003e，所以能自动适配暗色模式，不用写两套样式。\u003c/p\u003e\n\u003ch2 id=\"4-让暗色模式默认开启\"\u003e4. 让暗色模式默认开启\u003c/h2\u003e\n\u003cp\u003ePaperMod 默认是亮色模式，需要用户手动点切换按钮才能变成暗色。我观察了一下，现在很多技术博客默认就是暗色，看起来更有\u0026quot;技术感\u0026quot;一点。\u003c/p\u003e\n\u003cp\u003e把默认主题改成暗色很简单，在 \u003ccode\u003ehugo.yaml\u003c/code\u003e 里改：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003edefaultTheme\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003edark\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果你想尊重用户的系统设置，可以改成 \u003ccode\u003eauto\u003c/code\u003e，这样会根据系统主题自动切换。\u003c/p\u003e\n\u003cp\u003e不过要注意，改成 dark 之后，有些自己加的自定义元素可能颜色会不对劲，最好在暗色和亮色下都检查一遍。\u003c/p\u003e\n\u003ch2 id=\"5-给文章加阅读进度条\"\u003e5. 给文章加阅读进度条\u003c/h2\u003e\n\u003cp\u003e这个改动稍微麻烦一点，但效果还不错。我在文章页面顶部加了一个细长的进度条，随着滚动慢慢变长。\u003c/p\u003e\n\u003cp\u003e实现思路是用 JavaScript 监听滚动事件，计算滚动百分比，然后调整一个 div 的宽度。\u003c/p\u003e\n\u003cp\u003e在 \u003ccode\u003elayouts/partials/extend_head.html\u003c/code\u003e 里加样式：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003estyle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e#\u003c/span\u003e\u003cspan class=\"nn\"\u003ereading-progress\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eposition\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003efixed\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003etop\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eleft\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eheight\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e3\u003c/span\u003e\u003cspan class=\"kt\"\u003epx\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ebackground\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nf\"\u003evar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e--\u003c/span\u003e\u003cspan class=\"n\"\u003eprimary\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 8\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ewidth\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"kt\"\u003e%\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e 9\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ez-index\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e1000\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e10\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003etransition\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ewidth\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.1\u003c/span\u003e\u003cspan class=\"kt\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e11\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e12\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003estyle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e在 \u003ccode\u003elayouts/partials/extend_footer.html\u003c/code\u003e 里加脚本：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eaddEventListener\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;scroll\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kd\"\u003efunction\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nx\"\u003escrollTop\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edocumentElement\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003escrollTop\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003escrollTop\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nx\"\u003escrollHeight\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edocumentElement\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003escrollHeight\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edocumentElement\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eclientHeight\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nx\"\u003eprogress\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003escrollTop\u003c/span\u003e \u003cspan class=\"o\"\u003e/\u003c/span\u003e \u003cspan class=\"nx\"\u003escrollHeight\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003egetElementById\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;reading-progress\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"nx\"\u003estyle\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ewidth\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eprogress\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e8\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e然后在 \u003ccode\u003elayouts/partials/extend_head.html\u003c/code\u003e 里再把这个进度条 div 加进去：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;reading-progress\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这样每篇文章顶部都会有一个细细的进度条。虽然不是什么刚需，但能增加一点精致感。\u003c/p\u003e\n\u003ch2 id=\"6-自定义-404-页面\"\u003e6. 自定义 404 页面\u003c/h2\u003e\n\u003cp\u003ePaperMod 的 404 页面比较简陋，我换成了一个带搜索框的 404 页面。读者访问到不存在的页面时，可以直接搜索想找的内容。\u003c/p\u003e\n\u003cp\u003e创建 \u003ccode\u003elayouts/404.html\u003c/code\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e{{ define \u0026#34;main\u0026#34; }}\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;not-found\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eh1\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e404\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003eh1\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e4\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e这个页面不存在，可能被我不小心删掉了。\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e5\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e你可以试试搜索：\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e6\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003einput\u003c/span\u003e \u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;text\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;search-input\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eplaceholder\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;输入关键词...\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eautofocus\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e7\u003c/span\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;search-results\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e8\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e9\u003c/span\u003e\u003cspan class=\"cl\"\u003e{{ end }}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e具体搜索功能要配合 Fuse.js 实现，代码比较长，这里就不展开了。核心思路是用主题自带的搜索 JSON 数据，在 404 页面复用搜索逻辑。\u003c/p\u003e\n\u003ch2 id=\"7-修改文章列表的摘要长度\"\u003e7. 修改文章列表的摘要长度\u003c/h2\u003e\n\u003cp\u003ePaperMod 首页文章列表会显示摘要，但默认长度有点短，我觉得显示 150 个字符左右比较合适。\u003c/p\u003e\n\u003cp\u003e在 \u003ccode\u003ehugo.yaml\u003c/code\u003e 里配置：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eparams\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003eShowFullTextinRSS\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果你想更精细地控制摘要，可以在文章 front matter 里加 \u003ccode\u003esummary\u003c/code\u003e 字段，或者使用 `\u003c/p\u003e","title":"PaperMod 主题二次开发与小改造记录"},{"content":"为什么需要监控？ 最近沉迷在 NodeSeek 上捡垃圾，手里不知不觉攒了七八台各地的便宜 VPS。有些线路极其不稳定，动不动就掉线。平时要靠自己手动去 PING 看看死没死，效率太低。 为了解放自己，决定部署目前开源界最火的监控面板：Uptime Kuma。\n极速容器化部署 老规矩，绝对不污染宿主机，直接用 Docker 一把梭。在母鸡（主服务器）上运行：\n1docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1 配合 Nginx 反代绑定个域名，配好 SSL 证书，颜值极高的中文后台就出来了。\n配置深度监控项 我不光添加了简单的 Ping 和 HTTP(s) 检测，还用到了一些高级功能：\nTCP 端口检测：单独监听了某些服务器的 SSH (22) 和数据库 (3306) 端口，防止系统活着但服务死了。 DNS 解析检测：监控我博客域名的解析有没有被污染。 证书过期提醒：设置了 SSL 证书还有 7 天过期时提前发通知。 打通 Telegram 报警链路 在 TG 上找 BotFather 申请了一个机器人 Token。在 Uptime Kuma 后台填入 Token 和我的 Chat ID。 测试了一下拔网线，几秒钟后手机 TG 就收到了 [DOWN] Server-US-West 离线 的精准报警。这下连睡觉都能安心了。\n","permalink":"http://www.moxuan.xin/posts/uptime-kuma-server-monitoring/","summary":"\u003cp\u003e\u003cstrong\u003e为什么需要监控？\u003c/strong\u003e\n最近沉迷在 NodeSeek 上捡垃圾，手里不知不觉攒了七八台各地的便宜 VPS。有些线路极其不稳定，动不动就掉线。平时要靠自己手动去 PING 看看死没死，效率太低。\n为了解放自己，决定部署目前开源界最火的监控面板：Uptime Kuma。\u003c/p\u003e","title":"手里的小鸡越来越多？部署 Uptime Kuma 给全员上探针"},{"content":"商业软件的痛点 最近在外面想远程连回出租屋里的台式机搞点开发，用向日葵和 ToDesk 不是限速就是画质糊得像马赛克，而且免费版动不动就排队，实在忍不了了。 既然咱有云服务器，为啥不自己搞个内网穿透？\n技术选型：为什么是 Tailscale？ 对比了 FRP 和 ZeroTier，最后选了基于 WireGuard 协议的 Tailscale。它的打洞（NAT Traversal）能力极其变态，配置简直是傻瓜式，而且所有节点都会被分配一个固定的 100.x.x.x 的局域网 IP。\n部署踩坑记录 在 Linux 服务器和家里 Windows 电脑上分别装好客户端：\n1curl -fsSL [https://tailscale.com/install.sh](https://tailscale.com/install.sh) | sh 2tailscale up 本来以为直接连就行了，结果发现两边 PING 延迟高达 300ms，走的居然是官方国外的 DERP 中继服务器，没有直连（打洞失败）。\n自建 DERP 中继服务器（终极加速） 为了解决延迟，我决定用手里国内的腾讯云服务器自建中继节点。 拉了一个官方的 Docker 镜像：\n1version: \u0026#39;3\u0026#39; 2services: 3 derper: 4 image: fredliang/derper:latest 5 ports: 6 - 3478:3478/udp 7 - 443:443/tcp 8 environment: 9 - DERP_DOMAIN=derp.moxuan.de 配置好证书后，在 Tailscale 的 ACL 规则里强制指定我的节点。再次用 Windows 自带的 RDP 远程桌面连接家里电脑，延迟只有 20ms，画质无损拉满，拖动窗口丝般顺滑。真香！\n","permalink":"http://www.moxuan.xin/posts/tailscale-wireguard-remote-desktop/","summary":"\u003cp\u003e\u003cstrong\u003e商业软件的痛点\u003c/strong\u003e\n最近在外面想远程连回出租屋里的台式机搞点开发，用向日葵和 ToDesk 不是限速就是画质糊得像马赛克，而且免费版动不动就排队，实在忍不了了。\n既然咱有云服务器，为啥不自己搞个内网穿透？\u003c/p\u003e","title":"抛弃向日葵和 ToDesk，用 Tailscale 组建个人的大局域网"},{"content":"博客搭好之后，我就开始研究怎么让文章更容易被搜到。虽然说写博客主要是给自己看，但说实话，谁不希望自己辛辛苦苦写的文章能被人通过搜索引擎找到呢？更何况有些技术笔记确实能帮到别人。\n这篇就记录一下我给 Hugo 博客做 SEO 和性能优化时做的一些事情。都不是什么高深技术，但确实有效。\n1. 站点标题和描述要写好 Hugo 的配置文件里，title 和 params.description 会直接影响搜索引擎结果页的展示。别小看这一行字，很多人搜到你博客，第一眼看到的就是这个。\n我的 hugo.yaml 里大概是这么配的：\n1baseURL: \u0026#34;https://blog.moxuan.xin/\u0026#34; 2title: \u0026#34;Moxuan\u0026#39;s Blog\u0026#34; 3 4params: 5 description: \u0026#34;一个记录技术踩坑与个人学习的博客，主要分享 Linux 运维、Web 开发和服务器管理经验。\u0026#34; 6 author: \u0026#34;Moxuan\u0026#34; title 不用太长，简洁有力就行。description 建议控制在 80 到 120 字之间，太长搜索引擎会截断。\n2. 每篇文章的 front matter 要规范 Hugo 的 PaperMod 主题会读取 front matter 来生成 Open Graph 和 Twitter Cards。如果你不加这些，分享到社交平台的时候就只有光秃秃一个链接，很丑。\n我现在的 front matter 模板大概长这样：\n1--- 2title: \u0026#34;文章标题\u0026#34; 3date: 2024-03-10T14:20:00+08:00 4draft: false 5tags: [\u0026#34;Linux\u0026#34;, \u0026#34;Nginx\u0026#34;] 6categories: [\u0026#34;服务器运维\u0026#34;] 7description: \u0026#34;用一句话概括这篇文章讲了什么，控制在 100 字以内。\u0026#34; 8--- description 字段很重要，它会被用作：\n搜索引擎结果页的摘要 Open Graph 的 og:description 首页文章列表的摘要显示 如果你的主题支持 cover，还可以给文章配一张封面图，分享到社交平台效果会更好。\n3. 开启自动生成站点地图 Hugo 默认就会生成 sitemap.xml，但最好检查一下配置，确认没有被关掉。站点地图是告诉搜索引擎你有哪些页面的最有效方式。\n你可以在 hugo.yaml 里这样配置：\n1sitemap: 2 changefreq: \u0026#34;weekly\u0026#34; 3 priority: 0.5 4 filename: \u0026#34;sitemap.xml\u0026#34; 生成之后，访问 https://你的域名/sitemap.xml 应该能看到所有页面的列表。\n然后我还做了两件事：\n在百度资源平台和 Google Search Console 都提交了站点地图。 在 robots.txt 里允许搜索引擎抓取，并指向 sitemap。 Hugo 配置 enableRobotsTXT: true 之后会自动生成 robots.txt，默认是允许全部抓取的。如果你想自定义，可以在 layouts/robots.txt 里写自己的版本：\n1User-agent: * 2Allow: / 3 4Sitemap: https://blog.moxuan.xin/sitemap.xml 4. URL 结构要干净 Hugo 默认的文章 URL 是 https://域名/posts/文章标题/，这种结构已经挺好了。但我个人更喜欢把日期加进去，变成 https://域名/posts/2024/03/文章标题/ 这种层级。\n配置方式是在 hugo.yaml 里加 permalink：\n1permalinks: 2 posts: \u0026#34;/posts/:year/:month/:slug/\u0026#34; 这样 URL 会更清晰，读者从地址栏就能大致看出这是什么时候的文章。而且搜索引擎一般也比较喜欢有语义的 URL。\n不过要注意，改了 permalink 之后旧的 URL 会 404。如果是已经上线的博客，记得做 301 重定向。我是因为新站，所以无所谓。\n5. 图片优化 我之前写文章特别喜欢直接丢高清截图，一张图一两兆，结果首页加载慢得离谱。后来狠狠优化了一波：\n压缩图片\n用 TinyPNG 或者 ImageMagick 把图片压缩一下，一般能减少 60% 到 80% 的体积。我本地用 ImageMagick 批量压缩：\n1magick input.png -quality 85 output.jpg 延迟加载\nPaperMod 主题本身对图片有懒加载支持，但如果你是自己写主题，记得给 img 标签加上 loading=\u0026quot;lazy\u0026quot;。\n使用 WebP\n现在主流浏览器都支持 WebP 了，同样的视觉质量下体积比 JPEG 小很多。Hugo 有图片处理功能，可以在模板里自动转换格式，不过配置起来稍微麻烦一点，我目前还没完全搞，先用压缩后的 JPEG 顶着。\n6. 代码高亮不要拖慢页面 Hugo 自带的代码高亮是用 Chroma，默认配置下可能会给每行代码都生成行号，导致首页摘要里全是数字，很丑。\n我的高亮配置是这样的：\n1markup: 2 goldmark: 3 renderer: 4 unsafe: true 5 highlight: 6 codeFences: true 7 noClasses: false 8 lineNos: true 9 lineNumbersInTable: false 关键点是把 lineNumbersInTable 设为 false，这样就不会在摘要里显示一堆行号。另外 noClasses: false 表示用 CSS 类来高亮，而不是内联样式，可以让生成的 HTML 更小。\n7. 启用搜索功能 PaperMod 主题支持 Fuse.js 搜索，配置好之后读者可以通过搜索框快速找到文章。这对 SEO 本身没直接影响，但能降低跳出率，间接对搜索引擎友好。\n配置方法是在 hugo.yaml 里加上：\n1outputs: 2 home: 3 - HTML 4 - RSS 5 - JSON 主题会自动读取首页输出的 JSON 来构建搜索索引。记得改了配置之后要重新生成站点，不然搜索索引不会更新。\n8. 生成 RSS 虽然现在用 RSS 的人少了，但还是有一些老读者喜欢用阅读器订阅博客。Hugo 默认就生成 RSS，URL 是 /index.xml。\n我额外在页脚加了一个 RSS 订阅按钮，方便需要的人订阅。配置方法参考主题的 socialIcons：\n1params: 2 socialIcons: 3 - name: rss 4 url: \u0026#34;index.xml\u0026#34; 9. 性能测试 优化完之后，我会用几个工具测一下效果：\nLighthouse：Chrome 自带的性能评分工具，重点关注 Performance 和 SEO 分数。 PageSpeed Insights：Google 官方的在线测速工具，会给出具体优化建议。 GTmetrix：也是在线测速，可以看瀑布流分析具体哪个资源慢。 我优化前首页 Lighthouse 性能分数大概是 60 多分，优化后能到 90 分以上。主要的提升来自图片压缩和代码高亮配置的改动。\n10. 内容本身最重要 最后想说一句，所有 SEO 技巧都是锦上添花。如果你文章质量不行，再好的 SEO 也留不住读者。相反，如果你持续输出有价值的内容，即使不做太多优化，慢慢也会被搜索引擎收录和推荐。\n我的策略是：每篇文章都认真写，标题准确，描述清楚，代码可复现。剩下的交给时间。\n总结 这次优化主要做了这些事情：\n写好站点标题和描述 规范每篇文章的 front matter 生成并提交 sitemap 优化 URL 结构 压缩图片，启用懒加载 调整代码高亮配置 开启站内搜索 保留 RSS 订阅 用 Lighthouse 等工具验证效果 如果你也在用 Hugo，可以照着这个清单检查一下自己的博客，应该能有不少提升。\n","permalink":"http://www.moxuan.xin/posts/hugo-seo-performance-tuning/","summary":"\u003cp\u003e博客搭好之后，我就开始研究怎么让文章更容易被搜到。虽然说写博客主要是给自己看，但说实话，谁不希望自己辛辛苦苦写的文章能被人通过搜索引擎找到呢？更何况有些技术笔记确实能帮到别人。\u003c/p\u003e","title":"Hugo 博客 SEO 与性能调优实践"},{"content":"说实话，我之前也折腾过不少博客方案。最早是 WordPress，功能确实强，插件也多，但架不住后台太臃肿了，我那台 1 核 1G 的小 VPS 跑起来经常内存报警。后来换过 Hexo，静态生成倒是轻快了，但 node_modules 懂得都懂，换个电脑环境要配半天，而且构建速度在大文章多之后也开始变慢。\n兜兜转转，最后选了 Hugo。就一个字：快。整个站点几百篇文章，构建时间按毫秒算，本地预览几乎是秒开。而且它用 Go 写的，就一个二进制文件，部署起来极其省心。\n这篇文章就记录一下我从零开始用 Hugo 搭建这个博客的全过程，给同样想拥有一个干净技术博客的人一个参考。\n为什么最终选了 Hugo 市面上静态博客生成器很多，我简单列一下我当时考虑的几种：\nHexo：主题多，社区成熟，中文资料丰富。但基于 Node.js，依赖多，构建速度一般。 Jekyll：老牌工具，GitHub Pages 原生支持。但 Ruby 环境在国内配置起来比较麻烦。 Gatsby：功能强大，但太重了，更像一个前端项目而不是博客工具。 Hugo：单二进制、构建飞快、主题够用、配置简单。 我这个人比较懒，不想把太多时间花在维护工具链上。Hugo 的哲学就很对我胃口：下载一个文件，解压，运行，完事。\n安装 Hugo Hugo 的安装方式很多，我推荐直接下载官方预编译好的二进制文件，最不容易出错。\n去 GitHub Release 页面下载对应系统的压缩包，解压后把 hugo 可执行文件放到系统 PATH 里就行。比如在 Linux 下可以这么做：\n1# 下载 Linux 64 位版本（版本号自己去官网看最新的） 2wget https://github.com/gohugoio/hugo/releases/download/v0.123.0/hugo_extended_0.123.0_linux-amd64.tar.gz 3 4# 解压 5tar -zxvf hugo_extended_0.123.0_linux-amd64.tar.gz 6 7# 移动到 /usr/local/bin 方便全局调用 8sudo mv hugo /usr/local/bin/ 9 10# 验证 11hugo version 这里有个坑要注意：很多主题需要 Hugo 的 Extended 版本，因为要用到 SCSS/SASS 编译。如果你下载的是普通版，后续启用某些主题会报错。我一开始就下错了，浪费半小时排查，建议直接下 Extended 版。\nmacOS 用户用 Homebrew 更方便：\n1brew install hugo Windows 用户要么用 Scoop，要么去官网下载 exe 手动配置环境变量。\n创建站点 安装好之后，创建站点只需要一行命令：\n1hugo new site my-blog 2cd my-blog 执行完你会看到生成了这样的目录结构：\nmy-blog/ ├── archetypes/ # 内容模板 ├── assets/ # 需要 Hugo 处理的资源 ├── content/ # 文章内容，核心目录 ├── data/ # 数据文件 ├── layouts/ # HTML 模板 ├── static/ # 静态文件，直接复制到输出目录 ├── themes/ # 主题目录 └── hugo.toml # 站点配置文件 一开始看不懂没关系，大多数目录你都不会立刻用到。核心就两个：content/ 放文章，config 文件改站点设置。\n选择主题 Hugo 官方主题站有几百个主题，但质量参差不齐。我当初挑主题挑了整整两天，最后选了 PaperMod。原因挺简单的：\n界面干净，没有花里胡哨的动画。 适配了移动端，阅读体验不错。 功能全：搜索、归档、标签、暗色模式、代码复制、阅读时间这些都有。 配置文档虽然不算特别详细，但看示例站点基本能配出来。 安装主题一般用 git submodule：\n1git init 2git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod 然后在 hugo.toml 里加上：\n1theme = \u0026#39;PaperMod\u0026#39; 第一篇内容 Hugo 提供了很方便的 new content 命令：\n1hugo new content posts/hello-world.md 打开生成的文件，你会看到 front matter：\n1--- 2title: \u0026#34;Hello World\u0026#34; 3date: 2024-02-15T20:30:00+08:00 4draft: true 5--- 把 draft: true 改成 draft: false 就表示发布。写文章用 Markdown，Hugo 用的是 Goldmark 渲染器，支持标准的 Markdown 语法，也支持代码高亮。\n这里有一个我踩过的坑：Hugo 的代码块默认语言识别有时候不太准，建议每段代码块都显式标注语言，比如：\n1```bash 2echo \u0026#34;hello\u0026#34; ## 本地预览 写文章的时候肯定要实时预览，Hugo 的本地服务器支持热重载： ```bash hugo server -D -D 参数表示也渲染草稿。打开浏览器访问 http://localhost:1313 就能看到效果。你修改文章保存后，浏览器会自动刷新，非常方便。\n部署上线 静态博客的好处就是部署选择特别多。我用的是 GitHub Pages + GitHub Actions，免费还省心。\n基本思路是：\n源码推送到 GitHub 仓库。 配置 GitHub Actions 工作流，每次 push 自动运行 hugo 构建。 构建产物推送到 gh-pages 分支。 GitHub Pages 从这个分支部署。 我的 .github/workflows/hugo.yml 大概是这个样子：\n1name: Deploy Hugo site to Pages 2 3on: 4 push: 5 branches: [main] 6 7jobs: 8 build: 9 runs-on: ubuntu-latest 10 steps: 11 - uses: actions/checkout@v4 12 with: 13 submodules: recursive 14 - name: Setup Hugo 15 uses: peaceiris/actions-hugo@v2 16 with: 17 hugo-version: \u0026#39;0.123.0\u0026#39; 18 extended: true 19 - name: Build 20 run: hugo --minify 21 - name: Deploy 22 uses: peaceiris/actions-gh-pages@v3 23 with: 24 github_token: ${{ secrets.GITHUB_TOKEN }} 25 publish_dir: ./public 注意 submodules: recursive 一定要加，不然主题代码拉不下来，构建会直接报错。这个坑我踩过两次。\n域名和 HTTPS GitHub Pages 默认给你一个 用户名.github.io 的域名，如果想用自己的域名，可以在仓库设置里配置 Custom domain，然后在 DNS 服务商那里加一个 CNAME 记录指向 GitHub Pages。\n我的域名是在 Cloudflare 管理的，CNAME 配置好之后，GitHub 会自动申请 Let\u0026rsquo;s Encrypt 证书，全程不用操心。\n总结一下 Hugo 整个搭建流程其实不复杂：\n下载 Hugo Extended 版 用 hugo new site 创建站点 选一个主题，我用的是 PaperMod 用 hugo new content 写文章 本地用 hugo server -D 预览 推到 GitHub，用 Actions 自动部署 比起之前用 WordPress 的时候，我现在几乎不用管服务器维护这件事。文章写完了 push 一下，几秒钟后就自动上线了。对于一个只想安安静静写点东西的人来说，Hugo 真的挺合适的。\n如果你也打算搭一个个人博客，我的建议是不用太纠结主题，先跑起来再说。很多时候我们卡在\u0026quot;选主题\u0026quot;这一步就放弃了，其实内容才是最重要的。\n","permalink":"http://www.moxuan.xin/posts/from-zero-build-hugo-blog/","summary":"\u003cp\u003e说实话，我之前也折腾过不少博客方案。最早是 WordPress，功能确实强，插件也多，但架不住后台太臃肿了，我那台 1 核 1G 的小 VPS 跑起来经常内存报警。后来换过 Hexo，静态生成倒是轻快了，但 node_modules 懂得都懂，换个电脑环境要配半天，而且构建速度在大文章多之后也开始变慢。\u003c/p\u003e","title":"从零开始用 Hugo 搭一个干净的技术博客"},{"content":"折腾起因 在海鲜市场花 50 块钱收了个二手的千兆路由器，本来想刷个 OpenWrt 拿来做宿舍的主路由，跑点流量分流的插件。结果卖家没说清楚，这机子已经被乱刷固件搞成了“半砖”，通电后只有电源灯亮，插网线连不上后台，获取不到 IP。 本着汽修人不服输的动手精神，翻出吃灰的 CH340 USB 转 TTL 模块，直接开壳硬核救砖。\n拆机与接线 这路由器的卡扣是真的紧，废了两根撬棒才弄开。在主板上找到 GND, TX, RX 三个焊盘。 因为主板没排针，直接拿电烙铁飞线连到 TTL 模块上。注意：TX 接 RX，RX 接 TX，VCC 千万别接，不然容易烧主板。\n终端跑码与救砖过程\n电脑装好 CH340 驱动，打开 PuTTY，波特率设置 115200，连上 COM 口。 路由器通电，屏幕上终于看到熟悉的 U-Boot 跑码信息了！疯狂按键盘打断启动。 进入 U-Boot 命令行后，架设本地 TFTP 服务器，把原厂的 bootloader 和固件传进去： 1tftpboot 0x80060000 breed.bin 2erase 0x9f000000 +0x20000 3cp.b 0x80060000 0x9f000000 0x20000 成功刷入 Breed（不死控制台）。有了这玩意，以后怎么刷都不会变砖了。 编译与刷入 OpenWrt 重启按住 Reset 进 Breed，直接上传提前编译好的 OpenWrt 固件。重启后，熟悉的 LuCI 界面终于出来了。拯救电子垃圾的成就感，确实比拧螺丝爽多了。\n","permalink":"http://www.moxuan.xin/posts/flash-openwrt-router-unbrick/","summary":"\u003cp\u003e\u003cstrong\u003e折腾起因\u003c/strong\u003e\n在海鲜市场花 50 块钱收了个二手的千兆路由器，本来想刷个 OpenWrt 拿来做宿舍的主路由，跑点流量分流的插件。结果卖家没说清楚，这机子已经被乱刷固件搞成了“半砖”，通电后只有电源灯亮，插网线连不上后台，获取不到 IP。\n本着汽修人不服输的动手精神，翻出吃灰的 CH340 USB 转 TTL 模块，直接开壳硬核救砖。\u003c/p\u003e","title":"捡垃圾淘来的老路由器变砖？TTL 线救砖并刷入 OpenWrt 记录"},{"content":"👋 你好，我是 Moxuan 一个热爱折腾技术的学生，目前专注于 Linux 运维和 Web 开发。\n这个博客用来记录我在服务器运维、自建服务和开发过程中遇到的问题和解决方案。写博客的初衷很简单：一是给自己留个备忘录，二是希望这些踩坑经验能帮到和我遇到相同问题的人。\n💻 技术栈 运维相关\nLinux 系统管理（Ubuntu / Debian / CentOS） Nginx 反向代理与负载均衡 Docker 容器化部署 MySQL / MariaDB 数据库管理 开发相关\n熟悉 Shell 脚本自动化 了解基本的 Web 开发（HTML/CSS/JavaScript） 使用 Hugo 搭建静态博客 工具和服务\nGit 版本控制 Cloudflare CDN 和 Tunnels 各种自建服务（Vaultwarden、Uptime Kuma 等） 📝 博客相关 使用的工具\n静态生成器：Hugo 主题：PaperMod 部署：GitHub Pages + Cloudflare CDN 评论系统：暂未开启（考虑中） 写作风格\n实战经验为主，少讲理论 能复现的完整步骤 记录踩坑和解决方案 不定期更新 📮 联系方式 Email: qij50172@gmail.com GitHub: @drtfhbdg RSS: 订阅本站 如果你在文章中发现错误或有更好的解决方案，欢迎通过邮件告诉我。也欢迎交换友链！\n🙏 致谢 感谢所有在博客搭建和运维过程中给予帮助的开源项目和技术社区。\n特别感谢：\nHugo 及其社区 PaperMod 主题作者 GitHub Pages Cloudflare 最后更新：2026-08-05 ","permalink":"http://www.moxuan.xin/about/","summary":"一个热爱折腾技术的学生，记录 Linux 运维、Web 开发和服务器踩坑的日常。","title":"关于"},{"content":"一、基础 1️⃣ 初始化仓库 1git init 👉 把当前文件夹变成 Git 仓库（只用一次）\n2️⃣ 查看状态 1git status 👉 看：\n哪些文件被修改了 哪些还没提交 3️⃣ 添加到暂存区 1git add 文件名 或添加全部：\n1git add . 4️⃣ 提交代码 1git commit -m \u0026#34;提交说明\u0026#34; 例：\n1git commit -m \u0026#34;更新博客文章\u0026#34; ⚠️ 提示： 出现\n1nothing to commit, working tree clean 说明没有新改动\n5️⃣ 查看提交记录 1git log 简洁版：\n1git log --oneline 二、和 GitHub 远程仓库相关 6️⃣ 绑定远程仓库 1git remote add origin 仓库地址 查看：\n1git remote -v 7️⃣ 推送到 GitHub 1git push origin main 首次推送（推荐）：\n1git push -u origin main 以后直接：\n1git push 8️⃣ 拉取远程最新代码 1git pull 三、分支相关（以后会用到） 9️⃣ 查看分支 1git branch 🔟 创建分支 1git branch 分支名 创建并切换：\n1git checkout -b 分支名 11️⃣ 切换分支 1git checkout main （新写法）\n1git switch main 四、撤销 / 回退 12️⃣ 撤销未 add 的修改 1git checkout -- 文件名 13️⃣ 撤销已 add（但没 commit） 1git reset HEAD 文件名 14️⃣ 回退到上一次提交（不删代码） 1git reset --soft HEAD~1 15️⃣ 回退并丢弃修改（慎用） 1git reset --hard HEAD~1 五、强制推送 ⚠️ 非常危险，只在你确定要覆盖远程时用\n1git push -f origin main 六、特殊情况 1️⃣ Git 仓库乱了，想重新来 1rd /s /q .git 2git init 3git add . 4git commit -m \u0026#34;重新初始化仓库\u0026#34; 2️⃣ 之前绑错了 GitHub 仓库 直接删 .git，重新绑定一个干净的\n3️⃣ 想把别人项目“脱离原仓库” 保留代码，不保留提交历史\n⚠️ 注意事项（很重要） ❗ 不可恢复\n删除 .git 后，所有提交记录都没了 想恢复只能重新 clone ❗ 一定要在正确目录执行\n1dir 确认当前目录确实是你的项目根目录 再执行 rd /s /q .git\n删除后常见下一步（推荐流程） 重新推送到 GitHub 1git init 2git branch -M main 3git remote add origin https://github.com/用户名/仓库名.git 4git add . 5git commit -m \u0026#34;initial commit\u0026#34; 6git push -u origin main ","permalink":"http://www.moxuan.xin/posts/git%E5%B8%B8%E7%94%A8%E5%91%BD%E4%BB%A4/","summary":"\u003ch2 id=\"一基础\"\u003e一、基础\u003c/h2\u003e\n\u003ch3 id=\"1-初始化仓库\"\u003e1️⃣ 初始化仓库\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003einit\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e👉 把当前文件夹变成 Git 仓库（只用一次）\u003c/p\u003e\n\u003chr\u003e\n\u003ch3 id=\"2-查看状态\"\u003e2️⃣ 查看状态\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003estatus\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e👉 看：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e哪些文件被修改了\u003c/li\u003e\n\u003cli\u003e哪些还没提交\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch3 id=\"3-添加到暂存区\"\u003e3️⃣ 添加到暂存区\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003eadd\u003c/span\u003e \u003cspan class=\"nt\"\u003e文件名\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e或添加全部：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003eadd\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch3 id=\"4-提交代码\"\u003e4️⃣ 提交代码\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003ecommit\u003c/span\u003e \u003cspan class=\"nt\"\u003e-m\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;提交说明\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e例：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003egit\u003c/span\u003e \u003cspan class=\"nt\"\u003ecommit\u003c/span\u003e \u003cspan class=\"nt\"\u003e-m\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;更新博客文章\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e⚠️ 提示：\n出现\u003c/p\u003e","title":"Git常用命令"},{"content":"1. 基础项目操作 创建新 Hugo 站点 1 # 创建名为 my-blog 的新站点（会生成一个同名文件夹 2 3 hugo new site my-blog 用途：初始化一个全新的 Hugo 项目，生成基础目录结构（content、themes、config 等）。\n注意：执行后需要进入项目目录（cd my-blog）再进行后续操作。\n安装主题（以 Anake 为例） 1# 进入站点目录后，克隆主题到 themes 文件夹 2git clone https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke 3 4（可选）将主题配置添加到站点配置文件 5 6 echo \u0026#34;theme = \u0026#39;ananke\u0026#39;\u0026#34; \u0026gt;\u0026gt; config.toml 用途：为站点添加主题，是 Hugo 站点可视化的基础。\n提示：也可以手动下载主题压缩包解压到 themes 目录。\n2.文章 / 内容操作（核心） 创建新文章（最常用） 1# 创建一篇名为 \u0026#34;xxxxxx\u0026#34; 的文章，默认格式为 markdown 2 3hugo new content content/posts/xxxxx.md 4 5# 自定义文章标题（推荐） 6 7hugo new posts/2024-01-07-my-first-post.md 8 9# 创建非 posts 目录的内容（比如 pages 页面） 10 11hugo new about.md 核心说明：\n生成的文件位于 content/ 目录下（如 content/posts/my-first-post.md）。\n新文章默认包含 Front Matter（标题、日期、草稿状态等），且默认标记为 draft: true（草稿状态）。\n文件名建议用「日期 - 标题」格式，便于管理和 SEO。\n发布草稿文章 1# 方式1：直接编辑文章的 Front Matter，将 draft: true 改为 draft: false 2 3# 方式2：运行 Hugo 时强制渲染草稿 4 5hugo server -D # -D 是 --buildDrafts 的缩写，渲染草稿内容 3.本地预览 / 开发 启动本地开发服务器\n1# 基础启动（不渲染草稿、未来文章） 2 3hugo server 4 5# 常用启动方式（渲染草稿+实时刷新+显示详细日志） 6 7hugo server -D --watch --verbose 1# 1. 追踪所有本地修改的文件 2 3git add . 4 5# 2. 提交修改并写一句说明（替换引号里的内容） 6 7git commit -m \u0026#34;更新Hugo内容/样式\u0026#34; 8 9# 3. 推送到GitHub远程仓库（默认分支是main，按自己的分支名改） 10 11git push origin main hugo new content content/posts/xxxxx.md ","permalink":"http://www.moxuan.xin/posts/hugo%E5%B8%B8%E7%94%A8%E5%91%BD%E4%BB%A4/","summary":"\u003ch4 id=\"1-基础项目操作\"\u003e1. 基础项目操作\u003c/h4\u003e\n\u003ch5 id=\"创建新-hugo-站点\"\u003e创建新 Hugo 站点\u003c/h5\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-css\" data-lang=\"css\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e \u003cspan class=\"nt\"\u003e创建名为\u003c/span\u003e \u003cspan class=\"nt\"\u003emy-blog\u003c/span\u003e \u003cspan class=\"nt\"\u003e的新站点\u003c/span\u003e\u003cspan class=\"err\"\u003e（\u003c/span\u003e\u003cspan class=\"nt\"\u003e会生成一个同名文件夹\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e2\u003c/span\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e3\u003c/span\u003e\u003cspan class=\"cl\"\u003e \u003cspan class=\"nt\"\u003ehugo\u003c/span\u003e \u003cspan class=\"nt\"\u003enew\u003c/span\u003e \u003cspan class=\"nt\"\u003esite\u003c/span\u003e \u003cspan class=\"nt\"\u003emy-blog\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e用途\u003c/strong\u003e：初始化一个全新的 Hugo 项目，生成基础目录结构（content、themes、config 等）。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e注意\u003c/strong\u003e：执行后需要进入项目目录（\u003ccode\u003ecd my-blog\u003c/code\u003e）再进行后续操作。\u003c/p\u003e","title":"hugo常用命令"},{"content":"✅ 第 1步 在 Hugo 项目的 static 目录里创建一个 CNAME 文件 内容只有一行：blog.moxuan.de 然后重新 hugo 构建并 git push\n一般是你之前运行过：\n1hugo server 的那个目录，例如（示例）：\n1D:\\hugo\\mingyue\\ 你可以看到里面有这些目录之一：\n1archetypes 2content 3layouts 4static ← 我们要用的 5themes 6config.toml / hugo.yaml ✅ 第 2 步：在 static 目录创建 CNAME 文件 方法一（最稳，推荐） 进入：\n1D:\\hugo\\mingyue\\static\\ 右键 → 新建 → 文本文档\n重命名为：\n1CNAME ⚠️ 一定要 没有 .txt 后缀\n如果你看不到后缀： 文件资源管理器 → 查看 → 勾选「文件扩展名」\n✅ 第 3 步：编辑 CNAME 内容 用记事本打开 CNAME，内容 只写这一行：\n1blog.moxuan.de 不要空行 不要 http / https 不要多余空格 保存。\n三、重新生成 Hugo 静态文件 回到 Hugo 项目根目录，在终端执行：\n1hugo 执行完成后，检查：\n1public/CNAME 👉 这个文件必须存在 👉 内容也应该是 blog.moxuan.de\n如果这里有，说明 Hugo 已经帮你正确拷贝了。\n四、把 CNAME 推送到 GitHub 1️⃣ 查看 git 状态 1git status 你应该能看到类似：\n1new file: static/CNAME 2new file: public/CNAME 2️⃣ 提交并推送 1git add . 2git commit -m \u0026#34;Add CNAME for custom domain\u0026#34; 3git push 五、回到 GitHub Pages 重新绑定 打开：\n1仓库 → Settings → Pages 如果有旧的域名 → 点 Remove\n重新输入：\n1blog.moxuan.de 点击 Save\n等 2～10 分钟，点 Check again\n六、成功的标志（非常重要） 你会看到：\n❌ 红色 DNS check unsuccessful 消失 ✅ 出现 DNS check successful ✅ Enforce HTTPS 可以勾选 ","permalink":"http://www.moxuan.xin/posts/github-page%E9%85%8D%E7%BD%AE%E8%87%AA%E5%AE%9A%E4%B9%89%E5%9F%9F%E5%90%8D/","summary":"\u003ch2 id=\"-第-1步-在-hugo-项目的\"\u003e\u003cstrong\u003e✅ 第 1步 在 Hugo 项目的 \u003ccode\u003estatic\u003c/code\u003e 目录里创建一个 \u003ccode\u003eCNAME\u003c/code\u003e 文件\u003c/strong\u003e\u003c/h2\u003e\n\u003cp\u003e内容只有一行：\u003ccode\u003eblog.moxuan.de\u003c/code\u003e\n然后重新 \u003ccode\u003ehugo\u003c/code\u003e 构建并 \u003ccode\u003egit push\u003c/code\u003e\u003c/p\u003e\n\u003cp\u003e一般是你之前运行过：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"ln\"\u003e1\u003c/span\u003e\u003cspan class=\"cl\"\u003ehugo server\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e的那个目录，例如（示例）：\u003c/p\u003e","title":"Github page配置自定义域名"}]