diff --git a/.env.example b/.env.example index b8fc93a..f3c1fe1 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,26 @@ # MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值) MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD +MYSQL_DATABASE=hifast + +# 主库对外监听地址。远程从库需要访问时可保持 0.0.0.0,并在防火墙只放行从机公网 IP。 +MYSQL_PUBLISH_ADDR=0.0.0.0 + +# MySQL GTID 主从复制账号 +MYSQL_REPLICATION_USER=replicator +MYSQL_REPLICATION_PASSWORD=CHANGE_ME_TO_STRONG_REPLICATION_PASSWORD + +# Redis 密码。多实例/主从部署时,configs/ppanel.yaml 的 Redis.Pass 也需要填写相同值。 +REDIS_PASSWORD=CHANGE_ME_TO_STRONG_REDIS_PASSWORD + +# Redis 对外监听地址。远程业务实例或 Redis 从库需要访问时可保持 0.0.0.0,并在防火墙只放行从机公网 IP。 +REDIS_PUBLISH_ADDR=0.0.0.0 + +# 从机 docker-compose.replica.yml 使用:主机公网 IP 或内网/VPN IP +MASTER_MYSQL_HOST=CHANGE_ME_MASTER_MYSQL_HOST +MASTER_MYSQL_PORT=3306 +MASTER_REDIS_HOST=CHANGE_ME_MASTER_REDIS_HOST +MASTER_REDIS_PORT=6379 # Grafana 管理员密码 GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD diff --git a/.gitignore b/.gitignore index ce9f7ff..7218612 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,12 @@ package-lock.json # ==================== 脚本 ==================== *.sh script/*.sh +!mysql/*.sh +!redis/*.sh + +# ==================== 部署本地密钥 ==================== +mysql/.my.cnf +redis/sentinel.conf # ==================== CI/CD 本地运行配置 ==================== .run/ diff --git a/config/nginx-ppanel-upstream.conf.example b/config/nginx-ppanel-upstream.conf.example new file mode 100644 index 0000000..bd75d65 --- /dev/null +++ b/config/nginx-ppanel-upstream.conf.example @@ -0,0 +1,17 @@ +upstream ppanel_backend { + server MASTER_SERVER_IP:8080 max_fails=3 fail_timeout=10s; + server REPLICA_SERVER_IP:8080 max_fails=3 fail_timeout=10s; +} + +server { + listen 443 ssl http2; + server_name api.example.com; + + location / { + proxy_pass http://ppanel_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/doc/remote-replication-zh.md b/doc/remote-replication-zh.md new file mode 100644 index 0000000..4bb576c --- /dev/null +++ b/doc/remote-replication-zh.md @@ -0,0 +1,717 @@ +# PPanel 双机部署:远程 MySQL 主从、Redis 主从/Sentinel、多实例与监控 + +本文档说明如何部署两台机器: + +- 主机:运行主 MySQL、主 Redis、PPanel Server、Grafana、Prometheus、Loki、Tempo。 +- 从机:运行 MySQL 从库、Redis 从库、Redis Sentinel、额外 PPanel Server。 +- Nginx:只做 HTTP 负载均衡,不做数据库读写分离。 +- MCP Grafana:安装在本地电脑,不部署到服务器,用于后续排查线上 bug。 + +重要原则: + +- 所有 `ppanel-server` 实例都连接主 MySQL 和主 Redis。 +- 从机 MySQL 只做复制/灾备,不给业务直接写入。 +- 当前后端只有单个 `MySQL.Addr` 和单个 `Redis.Host`,不要让业务实例连接本机从库。 +- 故障切换采用人工确认,避免自动切换造成脑裂。 + +## 1. 拓扑与端口 + +示例变量: + +```text +主机公网 IP: MASTER_PUBLIC_IP +从机公网 IP: REPLICA_PUBLIC_IP +业务域名: api.example.com +数据库名: ppanel +``` + +主机端口: + +| 端口 | 用途 | 建议暴露范围 | +| --- | --- | --- | +| `8080` | 主机 PPanel Server | Nginx 所在机器 | +| `3306` | 主 MySQL | 只允许从机 IP | +| `6379` | 主 Redis | 只允许从机 IP | +| `3333` | Grafana | 默认 `127.0.0.1`,通过 SSH 隧道 | +| `9090` | Prometheus | 默认 `127.0.0.1` | +| `4317` | Tempo OTLP | 默认 `127.0.0.1` | + +从机端口: + +| 端口 | 用途 | 建议暴露范围 | +| --- | --- | --- | +| `8080` | 从机 PPanel Server | Nginx 所在机器 | +| `3307` | 本机 MySQL 从库调试端口 | 默认 `127.0.0.1` | +| `6380` | 本机 Redis 从库调试端口 | 默认 `127.0.0.1` | +| `26379` | 本机 Redis Sentinel | 默认 `127.0.0.1` | + +## 2. 文件准备 + +主机上传: + +```text +docker-compose.cloud.yml +.env +configs/ +mysql/ +redis/ +loki/ +grafana/ +prometheus/ +tempo/ +config/ +``` + +从机上传: + +```text +docker-compose.replica.yml +.env +configs/ +mysql/ +redis/ +tempo/ +cache/ +``` + +两台机器都创建运行目录: + +```bash +mkdir -p configs logs cache tempo_data mysql redis +``` + +主机如果使用监控 exporter,还需要准备 MySQL exporter 配置: + +```bash +cp mysql/.my.cnf.example mysql/.my.cnf +``` + +编辑 `mysql/.my.cnf`,把密码改成真实 `MYSQL_ROOT_PASSWORD`。该文件已被 `.gitignore` 忽略,不要提交。 + +## 3. 主机 `.env` + +在主机复制 `.env.example`: + +```bash +cp .env.example .env +``` + +主机 `.env` 示例: + +```dotenv +MYSQL_ROOT_PASSWORD=CHANGE_ME_STRONG_MYSQL_ROOT_PASSWORD +MYSQL_DATABASE=ppanel +MYSQL_PUBLISH_ADDR=0.0.0.0 + +MYSQL_REPLICATION_USER=replicator +MYSQL_REPLICATION_PASSWORD=CHANGE_ME_STRONG_REPLICATION_PASSWORD + +REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD +REDIS_PUBLISH_ADDR=0.0.0.0 + +GRAFANA_PASSWORD=CHANGE_ME_STRONG_GRAFANA_PASSWORD +PPANEL_SERVER_TAG=latest +``` + +如果只想让 MySQL/Redis 监听内网 IP,把 `MYSQL_PUBLISH_ADDR` 和 `REDIS_PUBLISH_ADDR` 改成内网地址。 + +## 4. 主机 `configs/ppanel.yaml` + +主机业务实例连接本机主库和主 Redis: + +```yaml +MySQL: + Addr: 127.0.0.1:3306 + Username: root + Password: CHANGE_ME_STRONG_MYSQL_ROOT_PASSWORD + Dbname: ppanel + Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai + MaxIdleConns: 10 + MaxOpenConns: 100 + SlowThreshold: 1000 + +Redis: + Host: 127.0.0.1:6379 + Pass: CHANGE_ME_STRONG_REDIS_PASSWORD + DB: 0 +``` + +注意:启用当前 compose 后 Redis 有密码,`Redis.Pass` 必须填写。 + +## 5. 主机防火墙 + +主机必须只允许从机 IP 访问 MySQL 和 Redis。 + +UFW 示例: + +```bash +ufw allow from REPLICA_PUBLIC_IP to any port 3306 proto tcp +ufw allow from REPLICA_PUBLIC_IP to any port 6379 proto tcp +ufw deny 3306/tcp +ufw deny 6379/tcp +ufw reload +``` + +firewalld 示例: + +```bash +firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="REPLICA_PUBLIC_IP" port protocol="tcp" port="3306" accept' +firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="REPLICA_PUBLIC_IP" port protocol="tcp" port="6379" accept' +firewall-cmd --reload +``` + +如果使用云厂商安全组,也要同步设置白名单。 + +## 6. 启动主机 + +检查 compose: + +```bash +docker compose -f docker-compose.cloud.yml config +``` + +启动: + +```bash +docker compose -f docker-compose.cloud.yml up -d +``` + +检查服务: + +```bash +docker ps +docker logs --tail=100 ppanel-mysql +docker logs --tail=100 ppanel-redis +docker logs --tail=100 ppanel-server +``` + +检查 MySQL GTID: + +```bash +docker exec ppanel-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SHOW VARIABLES LIKE 'gtid_mode';" +docker exec ppanel-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SHOW MASTER STATUS\\G" +``` + +检查 Redis: + +```bash +docker exec ppanel-redis redis-cli -a "$REDIS_PASSWORD" ping +``` + +如果主库已有数据,`mysql/init-master.sh` 不会自动重跑。手动创建复制账号: + +```bash +docker exec -i ppanel-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" < ppanel-gtid.sql.gz +``` + +把备份复制到从机: + +```bash +scp ppanel-gtid.sql.gz root@REPLICA_PUBLIC_IP:/root/ppanel-gtid.sql.gz +``` + +## 8. 从机 `.env` + +从机也复制 `.env.example`: + +```bash +cp .env.example .env +``` + +从机 `.env` 示例: + +```dotenv +MYSQL_ROOT_PASSWORD=CHANGE_ME_STRONG_MYSQL_ROOT_PASSWORD +MYSQL_DATABASE=ppanel + +MYSQL_REPLICATION_USER=replicator +MYSQL_REPLICATION_PASSWORD=CHANGE_ME_STRONG_REPLICATION_PASSWORD + +REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD + +MASTER_MYSQL_HOST=MASTER_PUBLIC_IP +MASTER_MYSQL_PORT=3306 +MASTER_REDIS_HOST=MASTER_PUBLIC_IP +MASTER_REDIS_PORT=6379 + +GRAFANA_PASSWORD=CHANGE_ME_STRONG_GRAFANA_PASSWORD +PPANEL_SERVER_TAG=latest +``` + +`MYSQL_ROOT_PASSWORD` 建议和主机一致,方便备份导入和后续切换。 + +## 9. 从机 `configs/ppanel.yaml` + +从机业务实例仍然连接主机 MySQL 和主机 Redis: + +```yaml +MySQL: + Addr: MASTER_PUBLIC_IP:3306 + Username: root + Password: CHANGE_ME_STRONG_MYSQL_ROOT_PASSWORD + Dbname: ppanel + Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai + MaxIdleConns: 10 + MaxOpenConns: 100 + SlowThreshold: 1000 + +Redis: + Host: MASTER_PUBLIC_IP:6379 + Pass: CHANGE_ME_STRONG_REDIS_PASSWORD + DB: 0 + +Trace: + Endpoint: "127.0.0.1:4317" +``` + +不要写成: + +```yaml +MySQL: + Addr: 127.0.0.1:3307 +Redis: + Host: 127.0.0.1:6380 +``` + +这样会让业务连接本机从库,写请求会失败或造成状态不一致。 + +## 10. 启动从机基础服务 + +检查 compose: + +```bash +docker compose -f docker-compose.replica.yml config +``` + +启动 MySQL 从库、Redis 从库、Sentinel 和 Tempo: + +```bash +docker compose -f docker-compose.replica.yml up -d mysql-replica redis-replica redis-sentinel tempo +``` + +查看日志: + +```bash +docker logs --tail=100 ppanel-mysql-replica +docker logs --tail=100 ppanel-redis-replica +docker logs --tail=100 ppanel-redis-sentinel +``` + +## 11. 导入主库备份到从库 + +导入前临时关闭从库只读: + +```bash +docker exec -i ppanel-mysql-replica mysql -uroot -p"$MYSQL_ROOT_PASSWORD" < /tmp/sentinel.conf + exec redis-server /tmp/sentinel.conf --sentinel + networks: + - ppanel_net + depends_on: + - redis-replica + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + tempo: + image: grafana/tempo:2.4.1 + container_name: ppanel-tempo + user: root + restart: always + command: + - "-config.file=/etc/tempo.yaml" + - "-target=all" + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo.yaml + - ./tempo_data:/var/tempo + ports: + - "127.0.0.1:4317:4317" + networks: + - ppanel_net + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + mysql_replica_data: + redis_replica_data: + tempo_data: + +networks: + ppanel_net: + name: ppanel_net + driver: bridge diff --git a/mysql/.my.cnf.example b/mysql/.my.cnf.example new file mode 100644 index 0000000..9233af8 --- /dev/null +++ b/mysql/.my.cnf.example @@ -0,0 +1,5 @@ +[client] +host=mysql +port=3306 +user=root +password=CHANGE_ME_TO_MYSQL_ROOT_PASSWORD diff --git a/mysql/configure-replica.sh b/mysql/configure-replica.sh new file mode 100644 index 0000000..34a76f8 --- /dev/null +++ b/mysql/configure-replica.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +: "${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}" +: "${MASTER_MYSQL_HOST:?MASTER_MYSQL_HOST is required}" +: "${MYSQL_REPLICATION_USER:?MYSQL_REPLICATION_USER is required}" +: "${MYSQL_REPLICATION_PASSWORD:?MYSQL_REPLICATION_PASSWORD is required}" + +MASTER_MYSQL_PORT="${MASTER_MYSQL_PORT:-3306}" +REPLICA_MYSQL_HOST="${REPLICA_MYSQL_HOST:-mysql-replica}" +REPLICA_MYSQL_PORT="${REPLICA_MYSQL_PORT:-3306}" + +echo "Waiting for MySQL source ${MASTER_MYSQL_HOST}:${MASTER_MYSQL_PORT}..." +until mysqladmin ping -h"${MASTER_MYSQL_HOST}" -P"${MASTER_MYSQL_PORT}" -u"${MYSQL_REPLICATION_USER}" -p"${MYSQL_REPLICATION_PASSWORD}" --silent; do + sleep 2 +done + +echo "Waiting for local replica ${REPLICA_MYSQL_HOST}:${REPLICA_MYSQL_PORT}..." +until mysqladmin ping -h"${REPLICA_MYSQL_HOST}" -P"${REPLICA_MYSQL_PORT}" -uroot -p"${MYSQL_ROOT_PASSWORD}" --silent; do + sleep 2 +done + +echo "Configuring GTID replication..." +mysql -h"${REPLICA_MYSQL_HOST}" -P"${REPLICA_MYSQL_PORT}" -uroot -p"${MYSQL_ROOT_PASSWORD}" <