这个博客已经运行了 11 年。从最早那台便宜到离谱的搬瓦工虚拟主机(内存只有几百 MB)开始,中间搬到过万网,万网后来被阿里收购,虚拟主机的价格也一路涨到一年 500 块这种离谱数字;后来又折腾去了轻量应用服务器,直到今天,又回归到最质朴的 ECS 主机——一个月八块钱,性能反而更好。
遥想当年折腾这些的时候,压根没有 AI 这种东西,所有资料都得自己在网上一点点搜,大部分还是从别人的博客里现学现卖的。如今 Web 技术发展得飞快,PHP + WordPress 这套组合看起来已经显得有点 old school 了,但这个博客还是安安静静地躺在某台服务器上,等着被搜索引擎收录,等着被人访问,然后由 PHP 和这个古老的数据库组合生成网页,呈现在访问者面前。这跟在社交平台上发一条动态是很不一样的体验——所有的搭建都是自己一手弄起来的,光是这件事本身,就还挺浪漫的。
所以昨天还半自言自语地问我对象:你说这服务器到底要不要续费?想了想,还是续了。现在的我对于每个月几块钱的服务器费用早就没什么经济压力了,只是单纯更喜欢这种自己搭建、自己发布内容的方式。而一篇篇博文也如实记录着我曾经的所思所想,这件事本身,就挺让人欣慰的。
这次续费之后,正好借着这个机会,把这套老掉牙的环境从 LNMP 一键包彻底搬到了 Docker Compose 上。整个过程踩了不少坑,记录下来,也算是给未来的自己(或者搜到这篇文章的人)留个参考。
文章目录
起点:新的服务买什么呢?
旧的轻量应用到期了,2cpu+1g+30gb硬盘+2m带宽一年就要300+我觉得还是贵了,偶然发现同样2cpu+1g+10~20gb硬盘的ecs买5年话平均一年只要100+,只是流量每月20g免费,算了一下其实非常适合我这种情况,果断换这种!

LNMP 一键包翻车了
尝试使用ecs自带的装宝塔面板,好嘛说内存最少要1g,报错装不上。那直接装LNMP一件包呢?老服务器上跑的是 lnmp.org 那套一键安装脚本,新服务器打算原样照搬。
安装脚本是:
wget https://soft.lnmp.com/lnmp/lnmp2.2.tar.gz -O lnmp2.2.tar.gz && tar zxf lnmp2.2.tar.gz && cd lnmp2.2 && ./install.sh lnmp
结果脚本卡在下载源上,soft.lnmp.com 解析出来的地址链路最终却指向了 127.0.0.1,一看论坛一个月前就有人问了这个问题但是没有任何回复,更像是这个下载源自己已经停摆了。折腾了一圈无果,推断lnmp.org这个项目已经维护有限了,索性放弃了这条路。
转向 Docker Compose
与其继续跟一个可能已经没人维护的下载源较劲,不如直接上 Docker Compose——反正核心需求就三样:Nginx、PHP-FPM、MySQL。
第一版:先跑通再说
最初的思路很朴素,三个容器:
- nginx:alpine 负责反向代理和静态文件
- wordpress:php7.4-fpm 跑 PHP
- mysql:5.7 存数据
配合一份docker-compose.yml 和一份 nginx 的 .conf,然后使用docker compose up -d来运行整套容器
第二版:加装 phpMyAdmin,方便维护数据库,还要解决SSL证书续签问题
加了一个 phpmyadmin 服务,但没有直接暴露在公网端口上,而是绑定在 127.0.0.1,通过 SSH 隧道(本地转发)访问:
ports:
- "127.0.0.1:8081:80"
平时用 SSH 客户端(这次用的是 WindTerm)开一条本地转发隧道,映射到本地端口,再用浏览器访问 http://127.0.0.1:8081,比直接把管理面板暴露在公网上安全得多。
证书续签现在成了个问题,刚建博客的时候记得ssl免费证书都是一年一签的,现在改成了3个月,好在可以用certbot镜像来实现自动续签。
完整docker-compose.yml分享
version: "3.8"
services:
db:
image: mysql:5.7
container_name: wp_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: SsYQGyJ1
# root 默认只允许从容器内部 localhost 登录,
# 这里放开允许从其他容器(wordpress 服务)远程连接
MYSQL_ROOT_HOST: '%'
TZ: Asia/Shanghai
volumes:
- /root/wp-docker/mysql-data:/var/lib/mysql
networks:
- wp_net
wordpress:
image: wordpress:php7.4-fpm
container_name: wp_app
restart: always
depends_on:
- db
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: *****
WORDPRESS_DB_PASSWORD: *****
WORDPRESS_TABLE_PREFIX: wp_1
TZ: Asia/Shanghai
volumes:
- /home/wwwroot/winotmk.com/htdocs:/var/www/html
networks:
- wp_net
phpmyadmin:
image: phpmyadmin:5.2
container_name: wp_pma
restart: always
depends_on:
- db
environment:
PMA_HOST: db
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
TZ: Asia/Shanghai
# 只监听在服务器本机,不直接对公网开放,
# 想访问就用 SSH 隧道转发到本地,见下方说明
ports:
- "127.0.0.1:8081:80"
networks:
- wp_net
nginx:
image: nginx:alpine
container_name: wp_nginx
restart: always
depends_on:
- wordpress
ports:
- "80:80"
- "443:443"
volumes:
- /home/wwwroot/winotmk.com/htdocs:/var/www/html:ro
- /root/wp-docker/nginx/conf.d:/etc/nginx/conf.d:ro
- /root/wp-docker/certbot/conf:/etc/letsencrypt:ro
- /root/wp-docker/certbot/www:/var/www/certbot:ro
networks:
- wp_net
certbot:
image: certbot/certbot
container_name: wp_certbot
restart: always
volumes:
- /root/wp-docker/certbot/conf:/etc/letsencrypt
- /root/wp-docker/certbot/www:/var/www/certbot
# 每12小时检查一次,证书剩余不足30天才会真正续期,不会频繁请求
entrypoint: >
/bin/sh -c "trap exit TERM;
while :; do certbot renew --webroot -w /var/www/certbot;
sleep 12h & wait $${!}; done;"
networks:
wp_net:
driver: bridge
所以实际上这个compose跑了以下几个容器
- 数据库mysql5.7
- wordpress(含有php7.4)
- phpmyadmin(数据库管理,需要用隧道连接然后127.0.0.1:8081端口)
- nginx服务
- certbot证书续费服务
这完整实现了以前LNMP包的功能(还有phpmysql管理和证书自动申请),遥想博客刚建立的时候docker听都没听过,也没有ai帮我一步到位给到这么好的方案
直接跑这个compose可能会遇到镜像拉取卡住问题,下面会提到配源以及docker cpmpose本身的安装问题
HTTPS 证书:两个方案的取舍
正好另一台服务器上跑着 Nginx Proxy Manager,自动续期 *.winotmk.com 的证书,一开始想着”直接把证书文件同步过来用不就行了”。
后来想了想,跨服务器同步证书文件挺麻烦——每次续期都要手动/定时同步一遍,多了一层依赖。干脆让这台服务器自己申请、自己续期,加一个 certbot 容器就够了,不用依赖任何外部服务器。
顺带弄明白了一件事:nginx 本身根本不负责证书申请这件事,它就是个按配置文件处理请求的软件,压根不知道什么是 ACME 协议。NPM 之所以看起来”自带续期功能”,其实是它内部集成了 certbot,加了一层管理界面把这两者粘合在了一起——原理上跟这次自己搭的 nginx + certbot 组合完全一样,只是少了个图形界面。
申请证书要分两步走
申请 HTTPS 证书本身,需要先有一个能正常工作的 HTTP 网站。因为 Let’s Encrypt 用 HTTP 验证域名归属,它会真的发一个请求去访问 http://你的域名/.well-known/acme-challenge/...,如果这时候连 HTTP 网站都没跑起来,验证根本无从谈起。
所以整个流程被拆成了两个阶段:
流程分两步:先用纯 HTTP 配置跑起来申请证书 → 拿到证书后切换成 HTTPS 配置。之后续期全自动
所以这里启动nginx有两个配置:
第一个配置
winotmk-step1-http.conf
# 第一阶段:申请证书用的纯 HTTP 配置
# 网站照常能通过 http 访问,同时给 certbot 验证用的路径开个口子
server {
listen 80;
server_name winotmk.com www.winotmk.com;
root /var/www/html;
index index.php index.html;
client_max_body_size 64m;
# Let's Encrypt 验证用,必须能被外网访问到
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}
location ~ /\.ht {
deny all;
}
}
第二个配置
winotmk-step2-http.conf
# 第二阶段:拿到证书后启用这份配置(HTTPS)
# 使用方法见下方部署说明:拿到证书后,删掉 step1 文件,
# 把这个文件改名去掉 .disabled 后缀,然后 reload nginx
server {
listen 80;
server_name winotmk.com www.winotmk.com;
# 续期验证依然要用这个路径,保留
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://winotmk.com$request_uri;
}
}
server {
listen 443 ssl;
server_name www.winotmk.com;
ssl_certificate /etc/letsencrypt/live/winotmk.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/winotmk.com/privkey.pem;
return 301 https://winotmk.com$request_uri;
}
server {
listen 443 ssl http2;
server_name winotmk.com;
ssl_certificate /etc/letsencrypt/live/winotmk.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/winotmk.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /var/www/html;
index index.php index.html;
client_max_body_size 64m;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
include fastcgi_params;
}
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
access_log off;
}
location ~ /\.ht {
deny all;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
换了配置后nginx可以像这样重新载入
cd /root/wp-docker
docker compose exec nginx nginx -s reload
部署步骤
前提: 确认 winotmk.com 和 www.winotmk.com 的 DNS A 记录直接指向这台服务器的公网 IP(不是走 NPM 那台),因为 Let’s Encrypt 要能直接访问到这台服务器的 80 端口做验证。
1. 放置文件
mkdir -p /root/wp-docker/nginx/conf.d /root/wp-docker/certbot/conf /root/wp-docker/certbot/www
把 docker-compose.yml 放到 /root/wp-docker/,winotmk-step1-http.conf 放到 /root/wp-docker/nginx/conf.d/(先只用这个文件,step2 那个先不用放进去,或者放进去但保持 .disabled 后缀,nginx 只加载 .conf 结尾的文件,不会理会它)。
2. 先用纯 HTTP 跑起来
cd /root/wp-docker
docker compose up -d
访问一下 https://winotmk.com,确认网站能正常打开(这一步能通,说明 80 端口和 DNS 都没问题,等下证书申请才能成功)。
3. 申请证书
cd /root/wp-docker
docker compose run --rm --entrypoint certbot certbot certonly \
--webroot -w /var/www/certbot \
-d winotmk.com -d www.winotmk.com \
--email 你的邮箱@qq.com \
--agree-tos --no-eff-email
跑成功会提示证书保存在 /etc/letsencrypt/live/winotmk.com/(对应宿主机 /root/wp-docker/certbot/conf/live/winotmk.com/)。
4. 切换到 HTTPS 配置
cd /root/wp-docker/nginx/conf.d
rm winotmk-step1-http.conf
mv winotmk-step2-ssl.conf.disabled winotmk-step2-ssl.conf
cd /root/wp-docker
docker compose exec nginx nginx -s reload
访问 https://winotmk.com,应该能看到锁头图标了。
5. 自动续期已经在跑了
certbot 那个容器会每 12 小时自动检查一次,证书快过期(剩 30 天内)才会真正续期,续期完文件会自动更新到共享的 /root/wp-docker/certbot/conf/ 目录。
唯一还差一步——nginx 不会自动感知证书文件变了,得 reload 一下才能加载新证书。加个每天跑一次的 cron:
6.证书续期后,nginx 不会自己感知
certbot 容器会按自己的节奏自动检查、自动续期,但 nginx 并不会主动感知证书文件变了,得手动或者定时 reload 一下才能吃到新证书。加了条 cron,每天固定 reload 一次,先打开配置文件:
crontab -e
我习惯用vim,在文件里加一行:
0 4 * * * cd /root/wp-docker && docker compose exec nginx nginx -s reload
:wq保存退出这样整套流程就完全自动化了,不用再依赖另一台服务器。
保存后可以验证一下有没有写进去:
crontab -l
中间的一堆基础设施小坑
搭建过程里还遇到几个跟 Docker 环境本身有关的问题,挨个记一下:
docker-compose报错版本不支持:原来服务器上装的是老版本的独立docker-compose(一代工具,命令带横杠),而不是新版docker compose插件(命令带空格)。两者是完全不同的两套东西,新版才认识 compose 文件里较新的语法。apt install docker-compose-plugin提示找不到包:说明 Docker 官方 APT 源压根没配置成功,得手动把 GPG key 和官方源都配好,再重新装一遍完整的 Docker 全家桶。- 镜像一直拉不下来,
context deadline exceeded:国内服务器访问 Docker Hub 经常连不上,配一个国内镜像加速源(或者用阿里云自己的容器镜像加速器)立刻解决。
安装docker compose相关:
# 装依赖
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
# 加官方 GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# 加官方源
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 更新并安装完整一套(包括 compose 插件)
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
这条命令会把 Docker 引擎和 compose 插件都装/更新到位
配置 Docker 镜像加速:
编辑(或新建)daemon 配置文件:
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json << 'EOF'
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://dockerproxy.com",
"https://docker.1panel.live"
]
}
EOF
重启 Docker 让配置生效:
sudo systemctl daemon-reload
sudo systemctl restart docker
验证配置是否生效:
docker info | grep -A 5 "Registry Mirrors"
能看到你刚才写的那几个地址就说明生效了。
再重新拉取compose就可以了
cd /root/wp-docker
docker compose up -d
一些docker compose的tips:
-d 参数的作用
docker compose up(不带-d)→ 前台运行,容器日志直接刷在你终端里,命令会一直”卡”在那不返回,这时候Ctrl+C确实会把整个 compose 项目的容器全部停掉。docker compose up -d(带-d)→ 后台运行,容器启动完命令立刻返回给你,把终端控制权还给你,容器在后台持续跑着,跟这个终端窗口/SSH 会话已经没关系了。这时候Ctrl+C什么都不会影响(因为命令早就执行完退出了,没东西可以打断)。
验证一下
跑完 docker compose up -d 后,你可以随便关掉这个 SSH 连接、断网、关电脑,容器都会继续在服务器上跑着。下次登录服务器随时可以检查状态:
cd /root/wp-docker
docker compose ps
能看到几个容器都是 Up 状态就说明没问题。
compose常用管理命令
docker compose logs -f # 看实时日志(这个是前台的,Ctrl+C 退出只是退出看日志,不影响容器)
docker compose logs -f nginx # 只看某个服务的日志
docker compose stop # 停止所有容器(但不删除)
docker compose down # 停止并删除容器(数据卷不受影响,因为挂载在宿主机目录)
docker compose restart nginx # 重启单个服务
真正会让 compose 项目停掉的,只有明确执行 docker compose stop 或 docker compose down,或者服务器本身重启/关机(不过如果设置了 restart: always,服务器重启后 Docker 服务恢复时会自动把这些容器又拉起来)。
最后一个坑:无限重定向
一切看起来都跑通了,切换到 HTTPS 配置后,访问网站却收到 ERR_TOO_MANY_REDIRECTS。
原因是经典的 nginx + PHP 组合坑:nginx 自己知道当前是 HTTPS 连接,但没有把这个信息透传给 PHP-FPM。WordPress 通过 $_SERVER['HTTPS'] 判断当前协议,nginx 不传这个参数的话,WordPress 会误以为自己还在 HTTP 下,于是主动跳转到 HTTPS;跳转后请求再次经过 nginx 转发给 PHP,PHP 又一次误判成 HTTP,又跳一次——无限循环。
另外也是因为因为wordpress的数据库里 wp_options 表的 siteurl 和 home 两个字段写的是www.winotmk.com
解决办法是在 PHP 处理块里显式声明这是 HTTPS 请求:
location ~ \.php$ {
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
include fastcgi_params;
}
另外也是因为因为wordpress的数据库里 wp_options 表的 siteurl 和 home 两个字段写的是www.winotmk.com,这会导致nginx把请求给到wordpress,然后wordpress又踢回到nginx来回跳了。
所以顺手也检查了一下数据库里 wp_options 表的 siteurl 和 home 两个字段,确保跟实际访问协议、域名保持一致。
检查解析的话可以用:
curl -I https://winotmk.com
curl -I https://winotmk.com
第二条应该会返回 301 跳转到 https,这是我们配置里设置的”http 自动跳 https”生效了。
收尾
折腾完这一圈,现在的架构大概是这样:
nginx:反向代理 + HTTPS 终止 + 静态资源缓存wordpress(PHP-FPM):跑博客程序本体db(MySQL):独立的wordpress库,专库专用phpmyadmin:只在 SSH 隧道后面才能访问certbot:自动申请、自动续期证书,nginx 每天定时 reload 感知新证书
整套东西加起来,每个月的服务器成本几块钱,比多年前那台”几百 MB 内存”的搬瓦工主机性能好了不知道多少倍,也比后来那个一年 500 的虚拟主机便宜太多。这个博客大概还会这样安安静静地跑下去,继续记录接下来的一些事情。
发表回复