Ansible角色
Ansible角色
[TOC]
彩蛋
Ansible 有一个非常有趣的小彩蛋:当它检测到你的系统中安装了 cowsay 时,就会自动调用它,把原本枯燥的任务名称(比如 PLAY RECAP、TASK [...])放进小牛的对话气泡里,让输出界面变得生动有趣
____________< PLAY RECAP > ------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||Client: ok=5 changed=3 failed=0 skipped=0 ignored=0Server: ok=8 changed=6 failed=0 skipped=0 ignored=0除了默认的小牛,cowsay 还有很多其他 ASCII 艺术图案
- 查看可用图案:你可以运行
cowsay -l来查看所有可用的图案
[root@Zabbix ~]# cowsay -lCow files in /usr/share/cowsay:cheese cower default dragon dragon-and-cow elephant elephant-in-snake eyes milk.....指定图案:你可以通过修改 Ansible 的配置文件 /etc/ansible/ansible.cfg 来指定默认使用的图案
# 在 [defaults] 部分添加或修改[defaults]# 设置为 'dragon',下次运行就会看到一条龙在说话cow_selection = dragon🚫 觉得太花哨?关掉它!
- 个人觉得比默认的
*好玩多了
编辑 Ansible 的主配置文件 /etc/ansible/ansible.cfg
# 在 [defaults] 部分[defaults]nocows = 1概述
- ==roles== 是对playbook的一种重新编排
🛠️ 如何创建角色?'ansible-galaxy init 角色名'# 它能一键生成标准的目录结构[root@m01 ansible]# mkdir roles[root@m01 ansible]# cd roles/[root@m01 roles]# ansible-galaxy init backup- Role backup was created successfully# roles/backup--->专门用来备份'当然我们还可以在roles目录下创建别的'[root@m01 roles]# tree ./backup/./backup/├── defaults ❌️│ └── main.yml├── files├── handlers│ └── main.yml├── meta ❌️│ └── main.yml├── README.md ❌️├── tasks│ └── main.yml├── templates├── tests ❌️│ ├── inventory│ └── test.yml└── vars └── main.yml# 我们先把没有用的删除掉![root@m01 roles]# cd backup/[root@m01 backup]# rm -rf defaults/ meta/ tests/ README.md[root@m01 backup]# tree.├── files # 静态文件├── handlers # 触发器│ └── main.yml✅️自动创建├── tasks # 核心任务│ └── main.yml✅️自动创建├── templates # 模版文件(.j2)└── vars # 固定变量 └── main.yml✅️自动创建# 我们经常用的就这几个!✅️我们直接在main.yml里面编写即可!backup重构
1)拷贝配置文件# 到template目录下[root@m01 backup]# cp ~/rsyncd.conf ./templates/rsyncd.conf.j2# rsync的配置文件[root@m01 backup]# cd templates/[root@m01 templates]# echo rsync_backup:123 > rsync.passwd.j2[root@m01 templates]# lsrsyncd.conf.j2 rsync.passwd.j2# 配置文件和密码文件[root@m01 templates]# tree ../templates/../templates/├── rsyncd.conf.j2└── rsync.passwd.j2
2)编辑核心剧本[root@m01 templates]# cd ..[root@m01 backup]# cd ..# 要在roles目录下创建playbook[root@m01 roles]# vim site.yml# 从hosts开始写- hosts: backup# 后面要改为all,会有不同的角色! roles: - role: backup when: ansible_hostname is match "backup" # when判断和role对齐 # 只有主机为backup才执行
3)恢复快照&&免密登录[root@m01 roles]# ssh-copy-id -i ~/.ssh/my_key.pub 172.16.1.41[root@m01 roles]# ssh backup "hostname -I"10.0.0.41 172.16.1.41'密码成功'
4)检测与测试[root@m01 roles]# ansible-playbook --syntax-check site.ymlplaybook: site.yml[root@m01 roles]# ansible-playbook site.ymlPLAY [backup]**********************TASK [Gathering Facts]ok: [backup]'收集信息'**********************TASK [backup : Install rsync server]ok: [backup]'安装rsync'**********************TASK [backup : Configure rsync file]changed: [backup] => (item={'src': 'rsyncd.conf.j2', 'dest': '/etc/rsyncd.conf', 'mode': '0644'})changed: [backup] => (item={'src': 'rsync.passwd.j2', 'dest': '/etc/rsync.passwd', 'mode': '0600'})# 拷贝文件--->配置文&&密码文件**********************TASK [backup : Create group "www"]changed: [backup]"创建组"**********************TASK [backup : Create user "www"]changed: [backup]'创建用户'**********************TASK [backup : Create dir]changed: [backup] => (item=/backup)changed: [backup] => (item=/data)'创建目录'**********************TASK [backup : Start rsync server]changed: [backup]'启动&&开机自启'
'测试验证'[root@m01 roles]# rsync -avz /etc/passwd rsync_backup@172.16.1.41::backupPassword: ---'密码123'sent 841 bytes received 43 bytes 353.60 bytes/sec[root@backup ~]#ls /backup/passwd/backup/passwd- roles/backup/vars/main.yml
- 存放着固定变量
user: wwwuid: 888rc_port: 873dir_1: /backupdir_2: /data- roles/backup/template/rsyncd.conf.j2
- 模版配置文件
uid = {{user}}gid = {{user}}port = {{rc_port}}fake super = yesuse chroot = nomax connections = 200timeout = 600ignore errorsread only = falselist = falseauth users = rsync_backupsecrets file = /etc/rsync.passwdpid file = /var/run/rsyncd.pidlock file = /var/run/rsync.locklog file =/var/log/rsyncd.log############################[backup]path = {{dir_1}}[data]path = {{dir_2}}- roles/backup/tasks/main.yml
- 任务核心
- name: Install rsync server yum: name: rsync state: present
- name: Configure rsync file template: src: "{{item.src}}" dest: "{{item.dest}}" mode: "{{item.mode}}" notify: Restart rsync # 只要修改了,就触发handler重启 loop: # src能够自动识别template里面的模版文件 - src: rsyncd.conf.j2 dest: /etc/rsyncd.conf mode: "0644" - src: rsync.passwd.j2 dest: /etc/rsync.passwd mode: "0600"
- name: Create group "{{user}}" group: name: "{{user}}" gid: "{{uid}}"
- name: Create user "{{user}}" user: name: "{{user}}" uid: "{{uid}}" group: "{{user}}" shell: /sbin/nologin create_home: false
- name: Create dir file: path: "{{item}}" state: directory owner: "{{user}}" group: "{{user}}" loop: # 因为变量没有在路径中所以要加引号 # /tmp/{{dir}}-->这种不需要加引号 - "{{dir_1}}" - "{{dir_2}}"
- name: Start rsync server systemd: name: rsyncd state: started enabled: yes- roles/backup/handlers/main.yml
- 触发器,修改配置文件--->给handler发信号
- name: Restart rsync# 和notify的名字相同 systemd: name: rsyncd state: restartednfs重构
1)恢复快照&&免密[root@m01 roles]# ssh-copy-id -i ~/.ssh/my_key.pub 172.16.1.31[root@m01 roles]# ssh nfs01 "hostname -I"10.0.0.31 172.16.1.31
2)创建角色[root@m01 roles]# ansible-galaxy init nfs- Role nfs was created successfully[root@m01 roles]# cd nfs/[root@m01 nfs]# lsdefaults files handlers meta README.md tasks templates tests vars[root@m01 nfs]# rm -rf defaults/ meta/ README.md tests/[root@m01 nfs]# lsfiles handlers tasks templates vars
3)拷贝配置文件[root@m01 nfs]# cp /home/ansible/exports ./templates/exports.j2[root@m01 nfs]# cat ./templates/exports.j2{{dir}} 172.16.1.0/24(rw,sync,all_squash,anonuid={{ id }},anongid={{ id }})
4)编辑核心剧本[root@m01 nfs]# cd ..# 继续上一个的写![root@m01 roles]# vim site.yml- hosts: all# 注意看自己的主机清单! roles: - role: backup when: ansible_hostname is match "backup" # when判断和role对齐 # 只有主机为backup才执行 - role: nfs when: ansible_hostname is match "nfs"[root@m01 roles]# tree nfs/nfs/├── files├── handlers│ └── main.yml├── tasks│ └── main.yml├── templates│ └── exports.j2└── vars └── main.yml
5)测试&验证[root@m01 roles]# ansible-playbook --syntax-check site.ymlplaybook: site.yml[root@m01 roles]# showmount -e 172.16.1.31clnt_create: RPC: ❌️Unable to receive[root@m01 roles]# ansible-playbook site.ymlTASK [backup : Install rsync server]skipping: [db02]skipping: [nfs01]skipping: [web01]skipping: [web02]ok: [backup]'只有backup执行,其他都跳过'**********************.................'先跑第一个角色backup,再跑nfs'**********************TASK [nfs : Install nfs server]skipping: [db02]skipping: [backup]skipping: [web02]skipping: [web01]ok: [nfs01]'只有nfs执行!!'**********************nfs01 : ok=7 changed=6[root@m01 roles]# showmount -e 172.16.1.31Export list for 172.16.1.31:/data/wp 172.16.1.0/24✅️'没问题👌'user: wwwdir: /data/wpid : 888- name: Install nfs server yum: name: nfs-utils state: present
- name: Configure nfs file template: src: exports.j2 dest: /etc/exports notify: Restart nfs
- name: Create group "{{user}}" group: name: "{{user}}" gid: "{{id}}"
- name: Create user "{{user}}" user: name: "{{user}}" uid: "{{id}}" group: "{{user}}" shell: /sbin/nologin create_home: false
- name: Create dir "{{dir}}"# 递归创建了目录/data/wp# 这两个目录的属主都是www file: path: "{{dir}}" state: directory owner: "{{user}}" group: "{{user}}"
- name: Start NFS Server systemd: name: nfs state: started enabled: yes- name: Restart nfs systemd: name: nfs state: restarted主配置文件
[root@Zabbix ~]# tree /ansible//ansible/└── roles ├── Client │ ...... ├── Server │ ...... └── site.yml
'hosts可以写在 - name '
- name: 配置 Server 主机 hosts: Server roles: - Server
- name: 配置 Client 主机 hosts: Client roles: - Client
- name: Server 测试验证 hosts: Server tasks: - name: Result test shell: 'unbound-control dump_cache | grep jd.com' register: result_end
- name: Print result_end debug: msg: "{{result_end.stdout_lines}}"面试题
请问用过ansible吗?你都用来干啥?用过、--->配置变更、数据收集facts模块(资产管理)、批量安装部署服务那你用过哪些模块?file copy template yum systemd cron handles vars when loop我执行一条命令、但是看不到结果怎么办?使用变量注册 register项目实训
| 短主机名 | FQDN | 管理 IP (ens1) | NAT IP (ens0) | 业务 IP (ens2) | 存储 IP (ens3) |
|---|---|---|---|---|---|
| ops-admin | ops-admin.example.com | 10.2.1.10 | 10.0.0.10 | - | - |
| gitlab-server | gitlab-server.example.com | 10.2.1.20 | 10.0.0.20 | - | - |
| zabbix-server | zabbix-server.example.com | 10.2.1.30 | 10.0.0.30 | - | - |
| jumpserver | jumpserver.example.com | 10.2.1.40 | 10.0.0.40 | - | - |
| waf01 | waf01.example.com | 10.2.1.50 | 10.0.0.50 | 10.2.2.50 | - |
| lb01 | lb01.example.com | 10.2.1.60 | 10.0.0.60 | 10.2.2.60 | - |
| web01 | web01.example.com | 10.2.1.71 | 10.0.0.71 | 10.2.2.71 | 10.2.3.71 |
| web02 | web02.example.com | 10.2.1.72 | 10.0.0.72 | 10.2.2.72 | 10.2.3.72 |
| share01 | share01.example.com | 10.2.1.80 | 10.0.0.80 | - | 10.2.3.80 |
| client | client.example.com | - | 10.0.0.90 | 10.2.2.90 | - |
| 网卡 | Connection ID |
|---|---|
| ens0 | ens0-nat |
| ens1 | ens1-mg |
| ens2 | ens2-bi |
| ens3 | ens3-st |
Ansible管理
环境准备
[root@ops-admin ~]# yum -y install tar zip[root@ops-admin ~]# curl -LsSf https://astral.sh/uv/install.sh | bashdownloading uv 0.11.25 x86_64-unknown-linux-gnuinstalling to /root/.local/bin uv uvxeverything's installed![root@ops-admin ~]# echo 'eval "$(uv generate-shell-completion bash)"' >> ~/.bashrc[root@ops-admin ~]# echo 'eval "$(uvx --generate-shell-completion bash)"' >> ~/.bashrc[root@ops-admin ~]# echo 'export UV_INDEX_URL="https://mirrors.aliyun.com/pypi/simple/"' >> ~/.bashrc[root@ops-admin ~]# source ~/.bashrc[root@ops-admin ~]# mkdir /Projects[root@ops-admin ~]# cd /Projects/[root@ops-admin Projects]# python3 --versionPython 3.12.13[root@ops-admin Projects]# uv venv .venv --python 3.12Using CPython 3.12.13 interpreter at: /usr/bin/python3.12Creating virtual environment at: .venvActivate with: source .venv/bin/activate[root@ops-admin Projects]# source .venv/bin/activate(.venv) [root@ops-admin Projects]# uv pip install ansibleResolved 10 packages in 3.51sPrepared 10 packages in 8.88sInstalled 10 packages in 3.89s + ansible==14.1.0 + ansible-core==2.21.1 + cffi==2.0.0 + cryptography==49.0.0 + jinja2==3.1.6 + markupsafe==3.0.3 + packaging==26.2 + pycparser==3.0 + pyyaml==6.0.3 + resolvelib==1.2.1(.venv) [root@ops-admin Projects]# ansible --versionansible [core 2.21.1](.venv) [root@ops-admin Projects]# cat > ./ansible.cfg <<EOF[defaults]host_key_checking = Falsedeprecation_warnings = Falseinterpreter_python = auto_silentinventory = ./inventorygathering = smart# 开启智能模式:如果有缓存就用缓存,没有才去收集fact_caching = jsonfile# 指定缓存方式为 jsonfile(存成本地文件)fact_caching_connection = /tmp/ansible_fact_cache# 指定缓存文件存到哪里fact_caching_timeout = 86400# 缓存多久失效(秒),这里设置 24 小时[privilege_escalation]become = truebecome_method = sudobecome_user = rootbecome_ask_pass = falseEOF(.venv) [root@ops-admin Projects]# ssh-keygen -t rsa -b 4096 -f ~/.ssh/my_rsa -N ''Generating public/private rsa key pair.Your identification has been saved in /root/.ssh/my_rsaYour public key has been saved in /root/.ssh/my_rsa.pubThe key fingerprint is:SHA256:ySF1tKhH31U5H9ZlMXlHrHSB3dgRuAJs1d32l0hnCn4 root@ops-adminThe key's randomart image is:+---[RSA 4096]----+| o.+.. =@^|| . * o =o&%|| . = + o.O+O|| = + + E..+|| . S . + .|| . || || || |+----[SHA256]-----+(.venv) [root@ops-admin Projects]# sed -i '/id_rsa/a\IdentityFile ~/.ssh/my_rsa' /etc/ssh/ssh_config(.venv) [root@ops-admin Projects]# grep -A1 id_rsa /etc/ssh/ssh_config# IdentityFile ~/.ssh/id_rsaIdentityFile ~/.ssh/my_rsa(.venv) [root@ops-admin Projects]# systemctl restart sshd(.venv) [root@ops-admin Projects]# cat > /etc/hosts <<EOF127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4::1 localhost localhost.localdomain localhost6 localhost6.localdomain610.2.1.10 ops-admin ops-admin.example.com10.2.1.20 gitlab-server gitlab-server.example.com10.2.1.30 zabbix-server zabbix-server.example.com10.2.1.40 jumpserver jumpserver.example.com10.2.1.50 waf01 waf01.example.com10.2.1.60 lb01 lb01.example.com10.2.1.71 web01 web01.example.com10.2.1.72 web02 web02.example.com10.2.1.80 share01 share01.example.comEOF(.venv) [root@ops-admin Projects]# awk '/^10\.2\.1/ {print $2}' /etc/hosts | grep -v ops-admin > ./inventory# 排除自身 ops-admin(.venv) [root@ops-admin Projects]# cat ./inventorygitlab-serverzabbix-serverjumpserverwaf01lb01web01web02share01[root@ops-admin ~]# ll ~/.ssh/my_rsa.pub# 如何批量分发密钥呢?-i指定公钥(.venv) [root@ops-admin Projects]# vim /server/sh/ssh-key.sh密钥分发
#!/bin/bash# ============================================# 批量分发 SSH 公钥脚本# 从 /etc/hosts 读取 10.2.1.x 网段主机,# 通过管理网将公钥分发到所有节点# ============================================
# ---- 配置区 ----PUBKEY="$HOME/.ssh/my_rsa.pub" # 要分发的公钥路径PASSWORD="1" # 目标主机的 root 密码# -----------------
echo "========================================"echo " 批量分发 SSH 公钥"echo " 公钥: $PUBKEY"echo "========================================"echo ""
# 检查公钥是否存在if [ ! -f "$PUBKEY" ]; then echo "错误: 公钥文件 $PUBKEY 不存在,请先生成!" exit 1fi
# 检查 sshpass 是否安装if ! command -v sshpass &>/dev/null; then echo "sshpass 未安装,正在安装..." dnf install -y sshpass &>/dev/null || yum install -y sshpass &>/dev/nullfi
# 从 /etc/hosts 提取所有 10.2.1.x 的短主机名# awk: 匹配 10.2.1 开头的行,打印第二列(短名)HOSTS=$(awk '/^10\.2\.1/ {print $2}' /etc/hosts)
if [ -z "$HOSTS" ]; then echo "错误: /etc/hosts 中没有找到 10.2.1.x 的主机!" exit 1fi
echo "目标主机列表:"echo "$HOSTS" | tr '\n' ' 'echo ""echo ""
# 逐个分发for host in $HOSTS; do echo ">>> 分发到 $host ..."
# sshpass: 自动输入密码 # ssh-copy-id: 将公钥复制到目标主机 # -i: 指定公钥文件 # -o StrictHostKeyChecking=no: 跳过首次连接 yes/no 确认 # -o UserKnownHostsFile=/dev/null: 不记录主机指纹(可选,干净) sshpass -p "$PASSWORD" ssh-copy-id \ -i "$PUBKEY" \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ root@$host 2>/dev/null
if [ $? -eq 0 ]; then echo " ✓ $host 分发成功" else echo " ✗ $host 分发失败,请检查网络和密码" fi echo ""done
echo "========================================"echo " 分发完成!验证免密登录:"echo " ssh root@ops-admin"echo "========================================"(.venv) [root@ops-admin Projects]# ansible --version | grep 'config file' config file = /Projects/ansible.cfg(.venv) [root@ops-admin Projects]# grep inventory ./ansible.cfginventory = ./inventory(.venv) [root@ops-admin Projects]# ansible all -m pingjumpserver | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}waf01 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}gitlab-server | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}zabbix-server | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}lb01 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}share01 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}web01 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}web02 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3.12" }, "changed": false, "ping": "pong"}📌 当前环境
| 组件 | 路径/状态 |
|---|---|
| Ansible 项目 | /Projects/ |
| 虚拟环境 | 需手动 source venv/bin/activate |
| 配置文件 | /Projects/ansible.cfg(已配置提权) |
| Inventory | /Projects/inventory(8 台受控节点) |
| 控制节点 | ops-admin (10.2.1.10) |
(.venv) [root@ops-admin Projects]# vim playbook-init.yml---# ============================================# Ansible 初始化剧本 — 第二组# 用法: cd /Projects && source venv/bin/activate && ansible-playbook playbook-init.yml# ============================================
- hosts: all gather_facts: yes tasks:
# ---- 主机名解析 ---- - name: "拷贝 /etc/hosts 到所有节点" copy: src: /etc/hosts dest: /etc/hosts owner: root group: root mode: '0644'
# ---- 软件包 ---- - name: "安装必要软件包" package: name: - tar - zip - unzip state: present
# ---- 创建 dev 用户和组 ---- - name: "创建开发组 dev (gid=1024)" group: name: dev gid: 1024 state: present
- name: "创建开发用户 dev (uid=1024)" user: name: dev uid: 1024 group: dev shell: /bin/bash create_home: yes state: present
# ---- NTP 时钟同步 ---- - name: "配置 NTP 上游服务器为腾讯云" lineinfile: path: /etc/chrony.conf regexp: '^pool' line: server ntp.tencent.com iburst
- name: "重启 chronyd 服务" systemd: name: chronyd state: restarted
- name: "检查时钟同步状态" shell: chronyc sources register: ntp_result
- name: "打印时钟同步结果" debug: msg: "{{ ntp_result.stdout_lines }}"| 序号 | 任务 | 模块 | 说明 |
|---|---|---|---|
| 1 | 拷贝 /etc/hosts | copy | 将 ops-admin 的 hosts 分发到所有节点 |
| 2 | 安装软件包 | package | 安装 tar、zip |
| 3 | 创建 dev 组 | group | gid=1024 |
| 4 | 创建 dev 用户 | user | uid=1024,shell=/bin/bash |
| 5 | 配置 NTP | lineinfile | 上游服务器改为 ntp.tencent.com |
| 6 | 重启 chronyd | systemd | 使 NTP 配置生效 |
| 7 | 检查同步状态 | command | chronyc sources |
| 8 | 打印结果 | debug | 输出同步状态 |
时钟同步
[root@ops-admin ~]# chronyc sourcesMS Name/IP address Stratum Poll Reach LastRx Last sample===============================================================================^? 118.31.40.99 0 6 0 - +0ns[ +0ns] +/- 0ns^? 118.31.3.89 0 6 0 - +0ns[ +0ns] +/- 0ns^? 47.96.149.233 0 6 0 - +0ns[ +0ns] +/- 0ns^? 116.62.13.223 0 6 0 - +0ns[ +0ns] +/- 0ns# 所有源的状态都是 ^?(不可达/未同步)[root@ops-admin ~]# egrep -v '^$|^#' /etc/chrony.confpool ntp1.aliyun.com iburst 'nts' ❌# 阿里云公共 NTP 服务器(ntp1.aliyun.com)不支持 NTS 协议# 要去掉 nts 参数sourcedir /run/chrony-dhcpdriftfile /var/lib/chrony/driftmakestep 1.0 3rtcsyncntsdumpdir /var/lib/chronylogdir /var/log/chrony[root@ops-admin ~]# sed -i '/^pool ntp1/c\server ntp.tencent.com iburst' /etc/chrony.conf'如果原来有的话要注释掉原来的'[root@ops-admin ~]# systemctl restart chronyd[root@ops-admin ~]# chronyc sourcesMS Name/IP address Stratum Poll Reach LastRx Last sample===============================================================================^* 106.55.184.199 2 6 13 0 +637us[ +246ms] +/- 70ms✅ `*` 当前 `最佳同步源`(被选中用来同步系统时间)(.venv) [root@ops-admin Projects]# ansible-playbook playbook-init.yml ____________< PLAY [all] > ------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
________________________< TASK [Gathering Facts] > ------------------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
ok: [gitlab-server]ok: [lb01]ok: [waf01]ok: [zabbix-server]ok: [jumpserver]ok: [web01]ok: [web02]ok: [share01] __________________________________________< TASK [拷贝 /etc/hosts 到所有节点] > ------------------------------------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [waf01]changed: [jumpserver]changed: [zabbix-server]changed: [gitlab-server]changed: [lb01]changed: [web01]changed: [web02]changed: [share01] ______________________________< TASK [安装必要软件包] > ------------------------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [jumpserver]changed: [waf01]changed: [lb01]changed: [zabbix-server]changed: [gitlab-server]changed: [web01]changed: [web02]changed: [share01] _______________________________________< TASK [创建开发组 dev (gid=1024)] > --------------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [waf01]changed: [gitlab-server]changed: [jumpserver]changed: [zabbix-server]changed: [lb01]changed: [web01]changed: [web02]changed: [share01] __________________________________________< TASK [创建开发用户 dev (uid=1024)] > ------------------------------------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [gitlab-server]changed: [waf01]changed: [zabbix-server]changed: [jumpserver]changed: [lb01]changed: [web01]changed: [web02]changed: [share01] _______________________________________________< TASK [配置 NTP 上游服务器为腾讯云] > ----------------------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [gitlab-server]changed: [jumpserver]changed: [waf01]changed: [zabbix-server]changed: [lb01]changed: [web01]changed: [web02]changed: [share01] ______________________________< TASK [重启 chronyd 服务] > ------------------------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [zabbix-server]changed: [waf01]changed: [jumpserver]changed: [lb01]changed: [gitlab-server]changed: [web01]changed: [web02]changed: [share01] _________________________________< TASK [检查时钟同步状态] > --------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
changed: [waf01]changed: [gitlab-server]changed: [jumpserver]changed: [lb01]changed: [zabbix-server]changed: [web01]changed: [web02]changed: [share01] _________________________________< TASK [打印时钟同步结果] > --------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
ok: [gitlab-server] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [zabbix-server] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [jumpserver] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [waf01] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [lb01] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [web01] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [web02] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]}ok: [share01] => { "msg": [ "MS Name/IP address Stratum Poll Reach LastRx Last sample", "=========================================================", "^* 106.55.184.199 2 6 1 1 -673ms 67ms" ]} ____________< PLAY RECAP > ------------ \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
gitlab-server : ok=9 changed=7jumpserver : ok=9 changed=7lb01 : ok=9 changed=7share01 : ok=9 changed=7waf01 : ok=9 changed=7web01 : ok=9 changed=7web02 : ok=9 changed=7zabbix-server : ok=9 changed=7GitLab 代码仓库
在gitlab-server安装极狐gitlab
节点:gitlab-server.example.com (10.2.1.20)
域名:gitlab-server.example.com
协议:HTTPS 自签名证书
环境要求与准备
(1)主机名解析
/etc/hosts 已通过 Ansible playbook-init.yml 分发到所有节点,无需手动配置。 管理网 IP 解析:
10.2.1.20 gitlab-server gitlab-server.example.com
验证:
ping -c1 gitlab-server.example.com# 自己ping自己64 bytes from gitlab-server (10.2.1.20): icmp_seq=1 ttl=64 time=0.025 ms(2)添加官方仓库并安装
curl -s https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.rpm.sh | bashdnf install -y gitlab-ce(3)生成自签名证书
mkdir -p /etc/gitlab/sslopenssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout /etc/gitlab/ssl/gitlab-server.example.com.key \ -out /etc/gitlab/ssl/gitlab-server.example.com.crt \ -subj "/C=CN/ST=Beijing/L=Beijing/O=Group2/CN=gitlab-server.example.com"(4)配置 HTTPS
编辑 /etc/gitlab/gitlab.rb:
cat > /etc/gitlab/gitlab.rb << 'EOF'external_url "https://gitlab-server.example.com"nginx["redirect_http_to_https"] = truenginx["ssl_certificate"] = "/etc/gitlab/ssl/gitlab-server.example.com.crt"nginx["ssl_certificate_key"] = "/etc/gitlab/ssl/gitlab-server.example.com.key"EOF(5)应用配置(约 5-10 分钟)
gitlab-ctl reconfigure(6)获取初始 root 密码
cat /etc/gitlab/initial_root_password# Password: O/WfB1SI77/4WmHsY5OcyhqQxUzJkl4iai5GvVObUIQ=
jiuzhao@Ubuntu ~$ sudo vim /etc/hosts10.0.0.20 gitlab-server.example.com# 添加这个解析https://gitlab-server.example.com初始化配置
(1)修改 root 密码
gitlab-rails runner "u = User.find_by_username('root')u.password = 'redhat123'u.password_confirmation = 'redhat123'u.save!"(2)创建普通用户 devuser
gitlab-rails runner "org = Organizations::Organization.default_organizationu = User.new( username: 'devuser', email: 'developer@localhost.com', name: 'devuser', password: 'redhat123', password_confirmation: 'redhat123', confirmed_at: Time.now, skip_confirmation: true, organization_id: org.id)ns = Namespace.new( name: 'devuser', path: 'devuser', type: 'User', owner: u, organization: org)u.namespace = nsu.save!puts 'devuser created'"(3)创建项目并添加成员
gitlab-rails runner "# 以 devuser 创建项目dev = User.find_by_username('devuser')project = Projects::CreateService.new( dev, name: 'automation_project', path: 'automation_project', visibility_level: Gitlab::VisibilityLevel::PRIVATE).executeputs project.full_path"Ansible 剧本 — 安装 Git
📌 ops-admin 本机
剧本路径:
/Projects/playbook-git.yml
说明:ops-admin 作为 Ansible 控制节点,本身也要安装 git 才能推送代码。因 ops-admin 不在 inventory 中,使用hosts: localhost+connection: local在本机执行。
- hosts: localhost gather_facts: yes tasks: - name: "安装 git" package: name: git state: present
- name: "配置 git 用户名" # git_config 是 ansible 内置模块,等价于: git config --global user.name "root" git_config: name: user.name value: "root" scope: global
- name: "配置 git 邮箱" # 等价于: git config --global user.email "root@ops-admin.example.com" git_config: name: user.email value: "root@ops-admin.example.com" scope: global
- name: "跳过 SSL 验证(自签证书用)" # 等价于: git config --global http.sslVerify false git_config: name: http.sslVerify value: "false" scope: global执行:
cd /Projects && source venv/bin/activateansible-playbook playbook-git.yml推送代码到 GitLab
GitLab Web 页面:
https://gitlab-server.example.com(HTTPS 443 端口,非 8888)
自签证书浏览器会报警告,点”高级 → 继续访问”即可
GitLab CE 自带中文,登录后在右上角头像 → Preferences → Localization → Language → 简体中文。但是部分页面还是英文
'在ops-admin中操作即可'cd /Projects# 进入本地项目目录
git config --global init.defaultBranch main# 设默认分支为 main(消除 init 时的提示)
git init# 把当前目录变成 git 仓库(不再是 master 而是 main)(.venv) [root@ops-admin Projects]# ls -lha | grep gitdrwxr-xr-x 6 root root 103 Jun 30 16:30 .git ✅# 如果没设上面那个全局配置,就 init 后手动改名:# git branch -m master main
echo "# automation_project" > README.md# 随便写个文件
git add README.md# 告诉 git:"这个文件我要提交"
git commit -m "init: 初始化自动化项目"# 确认提交(拍个快照),加个备注# 1 file changed, 1 insertion(+)# create mode 100644 README.md
git -c http.sslVerify=false push \ https://root:redhat123@gitlab-server.example.com/root/automation_project.git main# 推送至远程仓库,自签证书跳过 SSL 验证# - https 开头 = HTTPS 协议# - root:redhat123 = 用户名:密码# - gitlab-server.example.com = 目标主机# - /root/automation_project.git = 远程仓库路径# - main = 推送的分支名 # -c 跳过证书校验(因为是自签名的,不受信任)# 推送到远程(自签证书跳过验证) https://root:redhat123@gitlab-server.example.com/root/automation_project.git mainEnumerating objects: 3, done.Counting objects: 100% (3/3), done.Writing objects: 100% (3/3), 267 bytes | 267.00 KiB/s, done.Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)To https://gitlab-server.example.com/root/automation_project.git * [new branch] main -> main| 改成这样 | 效果 |
|---|---|
| https://root:redhat123@… | 以 root(管理员)推送 |
| https://devuser:redhat123@… | 以 devuser(普通用户)推送 |
Lb负载&Web服务
基础环境准备
(.venv) [root@ops-admin Projects]# mkdir nginx(.venv) [root@ops-admin Projects]# cat > ./nginx/nginx.conf <<'EOF'user www;worker_processes auto;worker_cpu_affinity auto; # cpu亲和pid /run/nginx.pid;error_log /var/log/nginx/error.log notice;worker_rlimit_nofile 65535; # 每个work能打开的文件描述符
events { worker_connections 32768; # 每个worker进程可以同时处理最大连接数 # 一个连接平均占用两个文件描述符 multi_accept on; use epoll;}
http { include mime.types; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
server_tokens off; # 禁止浏览器显示nginx版本号 client_max_body_size 20m; # 文件上传大小限制调整 默认1M sendfile on; tcp_nopush on; # tcp_nodelay on; keepalive_timeout 65;
# Gzip gzip on; # 小于1KB的文件不压缩(避免小文件压缩后反而变大) gzip_min_length 1k; gzip_comp_level 6; # 压缩等级6(通用) gzip_types text/plain application/javascript text/css application/xml application/json; # 压缩的类型:必须用空格隔开
# 子配置目录 include /etc/nginx/conf.d/*.conf;}EOF======================================(.venv) [root@ops-admin Projects]# cat > ./nginx/www.conf <<'EOF'[www]user = wwwgroup = wwwlisten = 127.0.0.1:9000listen.acl_users = apache,nginxlisten.allowed_clients = 127.0.0.1pm = dynamicpm.max_children = 50pm.start_servers = 5pm.min_spare_servers = 5pm.max_spare_servers = 35slowlog = /var/log/php-fpm/www-slow.logphp_admin_value[error_log] = /var/log/php-fpm/www-error.logphp_admin_flag[log_errors] = onphp_value[session.save_handler] = filesphp_value[session.save_path] = /var/lib/php/sessionphp_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcacheEOF======================================(.venv) [root@ops-admin Projects]# cat > ./nginx/php.ini <<'EOF'[PHP]engine = Onshort_open_tag = Offprecision = 14output_buffering = 4096zlib.output_compression = Offimplicit_flush = Offunserialize_callback_func =serialize_precision = -1disable_functions =disable_classes =zend.enable_gc = Onzend.exception_ignore_args = Onzend.exception_string_param_max_len = 0expose_php = Onmax_execution_time = 300max_input_time = 300memory_limit = 256Merror_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICTdisplay_errors = Offdisplay_startup_errors = Offlog_errors = Onignore_repeated_errors = Offignore_repeated_source = Offreport_memleaks = Onvariables_order = "GPCS"request_order = "GP"register_argc_argv = Offauto_globals_jit = Onpost_max_size = 64Mauto_prepend_file =auto_append_file =default_mimetype = "text/html"default_charset = "UTF-8"doc_root =user_dir =enable_dl = Offfile_uploads = Onupload_max_filesize = 64Mmax_file_uploads = 20allow_url_fopen = Onallow_url_include = Offdefault_socket_timeout = 60[CLI Server]cli_server.color = On[Date]date.timezone = Asia/Shanghai[filter][iconv][imap][intl][sqlite3][Pcre]pcre.jit=0[Pdo][Pdo_mysql]pdo_mysql.default_socket=[Phar][mail function]sendmail_path = /usr/sbin/sendmail -t -imail.add_x_header = Offmail.mixed_lf_and_crlf = Off[ODBC]odbc.allow_persistent = Onodbc.check_persistent = Onodbc.max_persistent = -1odbc.max_links = -1odbc.defaultlrl = 4096odbc.defaultbinmode = 1[MySQLi]mysqli.max_persistent = -1mysqli.allow_persistent = Onmysqli.max_links = -1mysqli.default_port = 3306mysqli.default_socket =mysqli.default_host =mysqli.default_user =mysqli.default_pw =[mysqlnd]mysqlnd.collect_statistics = Onmysqlnd.collect_memory_statistics = Off[PostgreSQL]pgsql.allow_persistent = Onpgsql.auto_reset_persistent = Offpgsql.max_persistent = -1pgsql.max_links = -1pgsql.ignore_notice = 0pgsql.log_notice = 0[bcmath]bcmath.scale = 0[browscap][Session]session.save_handler = filessession.use_strict_mode = 0session.use_cookies = 1session.use_only_cookies = 1session.name = PHPSESSIDsession.auto_start = 0session.cookie_lifetime = 0session.cookie_path = /session.cookie_domain =session.cookie_httponly =session.cookie_samesite =session.serialize_handler = phpsession.gc_probability = 1session.gc_divisor = 1000session.gc_maxlifetime = 1440session.referer_check =session.cache_limiter = nocachesession.cache_expire = 180session.use_trans_sid = 0session.sid_length = 26session.trans_sid_tags = "a=href,area=href,frame=src,form="session.sid_bits_per_character = 5[Assertion]zend.assertions = -1[mbstring][gd][exif][Tidy]tidy.clean_output = Off[soap]soap.wsdl_cache_enabled=1soap.wsdl_cache_dir="/tmp"soap.wsdl_cache_ttl=86400soap.wsdl_cache_limit = 5[sysvshm][ldap]ldap.max_links = -1[dba][opcache][curl][openssl][ffi]EOF======================================(.venv) [root@ops-admin Projects]# cat > ./nginx/default.conf.j2 <<'EOF'server { listen {{ansible_ens2.ipv4.address}}:8000 default_server; server_name _; root /usr/share/nginx/html;
location / { index index.html index.php; # 先静态后PHP }
error_page 500 502 503 504 /50x.html;
location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }}EOF======================================(.venv) [root@ops-admin Projects]# cat > ./nginx/proxy_set <<'EOF'proxy_set_header Host $http_host;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_http_version 1.1;
proxy_connect_timeout 30;proxy_send_timeout 60;proxy_read_timeout 60;
proxy_buffering on;proxy_buffer_size 32k;proxy_buffers 4 128k;EOF======================================(.venv) [root@ops-admin Projects]# cat > ./nginx/lb.conf <<'EOF'upstream webs { server 10.2.2.71:8000; server 10.2.2.72:8000;}
server { listen 10.2.2.60:80; server_name www.wp.com;
location / { proxy_pass http://webs; include proxy_set; }}EOFindex.html
(.venv) [root@ops-admin Projects]# vim ./nginx/index.html.j2<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.5, user-scalable=yes"> <title>web默认页 - {{ ansible_hostname }}</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: linear-gradient(145deg, #f0f4f8 0%, #e9eff5 100%); font-family: 'Segoe UI', 'Roboto', system-ui, sans-serif; min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; color: #1e293b; } .dashboard { max-width: 1100px; width: 100%; background: #ffffff; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.15); border-radius: 2.5rem; padding: 2.5rem; } @media (max-width: 640px) { .dashboard { padding: 1.8rem; border-radius: 2rem; } } .header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; margin-bottom: 2rem; border-bottom: 2px dashed #cbd5e1; padding-bottom: 1.2rem; } .host-badge { display: flex; flex-wrap: wrap; align-items: center; gap: 1.2rem; } .hostname { font-size: 2.5rem; font-weight: 700; background: linear-gradient(135deg, #0f2b5c, #1e4b6e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.5px; } .ip-address { background: #e0e7ff; padding: 0.5rem 1.3rem; border-radius: 3rem; font-weight: 600; font-size: 1.1rem; color: #1e3a8a; display: inline-flex; align-items: center; gap: 0.3rem; } .ip-address::before { content: "🌐"; } .php-status { background: #f1f5f9; border-radius: 2rem; padding: 0.4rem 1.4rem; font-weight: 600; display: flex; align-items: center; gap: 0.4rem; } .badge-success { background: #dcfce7; color: #166534; border-left: 4px solid #22c55e; } .badge-fail { background: #fee2e2; color: #991b1b; border-left: 4px solid #ef4444; } .grid-main { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin: 2rem 0 2.2rem; } @media (max-width: 750px) { .grid-main { grid-template-columns: 1fr; } } .card { background: #f8fafc; border-radius: 1.8rem; padding: 1.8rem; box-shadow: 0 10px 25px -8px rgba(0,0,0,0.08); border: 1px solid #e9eef2; transition: transform 0.2s; } .card:hover { transform: translateY(-3px); box-shadow: 0 20px 30px -12px rgba(0,0,0,0.12); } .card h3 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.3rem; display: flex; align-items: center; gap: 0.5rem; color: #0c4a6e; } .upload-area { border: 2px dashed #94a3b8; border-radius: 1.2rem; padding: 2rem 1rem; text-align: center; background: #ffffff; cursor: pointer; transition: 0.25s; margin-bottom: 1.2rem; } .upload-area:hover { border-color: #2563eb; background: #f0f4ff; } #file-input { display: none; } .preview-img { max-width: 100%; max-height: 150px; margin-top: 1rem; border-radius: 1rem; box-shadow: 0 8px 18px rgba(0,0,0,0.1); display: none; } .btn { background: #f1f5f9; border: 1px solid #cbd5e1; padding: 0.7rem 1.8rem; border-radius: 2.5rem; font-weight: 600; cursor: pointer; transition: 0.2s; display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.95rem; } .btn-primary { background: linear-gradient(135deg, #1e293b, #0f172a); color: white; border: none; box-shadow: 0 8px 18px rgba(15,23,42,0.3); } .btn-primary:hover { background: #1e293b; } .btn-danger { background: #dc2626; color: white; border: none; margin-left: 0.5rem; } .btn-danger:hover { background: #b91c1c; } .db-panel { display: flex; flex-direction: column; gap: 1rem; } .form-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.8rem; } .form-row label { font-weight: 600; min-width: 55px; color: #334155; } input, select { background: white; border: 1px solid #d1d5db; border-radius: 2rem; padding: 0.7rem 1.2rem; font-size: 0.95rem; flex: 1; outline: none; transition: 0.2s; font-family: inherit; } input:focus, select:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.2); } .db-result, .upload-status { margin-top: 0.5rem; font-weight: 600; } .file-info { margin-top: 1rem; background: #f1f5f9; padding: 0.8rem; border-radius: 1rem; display: none; } .file-info p { margin: 0.3rem 0; font-size: 0.9rem; }
/* 图片画廊 */ .gallery-title { margin-top: 1.8rem; margin-bottom: 0.8rem; font-weight: 600; color: #334155; } .gallery { display: flex; flex-wrap: wrap; gap: 0.8rem; max-height: 200px; overflow-y: auto; padding: 0.5rem; background: #ffffff; border-radius: 1rem; border: 1px solid #e2e8f0; } .gallery-item { width: 80px; height: 80px; border-radius: 0.8rem; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: 0.2s; position: relative; flex-shrink: 0; } .gallery-item img { width: 100%; height: 100%; object-fit: cover; } .gallery-item:hover { border-color: #2563eb; transform: scale(1.05); } .gallery-item .delete-icon { position: absolute; top: 2px; right: 2px; background: rgba(0,0,0,0.6); color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 12px; display: none; } .gallery-item:hover .delete-icon { display: flex; }
/* 详情弹窗 */ .modal { display: none; position: fixed; z-index: 100; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); justify-content: center; align-items: center; } .modal.active { display: flex; } .modal-content { background: white; border-radius: 2rem; padding: 2rem; max-width: 500px; width: 90%; text-align: center; position: relative; animation: fadeIn 0.3s; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } .modal img { max-width: 100%; max-height: 250px; border-radius: 1rem; margin-bottom: 1rem; } .modal .detail-row { display: flex; justify-content: space-between; margin: 0.5rem 0; font-size: 0.95rem; } .close-btn { position: absolute; top: 1rem; right: 1.5rem; font-size: 1.8rem; cursor: pointer; color: #64748b; } hr { margin: 1.5rem 0 1rem; border: 0.5px solid #e2e8f0; } .footer-note { text-align: right; font-size: 0.9rem; color: #64748b; margin-top: 1rem; } button:disabled { opacity: 0.6; cursor: not-allowed; } </style></head><body><div class="dashboard"> <div class="header"> <div class="host-badge"> <span class="hostname">{{ ansible_hostname }}</span> <span class="ip-address">{{ ansible_ens2.ipv4.address }}</span> </div> <div id="php-indicator" class="php-status badge-success"></div> </div>
<div class="grid-main"> <!-- 图片管理卡片 --> <div class="card"> <h3><span>📁</span> 图片管理</h3> <!-- 上传区域 --> <div id="upload-box" class="upload-area"> <div class="upload-icon">📤</div> <p><strong>点击或拖拽上传</strong></p> <p style="font-size:0.8rem; color:#64748b;">JPG, PNG, GIF</p> <input type="file" id="file-input" accept="image/*"> <img id="preview-image" class="preview-img" alt="预览"> </div> <div style="display: flex; justify-content: space-between; align-items: center;"> <button id="upload-btn" class="btn btn-primary">⬆️ 上传</button> <span id="upload-message" class="upload-status"></span> </div>
<!-- 已上传图片列表 --> <div class="gallery-title">🖼️ 已上传图片</div> <div id="gallery" class="gallery"> <!-- 动态填充缩略图 --> </div> </div>
<!-- 数据库卡片保持不变 --> <div class="card"> <h3><span>🗄️</span> 数据库连通性测试</h3> <div class="db-panel"> <div class="form-row"> <label>主机</label> <input type="text" id="db-host" placeholder="192.168.1.100" value="192.168.1.100"> </div> <div class="form-row"> <label>端口</label> <input type="number" id="db-port" placeholder="3306" value="3306"> </div> <div class="form-row"> <label>用户</label> <input type="text" id="db-user" placeholder="root" value="root"> </div> <div class="form-row"> <label>密码</label> <input type="password" id="db-pass" placeholder="数据库密码"> </div> <div class="form-row"> <label>库名</label> <input type="text" id="db-name" placeholder="test" value="test"> </div> <button id="test-db-btn" class="btn btn-primary" style="justify-content:center;">🔌 测试连接</button> <div id="db-result" class="db-result"></div> <div id="tables-info" style="display:none; margin-top:0.3rem;"> <div id="tables-count" style="font-weight:600;"></div> <div id="tables-list" class="tables-list"></div> </div> </div> </div> </div>
<hr> <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap;"> <div style="display: flex; gap: 1.5rem;"> <span><strong>PHP状态:</strong><span id="php-text">检测中</span></span> <span><strong>服务器时间:</strong><span id="server-time">--</span></span> </div> <div class="footer-note">Ansible 部署 • {{ ansible_hostname }}</div> </div></div>
<!-- 图片详情模态框 --><div id="imageModal" class="modal"> <div class="modal-content"> <span class="close-btn" id="closeModal">×</span> <img id="modal-img" src="" alt="大图"> <div class="detail-row"><strong>文件名:</strong><span id="modal-name"></span></div> <div class="detail-row"><strong>大小:</strong><span id="modal-size"></span></div> <div class="detail-row"><strong>修改时间:</strong><span id="modal-mtime"></span></div> <div class="detail-row"><strong>属主:</strong><span id="modal-owner"></span></div> <div style="margin-top:1.5rem; display: flex; justify-content: center; gap:1rem;"> <a id="modal-link" href="#" target="_blank" class="btn">🔍 查看原图</a> <button id="modal-delete" class="btn btn-danger">🗑️ 删除图片</button> </div> </div></div>
<script> // ---------- 全局 DOM ---------- const uploadBox = document.getElementById('upload-box'); const fileInput = document.getElementById('file-input'); const previewImg = document.getElementById('preview-image'); const uploadBtn = document.getElementById('upload-btn'); const uploadMessage = document.getElementById('upload-message'); const galleryDiv = document.getElementById('gallery'); const phpIndicator = document.getElementById('php-indicator'); const phpTextSpan = document.getElementById('php-text'); const serverTimeSpan = document.getElementById('server-time'); const testDbBtn = document.getElementById('test-db-btn'); const dbResultDiv = document.getElementById('db-result'); const tablesInfoDiv = document.getElementById('tables-info'); const tablesCountSpan = document.getElementById('tables-count'); const tablesListDiv = document.getElementById('tables-list');
// 模态框元素 const modal = document.getElementById('imageModal'); const closeModal = document.getElementById('closeModal'); const modalImg = document.getElementById('modal-img'); const modalName = document.getElementById('modal-name'); const modalSize = document.getElementById('modal-size'); const modalMtime = document.getElementById('modal-mtime'); const modalOwner = document.getElementById('modal-owner'); const modalLink = document.getElementById('modal-link'); const modalDelete = document.getElementById('modal-delete');
let selectedFile = null;
// 更新时间 function updateServerTime() { serverTimeSpan.textContent = new Date().toLocaleString('zh-CN', { hour12: false }); } updateServerTime(); setInterval(updateServerTime, 1000);
// PHP状态检测 async function checkPhpStatus() { try { const res = await fetch('index.php?action=phpinfo', { cache: 'no-store' }); if (res.ok) { const data = await res.json(); if (data && data.php === 'active') { phpIndicator.className = 'php-status badge-success'; phpIndicator.innerHTML = '✅ PHP 已连接'; phpTextSpan.textContent = '运行正常'; return true; } } throw new Error('PHP异常'); } catch (e) { phpIndicator.className = 'php-status badge-fail'; phpIndicator.innerHTML = '❌ PHP 未连接'; phpTextSpan.textContent = '不可用'; return false; } }
// ---------- 图片列表加载 ---------- async function loadImageList() { try { const res = await fetch('index.php?action=listimages'); const data = await res.json(); if (data.status === 'success' && Array.isArray(data.images)) { renderGallery(data.images); } else { galleryDiv.innerHTML = '<span style="color:#64748b; font-size:0.9rem;">暂无图片</span>'; } } catch (e) { galleryDiv.innerHTML = '<span style="color:#ef4444;">加载图片列表失败</span>'; } }
function renderGallery(images) { if (images.length === 0) { galleryDiv.innerHTML = '<span style="color:#64748b; font-size:0.9rem;">暂无图片</span>'; return; } galleryDiv.innerHTML = images.map(img => ` <div class="gallery-item" data-filename="${img.name}" onclick="openImageDetail('${img.name}')"> <img src="img/${img.name}" alt="${img.name}"> <span class="delete-icon" onclick="event.stopPropagation(); deleteImage('${img.name}')">✕</span> </div> `).join(''); }
// 打开详情弹窗 async function openImageDetail(filename) { // 请求该图片的详细信息 try { const res = await fetch(`index.php?action=imageinfo&file=${encodeURIComponent(filename)}`); const data = await res.json(); if (data.status === 'success') { modalImg.src = `img/${filename}`; modalName.textContent = filename; modalSize.textContent = data.size; modalMtime.textContent = data.mtime; modalOwner.textContent = data.owner; modalLink.href = `img/${filename}`; modalDelete.setAttribute('data-filename', filename); modal.classList.add('active'); } else { alert('获取图片信息失败'); } } catch (e) { alert('请求失败'); } }
// 关闭弹窗 closeModal.addEventListener('click', () => modal.classList.remove('active')); window.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('active'); });
// 删除图片 async function deleteImage(filename) { if (!confirm(`确定要删除 ${filename} 吗?`)) return; try { const formData = new FormData(); formData.append('action', 'deleteimage'); formData.append('filename', filename); const res = await fetch('index.php', { method: 'POST', body: formData }); const data = await res.json(); if (data.status === 'success') { loadImageList(); // 如果模态框正显示该图片则关闭 if (modalDelete.getAttribute('data-filename') === filename) { modal.classList.remove('active'); } } else { alert('删除失败: ' + data.message); } } catch (e) { alert('删除请求异常'); } }
// 模态框内删除按钮 modalDelete.addEventListener('click', () => { const filename = modalDelete.getAttribute('data-filename'); if (filename) deleteImage(filename); });
// ---------- 上传逻辑 ---------- uploadBox.addEventListener('click', () => fileInput.click()); uploadBox.addEventListener('dragover', e => { e.preventDefault(); uploadBox.style.borderColor = '#2563eb'; uploadBox.style.background = '#f0f4ff'; }); uploadBox.addEventListener('dragleave', () => { uploadBox.style.borderColor = '#94a3b8'; uploadBox.style.background = '#ffffff'; }); uploadBox.addEventListener('drop', e => { e.preventDefault(); uploadBox.style.borderColor = '#94a3b8'; uploadBox.style.background = '#ffffff'; if (e.dataTransfer.files.length > 0) handleFileSelect(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', e => { if (e.target.files.length > 0) handleFileSelect(e.target.files[0]); });
function handleFileSelect(file) { if (!file.type.startsWith('image/')) { alert('请选择图片文件'); return; } selectedFile = file; const reader = new FileReader(); reader.onload = e => { previewImg.src = e.target.result; previewImg.style.display = 'block'; }; reader.readAsDataURL(file); uploadMessage.textContent = `已选择: ${file.name}`; }
uploadBtn.addEventListener('click', async () => { if (!selectedFile) { uploadMessage.textContent = '⚠️ 请先选择图片'; return; } const formData = new FormData(); formData.append('image', selectedFile); uploadBtn.disabled = true; uploadMessage.textContent = '⏳ 上传中...';
try { const res = await fetch('index.php?action=upload', { method: 'POST', body: formData }); const result = await res.json(); if (result.status === 'success') { uploadMessage.textContent = '✅ 上传成功'; previewImg.style.display = 'none'; selectedFile = null; fileInput.value = ''; loadImageList(); // 刷新列表 } else { uploadMessage.textContent = `❌ ${result.message || '上传失败'}`; } } catch (err) { uploadMessage.textContent = '❌ 网络错误或PHP未响应'; } finally { uploadBtn.disabled = false; } });
// ---------- 数据库测试 ---------- testDbBtn.addEventListener('click', async () => { const host = document.getElementById('db-host').value.trim(); const port = document.getElementById('db-port').value.trim(); const user = document.getElementById('db-user').value.trim(); const pass = document.getElementById('db-pass').value; const dbname = document.getElementById('db-name').value.trim();
if (!host || !port || !user) { dbResultDiv.innerHTML = '⚠️ 主机、端口、用户不能为空'; dbResultDiv.style.color = '#b45309'; tablesInfoDiv.style.display = 'none'; return; }
dbResultDiv.innerHTML = '⏳ 正在测试连接...'; dbResultDiv.style.color = '#1e293b'; tablesInfoDiv.style.display = 'none'; testDbBtn.disabled = true;
try { const formData = new FormData(); formData.append('action', 'dbtest'); formData.append('db_host', host); formData.append('db_port', port); formData.append('db_user', user); formData.append('db_pass', pass); formData.append('db_name', dbname);
const res = await fetch('index.php', { method: 'POST', body: formData }); const result = await res.json(); if (result.status === 'success') { dbResultDiv.innerHTML = '✅ 数据库连接成功'; dbResultDiv.style.color = '#166534'; await fetchTableList(host, port, user, pass, dbname); } else { dbResultDiv.innerHTML = `❌ 连接失败: ${result.message || '未知错误'}`; dbResultDiv.style.color = '#991b1b'; } } catch (err) { dbResultDiv.innerHTML = '❌ 请求异常,PHP可能未运行'; dbResultDiv.style.color = '#991b1b'; } finally { testDbBtn.disabled = false; } });
async function fetchTableList(host, port, user, pass, dbname) { tablesInfoDiv.style.display = 'none'; try { const formData = new FormData(); formData.append('action', 'listtables'); formData.append('db_host', host); formData.append('db_port', port); formData.append('db_user', user); formData.append('db_pass', pass); formData.append('db_name', dbname);
const res = await fetch('index.php', { method: 'POST', body: formData }); const data = await res.json(); if (data.status === 'success') { tablesCountSpan.textContent = `📊 当前数据库共有 ${data.table_count} 张表`; tablesListDiv.innerHTML = data.tables && data.tables.length ? data.tables.map(t => `<span style="background:#e2e8f0; padding:0.2rem 0.6rem; border-radius:1rem; margin:0.2rem; display:inline-block;">${t}</span>`).join('') : '(无表)'; tablesInfoDiv.style.display = 'block'; } else { tablesInfoDiv.innerHTML = `<span style="color:#991b1b;">❌ 获取表信息失败: ${data.message}</span>`; tablesInfoDiv.style.display = 'block'; } } catch (e) { tablesInfoDiv.innerHTML = '<span style="color:#991b1b;">❌ 查询表请求异常</span>'; tablesInfoDiv.style.display = 'block'; } }
// 页面初始化 checkPhpStatus(); loadImageList();</script></body></html>index.php
(.venv) [root@ops-admin Projects]# vim ./nginx/index.php<?php/** * 运维默认页后端接口 * 支持:PHP状态、图片上传/列表/详情/删除、数据库连接/表查询 */header('Access-Control-Allow-Origin: *');header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
switch ($action) { case 'phpinfo': echo json_encode(['php' => 'active', 'version' => PHP_VERSION]); break; case 'upload': handleUpload(); break; case 'listimages': listImages(); break; case 'imageinfo': imageInfo(); break; case 'deleteimage': deleteImage(); break; case 'dbtest': testDatabaseConnection(); break; case 'listtables': listTables(); break; default: http_response_code(400); echo json_encode(['status' => 'error', 'message' => '未知操作']);}
/** * 图片上传 */function handleUpload() { $uploadDir = __DIR__ . '/img/'; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }
if (!isset($_FILES['image'])) { echo json_encode(['status' => 'error', 'message' => '没有收到文件']); return; }
$file = $_FILES['image']; $allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!in_array($file['type'], $allowedTypes)) { echo json_encode(['status' => 'error', 'message' => '仅支持 JPG/PNG/GIF']); return; }
$ext = pathinfo($file['name'], PATHINFO_EXTENSION); $newName = uniqid('img_', true) . '.' . $ext; $destPath = $uploadDir . $newName;
if (!move_uploaded_file($file['tmp_name'], $destPath)) { echo json_encode(['status' => 'error', 'message' => '文件移动失败,检查权限']); return; }
echo json_encode(['status' => 'success', 'message' => '上传成功', 'filename' => $newName]);}
/** * 列出所有图片(简单列表,含文件名) */function listImages() { $imgDir = __DIR__ . '/img/'; if (!is_dir($imgDir)) { echo json_encode(['status' => 'success', 'images' => []]); return; }
$files = array_diff(scandir($imgDir), ['.', '..']); $images = []; foreach ($files as $file) { $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'])) { $images[] = ['name' => $file]; } } echo json_encode(['status' => 'success', 'images' => array_values($images)]);}
/** * 获取单张图片详细信息 */function imageInfo() { $filename = $_GET['file'] ?? ''; if (empty($filename)) { echo json_encode(['status' => 'error', 'message' => '缺少文件名']); return; }
// 安全检查:不允许路径遍历 if (strpos($filename, '/') !== false || strpos($filename, '\\') !== false || $filename === '.' || $filename === '..') { echo json_encode(['status' => 'error', 'message' => '非法的文件名']); return; }
$filePath = __DIR__ . '/img/' . $filename; if (!file_exists($filePath)) { echo json_encode(['status' => 'error', 'message' => '文件不存在']); return; }
$size = filesize($filePath); $mtime = date("Y-m-d H:i:s", filemtime($filePath)); $owner = 'unknown'; if (function_exists('posix_getpwuid')) { $ownerInfo = posix_getpwuid(fileowner($filePath)); $owner = $ownerInfo ? $ownerInfo['name'] : 'unknown'; }
echo json_encode([ 'status' => 'success', 'size' => formatBytes($size), 'mtime' => $mtime, 'owner' => $owner ]);}
/** * 删除图片 */function deleteImage() { $filename = $_POST['filename'] ?? ''; if (empty($filename)) { echo json_encode(['status' => 'error', 'message' => '缺少文件名']); return; }
// 路径遍历检查 if (strpos($filename, '/') !== false || strpos($filename, '\\') !== false || $filename === '.' || $filename === '..') { echo json_encode(['status' => 'error', 'message' => '非法的文件名']); return; }
$filePath = __DIR__ . '/img/' . $filename; if (!file_exists($filePath)) { echo json_encode(['status' => 'error', 'message' => '文件不存在']); return; }
if (unlink($filePath)) { echo json_encode(['status' => 'success', 'message' => '删除成功']); } else { echo json_encode(['status' => 'error', 'message' => '删除失败,权限不足']); }}
// 以下为原有数据库相关函数(略作保留,内容不变)function testDatabaseConnection() { $host = $_POST['db_host'] ?? ''; $port = $_POST['db_port'] ?? '3306'; $user = $_POST['db_user'] ?? ''; $pass = $_POST['db_pass'] ?? ''; $dbname = $_POST['db_name'] ?? '';
if (empty($host) || empty($user)) { echo json_encode(['status' => 'error', 'message' => '主机和用户不能为空']); return; }
try { $dsn = "mysql:host=$host;port=$port;dbname=$dbname;charset=utf8mb4"; $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 3 ]); echo json_encode(['status' => 'success', 'message' => '数据库连接成功']); } catch (PDOException $e) { echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); }}
function listTables() { $host = $_POST['db_host'] ?? ''; $port = $_POST['db_port'] ?? '3306'; $user = $_POST['db_user'] ?? ''; $pass = $_POST['db_pass'] ?? ''; $dbname = $_POST['db_name'] ?? '';
if (empty($host) || empty($user) || empty($dbname)) { echo json_encode(['status' => 'error', 'message' => '参数不完整']); return; }
try { $dsn = "mysql:host=$host;port=$port;dbname=$dbname;charset=utf8mb4"; $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 3 ]); $stmt = $pdo->query("SHOW TABLES"); $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); echo json_encode([ 'status' => 'success', 'table_count' => count($tables), 'tables' => $tables ]); } catch (PDOException $e) { echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); }}
function formatBytes($bytes, $precision = 2) { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); return round($bytes / (1024 ** $pow), $precision) . ' ' . $units[$pow];}playbook
(.venv) [root@ops-admin Projects]# vim playbook-nginx.yml- hosts: lb01,web01,web02 tasks: - name: "配置nginx的源" yum_repository: name: nginx-stable description: nginx stable repo baseurl: https://nginx.org/packages/centos/$releasever/$basearch/ gpgcheck: no enabled: yes - name: "安装nginx" package: name: nginx state: present - name: "创建www组" group: name: www gid: 888 - name: "创建www用户" user: name: www uid: 888 group: www shell: /sbin/nologin create_home: false - name: "配置Nginx的主配置文件" copy: src: ./nginx/nginx.conf dest: /etc/nginx/ - name: "启动nginx" systemd: name: nginx state: started enabled: yes
- hosts: - web01 - web02 tasks: - name: "安装php服务" package: name: - php - php-bcmath - php-cli - php-common - php-devel - php-embedded - php-fpm - php-gd - php-intl - php-mbstring - php-mysqlnd - php-opcache - php-pdo - php-process - php-xml - php-json state: present - name: "拷贝配置文件" copy: src: "{{ item.src }}" dest: "{{ item.dest }}" loop: - src: ./nginx/www.conf dest: /etc/php-fpm.d/ - src: ./nginx/php.ini dest: /etc/ - src: ./nginx/index.php dest: /usr/share/nginx/html - name: "启动php服务" systemd: name: php-fpm state: started enabled: yes - name: "拷贝index.html和default.conf" template: src: "{{ item.src }}" dest: "{{ item.dest }}" loop: - src: ./nginx/default.conf.j2 dest: /etc/nginx/conf.d/default.conf - src: ./nginx/index.html.j2 dest: /usr/share/nginx/html/index.html - name: "创建img目录存在" file: path: /usr/share/nginx/html/img state: directory owner: www group: www mode: '0755' - name: "递归修改站点目录属主" file: path: /usr/share/nginx/html state: directory owner: www group: www recurse: yes
- hosts: lb01 tasks: - name: "拷贝lb配置文件" copy: src: "{{ item.src }}" dest: "{{ item.dest }}" loop: - src: ./nginx/lb.conf dest: /etc/nginx/conf.d/ - src: ./nginx/proxy_set dest: /etc/nginx/
- hosts: lb01,web01,web02 tasks: - name: "重启nginx服务" systemd: name: nginx state: restarted(.venv) [root@ops-admin Projects]# ansible-playbook playbook-nginx.ymlshare共享存储
- 在share01节点部署mysql和nfs
- 在mysql中创建测试库表
- 数据库不允许匿名访问, 设置root密码为Redhat@123,创建php用于连接的用户webuser,设置密码为Redhat@123
- 在nfs中创建共享目录/webshare,共享web页面(test.html、test.php)
- test.php用于展示mysql测试库表的内容
- 将nfs的共享目录挂载到web01和web02的web根目录/webdir
- web01和web02与share01的服务通信(php-fpm和nfs)使用ens3网卡
基础配置
(.venv) [root@ops-admin Projects]# mkdir -p share(.venv) [root@ops-admin Projects]# cat > ./share/exports.j2 <<'EOF'{{dir}} 10.2.3.0/24(rw,sync,all_squash,anonuid={{uid}},anongid={{uid}}){{wpdir}} 10.2.3.0/24(rw,sync,all_squash,anonuid={{uid}},anongid={{uid}})EOFmysql安装脚本
(.venv) [root@ops-admin sh]# pwd/server/sh(.venv) [root@ops-admin sh]# vim install_mysql.sh#!/bin/bash
# ============================================================# 脚本功能:在 Rocky Linux 9 上通过本地 RPM 包离线安装 MySQL 8.0.36# 适用场景:已将 mysql-8.0.36-1.el9.x86_64.rpm-bundle.tar 解压到 /tmp 目录# ============================================================
# ------ 颜色定义(方便提示) ------RED='\033[31m'GREEN='\033[32m'YELLOW='\033[33m'BLUE='\033[34m'NC='\033[0m' # 无颜色
# ------ 检查是否以 root 运行 ------if [ "$EUID" -ne 0 ]; then echo -e "${RED}错误:请使用 root 用户执行此脚本!${NC}" exit 1fi
# ------ 检查是否已安装 MySQL ------if rpm -q mysql-community-server &>/dev/null; then echo -e "${YELLOW}警告:检测到 MySQL 已安装,为避免冲突,脚本退出。${NC}" echo -e "${YELLOW}如需重新安装,请先卸载现有版本。${NC}" exit 0fi
# ------ 定义变量 ------RPM_DIR="/tmp"LOG_FILE="/var/log/mysqld.log"TEMP_PASS=""NEW_PASS="passwd" # 最终设置的 root 密码DB_NAME="wp"DB_USER="wpuser"DB_PASS="passwd"
echo -e "${BLUE}>>> 步骤1:切换到 RPM 包目录 ${RPM_DIR}${NC}"cd "$RPM_DIR" || { echo -e "${RED}错误:无法进入目录 $RPM_DIR${NC}"; exit 1; }
# ------ 安装 RPM 包(使用 dnf 自动解决依赖) ------echo -e "${BLUE}>>> 步骤2:安装 MySQL 8.0.36 RPM 包${NC}"dnf install -y \ mysql-community-common-8.0.36-1.el9.x86_64.rpm \ mysql-community-icu-data-files-8.0.36-1.el9.x86_64.rpm \ mysql-community-libs-8.0.36-1.el9.x86_64.rpm \ mysql-community-client-plugins-8.0.36-1.el9.x86_64.rpm \ mysql-community-client-8.0.36-1.el9.x86_64.rpm \ mysql-community-server-8.0.36-1.el9.x86_64.rpm \ mysql-community-devel-8.0.36-1.el9.x86_64.rpm
if [ $? -ne 0 ]; then echo -e "${RED}错误:RPM 安装失败,请检查包是否完整。${NC}" exit 1fiecho -e "${GREEN}✓ RPM 安装完成${NC}"
# ------ 清理安装包(删除 /tmp 下所有 .rpm 文件) ------echo -e "${BLUE}>>> 步骤3:删除临时 RPM 文件${NC}"ls | grep -v mysql-community-server-8.0.36-1.el9.x86_64.rpm | xargs rm -rf "$RPM_DIR"/*.rpmecho -e "${GREEN}✓ 清理完成${NC}"
# ------ 启动 MySQL 服务 ------echo -e "${BLUE}>>> 步骤4:启动 mysqld 并设置开机自启${NC}"systemctl enable --now mysqld &>/dev/nullif [ $? -ne 0 ]; then echo -e "${RED}错误:MySQL 服务启动失败,请检查日志 $LOG_FILE${NC}" exit 1fiecho -e "${GREEN}✓ MySQL 服务已启动${NC}"
# ------ 等待服务完全就绪(避免日志尚未生成) ------sleep 3
# ------ 从日志中提取临时密码 ------echo -e "${BLUE}>>> 步骤5:获取 root 临时密码${NC}"TEMP_PASS=$(grep 'temporary password' "$LOG_FILE" | awk '{print $NF}')if [ -z "$TEMP_PASS" ]; then echo -e "${RED}错误:未找到临时密码,请检查 $LOG_FILE${NC}" exit 1fiecho -e "${GREEN}✓ 临时密码获取成功${NC}"
# ------ 连接数据库并执行初始化 SQL ------echo -e "${BLUE}>>> 步骤6:修改密码策略并配置数据库${NC}"
# 注意:临时密码可能包含特殊字符,使用双引号包裹,并用 --connect-expired-password 选项mysql --connect-expired-password -uroot -p"$TEMP_PASS" <<EOF-- 1. 先修改 root 密码为复杂密码(必须符合默认策略:长度≥8,含大小写字母、数字、特殊字符)ALTER USER 'root'@'localhost' IDENTIFIED BY 'Oldboy123.com';
-- 2. 调整密码策略(允许简单密码)SET GLOBAL validate_password.length = 6;SET GLOBAL validate_password.policy = 0;
-- 3. 修改 root 密码为 'passwd'ALTER USER 'root'@'localhost' IDENTIFIED BY '${NEW_PASS}';
-- 4. 设置全局时区(东八区)SET GLOBAL time_zone = '+8:00';
-- 5. 创建数据库(指定字符集)CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8 COLLATE utf8_bin;
-- 6. 创建用户(允许任何主机连接,生产环境请按需修改)CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASS}';
-- 7. 使用 mysql_native_password 插件(兼容旧客户端)ALTER USER '${DB_USER}'@'%' IDENTIFIED WITH mysql_native_password BY '${DB_PASS}';
-- 8. 授权:授予 ${DB_NAME} 库的所有权限GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'%';
-- 9. 允许普通用户创建函数和触发器(Zabbix 等可能需要)SET GLOBAL log_bin_trust_function_creators = 1;
-- 10. 刷新权限FLUSH PRIVILEGES;EOF
if [ $? -eq 0 ]; then echo -e "${GREEN}✓ 数据库初始化完成!${NC}" echo -e "${GREEN}=========================================${NC}" echo -e "${GREEN} 安装成功!${NC}" echo -e "${GREEN} root 密码:${NEW_PASS}${NC}" echo -e "${GREEN} 数据库:${DB_NAME}${NC}" echo -e "${GREEN} 用户:${DB_USER}@'%' 密码:${DB_PASS}${NC}" echo -e "${GREEN} 时区已设置为东八区(+8:00)${NC}" echo -e "${GREEN}=========================================${NC}"else echo -e "${RED}错误:SQL 执行失败,请检查日志。${NC}" exit 1fi
# ------ 可选:重启服务使时区设置生效(其实动态设置已生效) ------# systemctl restart mysqld
echo -e "${BLUE}>>> 安装全部完成,脚本结束。${NC}"playbook
- hosts: share01 vars: dir: /webshare wpdir: /wp uid: 888 user: www tasks: - name: "安装nfs服务" package: name: nfs-utils state: present - name: "拷贝配置文件" template: src: ./share/exports.j2 dest: /etc/exports - name: "创建www组" group: name: "{{user}}" gid: "{{uid}}" - name: "创建www用户" user: name: "{{user}}" uid: "{{uid}}" group: "{{user}}" shell: /sbin/nologin create_home: false - name: "创建挂载目录" file: path: "{{dir}}" state: directory owner: "{{user}}" group: "{{user}}" - name: "启动nfs服务" systemd: name: nfs-server state: started enabled: yes - name: "解压MySQL压缩包" unarchive: src: ./share/mysql-8.0.36-1.el9.x86_64.rpm-bundle.tar dest: /tmp/ remote_src: no creates: /tmp/mysql-community-server-8.0.36-1.el9.x86_64.rpm # 只要这个rpm包存在就不会重复解压 - name: "拷贝mysql执行脚本" copy: src: /server/sh/install_mysql.sh dest: /server/sh/ - name: "执行mysql安装脚本" shell: "bash /server/sh/install_mysql.sh" register: install_result ignore_errors: yes - name: "如果脚本执行失败,打印错误并中止" fail: # fail 模块:主动触发失败,并输出自定义消息 msg: "❌ MySQL 安装失败,错误信息:{{ install_result.stderr_lines }}" when: install_result.rc != 0 # 当脚本执行失败才执行 - name: "脚本执行成功时打印成功信息" debug: msg: "✅ MySQL 安装成功!{{ install_result.stderr_lines }}" when: install_result.rc == 0
- hosts: web01,web02 vars: dir: /webshare tasks: - name: "web服务安装nfs工具包" package: name: nfs-utils state: present - name: "web服务挂载共享目录" mount: src: 10.2.3.80:/{{dir}} path: /usr/share/nginx/html/img fstype: nfs state: mountedWordpress部署
基础配置
(.venv) [root@ops-admin Projects]# vim ./nginx/wp.confserver { listen 80; server_name www.wp.com; root /code/wordpress;
location / { index index.php index.html; }
location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }}Redis缓存
(.venv) [root@ops-admin Projects]# vim ./share/valkey.conf# 拷贝至 /etc/valkey/valkey.confbind 127.0.0.1 10.2.3.80protected-mode yesport 6379tcp-backlog 511unixsocket /run/valkey/valkey.socktimeout 0tcp-keepalive 300daemonize nopidfile /run/valkey/valkey.pidloglevel noticelogfile /var/log/valkey/valkey.logdatabases 16always-show-logo nohide-user-data-from-log yesset-proc-title yesproc-title-template "{title} {listen-addr} {server-mode}"locale-collate ""stop-writes-on-bgsave-error yesrdbcompression yesrdbchecksum yesdbfilename dump.rdbrdb-del-sync-files nodir ./replica-serve-stale-data yesreplica-read-only yesrepl-diskless-sync yesrepl-diskless-sync-delay 5repl-diskless-sync-max-replicas 0repl-diskless-load disableddual-channel-replication-enabled norepl-disable-tcp-nodelay noreplica-priority 100acllog-max-len 128requirepass 123456lazyfree-lazy-eviction yeslazyfree-lazy-expire yeslazyfree-lazy-server-del yesreplica-lazy-flush yeslazyfree-lazy-user-del yeslazyfree-lazy-user-flush yesoom-score-adj nooom-score-adj-values 0 200 800disable-thp yesappendonly noappendfilename "appendonly.aof"appenddirname "appendonlydir"appendfsync everysecno-appendfsync-on-rewrite noauto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mbaof-load-truncated yesaof-use-rdb-preamble yesaof-timestamp-enabled no
slowlog-log-slower-than 10000slowlog-max-len 128latency-monitor-threshold 0notify-keyspace-events ""hash-max-listpack-entries 512hash-max-listpack-value 64list-max-listpack-size -2list-compress-depth 0set-max-intset-entries 512set-max-listpack-entries 128set-max-listpack-value 64zset-max-listpack-entries 128zset-max-listpack-value 64hll-sparse-max-bytes 3000stream-node-max-bytes 4096stream-node-max-entries 100activerehashing yesclient-output-buffer-limit normal 0 0 0client-output-buffer-limit replica 256mb 64mb 60client-output-buffer-limit pubsub 32mb 8mb 60hz 10dynamic-hz yesaof-rewrite-incremental-fsync yesrdb-save-incremental-fsync yesjemalloc-bg-thread yes(.venv) [root@ops-admin Projects]# vim ./share/wp-config.php# 拷贝至 /wp/wordpress/wp-config.php<?php
define( 'DB_NAME', 'wp' );
define( 'DB_USER', 'wpuser' );
define( 'DB_PASSWORD', 'passwd' );
define( 'DB_HOST', '10.2.3.80' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
define( 'AUTH_KEY', 'YYpir)>G!LZ53VG&{hG8j7$M+`Vc`a0S;uj*o)Cz$2vp>AZI-mt_c4`oVvZw:f7P' );define( 'SECURE_AUTH_KEY', '.Z$ntLw%}<7Cb9h2zA_].4k%ME6#Mkl^b@&F%s?ke<.Cnwt>WvEGJhL0:vl@e}m ' );define( 'LOGGED_IN_KEY', '8l=Atror^mZ>JkyzS`GlrR{muk8aZ=Jwy2<*s*|p@$DKBcxW{4S7vb@s*EbwP`bN' );define( 'NONCE_KEY', 'pqS&i&3-r#PhgGS{ {7/Sg}+Jk%{G9Zt+4NsD80&n!a.8%Cr_,o^Ex2xRbTy-zsa' );define( 'AUTH_SALT', '+SluVE?F;||2f0&a&`;-b=K{}_(5h6Gx(~=&S8&R_+mr[b/H](8*5ES&r(Yb[<(G' );define( 'SECURE_AUTH_SALT', 'O`SY9|ks|,}SSk3#7m`I6HYav,(rNQDVXt#ETaduD#A@V,Y&EsAN-x-vf{wGUdX>' );define( 'LOGGED_IN_SALT', '$1VZ-eT5d4;:h#NppV]ROT:$>j]W Dbgx{#MxrV[}.E|@g#Sj=$;:!V#^~=Vs= }' );define( 'NONCE_SALT', 'qc.s=BnM}uFC{Ws{JKkl@=0A+1*,RKuDw*+X7`;11u?3eT.a?X}N!&Oi5e$C6U)m' );
$table_prefix = 'wp_';
define( 'WP_DEBUG', false );
/* Add any custom values between this line and the "stop editing" line. */
define('WP_REDIS_HOST', '10.2.3.80');define('WP_REDIS_PORT', 6379);define('WP_REDIS_PASSWORD', '123456');
/* That's all, stop editing! Happy publishing. */
if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', __DIR__ . '/' );}
require_once ABSPATH . 'wp-settings.php';playbook
(.venv) [root@ops-admin Projects]# vim playbook-wp.yml- hosts: share01 vars: wpdir: /wp dir: /webshare user: www uid: 888 tasks: - name: "拷贝配置文件" template: src: ./share/exports.j2 dest: /etc/exports - name: "创建挂载目录" file: path: "{{wpdir}}" state: directory owner: "{{user}}" group: "{{user}}" - name: "重启nfs服务" systemd: name: nfs-server state: restarted - name: "下载wordpress包" get_url: url: https://cn.wordpress.org/latest-zh_CN.zip dest: /tmp/ force: no - name: "解压WordPress" unarchive: src: /tmp/wordpress-7.0-zh_CN.zip remote_src: yes dest: "{{wpdir}}" creates: "{{wpdir}}/wordpress" - name: "递归修改wordpress目录下的属主&属组" file: path: "{{wpdir}}/wordpress" owner: www group: www recurse: yes - name: "下载valkey缓存" package: name: valkey state: present - name: "拷贝配置文件 varkey.conf & wp-config.php" copy: src: "{{item.src}}" dest: "{{item.dest}}" loop: - src: ./share/valkey.conf dest: /etc/valkey/valkey.conf - src: ./share/wp-config.php dest: /wp/wordpress/wp-config.php - name: "启动 & 开机自启" systemd: name: valkey state: started enabled: yes
- hosts: web01,web02 vars: wpdir: /wp tasks: - name: "下载php-redis插件" package: name: php-redis state: present - name: "拷贝配置文件" copy: src: ./nginx/wp.conf dest: /etc/nginx/conf.d/ - name: "创建code目录" file: path: /code/ state: directory owner: www group: www - name: "web服务挂载共享目录" mount: src: "10.2.3.80:{{wpdir}}" path: /code fstype: nfs state: mounted - name: "重启nginx & php-fpm服务" systemd: name: "{{item.name}}" state: restarted loop: - name: nginx - name: php-fpm
- hosts: lb01 tasks: - name: "修改lb.conf配置文件" lineinfile: path: /etc/nginx/conf.d/lb.conf regexp: "{{item.regexp}}" line: "{{item.line}}" loop: - regexp: "server.*71:8000;" line: " server 10.2.2.71:80;" - regexp: "server.*72:8000;" line: " server 10.2.2.72:80;" - name: "重启nginx服务" systemd: name: nginx state: restarted数据库root用户:passwd
- WordPress数据库:wp
- 用户:wpuser
- 密码:passwd
- 默认的表前缀
- 管理员用户:admin
- 密码:Oldboy123.com
WordPress 博客安装 redis 插件,并启用
Waf雷池
在waf01节点部署雷池waf
- 参考雷池 WAF 帮助文档
- 所需变量写入vars.yml文件中
图形化安装
'最小化节点没有浏览器,所以需要安装图形化'# 雷池的应用证书,应该在雷池节点导入
yum -y groupinstall 'Server with GUI'# 安装图形化systemctl set-default graphical.target# 设置默认进入图形界面systemctl isolate graphical.target# 切换到图形界面安装脚本
#!/bin/bashset -e # 遇到错误立即退出
# ===== 1. 安装 iptables-nft(如果未安装) =====if ! rpm -q iptables-nft &>/dev/null; then echo "正在安装 iptables-nft..." dnf install -y iptables-nftelse echo "iptables-nft 已安装,跳过。"fi
# ===== 2. 安装 Docker(如果未安装) =====if ! command -v docker &>/dev/null; then echo "Docker 未安装,开始安装..." curl -sSL kpyun.fun/scripts/install-docker.sh | bash -s ielse echo "Docker 已安装,跳过。"fi
# ===== 新增:创建 Docker Compose 插件软链接(仅当不存在时) =====mkdir -p /usr/libexec/docker/cli-plugins/# 先判断软链接是否存在,已存在则跳过if [ ! -L /usr/libexec/docker/cli-plugins/docker-compose ]; then # 软链接不存在,再判断源文件是否存在,避免无效链接 if [ -f /usr/local/docker/docker-compose ]; then # 源文件存在,且软链接不存在则,创建软链接 ln -s /usr/local/docker/docker-compose /usr/libexec/docker/cli-plugins/docker-compose echo "软链接已创建:/usr/libexec/docker/cli-plugins/docker-compose -> /usr/local/docker/docker-compose" else echo "警告:源文件 /usr/local/docker/docker-compose 不存在,跳过软链接创建。" fielse echo "软链接已存在,跳过。"fi
# ===== 3. 准备 WAF 目录和 compose 文件 =====mkdir -p /wp && cd /wp
# compose.yaml 每次都重新下载(文件小无需判断)echo "下载 compose.yaml..."wget -q -O compose.yaml "https://waf-ce.chaitin.cn/release/latest/compose.yaml"
# ===== 4. 生成 .env 环境变量文件(幂等:内容不变就不重复写) =====# 用临时文件对比,避免每次都触发 docker-compose 重新创建cat > .env.tmp <<'EOF'SAFELINE_DIR=/wpIMAGE_TAG=9.1.0-ltsMGT_PORT=9443POSTGRES_PASSWORD=Redhat123SUBNET_PREFIX=172.22.222IMAGE_PREFIX=chaitinARCH_SUFFIX=RELEASE=REGION=MGT_PROXY=0EOF
if ! cmp -s .env .env.tmp 2>/dev/null; then mv .env.tmp .env echo ".env 文件已更新。"else rm -f .env.tmp echo ".env 文件无变化,跳过。"fi
# ===== 5. 导入 Docker 镜像(仅当镜像包存在时) =====if [ -f /tmp/all-9.1.0-lts.tar ]; then echo "导入 Docker 镜像..." docker image load < /tmp/all-9.1.0-lts.tar rm -f /tmp/all-9.1.0-lts.tarelse echo "未找到镜像包 /tmp/all-9.1.0-lts.tar,跳过导入。"fi
# ===== 6. 启动容器(docker-compose up -d 本身幂等) =====docker-compose up -d
# ===== 7. 生成自签名证书(如果不存在) =====mkdir -p ./sslif [ ! -f ./ssl/www.wp.com.key ] || [ ! -f ./ssl/www.wp.com.crt ]; then echo "生成自签名证书..." openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout ./ssl/www.wp.com.key \ -out ./ssl/www.wp.com.crt \ -subj "/C=CN/ST=Beijing/L=Beijing/O=Group2/CN=www.wp.com"else echo "证书已存在,跳过生成。"fi
echo "✅ 所有步骤完成。"playbook
- hosts: waf01,lb01,web01,web02 tasks: - name: "添加 hosts 解析记录" lineinfile: path: /etc/hosts line: "10.2.2.50 www.wp.com"
- hosts: waf01 vars: waf_image: "all-9.1.0-lts.tar" remote_image_path: "/tmp/{{ waf_image }}" compose_dir: "/wp" tasks: # 1. 安装 iptables-nft(yum/dnf 模块天然幂等) - name: "安装 iptables-nft 兼容包" dnf: name: iptables-nft state: present
# 2. 安装 Docker(如果未安装则执行安装脚本,利用 creates 实现幂等) - name: "安装 Docker(若尚未安装)" shell: "curl -sSL kpyun.fun/scripts/install-docker.sh | bash -s i" args: creates: "/usr/bin/docker" # 若 docker 二进制已存在,跳过执行
# ***** 新增:创建 docker compose 插件软链接(仅当不存在时) ***** - name: "创建 docker compose 插件软链接" shell: "ln -s /usr/local/docker/docker-compose /usr/libexec/docker/cli-plugins/docker-compose" args: creates: "/usr/libexec/docker/cli-plugins/docker-compose" # 若目标链接已存在,跳过
# ***** 新增:检查 safeline-mgt 镜像是否存在(用于后续条件判断) ***** - name: "检查 safeline-mgt 镜像是否存在" shell: "docker image inspect chaitin/safeline-mgt:9.1.0-lts >/dev/null 2>&1" register: image_check failed_when: false # 强制任务永远成功,不会标记为 failed changed_when: false # 该任务不会改变系统状态,抑制changed
# 3. 拷贝镜像包(copy 模块幂等:内容不变则不重复拷贝) - name: "拷贝雷池镜像包到远程" copy: src: "./waf/{{ waf_image }}" dest: "{{ remote_image_path }}"
# 4. 创建工作目录并下载 compose.yaml - name: "创建 /wp 目录" file: path: "{{ compose_dir }}" state: directory
- name: "下载最新 compose.yaml" get_url: url: "https://waf-ce.chaitin.cn/release/latest/compose.yaml" dest: "{{ compose_dir }}/compose.yaml" force: no # 如果存在,则不进行下载,不强制更新
# 5. 生成 .env 文件(template 模块可控制幂等,但这里用 copy 提供固定内容) - name: "生成 .env 环境变量文件" copy: content: | SAFELINE_DIR=/wp IMAGE_TAG=9.1.0-lts MGT_PORT=9443 POSTGRES_PASSWORD=Redhat123 SUBNET_PREFIX=172.22.222 IMAGE_PREFIX=chaitin ARCH_SUFFIX= RELEASE= REGION= MGT_PROXY=0 dest: "{{ compose_dir }}/.env" # 如果文件内容不变,则不会触发变更
# 6. 导入 Docker 镜像(仅当镜像包存在时执行) - name: "导入 Docker 镜像" shell: "docker image load < {{ remote_image_path }}" args: removes: "{{ remote_image_path }}" # 只有文件存在时才执行,不存在则跳过 # 执行后删除镜像包(可选) notify: "清理镜像包"
# ***** 修改:7. 启动容器,仅在镜像不存在时执行 ***** - name: "启动雷池容器(若镜像不存在)" community.docker.docker_compose_v2: project_src: "{{ compose_dir }}" state: present when: image_check.rc != 0 # 只有当镜像不存在时才启动
# 8. 生成自签名证书 - name: "确保 ssl 目录存在" file: path: "{{ compose_dir }}/ssl" state: directory
- name: "生成自签名证书" shell: | openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout /wp/ssl/www.wp.com.key \ -out /wp/ssl/www.wp.com.crt \ -subj "/C=CN/ST=Beijing/L=Beijing/O=Group2/CN=www.wp.com" args: creates: "/wp/ssl/www.wp.com.crt" # 证书已存在则跳过,保证幂等
handlers: - name: "清理镜像包" file: path: "{{ remote_image_path }}" state: absentcreates 是 Ansible 为 shell/command 这类天然非幂等模块提供的最简幂等保障机制,用文件存在性作为”任务是否已完成”的判断依据
⚠️ 注意:creates 只检查文件/目录是否存在,不检查内容、版本或权限。如果 /usr/bin/docker 已存在但版本不对,任务仍会被跳过。
| 参数 | 逻辑 | 场景 |
|---|---|---|
creates | 文件存在则跳过 | 安装/创建资源 |
removes | 文件不存在则跳过 | 删除/清理资源 |
[root@waf01 wp]# docker exec safeline-mgt resetadmin# 每次执行 resetadmin 会生成新密码[INFO] Initial username:admin[INFO] Initial password:5zb7qCqS[INFO] Done# 在打开页面之前生成一次密码,并在屏幕中捕获它~# https://10.2.2.50:9443[root@waf01 wp]# mkdir -p /usr/libexec/docker/cli-plugins/[root@waf01 wp]# ln -s /usr/local/docker/docker-compose /usr/libexec/docker/cli-plugins/docker-compose[root@waf01 wp]# docker compose versionDocker Compose version v2.29.2Jumpserver
[root@jumpserver ~]# dnf install -y iptables-nft[root@jumpserver ~]# ls /tmp/jumpserver-ce-v4.10.16-x86_64.tar.gz[root@jumpserver ~]# tar xf /tmp/jumpserver-ce-v4.10.16-x86_64.tar.gz -C /opt/[root@jumpserver ~]# cd /opt/jumpserver-ce-v4.10.16-x86_64/[root@jumpserver jumpserver-ce-v4.10.16-x86_64]# ./jmsctl.sh install>>> The Installation is Complete1. You can use the following command to start, and then visitcd /opt/jumpserver-ce-v4.10.16-x86_64./jmsctl.sh start
2. Other management commands./jmsctl.sh stop./jmsctl.sh restart./jmsctl.sh backup./jmsctl.sh upgradeFor more commands, you can enter ./jmsctl.sh --help to understand
3. Web accesshttp://10.0.0.40:80Default username: admin Default password: ChangeMe
More informationOfficial Website: https://www.jumpserver.com/Documentation: https://www.jumpserver.com/docs
curl -sSL https://resource.fit2cloud.com/jumpserver/jumpserver/releases/latest/download/quick_start.sh | bash文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!










