news 2026/9/10 3:23:52

树莓派Docker化部署acme.sh实现HTTPS证书自动续签

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
树莓派Docker化部署acme.sh实现HTTPS证书自动续签

树莓派 Docker 化网页服务器这个系列写到第五篇了。前面几篇咱们把 Nginx、PHP、Node 这类容器都跑起来了,网站确实能用 HTTP 正常访问了。可只要一部署到真实环境,浏览器那个“不安全”的红色小锁就会一直翘在那,访客心里犯嘀咕,你自己看着也别扭。更别提现在微信小程序、部分 App 接口、很多第三方回调,没有 HTTPS 根本不给调。所以这一篇专门把 HTTPS 这个事补上,而且是彻底补上——在树莓派上用 Docker 容器跑 acme.sh,让证书自动申请、自动续签,以后完全不用手动去碰。

1. 为什么偏偏是 acme.sh,还要把它容器化

先说证书从哪来。常见的免费方案有三类:云厂商(阿里云、腾讯云、华为云)的免费证书,一般 3 个月或 1 年有效期,需要手动申请、手动部署;Let’s Encrypt,90 天有效期,全自动续签;自签名证书,浏览器会直接报警告,自己测试玩玩可以,对外提供服务基本行不通。我直接排除了云厂商的方案,原因很简单:你买了几台云服务器可以每天登录控制台点几下,树莓派这种东西是放在家里或者机房角落的,没有人天天盯着它,一旦证书过期没人发现,网站就突然崩了。所以必须选一个能自动续签的方案。

Let’s Encrypt 为什么设计成 90 天有效期,而不是一年?这个其实是有意为之的。短周期的证书能让私钥泄露的风险窗口变小,同时促使整个生态把自动化做起来。对用户来说,这个设计也变相倒逼你用脚本去管理证书,而不是到期手动换一遍。我见过太多人申请一年期的免费证书,结果第 11 个月发现忘了续,临时抓瞎。自动续签这套流程,无论如何都得配起来。

接下来就是选客户端工具。目前最主流的是 certbot 和 acme.sh。certbot 功能全面,官方推荐,但相对重,而且不同 Linux 发行版、不同版本的适配有时候会闹脾气。acme.sh 是一个纯 shell 实现的 ACME 客户端,最大的优势是轻、依赖少,逻辑非常直观。它对 DNS API 的支持尤其全,腾讯云、阿里云、Cloudflare 这些主流 DNS 服务商的 API 全部内置,配置好密钥就能一键签发。对于树莓派这种性能不强的设备,acme.sh 这种体积小、占用低、一个脚本就能跑的工具,显然比整套 certbot 环境更合适。

那为什么还要把它塞进 Docker?有人可能会说,直接在树莓派的系统里装个 acme.sh 不是更省事吗?确实省事,但有一个核心问题:你在这台树莓派上手动装了环境之后,如果将来换机器、重装系统、或者推给另一台机器,所有配置都要重来一遍。而 Docker Compose 的用法是一份 YAML 配置管全部,拉到哪台机器上都能以一模一样的方式跑起来。另外,acme.sh 容器本身很干净,cron 服务、证书存储、重启策略全都封装好了,不需要在宿主机里加乱七八糟的计划任务。这个系列的既定路线就是全部容器化,所有服务都走 Docker,那证书管理也一样,保持架构上的一致性,后续维护成本才会低。

我当时的方案选型其实还有一层考量。acme.sh 容器使用的是 neilpang/acme.sh 这个镜像,它是 acme.sh 作者自己维护的官方容器版,镜像很小,定时任务已经内置,你只需要把证书目录挂载出来、设置邮箱和域名,它自己就会每天跑一次检查。这个方案在 x86 和 ARM 架构上都能跑,树莓派自然没问题,省了我自己去折腾 cron 的时间。

2. 先把自动续签的机制搞明白,后面才不会踩坑

在动手部署之前,我得先讲清楚 acme.sh 到底是怎么做到“自动续签”的。这个机制如果不理解,后面配置出问题或者调试异常主机时就会一头雾水。

ACME 协议的全称是 Automatic Certificate Management Environment,自动证书管理环境。流程大致是:客户端生成一对密钥(私钥和公钥),然后向 Let’s Encrypt 的服务器发起申请,同时通过某种方式证明“我确实拥有这个域名”,验证通过后 CA 颁发证书,客户端把证书保存到本地。整个交互基于 HTTPS 的 API 调用,acme.sh 就是用 curl 去完成这些请求。

续签的逻辑其实并不是“每隔 90 天自动换一次”,而是 acme.sh 在容器里装了一个 cron 任务,每天定时跑一次。每次运行时,它检查所有已签发的证书还剩多少天有效期。如果少于 60 天(默认阈值),才会真正执行续签操作;如果时间还充足,就什么都不做,直接跳过。

为什么要每天跑一遍,而不是算好时间在第 70 天跑一次?因为“计划赶不上变化”这件事在运维里太常见了。假如你在第 70 天设了一个定时任务,那天网络波动、服务器宕机或者 DNS 解析异常,这次任务就漏掉了,而下次执行要再等 70 天,证书肯定就过期了。每天跑一次的话,错过一天,第二天还会再试,容错率高很多。acme.sh 容器里默认的 cron 是每天 0 点跑一次,当然也可以自定义,但默认值已经足够可靠。

如何验证域名所有权是这个流程里最关键的环节,直接影响你要选哪种部署方式。ACME 协议里有两种主流验证方式:

HTTP-01 验证:CA 服务器会尝试访问http://你的域名/.well-known/acme-challenge/xxx这个特定路径下的文件。如果你的服务器能在 80 端口响应这个请求,CA 就确认你对域名有控制权。这个方式配置简单,适合绝大多数情况,但前提是你的 80 端口能被外网访问到。

DNS-01 验证:CA 服务器要求你在域名的 DNS 解析记录里添加一条 TXT 记录,记录里包含一串随机值。你需要在 DNS 服务商的 API 里自动添加这条记录。这个方式的优势在于不需要 80 端口开放,适合没有公网 IP、服务器藏在 NAT 后面、或者域名只用于 CDN 的情况。代价是必须支持 DNS 服务商的 API,acme.sh 集成了大量主流服务商接口。

我在树莓派上的实际情况是这样的:80 端口已经被容器的 Nginx占用了,但这个并不是什么大问题,因为 acme.sh 的 HTTP-01 验证并不需要独占 80 端口,它可以用 webroot 模式。就是说,你只需要让 Nginx 把/.well-known/acme-challenge/这个路径映射到某个真实目录,acme.sh 把验证文件写到这个目录,CA 就可以通过 HTTP 访问到并完成验证。

但是这里有个非常经典的坑:很多人的 Nginx 容器和 acme.sh 容器是分开跑的,目录挂载如果不一致,验证文件就写不进去,或者写进去了但 Nginx 访问不到。解决办法是让这两个容器共享同一个数据卷,或者直接用一个宿主机的公共目录,同时挂载进两个容器。这个细节后面部署的时候会详细说。

理解这个机制之后,你就能明白为什么 acme.sh 需要在容器里常驻运行了。它不是一次性执行完就退出的工具,它需要在后台保持 cron 服务运行,每天检查证书有效期。这就是为什么要用容器的方式跑它,而不是构建完镜像就结束——你需要一个守护进程,而不是单次脚本。

3. 树莓派上的完整部署流程,一步一步来

下面就是实操部分了。我家的树莓派 4B 现在跑着 Nginx 容器和几个应用容器,所有服务的配置文件都规划在/opt/docker这个目录下,每类服务有自己的子目录。这个习惯我从一开始就坚持,因为树莓派换过两次存储卡,恢复环境全靠这些文件。

先看一下整体目录规划:

/opt/docker ├── nginx/ │ ├── html/ # 存放静态页面或者验证文件的公共目录 │ └── conf.d/ # Nginx 站点配置 │ └── certs/ # 证书文件存放目录(acme.sh 会自动写到这里) └── acme.sh/ └── data/ # acme.sh 的数据目录,存储证书和账号信息

这个结构的关键设计是:/opt/docker/nginx/certs这个证书目录,会同时挂载进 acme.sh 容器和 Nginx 容器。acme.sh 把新证书写进去,Nginx 容器立刻就能读到。不需要额外执行拷贝操作,也避免了容器间通过宿主机中转文件的麻烦。

接下来创建 acme.sh 的docker-compose.yml。我放在/opt/docker/acme.sh/下面:

version: "3" services: acme.sh: image: neilpang/acme.sh:latest container_name: acme.sh restart: always environment: - TZ=Asia/Shanghai - LE_CONFIG_HOME=/acme.sh volumes: - /opt/docker/acme.sh/data:/acme.sh - /opt/docker/nginx/html:/webroot - /opt/docker/nginx/certs:/certs dns: - 223.5.5.5 - 119.29.29.29

逐个解释这些配置的含义,这不是为了凑字数,是因为每个参数都可能决定你排错方向:

  • restart: always:确保容器意外退出后会自动拉起,acme.sh 的 cron 服务不会因为容器挂掉而罢工。
  • TZ=Asia/Shanghai:容器默认时区是 UTC,如果你不设置时区,定时任务每天 0 点跑,实际上会变成 UTC 0 点,也就是北京时间早上 8 点,问题不大,但日志时间会很别扭,还是统一到北京时间比较舒服。
  • LE_CONFIG_HOME=/acme.sh:指定 acme.sh 配置目录,与宿主机/opt/docker/acme.sh/data绑定,确保容器重建后证书和账号信息不丢。
  • /opt/docker/nginx/html:/webroot:webroot 目录。acme.sh 做 HTTP 验证时会把验证文件写到容器的这个路径,由于这个目录同时也被 Nginx 挂载了,所以 Nginx 能读取到。这就解决了前面说的目录不一致问题。
  • /opt/docker/nginx/certs:/certs:证书输出目录。
  • dns:这里我设置了国内公共 DNS 服务器,因为 Let’s Encrypt 的 API 解析在国内网络环境下偶尔会抽风,换一个稳定点的 DNS 能减少一些随机性的超时问题。

镜像拉下来之后,第一次启动:

cd /opt/docker/acme.sh docker compose up -d

启动之后,先验证 acme.sh 的 cron 任务容器是不是正常起跑:

docker logs acme.sh

如果看到类似cron: started的日志,说明容器里的计划任务已经在跑了。

但是这里必须强调一个容易忽略的点:容器正常起来不代表你万事大吉了。你还需要手动做一次首次签发,因为 acme.sh 默认不会自动给一个从没签发过的域名去申请证书。你必须先给 acme.sh 一个明确的指令,告诉它你要签哪个域名、用什么验证方式。后续的自动续签工作,才是它在后台自己完成的。

首次签发命令如下:

docker exec acme.sh --issue -d example.com -d www.example.com -w /webroot --server letsencrypt

参数拆解:

  • --issue:执行签发动作。
  • -d example.com -d www.example.com:你要申请的域名。这里可以填多个-d参数,acme.sh 会把这些域名合并进一张证书里。一般我会把主域名和 www 域名都加进去,这样用户访问 www 前缀时也不会有证书报警。
  • -w /webroot:指定验证目录,对应容器里的/webroot
  • --server letsencrypt:暴露一下 CA 服务器,虽然当前默认就是 Let’s Encrypt,但显式声明能让后面切换 staging 或 ZeroSSL 的时候更清楚。

第一次执行会完成整个 ACME 流程:注册账号、创建私钥、生成验证文件、确认所有权、领取证书。如果看到输出结尾附近出现了Cert success或者类似的关键词,就说明成功了。证书文件会存放在配置目录下,你可以看一眼确认:

ls -la /opt/docker/acme.sh/data/example.com/

这个目录下会出现一个以域名命名的子目录,里面有几个文件:example.com.cer(证书链)、example.com.key(私钥)、ca.cer(CA 中间证书)、fullchain.cer(完整证书链)。这些文件就是你后面要挂进 Nginx 的东西。如果是多域名单证书,还会多一个fullchain.cer,这个才是 Nginx 里ssl_certificate要用的。

这里有个小细节要提前说:如果第一次签发报了invalid response from http://...这种错,大概率不是 acme.sh 的问题,而是验证链路没走通。先不要急着调参数,按第五章的排查思路检查路径映射和外网可达性。

4. 验证文件与证书如何被 Nginx 正确使用

证书签下来了,接下来就是把它接入 Nginx。这一部分每一步都有讲究,少一个环节证书就能生效,但你访问网站时就会遇到奇怪的互相不匹配的问题。

先配置 Nginx 容器的挂载。我的nginx/docker-compose.yml长这样:

version: "3" services: nginx: image: nginx:1.26-alpine container_name: nginx restart: always ports: - "80:80" - "443:443" volumes: - /opt/docker/nginx/html:/usr/share/nginx/html - /opt/docker/nginx/conf.d:/etc/nginx/conf.d - /opt/docker/nginx/certs:/etc/nginx/certs

注意,/opt/docker/nginx/certs这个目录同时挂载进 acme.sh 容器(作为/certs)和 Nginx 容器(作为/etc/nginx/certs)。这样做的效果是:acme.sh 续签后写入的新证书文件,Nginx 容器立刻就能看到,不需要任何拷贝动作。

接下来是 Nginx 的站点配置。我建议你新建一个独立的配置文件,比如/opt/docker/nginx/conf.d/example.com.conf,不要直接改 nginx.conf,这样每个站点独立配置,互不干扰:

server { listen 80; server_name example.com www.example.com; location /.well-known/acme-challenge/ { root /usr/share/nginx/html; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl; server_name example.com www.example.com; ssl_certificate /etc/nginx/certs/example.com/fullchain.cer; ssl_certificate_key /etc/nginx/certs/example.com/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; root /usr/share/nginx/html; }

两个 server 块分别处理 80 和 443 端口,这样只会让普通 HTTP 请求访问验证路径,其余请求统一 301 跳到 HTTPS。验证路径映射到 webroot 目录,acme.sh 才能不断续签,而ssl_certificatessl_certificate_key则直接指向共享证书目录里的文件。

修改完配置后,验证一下 Nginx 配置语法是否正确,然后重载:

docker exec nginx nginx -t docker exec nginx nginx -s reload

这时再访问你的域名,浏览器地址栏应该会出现小锁,不再有任何报错。证书自动续签到这里为止看似已经闭环:每天 acme.sh 检测、快到期就续签、文件被新证书覆盖。但是,这里有一个非常隐蔽的坑——续签成功了,Nginx 服务可能还在用旧证书。

这正是证书自动化的一个经典问题。acme.sh 续签成功后只是更新了文件,Nginx 不会自己热加载新的证书。你需要让 Nginx 重新加载一次配置文件,才能把新证书加载进内存。如果这一步没有自动化,那就等于 90 天后证书文件是新的,但 Nginx 还在用旧的,直到旧证书过期,你的网站照样报错。

解决方式有两种。第一种是使用 acme.sh 的--install-cert参数,在续签成功后执行 reload 命令:

docker exec acme.sh --install-cert -d example.com -d www.example.com \ --key-file /certs/example.com/example.com.key \ --fullchain-file /certs/example.com/fullchain.cer \ --reloadcmd "docker exec nginx nginx -s reload"

第二种是更省心的方式:给 Nginx 容器配一个周期性的 reload 计划任务,或者用 Watchtower 这类工具统一监听文件变化。我个人的习惯是第一周用--install-cert手动执行一遍,确认整个链路可靠,然后让 acme.sh 的 cron 每次续签后都自动触发 reloadcmd。这个--reloadcmd会被 acme.sh 记录到配置里,下次自动续签时它会自动带上执行,不需要每次都手动传。

只要目录挂载设计合理,这一套下来,从证书申请到续签后 Nginx 加载新证书,全链路不需要人工干预。

5. 排错指南:我在实际部署中踩过的坑

签发证书和自动续签,方案看着不复杂,但真正操作起来会遇到一些很磨人的细节问题。我把实际部署中踩过的几个坑列出来,按出现频率从高到低排。

5.1 验证文件 404,HTTP-01 验证始终失败

这是最常见的错误,现象是申请证书时 CA 提示无法访问http://你的域名/.well-known/acme-challenge/xxx。排查顺序这样走:

先确认你的 80 端口是否真的能从外网访问。树莓派在家用网络环境,运营商可能屏蔽了 80 端口,或者路由器没有做端口映射,外部 CA 访问不到你的树莓派。这时候就算 acme.sh 把验证文件写进去了,Let’s Encrypt 服务器也拉取不到。最简单的方式就是你手机切到 4G/5G 网络,直接用浏览器访问http://你的域名/.well-known/acme-challenge/test.txt,能打开就说明链路通。

再检查 Nginx 的路径映射。很多人的问题出在这个细节:acme.sh 容器里的 webroot 路径和 Nginx 容器里的 root 路径必须指向同一个真实目录。我见过有人 acme.sh 挂载的是/opt/docker/nginx/html,但 Nginx 配置里写的 root 是/usr/share/nginx/html,结果验证文件一边能写、一边读不到。解决办法就是确认两个容器挂载的宿主目录是同一个,且 Nginx 配置里的 root 与容器内挂载点一致。

5.2 端口 80 冲突,standalone 模式报 bind 失败

acme.sh 参数说明文档里会提到 standalone 模式,这种模式下它会自己监听 80 端口来完成验证。但如果你已经用 Nginx 或者其他 Web 服务器占了 80 端口,再运行 standalone 模式就会直接报 bind 失败。

解决办法很简单,不要用 standalone,改用 webroot 模式,配置好-w /webroot参数。如果你用的树莓派后面还有别的服务占着 80 端口,那更好办,用 DNS-01 验证完全不需要动 80 端口,只需要配好 DNS API 密钥,acme.sh 会自动往 DNS 解析记录里加一条 TXT 记录来验证域名所有权。我当时就是因为在树莓派上装了别的调试工具占用了 80 端口,干脆切换到了 DNS-01 模式,虽然多配了一步 DNS API 密钥,但后续再也没有端口冲突的烦恼。

5.3 容器时区错误,续签时间点不符合预期

默认情况下,Docker 容器使用 UTC 时区,而 acme.sh 的 cron 任务默认在每天 0 点执行。这样导致实际触发时间是北京时间的早上 8 点整。很多人对这个问题没有太多感知,但如果你需要观察日志定位问题,看到时间总差 8 个小时会很别扭。

更实际的一个问题是,如果某些脚本对时间有严格要求(比如你设置了--days参数来定义提前几天续签),时区混乱可能导致判断偏差。建议在 docker-compose 中显式设置环境变量TZ=Asia/Shanghai。这一点在树莓派这类嵌入式设备上尤其值得注意,因为它们不像云服务器那样有现成的时区同步工具,经常忘了配。

5.4 证书目录权限不对,Nginx 读不到私钥

这是另一个高发问题。acme.sh 容器运行的用户是 root,它写出的私钥文件权限是 600,属主是 root。如果你把证书目录挂载给了 Nginx 容器,而 Nginx 容器内部的工作进程不是以 root 身份运行,就可能出现权限不足、读不了私钥文件的情况。

典型报错是:

nginx: [emerg] cannot load certificate key "/etc/nginx/certs/example.com/example.com.key": Permission denied

排查方法很简单:

docker exec nginx ls -l /etc/nginx/certs/example.com/

看到私钥文件的属主和权限,如果是 root 600,而 Nginx 进程用户是 nginx,就会出问题。临时解决办法是:

chmod 644 /opt/docker/nginx/certs/example.com/example.com.key

但这不是长久之计,因为 acme.sh 每次续签后可能重新写入文件,权限可能被改回去。比较稳妥的做法是在 docker-compose 中给 acme.sh 容器设置用户 ID 与 Nginx 容器一致,或者在挂载的目录层级上统一权限。我在实际项目中是把 Nginx 容器的 user 改成 root(有些基础镜像限制了用户改不了),或者干脆把证书目录的属主直接 chown 成 nginx 用户。

5.5 Hash 算法导致部分客户端兼容性问题

Let’s Encrypt 早期默认签发的是 SHA-1 签名证书。现在主流 CA 都转向 SHA-256,所以如果你的 acme.sh 版本很老,签出来的证书可能还是旧的 Hash 算法,会有较老系统或浏览器的兼容性问题。

acme.sh 支持指定密钥强度,建议在签发时加上:

--keylength ec-256

这个参数会生成 ECC 证书,使用 SHA-256 签名算法,体积更小、握手更快、兼容性也更好。需要注意用 ECC 证书后,后面 Nginx 配置里的证书路径可能带_ecc后缀,比如/certs/example.com_ecc/fullchain.cer,别搞错了。

6. 让整个方案更省心的几个细节优化

主流程跑通之后,还有几个可以提高幸福感的小优化,值得顺手做掉。

一个是在 acme.sh 的配置里加一个--days参数,默认阈值是 60 天。如果你想早点替换证书、更激进一些,可以设置成--days 30,意味着只要证书剩余时间少于 30 天就会续签。不过默认情况已经够用了,不建议改得太激进,因为 Let’s Encrypt 对证书签发频率是有速率限制的,同一域名每周只能签发五次,设置得太激进容易被限流。

另一个优化是合理利用 staging 环境。Let’s Encrypt 提供了 staging 服务器,可以用来测试整个流程而不受签发频率限制,最重要的是不会真的生成一个可以被公网信任的证书,避免测试失误导致的警告。调试 HTTPS 配置时先切到 staging,确认没问题再切回正式环境,这个习惯我在实操中受益匪浅,特别是大规模迁移证书时。

还有一个小技巧是,在 acme.sh 的数据目录里加一个.curlrc文件,配置好代理或者超时参数。国内网络访问 Let’s Encrypt 服务偶尔会出现超时,设置一个合理的超时时间可以让 acme.sh 快速失败并重试,而不是无限等待。

最后,给各位一个非常重要的建议:把所有配置文件纳入 git 管理。树莓派上有多个服务和容器,配置文件版本化之后,你不但能回溯每次改动,还能快速恢复到上一个可用状态。我用 gitea 自建了一个私有仓库,所有/opt/docker下的配置文件都推上去。实践下来的感受是,虽然前期会多花几分钟敲 git 命令,但出了问题之后省下的排查时间远远超过投入。

到现在为止,树莓派上的 HTTPS 证书自动化已经完整跑通了。从手动申请几个月续一次,变成每天自动检测、到期前自动续签、续签后自动重载 Nginx,这套体系现在运行了将近半年,中间经历了一次完整的自动续签周期,确实没有人工干预过。

我在实际部署中的体会是,容器化的好处在证书管理这个场景体现得格外明显。因为 acme.sh 不像普通的 Web 服务那样经常有人去操作它,它更像是后台的一个守护进程,一旦配错就无人察觉。把它容器化之后,至少能够通过 Docker 的日志、容器状态这些标准手段去观察和诊断,而不需要在一台裸机上寻找那些散落各处的配置文件和计划任务。如果你也是用树莓派搭服务,建议按照这个方案把 HTTPS 一步到位,不然等到 90 天的有效期满时再想起来,那手忙脚乱的滋味可不好受。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 3:19:55

Heartbeat Tasks

Heartbeat Tasks 【免费下载链接】OpenViking Self-evolving Context Database for AI Agents. Unify Agent Memory, Knowledge RAG and Skills. 项目地址: https://gitcode.com/GitHub_Trending/op/OpenViking Active Tasks Completed 三个区块各司其职:- *…

作者头像 李华
网站建设 2026/9/10 3:19:50

2026随身WiFi选购指南:格行/波导/中兴信号调校与稳定性横评

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 3:18:50

lib60870-2.2.0源码深度解析:IEC 60870-104协议栈分层实现与故障调试

简介:本资源是lib60870-2.2.0开源C库的完整源代码分发包,面向电力自动化、工业通信领域的嵌入式开发工程师与协议栈学习者,用于快速构建符合IEC 60870-5-101/104标准的SCADA系统通信模块。包内共120个文件,涵盖41个C实现文件&…

作者头像 李华
网站建设 2026/9/10 3:17:36

从硬件到备份:自组四盘位家庭NAS完整实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华