企业级部署全栈图书管理系统:服务 + 加密 + 网络隔离 + WAF、DNS、本地源、NTP、异地备份

御坂スバル Lv1

写在前面

很多人觉得全栈工程师懂得前端和后端就好了,更资深一些的人知道测试也很重要。但我认为全栈工程师最重要的能力是——部署。

部署是上线前的最后一步,每一项配置都直接关系到网络安全。就算你系统写得再固若金汤,也扛不住黑客入侵服务器后的为所欲为。

集群架构

1. 一个现代服务的最小系统是什么

一个服务的最小系统由前端、后端、数据库三者构成

2. 服务会怎么“暴毙”

前端和后端作为暴露在公网的接口,首当其冲成为攻击目标。而黑客的攻击大致可分为三类:一是注入攻击,比如大名鼎鼎的SQL注入;二是流量窃听,比如通过抓包获取到用户名和密码;三是直接入侵服务器,是最严重的安全事故。

这里就引出了两个概念,一是WAF(Web Application Firewall),这类程序通过代理前端流量来拦截大多数注入攻击,属于傻瓜式一键部署,但是能让你晚上睡个安稳觉。二是加密传输,我们通过 TLS/SSL 加密将数据混淆,即使攻击者截获数据包,也无法解密出有用的信息。大家最熟悉的方案就是 HTTPS,最简单的实现方式就是给 Nginx 配置 SSL 证书来代理流量。

那么配置好 WAF 之后,如果黑客绕过 WAF 直接访问源站怎么办?那么,防黑客最有效的方法是什么?防火墙?程序写得足够健壮?110?都不是,防止黑客最有效的方法就是不联网。那你可能要问——不联网,服务怎么对外提供访问?前面其实已经回答了:让 WAF 代理流量,把前端和后端隐藏在内网之中!

这个时候我们就需要网络隔离,将非必要的服务彻底断绝外网。

前端和后端作为服务提供方,交由 WAF 代理流量;而数据库仅与后端通信,因此完全可以切断外网

回到数据库本身——即使服务代码写得固若金汤,物理硬件仍可能因不可抗力而损毁。那么这个时候我就想到了我们小学二年级信息老师都讲过的(毕导乱入)数据的”3-2-1”原则:至少三份副本、存放于两种不同介质、其中一份为异地备份。所以我们需要将数据库中的数据进行异地备份。

那么现在我们的节点就从三个增加到了五个,即:

flowchart LR
    extNet[fa:fa-globe 外部访问]

    subgraph dmz[DMZ 隔离区]
        waf[fa:fa-shield WAF]
    end

    subgraph intranet[内网]
        subgraph app[应用层]
            fe[fa:fa-desktop 前端]
            be[fa:fa-server 后端]
        end
        subgraph data[数据层]
            db[(fa:fa-database 数据库)]
            bak[(fa:fa-cloud 异地备份)]
        end
    end

    extNet <==>|"HTTPS"| waf
    waf -->|"反向代理"| fe
    waf -->|"反向代理"| be
    fe <-->|"TLS 加密"| be
    be -->|"CRUD"| db
    db -.->|"定期备份"| bak

3. 完全地……断开公网?絶対。

经常被打的同学都知道,漏洞是防不住的,对付漏洞最有效的方式就是持续更新补丁,但更新就需要连接外网。那我们已经被水泥焊死在内网里的服务们怎么办,软件源从哪来?DNS 解析从哪来?时间校准 NTP 从哪来?服务们吃什么?是啊,吃什么?

噢对了,还有 TLS 证书。内网服务之间要走 HTTPS,但断网之后 Let’s Encrypt 之类的公共 CA 就用不了了——你需要一台内部 CA 来签发证书,这里我们用 step-ca

还有一个更深的问题:就算把 DNS / NTP / 软件源 / CA 都自建好了,这些服务自己也需要联网——NTP 要同步上游授时、Docker 源要从 Docker Hub 拉镜像、Yum 源要从上游仓库同步软件包。难道给每台 infra 节点都配一个公网出口?那前面的网络隔离就白做了。

企业级的做法是:只在 DMZ 里放一台正向代理(Squid),由它统一出站,其他 infra 节点通过 HTTP_PROXY 走代理访问外网。代理本身配置白名单 ACL,只允许 NTP、DNS、Yum、Docker 等必需协议出站,其余一律拒绝。

那么这些不得不联网的基础设施怎么解决呢,那当然是自己搭了。我去,不早说!

于是我们的拓扑就变成了这样:

flowchart TB
    extNet[fa:fa-globe 外部访问]

    subgraph dmz[DMZ 隔离区]
        waf[fa:fa-shield WAF]
        proxy[fa:fa-forward Squid 代理]
    end

    subgraph intranet[内网]
        subgraph app[应用层]
            fe[fa:fa-desktop 前端]
            be[fa:fa-server 后端]
        end
        subgraph data[数据层]
            db[(fa:fa-database 数据库)]
            bak[(fa:fa-cloud 异地备份)]
        end
        subgraph infra[基础设施]
            dns[fa:fa-sitemap DNS]
            ntp[fa:fa-clock NTP]
            ca[fa:fa-certificate step-ca]
            subgraph repo[软件源]
                docker[(fa:fa-box Docker源)]
                yum[(fa:fa-archive Yum源)]
            end
        end
    end

    extNet <==>|"HTTPS"| waf
    proxy -->|"出站(白名单 ACL)"| extNet
    waf -->|"反向代理"| fe
    waf -->|"反向代理"| be
    fe <-->|"TLS 加密"| be
    be -->|"CRUD"| db
    db -.->|"定期备份"| bak
    infra -.->|"NTP / DNS"| waf
    infra -.->|"HTTP_PROXY 代理出站"| proxy

4. 管理网

好了,现在你有一套固若金汤的服务器集群了,可以开始维护了。

等等……你问我内网的机器怎么登录进去维护?

text
1
2
3
1. 拿着你的笔记本走进机房,记得穿大棉袄
2. 用RJ45接口的超六类双绞线连接内网交换机
3. 去HR领离职金

这太蠢了不是吗。所以怎么在家里也能加班为公司发光发热?答案是再组一套管理网

管理网(Management Network)是一个独立于业务网络的管理通道,专门用于运维人员登录、监控和维护内网服务器。它和业务流量走的不是同一条路——用户通过 WAF 访问服务,运维通过管理网访问服务器,互不干扰。

管理网的核心组件有两个:

  • VPN 网关:在你的笔记本和公司内网之间建立一条加密隧道,让你在公网上也能”虚拟”地接入内网。常用的有 WireGuard、OpenVPN、IPsec。
  • 堡垒机(Bastion Host / Jump Server):管理网的唯一入口。所有对内网服务器的 SSH 连接都必须经由堡垒机跳转,它负责认证、授权、操作审计。一句话——想进内网,先过堡垒机。

那么管理网的访问链路是:

flowchart LR
    admin[fa:fa-user 运维人员]

    subgraph mgmt[管理区]
        vpn[fa:fa-lock VPN 网关]
        bastion[fa:fa-terminal 堡垒机]
    end

    subgraph intranet[内网]
        fe[fa:fa-desktop 前端]
        be[fa:fa-server 后端]
        db[(fa:fa-database 数据库)]
    end

    admin <==>|"VPN 加密隧道"| vpn
    vpn -->|"SSH"| bastion
    bastion -->|"SSH"| fe
    bastion -->|"SSH"| be
    bastion -->|"SSH"| db

运维人员先拨入 VPN 进入管理区,再通过堡垒机跳转到内网各台服务器。堡垒机是唯一的入口,所有操作日志集中留存,出了事知道谁干的。

把业务网和管理网合在一起,完整的拓扑如下:

flowchart TB
    extNet[fa:fa-globe 外部访问]
    admin[fa:fa-user 运维人员]

    subgraph dmz[DMZ 隔离区]
        waf[fa:fa-shield WAF]
        proxy[fa:fa-forward Squid 代理]
        vpn[fa:fa-lock VPN 网关]
    end

    subgraph intranet[内网]
        subgraph app[应用层]
            fe[fa:fa-desktop 前端]
            be[fa:fa-server 后端]
        end
        subgraph data[数据层]
            db[(fa:fa-database 数据库)]
            bak[(fa:fa-cloud 异地备份)]
        end
        subgraph infra[基础设施]
            dns[fa:fa-sitemap DNS]
            ntp[fa:fa-clock NTP]
            ca[fa:fa-certificate step-ca]
            subgraph repo[软件源]
                docker[(fa:fa-box Docker源)]
                yum[(fa:fa-archive Yum源)]
            end
        end
        subgraph mgmt[管理网]
            bastion[fa:fa-terminal 堡垒机]
        end
    end

    extNet <==>|"HTTPS"| waf
    admin <==>|"VPN 加密隧道"| vpn
    proxy -->|"出站(白名单 ACL)"| extNet
    waf -->|"反向代理"| fe
    waf -->|"反向代理"| be
    vpn -->|"SSH"| bastion
    bastion -.->|"SSH 管理"| fe
    bastion -.->|"SSH 管理"| be
    bastion -.->|"SSH 管理"| db
    fe <-->|"TLS 加密"| be
    be -->|"CRUD"| db
    db -.->|"定期备份"| bak
    infra -.->|"NTP / DNS"| waf
    infra -.->|"HTTP_PROXY 代理出站"| proxy

到了这一步,整张拓扑就完整了——业务流量走 WAF 反向代理进入内网,管理流量走 VPN + 堡垒机独立通道,两套网络职责分离,谁也别越界。

练习案例

俗话说”读万卷书,行万里路”。道理讲了一堆,不动手过一遍很难真正记住。下面就来和我一起做一个图书管理系统的部署案例。

实验环境:VMware Workstation Pro + OpenEuler 24.04 LTS SP3

准备网卡

基于上面的拓扑,可以划分出五个网络:

网络名 用途
公网 模拟外部访问
DMZ 隔离区 部署 WAF / VPN / Squid 代理
业务内网 前端、后端、数据库、异地备份
管理网 运维人员 VPN 拨入后的管理通道
基础设施网 DNS / NTP / step-ca / 软件源

对应到 VMware 中,需要配置五张虚拟网卡:

VMnet 类型 网段 用途
VMnet8 NAT 192.168.70.0/24 模拟公网(外部访问)
VMnet2 仅主机(Host-only) 172.16.1.0/24 DMZ 隔离区
VMnet3 仅主机(Host-only) 172.16.2.0/24 业务内网
VMnet4 仅主机(Host-only) 172.16.3.0/24 管理网
VMnet5 仅主机(Host-only) 172.16.4.0/24 基础设施网

每个节点的规划

模板机就绪后,根据前面的拓扑,规划出每个节点的资源配置和所属网络。下面是一张完整的节点清单,供有基础的同学快速对照:

节点 vCPU 内存 硬盘 网络配置
DMZ隔离区 dmz-waf (WAF) 2 核 4 GB 系统盘 VMnet2(172.16.1.20)
VMnet3(172.16.2.10)
VMnet4(172.16.3.30)
VMnet5(172.16.4.30)
VMnet8(192.168.70.128)
dmz-proxy(Squid正向代理) 1 核 2 GB 系统盘 VMnet2(172.16.1.21)
VMnet4(172.16.3.31)
VMnet5(172.16.4.10)
VMnet8(192.168.70.129)
基础设施 infra-dns(DNS) 1核 2GB 系统盘 VMnet5(172.16.4.20)
VMnet4(172.16.3.32)
infra-ntp(ntp) 1核 2GB 系统盘 VMnet5(172.16.4.21)
VMnet4(172.16.3.33)
infra-docker(本地docker仓库) 2核 4GB 系统盘
仓库盘:200GB×1
VMnet5(172.16.4.22)
VMnet4(172.16.3.34)
infra-yum(本地Yum源) 2核 4GB 系统盘
仓库盘:200GB×1
VMnet5(172.16.4.23)
VMnet4(172.16.3.35)
infra-ca(step-ca) 1核 2GB 系统盘 VMnet5(172.16.4.24)
VMnet4(172.16.3.36)
业务内网 app-frontend(前端) 2核 4GB 系统盘 VMnet3(172.16.2.20)
VMnet4(172.16.3.37)
VMnet5(172.16.4.31)
app-backend(后端) 2核 4GB 系统盘 VMnet3(172.16.2.21)
VMnet4(172.16.3.38)
VMnet5(172.16.4.32)
数据层 data-db(数据库) 2核 4GB 系统盘
数据盘:50GB×5(raid5×4,热备份×1)
VMnet3(172.16.2.22)
VMnet4(172.16.3.39)
VMnet5(172.16.4.33)
data-backup(异地备份) 1核 2GB 系统盘
数据盘:50GB×5(raid5×4,热备份×1)
VMnet3(172.16.2.23)
VMnet4(172.16.3.40)
VMnet5(172.16.4.34)
管理网 mgmt-vpn(WireGuard VPN) 2核 2GB 系统盘 VMnet4(172.16.3.20)
VMnet2(172.16.1.22)
VMnet5(172.16.4.35)
VMnet8(192.168.70.130)
mgmt-bastion(堡垒机) 2核 2GB 系统盘 VMnet4(172.16.3.21)
VMnet5(172.16.4.36)

内网网络地址标准:

.10 源头

.20 本域

.30+ 用户

模板机

网卡配置好之后,下一步就是装系统了。

首先我们先装 DNS 主机:配置虚拟硬件、装系统、更新软件、开启 BBR、部署服务……

然后是 NTP 主机:配置虚拟硬件、装系统、更新软件、开启 BBR、部署服务……

再然后是……

打住。打开你的 VMware,随便找一个虚拟机,右键 → 管理 → 看到那个大大的”克隆”了吗?我们完全可以装一台机器作为模板,后面有新节点时直接克隆就好,省掉一遍遍重复劳动。下面来说说模板机怎么装。

虚拟硬件配置

配置项 说明
vCPU 2 核
内存 4 GB,关闭 swap
网卡 五张虚拟网卡全部挂载:VMnet8(公网)、VMnet2(DMZ)、VMnet3(业务)、VMnet4(管理)、VMnet5(基础设施),VMware 里按此顺序加好
存储 60 GB
CD/DVD 挂载 OpenEuler 安装镜像,安装后移除
UEFI 必须开启(UEFI 引导更贴合云服务环境)这一步非常容易忘

声卡、USB 控制器等无用设备全部移除,模板机不需要这些。

磁盘分区

安装程序走到磁盘分区这一步,选择手动分区。OpenEuler 走 UEFI 引导,所以需要一个 EFI 分区。整体使用 LVM 方案,方便后续扩容:

挂载点 大小 文件系统 说明
/boot/efi 512M FAT32 UEFI 引导必需
/boot 1G XFS 内核和引导文件
/ (LVM) 30G XFS 系统根分区
/var (LVM) 15G XFS 日志和可变数据,独立分区防止写满 / 导致宕机
/tmp (LVM) 5G XFS 临时文件,挂载选项 noexec,nodev,nosuid

剩余约 8.5G 空间留在 LVM 卷组中不分配,后续哪个分区不够了可以直接 lvextend

注意/tmp 的挂载选项很关键——noexec 禁止执行可执行文件,能挡住一大票通过临时目录投递的恶意脚本。

配置模板机

系统装好之后,还需要做一些标准化配置,这样克隆出来的节点不用每次都重新折腾。

0. 固定网卡命名

模板机挂了五张网卡,克隆出来的机器也一样——但问题是刚开机时你根本分不清 ens32 / ens34 / ens35 / ens36 / ens37 分别对应哪张网络。靠 MAC 地址去 VMware 里对也行,但每次克隆完都来一遍太蠢了。

更优雅的做法:模板机里一次性写死 udev 规则,按照 PCI 路径给网卡重命名。PCI 路径在克隆后不变,所以克隆机开机就能看到 eth-pubeth-dmz 这样有意义的接口名。

首先把所有网卡切成 DHCP,拿到 IP 就能确定映射关系(VMnet8 NAT 有 DHCP,其他 Host-only 网络也默认启用了):

1
2
3
4
5
6
7
8
# 五张网卡全切 DHCP
for iface in $(ls /sys/class/net | grep -E "^e"); do
sudo nmcli dev set "$iface" managed yes 2>/dev/null
sudo nmcli con add con-name "dhcp-$iface" ifname "$iface" type ethernet 2>/dev/null
sudo nmcli con up "dhcp-$iface" 2>/dev/null
done
sleep 5
ip -br addr show | grep -E "^e"

根据拿到的 IP 段就能确定映射:

IP 段 对应网络
192.168.70.x VMnet8 公网
172.16.1.x VMnet2 DMZ
172.16.2.x VMnet3 业务
172.16.3.x VMnet4 管理
172.16.4.x VMnet5 基础设施

然后查出每张网卡的 PCI 路径:

1
2
3
4
for iface in $(ls /sys/class/net | grep -E "^e"); do
pci=$(basename "$(readlink /sys/class/net/$iface/device)" 2>/dev/null)
echo "$iface$pci"
done

输出类似:

1
2
3
4
5
ens33 → pci-0000:02:01.0   # VMnet8 → eth-pub
ens34 → pci-0000:02:02.0 # VMnet2 → eth-dmz
ens35 → pci-0000:02:03.0 # VMnet3 → eth-app
ens36 → pci-0000:02:04.0 # VMnet4 → eth-mgmt
ens37 → pci-0000:02:05.0 # VMnet5 → eth-infra

如果你在 VMware 里挂网卡的顺序不一样,实际的 PCI 路径也会不同。对照 DHCP 拿到的 IP 段修改下面 udev 规则里的 PCI 路径即可。

写 udev 规则——用 PCI 路径,不要用 MAC 地址(MAC 克隆后会变,PCI 路径不变):

1
2
3
4
5
6
7
8
9
sudo tee /etc/udev/rules.d/70-vmnet-names.rules << 'EOF'
# 固定网卡命名 —— 按 PCI 路径匹配,克隆后仍然有效
# 这里pci路径因人而异
SUBSYSTEM=="net", ACTION=="add", DEVPATH=="*/0000:02:00.0/net/*", NAME="eth-pub"
SUBSYSTEM=="net", ACTION=="add", DEVPATH=="*/0000:02:01.0/net/*", NAME="eth-dmz"
SUBSYSTEM=="net", ACTION=="add", DEVPATH=="*/0000:02:02.0/net/*", NAME="eth-app"
SUBSYSTEM=="net", ACTION=="add", DEVPATH=="*/0000:02:03.0/net/*", NAME="eth-mgmt"
SUBSYSTEM=="net", ACTION=="add", DEVPATH=="*/0000:02:04.0/net/*", NAME="eth-infra"
EOF

命名用 eth- 前缀加网络用途后缀,一眼看出这是哪张网,不用再去记 ensXX 映射表。

删掉 NetworkManager 里的 DHCP 连接,重启让 udev 规则生效:

1
2
sudo rm -f /etc/NetworkManager/system-connections/*.nmconnection
sudo reboot

重启后 ip link 看到的就是 eth-pubeth-dmzeth-appeth-mgmteth-infra 了。现在只有本地回环和这些裸接口,没网。给 eth-pub 开 DHCP 恢复上网:

1
2
sudo nmcli con add con-name "dhcp-pub" ifname eth-pub type ethernet
sudo nmcli con up "dhcp-pub"

后面配模板机的步骤都走 eth-pub 这条线。

1. 加固 /tmp 挂载选项

默认 /tmp 没有安全限制。编辑 /etc/fstab,找到 /tmp 所在行,补齐挂载选项:

1
sudo vi /etc/fstab
1
2
# /tmp 行改为:
/dev/mapper/openeuler-tmp /tmp xfs noexec,nodev,nosuid 0 0

然后重新挂载使其生效:

1
2
3
4
5
# 1. 刷新 systemd 配置
systemctl daemon-reload

# 2. 重新挂载 /tmp 应用新参数
mount -o remount /tmp

2. 创建 deploy 组和 deploy 用户

生产环境不要用 root 直接操作,建一个专门的部署运维账号:

1
2
3
sudo groupadd deploy
sudo useradd -m -g deploy -s /bin/bash deploy
sudo passwd deploy # 为deploy账户设置密码

把 deploy 加入 wheel 组以便 sudo

1
sudo usermod -aG wheel deploy

生产环境建议把 sudo 权限收敛到具体命令上(visudo 配 Cmnd_Alias),模板机这里先简化处理。

3. SSH 安全加固

用密钥登录替代密码登录。先把你的公钥写入 deploy 用户的 authorized_keys

1
2
3
4
5
sudo mkdir -p /home/deploy/.ssh
sudo vi /home/deploy/.ssh/authorized_keys # 粘贴你的公钥
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

然后新建 SSH 配置片段,禁用密码认证和 root 登录,只允许 deploy 用户密钥登录:

1
2
sudo mkdir -p /etc/ssh/sshd_config.d/
sudo vi /etc/ssh/sshd_config.d/99-hardening.conf
1
2
3
4
5
# 99-hardening.conf —— 模板机 SSH 安全加固
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AllowUsers deploy

检查一下配置语法无报错后重启 sshd:

1
sudo sshd -t && sudo systemctl restart sshd

注意:公钥一定要在这步之前放进去,否则 PasswordAuthentication no + AllowUsers deploy 生效后你就彻底登不上去了。

4. 更新系统并安装基础包

1
2
sudo dnf update -y
sudo dnf install -y vim curl wget tar lsof jq bind-utils chrony tcpdump open-vm-tools

这边建议重启一下,然后卸载旧内核

5. 安装 Docker 和 Docker Compose

所有服务都跑在容器里,模板机先装好 Docker。OpenEuler 没有官方 Docker 源,用 CentOS 的源并强制指定为 el8 兼容版本:

1
2
3
4
5
sudo curl -o /etc/yum.repos.d/docker-ce.repo https://download.docker.com/linux/centos/docker-ce.repo
sudo sed -i 's/rhel/centos/g' /etc/yum.repos.d/docker-ce.repo
sudo sed -i 's/$releasever/8/g' /etc/yum.repos.d/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker

docker-compose-plugin 提供 docker compose(V2),不是旧版 docker-compose(V1)。注意中间是空格不是横杠。

把 deploy 用户加入 docker 组,避免每次 docker 命令都要 sudo:

1
sudo usermod -aG docker deploy

6. 修复 Docker 与 firewalld 的冲突

OpenEuler 的 firewalld 默认使用 nftables 作为后端,而 Docker 直接操作 iptables。两者各自管理防火墙规则,经常出现 firewalld 重载后 Docker 容器的网络全部挂掉的情况(容器 ping 不通外网、发布的端口失效)。

解决方案是让 firewalld 退回 iptables 后端,这样两者操作同一套规则体系,不再互相覆盖。

编辑 /etc/firewalld/firewalld.conf,找到 FirewallBackend 行改为:

1
sudo vim /etc/firewalld/firewalld.conf
1
2
# 将 nftables 改为 iptables
FirewallBackend=iptables

有了vim就是舒服,还是这个好使,哦哦齁齁齁齁齁齁齁

然后确保 Docker daemon 显式开启 iptables 管理:

1
sudo vim /etc/docker/daemon.json
1
2
3
{
"iptables": true
}

重启顺序很重要——必须先启 firewalld,再启 Docker,这样 Docker 的 iptables 规则才能正确追加到 firewalld 已有的规则之后:

1
2
sudo systemctl restart firewalld
sudo systemctl restart docker

验证一下:

1
2
sudo firewall-cmd --state          # firewalld 运行正常
docker run --rm alpine ping -c1 baidu.com # 容器能访问外网

上一步把 deploy 加入了 docker 组,需要刷新登录会话才生效。OpenEuler 有个坑——newgrp 甚至重新登录后 groups 仍然可能显示旧信息,用 exec su -l $USER 强制重建登录会话即可解决。

7. 关闭 GRUB 等待

服务器不需要双系统菜单,把 GRUB 等待时间改为 0,开机直接进系统:

1
2
3
4
sudo vim /etc/default/grub
# 将 GRUB_TIMEOUT=5 改为 GRUB_TIMEOUT=0

sudo grub2-mkconfig -o /boot/grub2/grub.cfg

8. 清理、关机、拍快照

克隆前最后一步:清理敏感信息,避免每台机器残留相同的机器 ID 和网络标识:

1
2
3
4
5
6
sudo rm -f /etc/machine-id
sudo touch /etc/machine-id
sudo chmod 444 /etc/machine-id

sudo rm -f /etc/ssh/ssh_host_*
sudo sed -i '/^UUID/d' /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null

/etc/machine-id 清空后 systemd 会在下次启动时重新生成;SSH 主机密钥同理,克隆后首次启动会自动创建新的。

关机:

1
sudo poweroff

关机后在 VMware 里拍一个快照,命名为 basic——这是模板机的干净基线,后面如果配置搞砸了可以直接回滚到这里。

后续流程:用 basic 快照克隆出基础设施节点并部署完毕 → 回到模板机,配置它使用基础设施服务(DNS、NTP、CA 证书、Docker 镜像源、Yum 源)→ 拍第二张快照 after-infra。之后克隆业务节点和管理节点时从 after-infra 起手,就不用每台机器重复配一遍基础设施了。

基础设施节点

要想让我们这一套集群跑起来,直觉上要先搭建基础设施节点,然而基础设施节点有没有依赖呢,有的,所以我们要部署的第一个节点是squid正向代理

Squid 正向代理(dmz-proxy)

1. 从basic 快照链接克隆一台新机器,按上表补足硬件配置后开机。dmz-proxy 不需要业务网卡,先把 eth-app 对应的网卡在 VMware 设置里移除。开机后配置 IP:

1
2
3
4
5
6
7
8
9
10
11
# 清理残留连接(basic 快照已清空 NM,这里只删自动生成的 eth-* 连接)
for iface in eth-pub eth-dmz eth-app eth-mgmt eth-infra; do
sudo nmcli con delete "$iface" 2>/dev/null
done

# 按规划配静态 IP(eth-app 没连 VMnet3,直接 disable)
sudo nmcli con add con-name "net-dmz" ifname eth-dmz type ethernet ipv4.method manual ipv4.addresses 172.16.1.21/24
sudo nmcli con add con-name "net-pub" ifname eth-pub type ethernet ipv4.method manual ipv4.addresses 192.168.70.129/24 ipv4.gateway 192.168.70.2 ipv4.dns "223.5.5.5"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet ipv4.method manual ipv4.addresses 172.16.3.31/24
sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet ipv4.method manual ipv4.addresses 172.16.4.10/24
sudo nmcli dev set eth-app managed off

最后设置主机名:

1
sudo hostnamectl set-hostname dmz-proxy

2. 创建工作目录和 docker-compose.yml。cache 和 logs 落在宿主机 /var 下(模板机 /var 独立分区,防止缓存写满根目录):

1
2
3
4
sudo mkdir -p /opt/squid/config
sudo mkdir -p /var/spool/squid /var/log/squid
sudo vi /opt/squid/docker-compose.yml
sudo chmod 755 /opt/squid/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
services:
squid:
image: ubuntu/squid:latest
container_name: squid
restart: unless-stopped
ports:
- "172.16.4.10:3128:3128" # 只监听基础设施网
volumes:
- /opt/squid/config/squid.conf:/etc/squid/squid.conf:ro
- /var/log/squid:/var/log/squid
- /var/spool/squid:/var/spool/squid
ulimits:
nofile:
soft: 65535
hard: 65535
networks:
- proxy-net
healthcheck:
test: ["CMD", "sh", "-c", "curl -x http://localhost:3128 -s -o /dev/null -w '%{http_code}' http://baidu.com | grep -qE '^(2|3|4)'"]
interval: 30s
timeout: 10s
retries: 3

networks:
proxy-net:
driver: bridge

端口绑在 172.16.4.10 上,只有基础设施网的节点能访问 3128。

容器内 squid 以 proxy 用户运行(Debian/Ubuntu 固定 UID=13),bind mount 的宿主目录需要改属主才能写入:

1
2
3
4
# 确认容器内 proxy 用户的 UID,我这里是13
sudo docker run --rm --entrypoint "" ubuntu/squid:latest id proxy
# /var/spool/squid 和 /var/log/squid 归属 UID 13
sudo chown -R 13:13 /var/spool/squid /var/log/squid

3. 编写 squid.conf——白名单 ACL,只放行 Yum 和 Docker 所需的出站协议:

1
sudo vim /opt/squid/config/squid.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
http_port 3128

# === 源 IP 白名单 ===
acl infra_net src 172.16.4.0/24
acl dmz_net src 172.16.1.0/24

# === 安全端口:只允许 HTTP(80) 和 HTTPS(443) ===
acl SSL_ports port 443
acl Safe_ports port 80
acl Safe_ports port 443
acl Safe_ports port 1025-65535
acl CONNECT method CONNECT

# === 出站目的域名白名单 ===
# Yum 源(USTC 镜像站)
acl allowed_dst dstdomain .ustc.edu.cn
# Docker Hub + 私有仓库
acl allowed_dst dstdomain .docker.com .docker.io git.msksbr.com
# GitHub(step CLI 等工具链——下载重定向到 githubusercontent.com)
acl allowed_dst dstdomain .github.com .githubusercontent.com
# NTP 和 DNS 是 UDP,不走 HTTP 代理,留给 iptables SNAT 处理

# === 规则生效顺序 ===
http_access deny !Safe_ports
http_access allow infra_net CONNECT SSL_ports allowed_dst
http_access allow infra_net allowed_dst
http_access allow dmz_net CONNECT SSL_ports allowed_dst
http_access allow dmz_net allowed_dst
http_access deny all

# === 文件描述符硬限制(Docker 容器内 ulimit -n 可能为极大值导致 OOM)===
max_filedescriptors 65535

# === 缓存 ===
cache_dir ufs /var/spool/squid 100 16 256
maximum_object_size 1024 MB
cache_mem 256 MB
coredump_dir /var/spool/squid

# === 日志 ===
access_log daemon:/var/log/squid/access.log squid
cache_log /var/log/squid/cache.log

# === 隐藏 Squid 版本号 ===
httpd_suppress_version_string on

NTP(UDP 123)和 DNS(UDP 53)不走 HTTP 代理,因此不在这里配置。infra-ntp 和 infra-dns 的出站走 dmz-proxy 本身的防火墙 SNAT,后续部署这两个节点时会配。

4. 启动并验证:

1
2
cd /opt/squid
docker compose up -d

dmz-proxy 自身测试代理是否可用(172.16.4.10 绑的就是本机):

1
curl -x http://172.16.4.10:3128 -I https://mirrors.ustc.edu.cn

返回 HTTP 200 说明代理正常工作。docker logs squid -f 可以看到实时请求日志。

5. Squid 跑通后锁门——配置 firewalld,只允许基础设施网访问 3128,管理网 SSH,其余一律拒绝:

1
2
3
4
5
6
7
8
9
10
11
12
# eth-pub → public(最不可信),eth-infra → internal,eth-mgmt → internal
sudo firewall-cmd --permanent --change-interface=eth-pub --zone=public
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# 基础设施网允许访问 Squid 3128
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="3128" accept' --zone=internal

# 管理网允许 SSH
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

public 区默认只允许出站,不用额外配——proxy 需要对外发包,正是这个行为。

PowerDNS 权威 DNS(infra-dns)

在开始部署 DNS 之前,先在 dmz-proxy 上打开 NAT 转发——DNS 和 NTP 都是 UDP 协议,不走 HTTP 代理,但需要经 proxy 出站访问上游(如 223.5.5.5):

0. dmz-proxy 开启 IP 转发和 NAT:

1
2
3
4
5
6
7
8
9
# 开启内核 IP 转发(持久化)
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-forwarding.conf
sudo sysctl -p /etc/sysctl.d/99-forwarding.conf

# firewalld 加 masquerade + forward 规则(infra → eth-pub)
sudo firewall-cmd --permanent --zone=public --add-masquerade
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -i eth-infra -o eth-pub -j ACCEPT
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -i eth-pub -o eth-infra -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo firewall-cmd --reload

验证:在其他 infra 节点上把默认网关指向 proxy(172.16.4.10)、DNS 设为 223.5.5.5,ping 223.5.5.5 能通即表示 NAT 生效。

1. 从 basic 快照链接克隆 infra-dns,移除 eth-dmz/eth-app/eth-pub 对应的网卡(只保留 eth-infra + eth-mgmt),开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 清理残留
for iface in eth-infra eth-mgmt; do
sudo nmcli con delete "$iface" 2>/dev/null
done

# 静态 IP,默认网关指向 proxy 的 infra 口(172.16.4.10)
sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.20/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "223.5.5.5"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.32/24

sudo hostnamectl set-hostname infra-dns

2. 创建目录和 docker-compose.yml。数据库和日志落在 /var 分区:

1
2
3
4
5
sudo mkdir -p /opt/pdns/config
sudo mkdir -p /var/lib/powerdns /var/log/pdns
sudo chown -R 953:953 /var/lib/powerdns /var/log/pdns
sudo vim /opt/pdns/docker-compose.yml
sudo chmod 755 /opt/pdns/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
services:
pdns-auth:
image: powerdns/pdns-auth-48
container_name: pdns-auth
restart: unless-stopped
volumes:
- /opt/pdns/config/pdns.conf:/etc/powerdns/pdns.conf:ro
- /var/lib/powerdns:/var/lib/powerdns
- /var/log/pdns:/var/log/pdns
networks:
- dns-net

pdns-recursor:
image: powerdns/pdns-recursor-53
container_name: pdns-recursor
restart: unless-stopped
ports:
- "172.16.4.20:53:53/tcp" # 对外提供 DNS 服务
- "172.16.4.20:53:53/udp"
volumes:
- /opt/pdns/config/recursor.yml:/etc/powerdns/recursor.yml:ro
- /var/log/pdns:/var/log/pdns
networks:
- dns-net

networks:
dns-net:
driver: bridge

Auth 不暴露任何端口——由 Recursor 接收 DNS 查询(53),内部域转发给 Auth,外网递归上游。所有管理操作通过 docker compose exec pdns pdnsutil 直接在容器内执行。Auth 容器内 pdns 用户 UID=953(Alpine 固定值)。

3. 编写 Auth 配置 pdns.conf——SQLite3 后端:

1
sudo vim /opt/pdns/config/pdns.conf
1
2
3
4
5
6
7
8
9
10
# === 后端 ===
launch=gsqlite3
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3

# === 域名 ===
default-soa-content=ns1.internal.msksbr.com hostmaster.internal.msksbr.com 0 10800 3600 604800 3600

# === 安全 ===
setgid=pdns
setuid=pdns

编写 Recursor 配置 recursor.yml

1
sudo vim /opt/pdns/config/recursor.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
incoming:
listen:
- 0.0.0.0:53
allow_from:
- 172.16.0.0/16

recursor:
system_resolver_ttl: 3600
forward_zones:
- zone: internal.msksbr.com
forwarders:
- pdns-auth:53
forward_zones_recurse:
- zone: "."
forwarders:
- 223.5.5.5

dnssec:
validation: off

logging:
quiet: true

allow-from 允许所有内网段递归查询。DNSSEC 关闭——内网 zone 未签名,开启会导致 SERVFAIL。

4. 初始化 SQLite 数据库——PowerDNS 不会自动建 schema,需要手动执行:

1
2
3
4
5
6
7
8
9
# 用容器内的基础 schema 初始化 SQLite 数据库
cd /opt/pdns
sudo docker run --rm \
-v /var/lib/powerdns:/var/lib/powerdns \
--entrypoint /bin/sh powerdns/pdns-auth-48 \
-c "sqlite3 /var/lib/powerdns/pdns.sqlite3 < /usr/local/share/doc/pdns/schema.sqlite3.sql"
sudo chown 953:953 /var/lib/powerdns/pdns.sqlite3
sudo docker compose up -d
sleep 3
1
2
# 创建内网域,用 pdnsutil 比 curl API 更可靠
sudo docker compose exec pdns-auth pdnsutil create-zone internal.msksbr.com ns1.internal.msksbr.com
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 批量添加各节点的 A 记录
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. proxy A 172.16.4.10
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. dns A 172.16.4.20
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. ntp A 172.16.4.21
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. docker A 172.16.4.22
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. yum A 172.16.4.23
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. ca A 172.16.4.24
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. waf A 172.16.3.30
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. frontend A 172.16.2.20
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. backend A 172.16.2.21
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. db A 172.16.2.22
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. backup A 172.16.2.23
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. vpn A 172.16.3.20
sudo docker compose exec pdns-auth pdnsutil add-record internal.msksbr.com. bastion A 172.16.3.21

5. 验证 DNS 解析:

1
2
3
4
# 内网域名——Recursor 转发给 Auth 应答
dig @172.16.4.20 proxy.internal.msksbr.com +short
# 外网域名——Recursor 递归到 223.5.5.5
dig @172.16.4.20 baidu.com +short

内网返回 172.16.4.10,外网返回百度 IP,说明 Auth + Recursor 配对正常工作。其他节点只需把 DNS 指到 172.16.4.20,内外网解析一站搞定,不需要 /etc/resolv.conf 里配双 DNS。

6. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
12
# eth-infra → internal, eth-mgmt → internal
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# DNS (TCP+UDP 53) 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="53" accept' --zone=internal
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="udp" port="53" accept' --zone=internal

# SSH 管理网
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

NTP(infra-ntp)

NTP 不走容器——chronyd 需要 SYS_TIME capability 才能调整系统时钟,容器化要么给 --privileged 要么放弃。宿主机直接跑最稳妥。

1. 从 basic 快照链接克隆 infra-ntp,移除 eth-dmz/eth-app/eth-pub(只保留 eth-infra + eth-mgmt),开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
for iface in eth-infra eth-mgmt; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.21/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.33/24

sudo hostnamectl set-hostname infra-ntp

DNS 指向刚部署的 infra-dns(172.16.4.20),内网域名和外网递归一站解决。

2. 配置 chronyd——上游授时走阿里云和腾讯云 NTP,监听 infra 口为内网提供服务:

chronyd 在模板机阶段已安装(dnf install -y chrony),直接编辑配置:

1
2
sudo cp /etc/chrony.conf{,.bak}
sudo vim /etc/chrony.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# === 上游授时(经 dmz-proxy NAT 出站)===
pool ntp.aliyun.com iburst
pool ntp.tencent.com iburst

# === 监听 infra 口,为内网提供 NTP 服务 ===
bindaddress 172.16.4.21
allow 172.16.0.0/16

# === 即使上游不可达,也以本地时钟兜底 ===
local stratum 10

# === 日志 ===
logdir /var/log/chrony
log measurements statistics tracking

# === 步进阈值(首次启动允许大步跳,之后只看守)===
makestep 1.0 3

makestep 1.0 3:前 3 次更新若偏移 >1 秒则直接跳变,之后走 slewing 缓慢修正,避免日志时间线倒退。

3. 启动并验证:

1
2
3
4
sudo systemctl enable chronyd
sudo systemctl restart chronyd # 模板机已安装且运行,restart 才能加载新配置
chronyc sources -v
chronyc tracking

sources -v 看到上游带 ^* 标记说明已同步。tracking 确认 Stratum 不再为 0。

本机验证:

1
chronyc -a clients

4. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# NTP (UDP 123) 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="udp" port="123" accept' --zone=internal

# SSH 管理网
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

chronyd 出站同步上游走 proxy NAT,internal zone 默认放行出站,无需额外规则。

5. 跨节点验证(firewalld 放行之后才能通):

1
2
3
4
# 在 infra-dns 上执行——先停掉本地 chronyd 释放 NTP 端口,测完再起
sudo systemctl stop chronyd
sudo chronyd -q "server 172.16.4.21 iburst"
sudo systemctl start chronyd

无报错即为 NTP 服务可达。

step-ca 内部 CA(infra-ca)

内网服务之间走 HTTPS 需要 TLS 证书。断网后 Let’s Encrypt 等公共 CA 不可用,自建一台内部 CA 用 ACME 协议签发。

1. 从 basic 快照链接克隆 infra-ca,移除 eth-dmz/eth-app/eth-pub(只保留 eth-infra + eth-mgmt),开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
for iface in eth-infra eth-mgmt; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.24/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.36/24

sudo hostnamectl set-hostname infra-ca

2. 创建目录和 docker-compose.yml

1
2
3
4
5
6
sudo mkdir -p /opt/step-ca/config
sudo mkdir -p /opt/step-ca/secrets
sudo mkdir -p /opt/step-ca/certs
sudo mkdir -p /var/lib/step-ca /var/log/step-ca
sudo vim /opt/step-ca/docker-compose.yml
sudo chmod 755 /opt/step-ca/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
restart: unless-stopped
ports:
- "172.16.4.24:9000:9000"
volumes:
- /opt/step-ca/config:/home/step/config:ro
- /opt/step-ca/secrets:/home/step/secrets
- /opt/step-ca/certs:/home/step/certs:ro
- /var/lib/step-ca:/home/step/db
- /var/log/step-ca:/var/log/step
networks:
- ca-net

networks:
ca-net:
driver: bridge

3. 初始化 CA——生成根证书、中间证书和 provisioner:

1
2
# 生成 provisioner 密码(签发证书时认证用)
openssl rand -base64 24 | sudo tee /opt/step-ca/secrets/password > /dev/null
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 首次初始化,挂载整个 /opt/step-ca 到 /home/step
# --user 0 以 root 运行,否则容器内 step 用户写不进 root 属主的宿主机目录
sudo docker run --rm \
--user 0 \
-v /opt/step-ca:/home/step \
smallstep/step-ca:latest \
step ca init \
--name "Internal CA" \
--dns ca.internal.msksbr.com \
--address :9000 \
--provisioner admin \
--password-file /home/step/secrets/password

# 初始化后会生成 certs/ config/ secrets/ db/,把 db 移到 /var
sudo mv /opt/step-ca/db /var/lib/step-ca

# 确认容器内 step 用户 UID,然后 chown
sudo docker run --rm --entrypoint "" smallstep/step-ca:latest id step
# 输出 uid=XXX(step) gid=XXX(step),我这里是1000
sudo chown -R 1000:1000 /opt/step-ca/secrets /opt/step-ca/certs /var/lib/step-ca /var/log/step-ca

--dns ca.internal.msksbr.com 用于证书中包含 CA 的域名。--provisioner admin 创建一个名为 admin 的 provisioner,后续用 step ca certificate 签发节点证书时需提供对应密码。

4. 启动并验证:

1
2
cd /opt/step-ca
docker compose up -d

验证 CA 健康状态和根证书指纹:

1
2
docker compose exec step-ca step ca health
docker compose exec step-ca step certificate fingerprint /home/step/certs/root_ca.crt

签发一张测试证书验证全链路:

1
2
3
4
cat /opt/step-ca/secrets/password | docker compose exec -T step-ca \
step ca certificate test.internal.msksbr.com /tmp/test.crt /tmp/test.key \
--provisioner admin --password-file /dev/stdin
docker compose exec step-ca step certificate inspect /tmp/test.crt --short

step ca certificate 走 ACME 协议向 CA 签发证书,--provisioner admin 指定认证身份,密码走 stdin 传入。无报错且 DNS name 为 test.internal.msksbr.com 即全链路通过。

5. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# step-ca ACME (TCP 9000) 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="9000" accept' --zone=internal

# SSH 管理网
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

Docker Registry 双容器方案(infra-docker)

两个 registry:2 各司其职:

端口 模式 用途
5000 标准读写 Gitea 私有镜像 cron 同步后推入
5001 pull-through proxy 代理 Docker Hub,registry-mirrors 指向这里

集群节点只需配一个 registry-mirrors,Docker Hub 走 5001 透明代理,私有镜像走 5000 本地存储。互不干扰。

1. 从 basic 快照链接克隆 infra-docker,移除 eth-dmz/eth-app/eth-pub(只保留 eth-infra + eth-mgmt)。额外挂一块 200GB 数据盘。开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
for iface in eth-infra eth-mgmt; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.22/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.34/24

sudo hostnamectl set-hostname infra-docker

2. 200GB 数据盘走 LVM,挂载到 /var/lib/registry

1
2
3
4
5
6
7
8
9
# 找到数据盘(新挂载的盘,通常为 sdb)
lsblk
sudo pvcreate /dev/sdb
sudo vgcreate vg_registry /dev/sdb
sudo lvcreate -l 100%FREE -n lv_registry vg_registry
sudo mkfs.xfs /dev/vg_registry/lv_registry
sudo mkdir -p /var/lib/registry
sudo mount /dev/vg_registry/lv_registry /var/lib/registry
echo '/dev/vg_registry/lv_registry /var/lib/registry xfs defaults 0 0' | sudo tee -a /etc/fstab

LVM 方便后续在线扩容——缓存满时加一块盘 vgextendlvextendxfs_growfs,不停服务。

3. 创建目录和配置文件:

1
sudo mkdir -p /opt/registry/{config,proxy}

标准 registry 配置文件(5000,接受 push):

1
2
sudo vim /opt/registry/config/main.yml
sudo chmod 644 /opt/registry/config/main.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
version: "0.1"
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry
maintenance:
uploadpurging:
enabled: true
age: 168h
interval: 24h
dryrun: false
http:
addr: :5000

proxy registry 配置文件(5001,只读代理 Docker Hub):

1
2
sudo vim /opt/registry/config/proxy.yml
sudo chmod 644 /opt/registry/config/proxy.yml
1
2
3
4
5
6
7
8
9
10
version: "0.1"
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry-proxy
http:
addr: :5001
proxy:
remoteurl: https://registry-1.docker.io

5000 没有 proxy 配置——标准可读可写。5001 配了 proxy.remoteurl——自动变成 pull-through cache,本地有缓存直接返回,没有则代理到 Docker Hub 并缓存。

docker-compose.yml

1
2
sudo vim /opt/registry/docker-compose.yml
sudo chmod 755 /opt/registry/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
services:
registry-main:
image: registry:2
container_name: registry-main
restart: unless-stopped
ports:
- "172.16.4.22:5000:5000"
volumes:
- /opt/registry/config/main.yml:/etc/docker/registry/config.yml:ro
- /var/lib/registry/main:/var/lib/registry
networks:
- registry-net

registry-proxy:
image: registry:2
container_name: registry-proxy
restart: unless-stopped
ports:
- "172.16.4.22:5001:5001"
environment:
- HTTP_PROXY=http://172.16.4.10:3128
- HTTPS_PROXY=http://172.16.4.10:3128
- NO_PROXY=172.16.0.0/16,localhost
volumes:
- /opt/registry/config/proxy.yml:/etc/docker/registry/config.yml:ro
- /var/lib/registry/proxy:/var/lib/registry
networks:
- registry-net
healthcheck:
test: ["CMD", "sh", "-c", "wget -q -O /dev/null http://localhost:5001/v2/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3

networks:
registry-net:
driver: bridge

只有 registry-proxy 需要 HTTP_PROXY——它要出站访问 Docker Hub。registry-main 不走代理,只在内网收 push / 响应 pull。

创建两个容器的存储子目录:

1
sudo mkdir -p /var/lib/registry/{main,proxy}

4. 私有镜像同步脚本:

git.msksbr.com/msksbr/* 上的私有镜像,定时从 Gitea 拉取并推入 5000:

1
2
3
sudo mkdir -p /opt/registry/sync.d
sudo vim /opt/registry/sync.d/sync-gitea.sh
sudo chmod +x /opt/registry/sync.d/sync-gitea.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash
set -e

GITEA_UPSTREAM="git.msksbr.com"
LOCAL_REGISTRY="172.16.4.22:5000"
IMAGES=("bookmgr:latest" "bookmgr-client:latest")

for img in "${IMAGES[@]}"; do
echo "=== syncing ${img} ==="
docker pull "${GITEA_UPSTREAM}/msksbr/${img}"
docker tag "${GITEA_UPSTREAM}/msksbr/${img}" "${LOCAL_REGISTRY}/msksbr/${img}"
docker push "${LOCAL_REGISTRY}/msksbr/${img}"
echo "=== done ${img} ==="
done

后续有新的私有镜像,加到 IMAGES 数组里即可。

部署 systemd timer 每晚凌晨 3 点自动同步:

1
sudo vim /etc/systemd/system/sync-gitea.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Sync Gitea images to local registry
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
User=deploy
ExecStart=/bin/bash /opt/registry/sync.d/sync-gitea.sh
1
sudo vim /etc/systemd/system/sync-gitea.timer
1
2
3
4
5
6
7
8
9
[Unit]
Description=Sync Gitea images daily at 3am

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now sync-gitea.timer

Persistent=true:如果凌晨 3 点机器没开机,下次启动时立刻补跑一次。

5. 启动并验证:

1
2
3
cd /opt/registry
docker compose up -d
sleep 3

Docker daemon 默认对远程 registry 走 HTTPS,内网 registry 还没配 TLS(统一后面加),先标记 5000 为 insecure。5001 不需要——它是 registry-mirrors 的目标,daemon 对 mirror 容忍 HTTP:

1
sudo vim /etc/docker/daemon.json
1
2
3
4
{
"iptables": true,
"insecure-registries": ["172.16.4.22:5000", "172.16.4.22:5001"]
}
1
sudo systemctl restart docker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 两个 registry 都存活
docker ps --format "table {{.Names}}\t{{.Status}}"

# 5001 proxy——首次拉取触达 Docker Hub 并缓存
docker pull 172.16.4.22:5001/library/nginx:alpine
docker rmi 172.16.4.22:5001/library/nginx:alpine
docker pull 172.16.4.22:5001/library/nginx:alpine # 第二次应明显更快

# 私有镜像同步——手动跑脚本验证全链路
sudo systemctl start sync-gitea.service
sudo journalctl -u sync-gitea.service -f

# 确认两端都有数据
curl -s http://172.16.4.22:5000/v2/_catalog
# 返回 {"repositories":["msksbr/bookmgr","msksbr/bookmgr-client"]}
curl -s http://172.16.4.22:5001/v2/_catalog
# 返回 {"repositories":["library/nginx"]}

6. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
12
13
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# Registry 5000 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="5000" accept' --zone=internal

# Registry 5001 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="5001" accept' --zone=internal

# SSH 管理网
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

本地 Yum 源(infra-yum)

nginx:alpine 托管 RPM 静态文件,宿主机 reposync + createrepo_c 定时从 USTC 同步。只保留最新版本,--delete 自动清过期包,200GB 绰绰有余。

1. 从 basic 快照链接克隆 infra-yum,移除 eth-dmz/eth-app/eth-pub(只保留 eth-infra + eth-mgmt)。额外挂一块 200GB 数据盘。开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
for iface in eth-infra eth-mgmt; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.23/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.35/24

sudo hostnamectl set-hostname infra-yum

2. 200GB 数据盘走 LVM,挂载到 /var/www/repos

1
2
3
4
5
6
7
8
lsblk
sudo pvcreate /dev/sdb
sudo vgcreate vg_yum /dev/sdb
sudo lvcreate -l 100%FREE -n lv_yum vg_yum
sudo mkfs.xfs /dev/vg_yum/lv_yum
sudo mkdir -p /var/www/repos
sudo mount /dev/vg_yum/lv_yum /var/www/repos
echo '/dev/vg_yum/lv_yum /var/www/repos xfs defaults 0 0' | sudo tee -a /etc/fstab

3. 安装同步工具,创建目录:

1
2
sudo dnf install -y dnf-utils createrepo_c
sudo mkdir -p /opt/yum

4. nginx 容器托管静态文件:

1
2
sudo vim /opt/yum/docker-compose.yml
sudo chmod 755 /opt/yum/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
nginx:
image: nginx:alpine
container_name: yum-nginx
restart: unless-stopped
ports:
- "172.16.4.23:80:80"
volumes:
- /opt/yum/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /var/www/repos:/var/www/repos:ro
networks:
- yum-net

networks:
yum-net:
driver: bridge
1
sudo vim /opt/yum/nginx.conf
1
2
3
4
5
6
server {
listen 80;
server_name _;
root /var/www/repos;
autoindex on;
}
1
2
cd /opt/yum
docker compose up -d

5. 配置上游 USTC 源并写同步脚本:

先把 basic 快照残留的默认源全部清掉:

1
sudo rm -f /etc/yum.repos.d/*.repo

OpenEuler 的四个仓库直连 USTC,debuginfo / source / update-source 全部跳过——运行时不需要调试符号和源码:

1
sudo vim /etc/yum.repos.d/ustc.repo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[openeuler-os]
name=OpenEuler OS (USTC)
baseurl=https://mirrors.ustc.edu.cn/openeuler/openEuler-24.03-LTS-SP3/OS/x86_64/
enabled=1
gpgcheck=0

[openeuler-everything]
name=OpenEuler Everything (USTC)
baseurl=https://mirrors.ustc.edu.cn/openeuler/openEuler-24.03-LTS-SP3/everything/x86_64/
enabled=1
gpgcheck=0

[openeuler-epol]
name=OpenEuler EPOL (USTC)
baseurl=https://mirrors.ustc.edu.cn/openeuler/openEuler-24.03-LTS-SP3/EPOL/main/x86_64/
enabled=1
gpgcheck=0

[openeuler-update]
name=OpenEuler Update (USTC)
baseurl=https://mirrors.ustc.edu.cn/openeuler/openEuler-24.03-LTS-SP3/update/x86_64/
enabled=1
gpgcheck=0

Docker CE 不在 USTC 上,按模板机的方式从官方获取 repo 文件——跟模板机装 Docker 时一模一样:

1
2
3
sudo curl -o /etc/yum.repos.d/docker-ce.repo https://download.docker.com/linux/centos/docker-ce.repo
sudo sed -i 's/rhel/centos/g' /etc/yum.repos.d/docker-ce.repo
sudo sed -i 's/$releasever/8/g' /etc/yum.repos.d/docker-ce.repo

两套 repo 文件、两个上游(USTC + Docker 官方),reposync 会遍历所有 enabled 的 repo,统一拉进本地 nginx 托管。集群节点只需配一个本地源地址就能同时拿到系统包和 Docker 包。

同步脚本:

1
2
sudo vim /opt/yum/sync.sh
sudo chmod +x /opt/yum/sync.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
set -e

# reposync 是 HTTP 请求,走 Squid 代理出站
export HTTP_PROXY=http://172.16.4.10:3128
export HTTPS_PROXY=http://172.16.4.10:3128

REPO_BASE=/var/www/repos

declare -A REPOS
REPOS[openeuler-os]="${REPO_BASE}/openeuler-os"
REPOS[openeuler-everything]="${REPO_BASE}/openeuler-everything"
REPOS[openeuler-epol]="${REPO_BASE}/openeuler-epol"
REPOS[openeuler-update]="${REPO_BASE}/openeuler-update"
REPOS[docker-ce-stable]="${REPO_BASE}/docker-ce"

for repoid in "${!REPOS[@]}"; do
dest="${REPOS[$repoid]}"
echo "=== syncing ${repoid} ==="
mkdir -p "$dest"
reposync --repoid="$repoid" \
--newest-only \
--delete \
--download-metadata \
--download-path="$dest"
createrepo_c --update "$dest"
echo "=== done ${repoid} ==="
done

--download-metadata 会下载上游的 repodata/,但 createrepo_c --update 之后会重生成,确保索引一致。--newest-only 是精简要害——只保留每个包的最新一个版本,不然全版本同步轻松破 500GB。

首次同步耗时较长,先手动跑一把:

1
2
sudo mkdir -p /var/log/yum
sudo /opt/yum/sync.sh 2>&1 | sudo tee /var/log/yum/sync.log

部署 systemd timer,每周日凌晨 2 点自动同步:

1
sudo vim /etc/systemd/system/sync-yum.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Sync Yum repos from USTC
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/bin/bash /opt/yum/sync.sh
StandardOutput=append:/var/log/yum/sync.log
StandardError=append:/var/log/yum/sync.log
1
sudo vim /etc/systemd/system/sync-yum.timer
1
2
3
4
5
6
7
8
9
[Unit]
Description=Sync Yum repos weekly on Sunday 2am

[Timer]
OnCalendar=Sun *-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now sync-yum.timer

每周同步一次足够——系统更新不用追着上游跑,月底巡检时一起跑就行。Persistent=true:如果周日凌晨机器没开,下次开机立刻补跑。

6. 验证:

1
2
3
4
5
6
7
8
9
10
# nginx 正常托管
curl -s -o /dev/null -w '%{http_code}' http://172.16.4.23/
# 返回 200

# 能看到 repo 目录和 repodata
curl -s http://172.16.4.23/ | head -20

# 本机先切到本地源,跑一次 dnf 安装验证全链路
sudo mv /etc/yum.repos.d/ustc.repo /tmp/
sudo vim /etc/yum.repos.d/local.repo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[openeuler-os]
name=Local OpenEuler OS
baseurl=http://172.16.4.23/openeuler-os
enabled=1
gpgcheck=0

[openeuler-everything]
name=Local OpenEuler Everything
baseurl=http://172.16.4.23/openeuler-everything
enabled=1
gpgcheck=0

[openeuler-epol]
name=Local OpenEuler EPOL
baseurl=http://172.16.4.23/openeuler-epol
enabled=1
gpgcheck=0

[openeuler-update]
name=Local OpenEuler Update
baseurl=http://172.16.4.23/openeuler-update
enabled=1
gpgcheck=0

[docker-ce]
name=Local Docker CE
baseurl=http://172.16.4.23/docker-ce
enabled=1
gpgcheck=0
1
2
sudo dnf clean all
sudo dnf install -y htop # 如果从本地源成功安装,链路通过

htop 装完无误后把 ustc.repo 恢复——infra-yum 自己仍需要 USTC 作为上游才能 reposync

1
2
3
sudo mv /tmp/ustc.repo /etc/yum.repos.d/
sudo rm /etc/yum.repos.d/local.repo
sudo dnf clean all

infra-yum 节点本身的 dnf install 仍然走 USTC(它自己就是源服务器,不需要绕一圈拉自己的源)。集群里其他节点配本地源——这个统一在 after-infra 快照环节处理。

7. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal

# HTTP (TCP 80) 对内网开放
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="80" accept' --zone=internal

# SSH 管理网
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

统一配置 TLS and after-infra

基础设施全部就绪后,把根证书分发到所有节点并逐个信任,然后在 infra-docker 和 infra-yum 上通过 ACME 在本机签发各自的服务证书,配置自动续签。最后回到模板机接入所有基础设施、拍 after-infra 快照。

之前的做法是在 infra-ca 上手工签发证书、再把 cert + key 通过物理机 scp 到目标节点。但这样一来,每加一个新服务就要来回传文件;step-ca 默认证书有效期只有 24h,总不能每天手动续签一遍。

正确的做法是借助 ACME:让每个节点自己向 CA 签发并续签证书。step-ca 原生支持 ACME 协议,只需把 step CLI 装到各个节点上即可。

分发根证书(一次性)

先在物理机上从 infra-ca 拉取根证书,再推到所有节点。这一步只做一次——后续由 step CLI 走 ACME 自动签发,无需再传文件。

物理机上:

1
2
3
4
5
6
scp deploy@172.16.3.36:/opt/step-ca/certs/root_ca.crt .

# 推到所有 infra 节点(dmz-proxy / infra-dns / infra-ntp / infra-docker / infra-yum / infra-ca)
for ip in 31 32 33 34 35 36; do
scp root_ca.crt deploy@172.16.3.$ip:/tmp/
done

infra-ca 节点上根证书已在 /opt/step-ca/certs/ 但未做系统级信任,一样要跑 cp + update-ca-trust。模板机留到 after-infra 再处理。

每个节点上执行系统级信任:

1
2
sudo cp /tmp/root_ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

验证——任意节点上能通过 HTTPS 访问 CA:

1
2
curl -s -o /dev/null -w '%{http_code}' https://ca.internal.msksbr.com:9000/health
# 返回 200(TLS 握手通过,说明系统已信任 step-ca 根证书)

infra-docker 启用 TLS

证书由 infra-ca 签发,域名 docker.internal.msksbr.com 在 DNS 里已经指向 172.16.4.22。两个 registry 容器共享同一张证书(端口不同不区分)。

0. 把 step RPM 纳入本地 Yum 源:

先把 step CLI 的 RPM 拉到 infra-yum 本地源里,这样所有节点都能 dnf install -y step,不用每台都 curl。

infra-yum 上:

1
2
3
4
cd /opt/yum
sudo mkdir -p /var/www/repos/step
sudo vim sync-step.sh
sudo chmod +x sync-step.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/bash
# 从 GitHub 拉取最新 step CLI RPM,纳入本地源
set -e

REPO_DIR="/var/www/repos/step"
LOG_FILE="/var/log/yum-sync.log"

export https_proxy=http://172.16.4.10:3128

# 用 GitHub API 拿最新版本号
LATEST=$(curl -s https://api.github.com/repos/smallstep/cli/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4)
VERSION="${LATEST#v}"

RPM_FILE="step-cli-${VERSION}-1.x86_64.rpm"
DOWNLOAD_URL="https://github.com/smallstep/cli/releases/download/${LATEST}/${RPM_FILE}"

# 检查本地是否已有同版本
if [ -f "${REPO_DIR}/${RPM_FILE}" ]; then
echo "[$(date)] step CLI ${VERSION} already synced, skip." | tee -a "$LOG_FILE"
exit 0
fi

# 下载并放入本地源
curl -Lo "/tmp/${RPM_FILE}" "$DOWNLOAD_URL"
sudo mv "/tmp/${RPM_FILE}" "$REPO_DIR/"
# 清理旧版本(只保留最新)
sudo find "$REPO_DIR" -name 'step-cli-*.rpm' ! -name "$RPM_FILE" -delete
sudo createrepo_c --update "$REPO_DIR"

echo "[$(date)] step CLI ${VERSION} synced." | tee -a "$LOG_FILE"
1
2
# 首次执行
sudo ./sync-step.sh

LATEST 走 GitHub API 拿最新 tag,VERSION=${LATEST#v} 剥掉前缀 vfind ... -delete 清理旧 RPM 只保留最新版,createrepo_c --update 增量更新元数据。

加到 cron 每周同步一次,追新版本自动入库:

1
2
3
sudo tee /etc/cron.d/step-sync << 'EOF'
0 5 * * 1 root /opt/yum/sync-step.sh
EOF

然后回到各节点的 Yum 配置里补上 step repo。会后文模板机 after-infra 的 local.repo 会一并加上;这里 infra-docker 和 infra-yum 自己先单独加:

1
2
3
4
5
6
7
sudo tee /etc/yum.repos.d/step.repo << 'EOF'
[step]
name=Local Step CLI
baseurl=http://172.16.4.23/step
enabled=1
gpgcheck=0
EOF

此时 infra-yum 还没上 TLS,URL 用 http://172.16.4.23。等后面 infra-yum 切到 HTTPS 后,再改回 https://yum.internal.msksbr.com/step

1
2
sudo dnf makecache
sudo dnf install -y step-cli

1. Bootstrap——连接 CA:

1
2
3
4
5
# 从已信任的根证书提取 fingerprint
FP=$(step certificate fingerprint /etc/pki/ca-trust/source/anchors/root_ca.crt)

# bootstrap CA 配置(ca-url 写入 ~/.step/config/defaults.json)
step ca bootstrap --ca-url https://ca.internal.msksbr.com:9000 --fingerprint "$FP"

step ca bootstrap 连上 CA 后下载根证书并写入本地配置。之后 step ca certificatestep ca renew 会自动走这个 CA URL,无需每次指定。

2. 签发证书——在 infra-docker 本机执行:

签发证书需要 provisioner 密码,先从 infra-ca 取过来。在物理机上:

1
2
scp deploy@172.16.3.36:/opt/step-ca/secrets/password ./ca-password
scp ca-password deploy@172.16.3.34:/tmp/

回到 infra-docker

1
2
3
4
5
6
7
8
9
10
11
12
sudo mkdir -p /opt/registry/certs

step ca certificate docker.internal.msksbr.com \
/opt/registry/certs/docker.crt \
/opt/registry/certs/docker.key \
--provisioner admin --password-file /tmp/ca-password

sudo chmod 600 /opt/registry/certs/docker.key
sudo chmod 644 /opt/registry/certs/docker.crt

# 密码用完即删
rm -f /tmp/ca-password

step ca certificate 走 ACME 向 CA 签发证书,--provisioner admin 指明认证身份。证书直接生成在 infra-docker 本机,无需物理机中转。

3. 更新 docker-compose.yml——两个容器各加 TLS 环境变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
services:
registry-main:
image: registry:2
container_name: registry-main
restart: unless-stopped
ports:
- "172.16.4.22:5000:5000"
environment:
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/docker.crt
- REGISTRY_HTTP_TLS_KEY=/certs/docker.key
volumes:
- /opt/registry/config/main.yml:/etc/docker/registry/config.yml:ro
- /var/lib/registry/main:/var/lib/registry
- /opt/registry/certs:/certs:ro
networks:
- registry-net

registry-proxy:
image: registry:2
container_name: registry-proxy
restart: unless-stopped
ports:
- "172.16.4.22:5001:5001"
environment:
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/docker.crt
- REGISTRY_HTTP_TLS_KEY=/certs/docker.key
- HTTP_PROXY=http://172.16.4.10:3128
- HTTPS_PROXY=http://172.16.4.10:3128
- NO_PROXY=172.16.0.0/16,localhost
volumes:
- /opt/registry/config/proxy.yml:/etc/docker/registry/config.yml:ro
- /var/lib/registry/proxy:/var/lib/registry
- /opt/registry/certs:/certs:ro
networks:
- registry-net
healthcheck:
test: ["CMD", "sh", "-c", "wget -q -O /dev/null https://localhost:5001/v2/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3

healthcheck 改为 https://localhost,HTTP 已不再暴露。

重启:

1
2
cd /opt/registry
docker compose up -d

4. 信任 CA 证书——Docker daemon 需要认可 step-ca 签发的证书:

1
2
3
4
sudo mkdir -p /etc/docker/certs.d/docker.internal.msksbr.com:5000
sudo mkdir -p /etc/docker/certs.d/docker.internal.msksbr.com:5001
sudo cp /etc/pki/ca-trust/source/anchors/root_ca.crt /etc/docker/certs.d/docker.internal.msksbr.com:5000/ca.crt
sudo cp /etc/pki/ca-trust/source/anchors/root_ca.crt /etc/docker/certs.d/docker.internal.msksbr.com:5001/ca.crt

系统级信任(update-ca-trust)对 Docker daemon 无效——Docker 用自己的一套证书目录,必须单独复制 CA 证书。

5. 去掉 insecure-registries——现在走 HTTPS 了:

1
sudo vim /etc/docker/daemon.json
1
2
3
{
"iptables": true
}
1
sudo systemctl restart docker

6. 自动续签——systemd timer:

证书有效期 24h,过期前需续签。创建 systemd service + timer,每天自动续签并重启容器。

1
sudo vim /etc/systemd/system/docker-cert-renew.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Renew Docker TLS certificate
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/step ca renew --force /opt/registry/certs/docker.crt /opt/registry/certs/docker.key
ExecStartPost=/usr/bin/docker compose -f /opt/registry/docker-compose.yml restart
1
sudo vim /etc/systemd/system/docker-cert-renew.timer
1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Daily renewal of Docker TLS certificate
Requires=docker-cert-renew.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=3600

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now docker-cert-renew.timer

step ca renew 用现有证书向 CA 发起续签(不需要 provisioner 密码),--force 强制续签即使未临期。ExecStartPost 重启 registry 容器加载新证书。RandomizedDelaySec 错开各节点续签时机,避免同时打到 CA。

7. 验证:

1
2
3
4
5
6
7
# 5000——push/pull 走 TLS
docker pull docker.internal.msksbr.com:5000/msksbr/bookmgr:latest
docker tag docker.internal.msksbr.com:5001/library/alpine docker.internal.msksbr.com:5000/test/alpine
docker push docker.internal.msksbr.com:5000/test/alpine

# 5001——pull-through proxy 走 TLS
docker pull docker.internal.msksbr.com:5001/library/alpine

infra-yum 启用 TLS

域名 yum.internal.msksbr.com 在 DNS 里已经指向 172.16.4.23。步骤与 infra-docker 一致:装 step CLI → bootstrap → 本机签发 → 配置 nginx → 续签 timer。

0. 安装 step CLI + Bootstrap:

RPM 上一步已放入本地 yum 源,这里直接本地安装:

1
2
3
4
5
# RPM 就在自己盘上,上次 sync-step.sh 已拉到本地
sudo dnf install -y /var/www/repos/step/step-cli-*.rpm

FP=$(step certificate fingerprint /etc/pki/ca-trust/source/anchors/root_ca.crt)
step ca bootstrap --ca-url https://ca.internal.msksbr.com:9000 --fingerprint "$FP"

根证书已在上一步”分发根证书”中信任,否则 bootstrap 会报 TLS 握手失败。

1. 签发证书:

物理机上传 provisioner 密码:

1
2
scp ca-password deploy@172.16.3.35:/tmp/
# ca-password 是上一步从 infra-ca 拉下来的,还在当前目录

回到 infra-yum

1
2
3
4
5
6
7
8
9
10
sudo mkdir -p /opt/yum/certs

step ca certificate yum.internal.msksbr.com \
/opt/yum/certs/yum.crt \
/opt/yum/certs/yum.key \
--provisioner admin --password-file /tmp/ca-password

sudo chmod 600 /opt/yum/certs/yum.key
sudo chmod 644 /opt/yum/certs/yum.crt
rm -f /tmp/ca-password

2. nginx 改 HTTPS:

1
sudo vim /opt/yum/nginx.conf
1
2
3
4
5
6
7
8
server {
listen 443 ssl;
server_name _;
ssl_certificate /certs/yum.crt;
ssl_certificate_key /certs/yum.key;
root /var/www/repos;
autoindex on;
}

3. docker-compose.yml 加证书挂载、端口改 443:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
services:
nginx:
image: docker.internal.msksbr.com:5001/library/nginx:alpine
container_name: yum-nginx
restart: unless-stopped
ports:
- "172.16.4.23:443:443"
volumes:
- /opt/yum/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /var/www/repos:/var/www/repos:ro
- /opt/yum/certs:/certs:ro
networks:
- yum-net

networks:
yum-net:
driver: bridge

重启:

1
2
cd /opt/yum
docker compose up -d

4. 自动续签——systemd timer(重启 nginx 容器加载新证书):

1
sudo vim /etc/systemd/system/yum-cert-renew.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Renew Yum TLS certificate
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/step ca renew --force /opt/yum/certs/yum.crt /opt/yum/certs/yum.key
ExecStartPost=/usr/bin/docker compose -f /opt/yum/docker-compose.yml restart
1
sudo vim /etc/systemd/system/yum-cert-renew.timer
1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Daily renewal of Yum TLS certificate
Requires=yum-cert-renew.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=4800

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now yum-cert-renew.timer

RandomizedDelaySec 取 4800s(80 分钟),和 infra-docker 的 3600s 错开,CA 不会在同一时刻被多个节点续签请求淹没。

5. 本机验证,再补 firewalld 443 口:

1
2
curl -s -o /dev/null -w '%{http_code}' https://yum.internal.msksbr.com/
# 返回 200
1
2
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.4.0/24" port protocol="tcp" port="443" accept' --zone=internal
sudo firewall-cmd --reload

已有基础设施节点回补配置

根证书已在上一步全节点分发并信任,这里只需回补 NTP 和确认 DNS 解析

NTP——所有节点 chronyd 指向 infra-ntp(172.16.4.21):

1
2
3
4
5
6
# 在 dmz-proxy / infra-dns / infra-ntp / infra-ca / infra-docker / infra-yum 上各执行一次
sudo sed -i 's/^pool .*/# &/' /etc/chrony.conf
sudo sed -i 's/^server .*/# &/' /etc/chrony.conf
echo "server 172.16.4.21 iburst" | sudo tee -a /etc/chrony.conf
sudo systemctl restart chronyd
chronyc sources -v

infra-ntp 自己不需要改——它本身就是上游。

DNS 和 Docker registry-mirrors——各节点统一指向 infra 服务:

DNS 在部署阶段已全部指向 172.16.4.20,无需改动。Docker daemon 的 registry-mirrorsinsecure-registries 在 TLS 升级时已移除旧配置——此时各节点 pull 镜像走 docker.internal.msksbr.com 打全标签即可。私有镜像推送到 5000,Hub 缓存走 5001。

验证 DNS 解析 + CA 信任链到位:

1
2
3
4
5
# 任意 infra 节点上
curl -s -o /dev/null -w '%{http_code}' https://docker.internal.msksbr.com:5000/v2/_catalog
# 返回 200(TLS 握手通过,CA 信任生效)
curl -s -o /dev/null -w '%{http_code}' https://yum.internal.msksbr.com/
# 返回 200

至此六个基础设施节点全部互联互通:DNS 解析内网域名、NTP 时间同步、CA 证书互信、TLS 自动续签。infra-docker 和 infra-yum 的 HTTPS 链路全网信任。

模板机 after-infra 快照

回到模板机,接入已部署的所有基础设施,拍第二张快照。之后业务层、数据层、管理网节点都从 after-infra 起手。

1. DNS → infra-dns:

1
2
3
4
5
6
7
8
# 先给模板机接上网(如果还没配 eth-infra)
sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.100/24 \
ipv4.gateway 172.16.4.10 ipv4.dns "172.16.4.20"

# 验证内外网解析
dig +short dns.internal.msksbr.com
dig +short baidu.com

2. NTP → infra-ntp:

1
2
3
4
5
sudo sed -i 's/^pool .*/# &/' /etc/chrony.conf
sudo sed -i 's/^server .*/# &/' /etc/chrony.conf
echo "server 172.16.4.21 iburst" | sudo tee -a /etc/chrony.conf
sudo systemctl restart chronyd
chronyc sources -v

3. 信任 step-ca 根证书:

根证书已在之前的步骤中分发过一轮,若模板机当时未配置 IP,则现在补传。在物理机上:

1
2
scp deploy@172.16.3.36:/opt/step-ca/certs/root_ca.crt .
scp root_ca.crt deploy@172.16.4.100:/tmp/ # 模板机 eth-infra 临时 IP,还没正式配则走控制台

模板机上:

1
2
sudo cp /tmp/root_ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

这一步很关键——后续所有走 HTTPS 的内网服务都需要系统信任内部 CA,否则 curl / dnf / docker 全部证书报错。后面克隆出来的业务/数据/管理网节点也会继承这个信任,无需再次配置。

4. Docker → infra-docker:

1
2
3
4
sudo mkdir -p /etc/docker/certs.d/docker.internal.msksbr.com:5001
sudo cp /tmp/root_ca.crt /etc/docker/certs.d/docker.internal.msksbr.com:5001/ca.crt

sudo vim /etc/docker/daemon.json
1
2
3
4
{
"iptables": true,
"registry-mirrors": ["https://docker.internal.msksbr.com:5001"]
}
1
2
3
4
5
6
sudo systemctl restart docker

# 验证——nginx:alpine 走 mirror 代理,第一次从 Hub 拉,第二次秒出
docker pull nginx:alpine
docker rmi nginx:alpine
docker pull nginx:alpine

5. Yum → infra-yum:

1
2
sudo rm -f /etc/yum.repos.d/*.repo
sudo vim /etc/yum.repos.d/local.repo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[openeuler-os]
name=Local OpenEuler OS
baseurl=https://yum.internal.msksbr.com/openeuler-os
enabled=1
gpgcheck=0

[openeuler-everything]
name=Local OpenEuler Everything
baseurl=https://yum.internal.msksbr.com/openeuler-everything
enabled=1
gpgcheck=0

[openeuler-epol]
name=Local OpenEuler EPOL
baseurl=https://yum.internal.msksbr.com/openeuler-epol
enabled=1
gpgcheck=0

[openeuler-update]
name=Local OpenEuler Update
baseurl=https://yum.internal.msksbr.com/openeuler-update
enabled=1
gpgcheck=0

[docker-ce]
name=Local Docker CE
baseurl=https://yum.internal.msksbr.com/docker-ce
enabled=1
gpgcheck=0

[step]
name=Local Step CLI
baseurl=https://yum.internal.msksbr.com/step
enabled=1
gpgcheck=0
1
2
3
sudo dnf clean all
sudo dnf makecache
sudo dnf install -y htop # 从本地源 HTTPS 安装,验证全链路

6. 清理、关机、拍快照:

1
2
3
4
5
6
7
8
sudo rm -f /etc/machine-id
sudo touch /etc/machine-id
sudo chmod 444 /etc/machine-id

sudo rm -f /etc/ssh/ssh_host_*
sudo sed -i '/^UUID/d' /etc/NetworkManager/system-connections/*.nmconnection 2>/dev/null

sudo poweroff

关机后在 VMware 里拍快照,命名为 after-infra。后续克隆业务层、数据层、管理网节点都从这里起手,DNS / NTP / CA / Docker / Yum 全部就绪,且根证书已信任——新节点只需 step ca bootstrap + step ca certificate 就能自签 TLS 证书。

数据层

应用跑起来之前,先把数据库架好。图书管理系统用 MySQL 8.0,数据落盘后用 Percona XtraBackup 定期热备。

MySQL 8.0(data-db)

1. 从 after-infra 快照链接克隆 data-db,移除 eth-dmz/eth-pub(只保留 eth-infra + eth-mgmt + eth-app),额外挂载 5 块 50GB 数据盘。开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
for iface in eth-infra eth-mgmt eth-app; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.33/24 \
ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.39/24
sudo nmcli con add con-name "net-app" ifname eth-app type ethernet \
ipv4.method manual ipv4.addresses 172.16.2.22/24

sudo hostnamectl set-hostname data-db

数据层节点不设默认网关——DNS/NTP/Docker/Yum/CA 全在直连的 infra 子网内(172.16.4.0/24),不需要经代理出站。数据库能出外网还叫什么数据库。

2. 5 块数据盘组 RAID5(4 数据 + 1 热备),再上 LVM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lsblk
# 假设新增的盘为 sdb sdc sdd sde sdf

sudo mdadm --create /dev/md0 --level=5 --raid-devices=4 --spare-devices=1 \
/dev/sdb /dev/sdc /dev/sdd /dev/sde /dev/sdf
sudo mdadm --detail --scan | sudo tee -a /etc/mdadm.conf

sudo pvcreate /dev/md0
sudo vgcreate vg_mysql /dev/md0
sudo lvcreate -l 100%FREE -n lv_mysql vg_mysql
sudo mkfs.xfs /dev/vg_mysql/lv_mysql

sudo mkdir -p /var/lib/mysql
sudo mount /dev/vg_mysql/lv_mysql /var/lib/mysql
echo '/dev/vg_mysql/lv_mysql /var/lib/mysql xfs defaults 0 0' | sudo tee -a /etc/fstab

RAID5 允许坏一块盘不丢数据,热备盘自动顶上重建。LVM 之上方便后续 lvextend 在线扩容——数据涨了加盘、扩 VG、扩 LV、xfs_growfs,不停 MySQL。

3. 安装 step CLI + 签发 TLS 证书:

MySQL 8.0 原生支持 TLS——证书由 step-ca 签发,域名 db.internal.msksbr.com 在 DNS 里已经指向 172.16.2.22。

1
2
3
4
5
# step CLI 从本地 yum 装(after-infra 已信任 CA 根证书)
sudo dnf install -y step-cli

FP=$(step certificate fingerprint /etc/pki/ca-trust/source/anchors/root_ca.crt)
step ca bootstrap --ca-url https://ca.internal.msksbr.com:9000 --fingerprint "$FP"

provisioner 密码从物理机传过来:

1
2
# 物理机
scp ca-password deploy@172.16.3.39:/tmp/

签发证书:

1
2
3
4
5
6
7
8
9
10
11
12
sudo mkdir -p /opt/mysql/certs
sudo chown deploy:deploy /opt/mysql/certs

step ca certificate db.internal.msksbr.com \
/opt/mysql/certs/db.crt \
/opt/mysql/certs/db.key \
--provisioner admin --password-file /tmp/ca-password

sudo chmod 600 /opt/mysql/certs/db.key
sudo chmod 644 /opt/mysql/certs/db.crt
sudo chown -R 999:999 /opt/mysql/certs
rm -f /tmp/ca-password

证书在 data-db 本机签发,无需物理机中转。db.internal.msksbr.com 的 A 记录已指向 172.16.2.22,内网节点通过域名走 TLS 连接即可验证证书。

4. 创建目录和配置文件:

1
sudo mkdir -p /opt/mysql/config

MySQL 配置文件——开启 GTID 复制 + ROW binlog,为后续加只读副本做准备:

1
sudo vim /opt/mysql/config/my.cnf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[mysqld]
# 基础配置
server-id = 1
bind-address = 0.0.0.0
port = 3306
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

# GTID 复制
gtid_mode = ON
enforce_gtid_consistency = ON
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
binlog_expire_logs_seconds = 604800
log_slave_updates = ON

# TLS
ssl_cert = /certs/db.crt
ssl_key = /certs/db.key
ssl_ca = /certs/ca.crt
require_secure_transport = ON

# InnoDB
innodb_buffer_pool_size = 2G
innodb_flush_log_at_trx_commit = 1
innodb_file_per_table = ON

require_secure_transport=ON 强制所有 TCP 连接走 TLS——连上了但没证书的一律拒绝。ssl_ca 指向根证书让 MySQL 能验证客户端证书链。gtid_mode=ON + binlog_format=ROW 是 GTID 复制的最低要求,后续加副本时无需改配置重起。binlog_expire_logs_seconds=604800 保留 7 天 binlog——够 XtraBackup 追增量。MySQL 8.0.46 已废弃 binlog_expire_logs_days

5. docker-compose.yml:

1
sudo vim /opt/mysql/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
services:
mysql:
image: docker.internal.msksbr.com:5001/library/mysql:8.0
container_name: mysql
restart: unless-stopped
ports:
- "172.16.2.22:3306:3306"
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/root_password
MYSQL_DATABASE: bookmgr
MYSQL_USER: bookmgr
MYSQL_PASSWORD_FILE: /run/secrets/user_password
volumes:
- /opt/mysql/config/my.cnf:/etc/mysql/conf.d/my.cnf:ro
- /var/lib/mysql:/var/lib/mysql
- /opt/mysql/certs:/certs:ro
secrets:
- root_password
- user_password
networks:
- mysql-net

xtrabackup:
image: docker.internal.msksbr.com:5001/percona/percona-xtrabackup:8.0
profiles: [backup]
network_mode: host
user: "999:999"
volumes:
- /var/lib/mysql:/var/lib/mysql:ro
- /opt/mysql/certs:/certs:ro
- /opt/mysql/secrets:/run/secrets:ro

secrets:
root_password:
file: /opt/mysql/secrets/root_password
user_password:
file: /opt/mysql/secrets/user_password

networks:
mysql-net:
driver: bridge

生成密码文件:

1
2
3
4
sudo mkdir -p /opt/mysql/secrets
openssl rand -base64 24 | sudo tee /opt/mysql/secrets/root_password > /dev/null
openssl rand -base64 24 | sudo tee /opt/mysql/secrets/user_password > /dev/null
sudo chmod 600 /opt/mysql/secrets/*

MySQL 8.0 镜像支持 _FILE 后缀的环境变量,从 Docker secret 文件读密码——不暴露在 docker inspect 或环境变量中。

把根证书复制到 certs 目录,MySQL 需要它验证客户端证书链:

1
2
sudo cp /etc/pki/ca-trust/source/anchors/root_ca.crt /opt/mysql/certs/ca.crt
sudo chmod 644 /opt/mysql/certs/ca.crt

6. 启动并导入数据:

1
2
3
cd /opt/mysql
docker compose up -d
docker compose logs -f mysql # 等初始化完成

MYSQL_DATABASE + MYSQL_USER 环境变量已让 MySQL 自动建好 bookmgr 库和用户,接下来只差表。物理机从 git.msksbr.com/msksbr/bookMgr 拉取 SQL 文件,scp 到 data-db 的 /tmp/,然后导入:

1
2
3
4
ROOT_PW=$(sudo cat /opt/mysql/secrets/root_password)
docker cp /tmp/bookmgr.schema.sql mysql:/tmp/
docker exec -i mysql mysql -uroot -p"$ROOT_PW" bookmgr < /tmp/bookmgr.schema.sql
# 有测试数据的话同理导入 bookm.sql

不用 docker-entrypoint-initdb.d 那套——那个只在数据目录为空时跑一次,后面想加表还得进来 exec,还不如一开始就手动导入来得直接。作者用的是带测试条目的版本bookmgr.sql,读者可以只导 bookmgr.schema.sql

MYSQL_USER 环境变量自动创建的用户不带 REQUIRE SSL,补上:

1
2
3
4
ROOT_PW=$(sudo cat /opt/mysql/secrets/root_password)
docker compose exec mysql mysql -uroot -p"$ROOT_PW" -e "
ALTER USER 'bookmgr'@'%' REQUIRE SSL;
"

docker compose exec 走的是容器内的 Unix socket,不受 require_secure_transport 限制——本地管理操作不受影响。REQUIRE SSL 只限制 TCP 远程连接。

MYSQL_USER 自动创建的 bookmgr 用户默认拥有 bookmgr 库的全部权限,含 DDL(DROP/ALTER/CREATE)。应用只需要 CRUD,收回多余权限:

1
2
3
4
5
docker compose exec mysql mysql -uroot -p"$ROOT_PW" -e "
REVOKE ALL PRIVILEGES ON bookmgr.* FROM 'bookmgr'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON bookmgr.* TO 'bookmgr'@'%';
FLUSH PRIVILEGES;
"

最小权限原则:应用账号只管增删改查,DDL 留给 root 通过 socket 本地操作。

验证:

1
2
3
4
5
6
ROOT_PW=$(sudo cat /opt/mysql/secrets/root_password)
docker compose exec mysql mysql -uroot -p"$ROOT_PW" -e "
SELECT @@gtid_mode, @@binlog_format, @@server_id;
SHOW DATABASES;
USE bookmgr; SHOW TABLES;
"

7. 证书自动续签:

和 infra-docker 一样,systemd timer 每天续签并重启容器。

1
sudo vim /etc/systemd/system/mysql-cert-renew.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Renew MySQL TLS certificate
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/step ca renew --force /opt/mysql/certs/db.crt /opt/mysql/certs/db.key
ExecStartPost=/usr/bin/docker compose -f /opt/mysql/docker-compose.yml restart
1
sudo vim /etc/systemd/system/mysql-cert-renew.timer
1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Daily renewal of MySQL TLS certificate
Requires=mysql-cert-renew.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=5400

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now mysql-cert-renew.timer

RandomizedDelaySec=5400(90 分钟)和 docker/yum 的错开,CA 不会被同时打到。

验证 TLS:

1
2
3
4
ROOT_PW=$(sudo cat /opt/mysql/secrets/root_password)
docker compose exec mysql mysql -uroot -p"$ROOT_PW" -e "SHOW VARIABLES LIKE '%ssl%';"
# have_ssl = YES
# require_secure_transport = ON

8. XtraBackup 热备——全量 + 增量,流式推到 data-backup:

XtraBackup 必须跑在 data-db 上(需要本地访问 MySQL datadir),备份结果通过 SSH 管道流到 data-backup 异地存储。

创建备份专用 OS 用户——backup,组 app

1
2
sudo groupadd -g 2001 app
sudo useradd -m -u 2001 -g app -G docker -s /bin/bash backup

backup 用户 uid/gid 与 data-backup 保持一致。加入 docker 组才能跑 docker compose

SSH 密钥——data-db → data-backup:

1
2
3
sudo -u backup ssh-keygen -t ed25519 -f /home/backup/.ssh/id_ed25519 -N ""
# 查看公钥,手动传到 data-backup 的 backup 用户 authorized_keys(backup 用户不设密码,无法 ssh-copy-id)
sudo cat /home/backup/.ssh/id_ed25519.pub

备份脚本:

1
2
3
sudo mkdir -p /opt/mysql/backup
sudo vim /opt/mysql/backup/backup.sh
sudo chmod +x /opt/mysql/backup/backup.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/bash
set -e

BACKUP_HOST="172.16.2.23"
BACKUP_DIR="/var/lib/backup"
LOG_FILE="/var/log/mysql-backup.log"
COMPOSE_FILE="/opt/mysql/docker-compose.yml"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DAY_OF_WEEK=$(date +%u)
BACKUP_PW=$(cat /opt/mysql/secrets/backup_password)

[ "$DAY_OF_WEEK" -eq 7 ] && TYPE="full" || TYPE="inc"

# 增量需要基于最近一次全量,找不到全量则退化为全量
if [ "$TYPE" = "inc" ]; then
LATEST_FULL=$(ssh backup@"$BACKUP_HOST" "ls -d $BACKUP_DIR/full_* 2>/dev/null | tail -1")
[ -z "$LATEST_FULL" ] && TYPE="full"
fi

DIR="${BACKUP_DIR}/${TYPE}_${TIMESTAMP}"
ssh backup@"$BACKUP_HOST" "mkdir -p $DIR"

docker compose -f "$COMPOSE_FILE" --profile backup run --rm xtrabackup \
xtrabackup \
--host=172.16.2.22 --port=3306 \
--user=backup --password="$BACKUP_PW" \
--ssl-mode=VERIFY_CA --ssl-ca=/certs/ca.crt \
--backup --stream=xbstream --target-dir=/tmp \
--datadir=/var/lib/mysql \
2>> "$LOG_FILE" \
| ssh backup@"$BACKUP_HOST" \
"docker run --rm -i --user 2001:2001 \
-v /var/lib/backup:/var/lib/backup \
docker.internal.msksbr.com:5001/percona/percona-xtrabackup:8.0 \
xbstream -x -C $DIR"

xtrabackup 走 TCP 连接本机 MySQL 172.16.2.22:3306network_mode: host 时默认走 socket 连不到容器),读 /var/lib/mysql 数据文件做物理备份,--stream=xbstream 输出到 stdout,SSH 管道推到 data-backup 后由对端容器里的 xbstream -x 解开落盘。profiles: [backup] 保证 docker compose up -d 时不会启动此服务——仅 cron 按需 invoke。

备份专用 MySQL 用户 + 密码:

1
2
3
4
5
6
7
8
9
10
11
12
# 在 data-db 上,用 root 创建备份专用用户
BACKUP_PW=$(openssl rand -base64 24)
echo "$BACKUP_PW" | sudo tee /opt/mysql/secrets/backup_password > /dev/null
sudo chmod 640 /opt/mysql/secrets/backup_password
sudo chgrp app /opt/mysql/secrets/backup_password

ROOT_PW=$(sudo cat /opt/mysql/secrets/root_password)
docker compose exec mysql mysql -uroot -p"$ROOT_PW" -e "
CREATE USER 'backup'@'172.16.2.22' IDENTIFIED BY '$BACKUP_PW' REQUIRE SSL;
GRANT BACKUP_ADMIN, PROCESS, LOCK TABLES, SELECT, REPLICATION CLIENT ON *.* TO 'backup'@'172.16.2.22';
FLUSH PRIVILEGES;
"

@'172.16.2.22' 而非 @'localhost'——MySQL 把 TCP 连接和 socket 视为不同来源,network_mode: host 走 TCP 时 xtrabackup 来源 IP 就是本机 172.16.2.22@'localhost' 只认 socket。

Cron + 日志文件权限:

1
2
3
4
5
6
7
8
# 日志文件归 backup 用户
sudo touch /var/log/mysql-backup.log
sudo chown backup:app /var/log/mysql-backup.log

sudo tee /etc/cron.d/mysql-backup << 'EOF'
0 3 * * 7 backup /opt/mysql/backup/backup.sh # 周日全量
0 3 * * 1-6 backup /opt/mysql/backup/backup.sh # 周一到六增量
EOF

手动跑一次验证:

1
sudo -i -u backup /opt/mysql/backup/backup.sh
1
2
# 在 data-backup 上检查产物
ssh backup@172.16.2.23 "ls -la /var/lib/backup/full_*/"

备份目录有 xtrabackup_checkpoints 文件、日志无报错,全链路通。后续 cron 到点自动执行。

9. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-app --zone=internal

# 业务网允许 3306(app-backend 需要连)
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.2.0/24" port protocol="tcp" port="3306" accept' --zone=internal

# 管理网 SSH
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

只有 app-backend(172.16.2.21)会连 3306,但放行整个 /24 也不会有其他节点访问——内网没有多余的服务。

data-backup 异地存储

data-backup 是纯存储节点——不跑任何备份任务。备份由 data-db 的 xtrabackup 容器执行,经 SSH 管道流式推到本机落盘。只需要 RAID 存储 + SSH 接收。

1. 从 after-infra 快照链接克隆 data-backup,移除 eth-dmz/eth-pub(只保留 eth-infra + eth-mgmt + eth-app),额外挂载 5 块 50GB 数据盘。开机,组 RAID5 + LVM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lsblk
# 新增的盘为 sdb sdc sdd sde sdf

sudo mdadm --create /dev/md0 --level=5 --raid-devices=4 --spare-devices=1 \
/dev/sdb /dev/sdc /dev/sdd /dev/sde /dev/sdf
sudo mdadm --detail --scan | sudo tee -a /etc/mdadm.conf

sudo pvcreate /dev/md0
sudo vgcreate vg_backup /dev/md0
sudo lvcreate -l 100%FREE -n lv_backup vg_backup
sudo mkfs.xfs /dev/vg_backup/lv_backup

sudo mkdir -p /var/lib/backup
sudo mount /dev/vg_backup/lv_backup /var/lib/backup
echo '/dev/vg_backup/lv_backup /var/lib/backup xfs defaults 0 0' | sudo tee -a /etc/fstab

配置 IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
for iface in eth-infra eth-mgmt eth-app; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.34/24 \
ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.40/24
sudo nmcli con add con-name "net-app" ifname eth-app type ethernet \
ipv4.method manual ipv4.addresses 172.16.2.23/24

sudo hostnamectl set-hostname data-backup

预拉取接收端需要的镜像——data-db 的备份流通过 SSH 管道过来后,由 xbstream -x 解开落盘:

1
docker pull docker.internal.msksbr.com:5001/percona/percona-xtrabackup:8.0

2. 创建备份专用用户——backup,组 app

1
2
3
4
5
6
7
8
sudo groupadd -g 2001 app
sudo useradd -m -u 2001 -g app -G docker -s /bin/bash backup
sudo mkdir -p /home/backup/.ssh
sudo chmod 700 /home/backup/.ssh
sudo chown -R backup:app /home/backup

# backup 目录归 backup 用户
sudo chown -R backup:app /var/lib/backup

backup 用户给 uid/gid 2001,与应用层服务保持一致,后面 bookmgr 后端也会归入 app 组。/var/lib/backupbackup:app,备份流写到这个目录不需要 root。

模板机 sshd 只允许 deploy,加上 backup

1
2
sudo sed -i 's/AllowUsers deploy/AllowUsers deploy backup/' /etc/ssh/sshd_config.d/99-hardening.conf
sudo systemctl reload sshd

3. 手动传 SSH 公钥:

data-db 上 backup 用户的公钥需要手动放到 data-backup 的 backup 用户 authorized_keys——backup 用户不设密码,ssh-copy-id 走不通。

data-db 上:

1
2
sudo cat /home/backup/.ssh/id_ed25519.pub
# 复制输出内容

回到 data-backup

1
2
3
4
5
sudo tee /home/backup/.ssh/authorized_keys << 'EOF'
# 粘贴 data-db 的公钥
EOF
sudo chmod 600 /home/backup/.ssh/authorized_keys
sudo chown backup:app /home/backup/.ssh/authorized_keys

这就是我说的”只能手动传”——生产环境的服务账户不设密码、不开 PasswordAuthentication,公钥必须由管理员亲自放进去。

4. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-app --zone=internal

# data-db 通过业务网 SSH 推送备份流
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.2.22" port protocol="tcp" port="22" accept' --zone=internal

# 管理网 SSH
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

data-backup 只接受 SSH 入站——data-db 推送备份流、管理网运维。

应用层

app-backend(Spring Boot 后端)

bookMgr 后端——Spring Boot 4.0 + Kotlin + MyBatis-Plus,JWT + Argon2 认证,等保三级审计日志。源码在 git.msksbr.com/msksbr/bookMgrdeploy.sh 构建 Docker 镜像并推到 Gitea Registry。

0. 镜像准备——推到本地私有仓库:

app-backend 没有默认网关,拉不了 Gitea Registry。在能访问互联网的机器上(dev 机或 infra-docker)先把镜像从 Gitea 拖下来,再推到本地私有仓库:

1
2
3
4
5
# 在能上网的机器上
docker pull git.msksbr.com/msksbr/bookmgr:v0.1f
docker tag git.msksbr.com/msksbr/bookmgr:v0.1f \
docker.internal.msksbr.com:5000/msksbr/bookmgr:v0.1f
docker push docker.internal.msksbr.com:5000/msksbr/bookmgr:v0.1f

本地 Registry 在 5000 端口(私有仓库),5001 是 Hub 代理缓存。私有镜像一律走 5000。

1. 从 after-infra 快照链接克隆 app-backend,移除 eth-pub/eth-dmz(保留 eth-infra + eth-mgmt + eth-app)。开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
for iface in eth-infra eth-mgmt eth-app; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.32/24 \
ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.38/24
sudo nmcli con add con-name "net-app" ifname eth-app type ethernet \
ipv4.method manual ipv4.addresses 172.16.2.21/24

sudo hostnamectl set-hostname app-backend

同 data-db,无默认网关——所有依赖(DNS/Yum/Docker/CA)全在直连的 infra 子网。

2. step CLI 签发 TLS 证书:

DNS 里已有 backend.internal.msksbr.com → 172.16.2.21(infra-dns 部署时加的),后端就用这个域名,不用新建。

验证 DNS:

1
2
dig @172.16.4.20 backend.internal.msksbr.com +short
# 172.16.2.21

在 app-backend 上安装 step CLI 并签发证书:

1
2
3
4
sudo dnf install -y step-cli

FP=$(step certificate fingerprint /etc/pki/ca-trust/source/anchors/root_ca.crt)
step ca bootstrap --ca-url https://ca.internal.msksbr.com:9000 --fingerprint "$FP"

provisioner 密码从物理机传过来:

1
2
# mgmt-bastion(或任意能 SSH 到 app-backend 的机器)
scp ca-password deploy@172.16.3.38:/tmp/

签发:

1
2
3
4
5
6
7
8
9
10
11
sudo mkdir -p /opt/bookmgr/certs
sudo chown deploy:deploy /opt/bookmgr/certs

step ca certificate backend.internal.msksbr.com \
/opt/bookmgr/certs/backend.crt \
/opt/bookmgr/certs/backend.key \
--provisioner admin --password-file /tmp/ca-password

sudo chmod 600 /opt/bookmgr/certs/backend.key
sudo chmod 644 /opt/bookmgr/certs/backend.crt
rm -f /tmp/ca-password

证书 CN 为 backend.internal.msksbr.com,WAF 通过这个域名走 HTTPS 连后端。

3. 目录结构 + 环境变量 + docker-compose.yml:

1
2
sudo mkdir -p /opt/bookmgr /var/log/bookmgr
sudo chown -R deploy:deploy /opt/bookmgr

bookmgr 数据库用户的密码在 data-db 上,先从管理网传过来:

1
2
3
# data-db 上查看密码
sudo cat /opt/mysql/secrets/user_password
# 复制输出

回到 app-backend,生成 JWT 密钥,填入刚才复制的 DB 密码:

1
2
JWT_SECRET=$(openssl rand -base64 48)
DB_PW='<从 data-db 复制的密码>'

JWT 密钥 48 字节 base64 → 64 字符,满足 HMAC-SHA256 最低 32 字节要求。

.env 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
sudo tee /opt/bookmgr/.env << EOF
# 数据库
DB_DRIVER=com.mysql.cj.jdbc.Driver
DB_TYPE=mysql
DB_URL=172.16.2.22
DB_PORT=3306
DB_NAME=bookmgr
DB_USER=bookmgr
DB_PASSWORD=${DB_PW}
# JWT
JWT_SECRET=${JWT_SECRET}
# 日志
LOG_PATH=/var/log/bookmgr
LOG_EXPORT_AUDIT=true
LOG_EXPORT_RUNTIME=true
LOG_LEVEL=INFO
# TLS(Spring Boot 3.1+ 原生 PEM 支持)
SERVER_SSL_CERTIFICATE=/certs/backend.crt
SERVER_SSL_CERTIFICATE_PRIVATE_KEY=/certs/backend.key
SERVER_SSL_ENABLED=true
SERVER_PORT=8443
EOF
sudo chmod 600 /opt/bookmgr/.env
sudo chown deploy:deploy /opt/bookmgr/.env

docker-compose.yml

1
sudo vim /opt/bookmgr/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
bookmgr:
image: docker.internal.msksbr.com:5000/msksbr/bookmgr:v0.1f
container_name: bookmgr
restart: unless-stopped
ports:
- "172.16.2.21:8443:8443"
env_file:
- /opt/bookmgr/.env
volumes:
- /opt/bookmgr/certs:/certs:ro
- /var/log/bookmgr:/var/log/bookmgr
networks:
- app-net

networks:
app-net:
driver: bridge

Spring Boot 4.0 原生支持 PEM 证书——SERVER_SSL_CERTIFICATE + SERVER_SSL_CERTIFICATE_PRIVATE_KEY 直接加载,不需要 keystore。端口 8443,WAF 走 HTTPS 连接。env_file 而非 environment——敏感值(JWT / DB 密码)不暴露在 docker inspect 中。

4. 拉镜像 + 启动:

1
2
3
4
cd /opt/bookmgr
docker compose pull
docker compose up -d
docker compose logs -f # 等 Spring Boot 启动完成

验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
CA_CERT="/etc/pki/ca-trust/source/anchors/root_ca.crt"
BASE="https://backend.internal.msksbr.com:8443"

# 健康测试——/api/auth/logout 是 no-op,POST 空跑测路由可用性
curl --cacert "$CA_CERT" -s -o /dev/null -w "HTTP %{http_code}" \
-X POST "$BASE/api/auth/logout"
# 应输出 200

# 查所有图书(无需认证)
curl --cacert "$CA_CERT" -s "$BASE/api/books/getall" | python3 -m json.tool

# 搜索图书
curl --cacert "$CA_CERT" -s "$BASE/api/books/search?query=kotlin" | python3 -m json.tool

logout 返回 200 说明路由通了;getall 返回 15 条图书、search 命中 “Kotlin实战”,中文不乱码——HTTPS → Spring Boot → MyBatis → MySQL(TLS 加密连接)全链路通。

5. TLS 证书自动续签:

1
sudo vim /etc/systemd/system/bookmgr-cert-renew.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Renew bookmgr TLS certificate
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/step ca renew --force /opt/bookmgr/certs/backend.crt /opt/bookmgr/certs/backend.key
ExecStartPost=/usr/bin/docker compose -f /opt/bookmgr/docker-compose.yml restart
1
sudo vim /etc/systemd/system/bookmgr-cert-renew.timer
1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Daily renewal of bookmgr TLS certificate
Requires=bookmgr-cert-renew.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=2700

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now bookmgr-cert-renew.timer

RandomizedDelaySec=2700(45 分钟)——MySQL 用了 5400,bookmgr 用 2700,继续错开。

6. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-app --zone=internal

# WAF 走业务网访问 8443(HTTPS)
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.2.10" port protocol="tcp" port="8443" accept' --zone=internal

# 管理网 SSH
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

8443 只放 WAF(172.16.2.10),其他节点一律不通。管理网走 SSH,不需要访问应用端口。

app-frontend(React 前端)

bookMgr 前端——React 19 + TypeScript + Vite,shadcn/ui + Tailwind CSS,SPA 单页应用。源码在 git.msksbr.com/msksbr/bookMgr-client,多阶段 Docker 构建:node:22-alpine 编译 → nginx:alpine 托管 dist/,默认监听 80,try_files $uri /index.html 做 SPA fallback。

0. 镜像准备——推到本地私有仓库:

1
2
3
4
5
# 在能上网的机器上
docker pull git.msksbr.com/msksbr/bookmgr-client:latest
docker tag git.msksbr.com/msksbr/bookmgr-client:latest \
docker.internal.msksbr.com:5000/msksbr/bookmgr-client:latest
docker push docker.internal.msksbr.com:5000/msksbr/bookmgr-client:latest

1. 从 after-infra 快照链接克隆 app-frontend,移除 eth-pub/eth-dmz(保留 eth-infra + eth-mgmt + eth-app)。开机配置 IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
for iface in eth-infra eth-mgmt eth-app; do
sudo nmcli con delete "$iface" 2>/dev/null
done

sudo nmcli con add con-name "net-infra" ifname eth-infra type ethernet \
ipv4.method manual ipv4.addresses 172.16.4.31/24 \
ipv4.dns "172.16.4.20"
sudo nmcli con add con-name "net-mgmt" ifname eth-mgmt type ethernet \
ipv4.method manual ipv4.addresses 172.16.3.37/24
sudo nmcli con add con-name "net-app" ifname eth-app type ethernet \
ipv4.method manual ipv4.addresses 172.16.2.20/24

sudo hostnamectl set-hostname app-frontend

2. step CLI + 签发 TLS 证书:

1
2
3
4
sudo dnf install -y step-cli

FP=$(step certificate fingerprint /etc/pki/ca-trust/source/anchors/root_ca.crt)
step ca bootstrap --ca-url https://ca.internal.msksbr.com:9000 --fingerprint "$FP"

provisioner 密码从管理网传:

1
2
# mgmt-bastion
scp /tmp/ca-password deploy@172.16.3.37:/tmp/

签发:

1
2
3
4
5
6
7
8
9
10
11
sudo mkdir -p /opt/frontend/certs
sudo chown deploy:deploy /opt/frontend/certs

step ca certificate frontend.internal.msksbr.com \
/opt/frontend/certs/frontend.crt \
/opt/frontend/certs/frontend.key \
--provisioner admin --password-file /tmp/ca-password

sudo chmod 600 /opt/frontend/certs/frontend.key
sudo chmod 644 /opt/frontend/certs/frontend.crt
rm -f /tmp/ca-password

3. nginx SSL 配置 + docker-compose.yml:

原镜像的 nginx.conf 只配了 80 明文。自定义一份加上 TLS:

1
2
sudo mkdir -p /opt/frontend
sudo vim /opt/frontend/nginx.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
listen 443 ssl;
server_name frontend.internal.msksbr.com;

ssl_certificate /certs/frontend.crt;
ssl_certificate_key /certs/frontend.key;

root /usr/share/nginx/html;

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_vary on;

location / {
try_files $uri /index.html;
}
}

docker-compose.yml

1
sudo vim /opt/frontend/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
frontend:
image: docker.internal.msksbr.com:5000/msksbr/bookmgr-client:latest
container_name: frontend
restart: unless-stopped
ports:
- "172.16.2.20:443:443"
volumes:
- /opt/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /opt/frontend/certs:/certs:ro
networks:
- front-net

networks:
front-net:
driver: bridge

4. 拉镜像 + 启动:

1
2
3
cd /opt/frontend
docker compose pull
docker compose up -d

验证——直接走 443 标准端口,无需指定端口号:

1
2
3
curl --cacert /etc/pki/ca-trust/source/anchors/root_ca.crt \
https://frontend.internal.msksbr.com
# 返回 index.html——SPA 入口已送达

5. TLS 证书自动续签:

1
sudo vim /etc/systemd/system/frontend-cert-renew.service
1
2
3
4
5
6
7
8
9
[Unit]
Description=Renew frontend TLS certificate
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/step ca renew --force /opt/frontend/certs/frontend.crt /opt/frontend/certs/frontend.key
ExecStartPost=/usr/bin/docker compose -f /opt/frontend/docker-compose.yml restart
1
sudo vim /etc/systemd/system/frontend-cert-renew.timer
1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Daily renewal of frontend TLS certificate
Requires=frontend-cert-renew.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1800

[Install]
WantedBy=timers.target
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now frontend-cert-renew.timer

RandomizedDelaySec=1800(30 分钟)——MySQL 5400、bookmgr 2700、frontend 1800,继续错开。

6. 锁门——firewalld:

1
2
3
4
5
6
7
8
9
10
11
sudo firewall-cmd --permanent --change-interface=eth-infra --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-mgmt --zone=internal
sudo firewall-cmd --permanent --change-interface=eth-app --zone=internal

# WAF 走业务网访问 443
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.2.10" port protocol="tcp" port="443" accept' --zone=internal

# 管理网 SSH
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.3.0/24" port protocol="tcp" port="22" accept' --zone=internal

sudo firewall-cmd --reload

443 只放 WAF(172.16.2.10)。前端不需要对外暴露——WAF 是唯一入口。

  • 标题: 企业级部署全栈图书管理系统:服务 + 加密 + 网络隔离 + WAF、DNS、本地源、NTP、异地备份
  • 作者: 御坂スバル
  • 创建于 : 2026-05-27 00:36:00
  • 更新于 : 2026-05-31 11:23:44
  • 链接: https://msksbr.com/2026/05/27/bookMgr-deploy/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论