[{"content":"Jellyfin 是一个开源媒体服务器，可以用来管理电影、电视剧、音乐和照片，并通过浏览器、电视盒子、手机 App 访问。\n本文记录在 QNAP NAS 上用 Docker 部署 Jellyfin 的方式。部署方式优先使用 Docker Compose，Container Station 用来查看容器状态和日志。\n官方文档入口：\nJellyfin Docker 安装文档 Jellyfin Docker 下载页 Jellyfin GitHub Releases QNAP Container Station 3 版本说明 本文固定使用 Jellyfin：\njellyfin/jellyfin:10.11.11 不要直接使用 jellyfin/jellyfin:latest。Jellyfin 官方说明 latest 会跟随最新稳定版，包括大版本和小版本变化。家庭媒体库长期运行时，建议固定到具体版本，升级前先备份配置。\n官方容器镜像常见标签含义：\nlatest 最新稳定版，浮动 10 最新 10.Y.Z，浮动 10.11 最新 10.11.Z，浮动 10.11.11 固定具体版本 部署规划 本文假设 QNAP NAS 的 IP 是：\n192.168.3.200 Jellyfin 访问地址：\nhttp://192.168.3.200:8096 目录规划：\n/share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose 放 compose.yaml /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config 放 Jellyfin 配置 /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache 放 Jellyfin 缓存和转码临时文件 /share/CACHEDEV2_DATA/Media 放媒体文件 如果你的 QNAP 默认存储池不是 CACHEDEV2_DATA，需要按实际路径修改。可以通过下面命令查看默认数据卷：\ngetcfg SHARE_DEF defVolMP -f /etc/config/def_share.info 前置条件 QNAP 需要先安装：\nContainer Station Docker Engine Docker Compose 在 QNAP Web 管理页面打开：\nApp Center -\u003e 搜索 Container Station -\u003e 安装 然后开启 SSH：\n控制台与管理工具 -\u003e Telnet / SSH -\u003e 允许 SSH 连接 从电脑登录 NAS：\nssh admin@192.168.3.200 检查 Docker：\ndocker version docker compose version 如果 SSH 中找不到 docker，先查找 Container Station 自带的 Docker 路径：\nfind /share -name docker -type f 2\u003e/dev/null | grep -Ei 'container.*station|/\\.qpkg/' 常见路径类似：\n/share/CACHEDEV2_DATA/.qpkg/container-station/bin/docker 临时加入 PATH：\nexport PATH=/share/CACHEDEV2_DATA/.qpkg/container-station/bin:$PATH 如果路径不是 CACHEDEV2_DATA，按实际查到的路径修改。\n创建目录 创建 Jellyfin 部署目录：\nexport NAS_DATA_ROOT=/share/CACHEDEV2_DATA mkdir -p ${NAS_DATA_ROOT}/DockerDatas/jellyfin/compose mkdir -p ${NAS_DATA_ROOT}/DockerDatas/jellyfin/config mkdir -p ${NAS_DATA_ROOT}/DockerDatas/jellyfin/cache mkdir -p ${NAS_DATA_ROOT}/Media 把电影、电视剧、音乐等文件放到 ${NAS_DATA_ROOT}/Media 下，例如：\n/share/CACHEDEV2_DATA/Media/Movies /share/CACHEDEV2_DATA/Media/TV /share/CACHEDEV2_DATA/Media/Music 如果你的媒体文件已经在其他共享目录，例如 /share/CACHEDEV2_DATA/Multimedia，后面的 Compose 文件里把媒体路径改成实际目录即可。\nDocker Compose 部署 进入部署目录：\ncd ${NAS_DATA_ROOT}/DockerDatas/jellyfin/compose 创建 compose.yaml：\ncat \u003e compose.yaml \u003c\u003c'EOF' services: jellyfin: image: jellyfin/jellyfin:10.11.11 container_name: jellyfin restart: unless-stopped ports: - \"8096:8096/tcp\" - \"7359:7359/udp\" environment: TZ: Asia/Shanghai JELLYFIN_PublishedServerUrl: http://192.168.3.200:8096 volumes: - /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config:/config - /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache:/cache - type: bind source: /share/CACHEDEV2_DATA/Media target: /media read_only: false EOF 这里把媒体目录挂载成只读，Jellyfin 可以扫描和播放，但不能修改媒体文件。这样更适合 NAS 上已经整理好的媒体库。\n如果要让 Jellyfin 下载字幕、写入 NFO 或直接管理媒体文件，可以把 read_only: true 改成：\nread_only: false 启动：\ndocker compose up -d 查看状态：\ndocker compose ps 查看日志：\ndocker compose logs -f docker run 部署 如果只是临时测试，也可以直接使用 docker run：\ndocker run -d \\ --name jellyfin \\ --restart unless-stopped \\ -p 8096:8096/tcp \\ -p 7359:7359/udp \\ -e TZ=Asia/Shanghai \\ -e JELLYFIN_PublishedServerUrl=http://192.168.3.200:8096 \\ -v /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config:/config \\ -v /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache:/cache \\ --mount type=bind,source=/share/CACHEDEV2_DATA/Media,target=/media,readonly \\ jellyfin/jellyfin:10.11.11 长期运行更推荐 Compose，后续升级、备份和迁移都更清楚。\n访问和初始化 浏览器访问：\nhttp://192.168.3.200:8096 第一次打开会进入 Jellyfin 初始化向导：\n1. 选择语言 2. 创建管理员用户 3. 添加媒体库 4. 配置远程访问 5. 完成初始化 添加媒体库时，容器内路径使用：\n/media/Movies /media/TV /media/Music 如果页面打不开，先检查：\ndocker compose ps docker compose logs -f curl -I http://127.0.0.1:8096 curl -I http://192.168.3.200:8096 Container Station 中查看 通过 SSH 使用 Docker Compose 启动后，Jellyfin 容器通常也会出现在 Container Station 的容器列表里。\n可以在 Container Station 中查看：\n容器状态 CPU / 内存占用 端口映射 容器日志 但建议不要在 Container Station 里随手修改容器配置。长期维护以 compose.yaml 为准，避免 GUI 配置和文件配置不一致。\n硬件转码 如果 QNAP 是 Intel 或 AMD x86_64 机型，可能可以使用 /dev/dri 做硬件转码。先检查设备是否存在：\nls -l /dev/dri 如果能看到类似：\ncard0 renderD128 再查看 renderD128 的组 ID：\nstat -c '%n %a %U %G %g' /dev/dri/renderD128 示例输出：\n/dev/dri/renderD128 660 root render 107 最后一列 107 就是渲染设备的 GID。为了让容器内的 Jellyfin 进程能访问这个设备，可以在 Compose 目录里创建 .env：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose echo \"RENDER_GID=$(stat -c '%g' /dev/dri/renderD128)\" \u003e .env cat .env 然后在 compose.yaml 中给 Jellyfin 增加 devices 和 group_add：\nservices: jellyfin: image: jellyfin/jellyfin:10.11.11 container_name: jellyfin restart: unless-stopped devices: - /dev/dri:/dev/dri group_add: - \"${RENDER_GID}\" ports: - \"8096:8096/tcp\" - \"7359:7359/udp\" environment: TZ: Asia/Shanghai JELLYFIN_PublishedServerUrl: http://192.168.3.200:8096 volumes: - /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config:/config - /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache:/cache - type: bind source: /share/CACHEDEV2_DATA/Media target: /media read_only: true networks: - homestore networks: homestore: driver: bridge name: homestore 修改后重启：\ndocker compose down docker compose up -d 确认容器里能看到设备：\ndocker exec -it jellyfin ls -l /dev/dri 然后在 Jellyfin Web 页面中打开：\n控制台 -\u003e 播放 -\u003e 转码 -\u003e 硬件加速 Intel 核显一般选择：\nIntel Quick Sync 例如 QNAP 使用 Intel Celeron N5095 这类 Intel 核显机型时，优先选择：\nIntel Quicksync (QSV) 如果 Quick Sync 不正常，可以退一步测试：\nVAAPI 常用设置建议：\n硬件加速: Intel Quick Sync 硬件解码: 先勾 H.264、HEVC、MPEG2、VC1、VP9 硬件编码: 开启 允许硬件色调映射: 先关闭，确认普通转码正常后再测试 转码临时路径: 保持默认或使用 /cache 老款 Intel 核显不一定支持 AV1、HEVC 10-bit、HDR 色调映射等能力。不要一次把所有选项都勾上，先让 1080p H.264 / HEVC 转码跑通，再逐项增加。\n测试方式：\n1. 手机播放一个高码率视频 2. 把质量手动调低，例如 1080p - 8 Mbps 3. 打开播放信息 4. 确认播放方式从 Direct 变成 Transcode 5. 观察 NAS CPU 是否明显低于纯软件转码 同时查看 Jellyfin 日志：\ndocker compose logs -f 如果硬件转码正常，日志里的 ffmpeg 命令通常会出现和 QSV 或 VAAPI 相关的参数。\n注意，修改 compose.yaml 只代表容器能看到 /dev/dri，不代表 Jellyfin 已经启用硬件转码。可以检查配置文件：\ngrep -n 'HardwareAccelerationType' \\ /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config/config/encoding.xml 如果输出是：\n\u003cHardwareAccelerationType\u003enone\u003c/HardwareAccelerationType\u003e 说明 Jellyfin 后台仍然没有开启硬件加速。需要在 Web 页面里选择 Intel Quick Sync 或 VAAPI 并保存。\n也可以通过日志判断当前是不是硬件转码：\ndocker compose logs -f 如果 ffmpeg 命令里看到：\n-codec:v:0 libx264 说明当前还是 CPU 软件转码。如果硬件转码生效，通常会看到：\nqsv vaapi 例如看到下面这些参数，就说明 QSV 已经生效：\n-init_hw_device qsv -codec:v:0 h264_qsv scale_vaapi format=qsv 如果 QSV 已经生效但 iPhone 仍然卡，问题就不再是“硬件转码没打开”，而要继续看客户端和播放链路：\n播放信息是否仍然是 Direct 是否通过 Safari Web 页面播放 是否走了 HTTPS 反向代理 是否开启了复杂字幕 是否使用了过低码率或频繁拖动进度条 建议优先测试这几种组合：\n1. iPhone 直连 http://192.168.3.200:8096 2. 固定质量 1080p 8 Mbps 或 720p 4 Mbps 3. 关闭字幕 4. 使用 Jellyfin App 或外部播放器 VLC 5. 确认播放信息显示 Transcode，而不是 Direct 是否能用硬件转码取决于 NAS CPU、QNAP 系统、驱动、设备权限和视频编码格式。不要一开始就依赖硬解，先确认普通播放正常，再开启硬解测试。\n如果开启硬件转码后无法播放，先回到 Jellyfin 后台关闭硬件加速，再查看日志：\ndocker compose logs -f 手机播放卡顿排查 如果电脑播放正常，但手机播放视频卡顿，先打开手机播放器里的播放信息。看到类似：\n质量: 自动 - Direct 播放速度: 1x 这里的 Direct 很关键。它表示 Jellyfin 正在让手机直接播放原始视频文件，NAS 基本没有参与转码。此时卡顿通常不是 Jellyfin 容器本身的问题，而是下面几类原因：\n手机 Wi-Fi 信号弱，尤其是 2.4G 网络 原片码率太高，例如 4K、蓝光 Remux、大体积 HEVC 手机硬件或当前播放器对视频编码支持不好 手机实际走了公网、反向代理或弱网络链路 字幕、音轨、封装格式导致手机端播放压力变大 硬件转码只对 Transcode 生效。只要播放信息仍然显示 Direct，即使已经配置好 /dev/dri，NAS 的硬件转码也不会参与这次播放。\n先确认手机和电脑是不是同一条访问路径。为了排除反向代理和公网链路，手机先连同一个局域网 Wi-Fi，并直接访问：\nhttp://192.168.3.200:8096 不要一开始就用：\nhttps://jellyfin.jihw.top 如果局域网直连不卡，而域名访问卡，问题多半在 Nginx Proxy Manager、路由器、公网回流、DNS 或外网带宽上。\n再看原片码率。播放时打开“播放信息”，重点看：\n视频编码: H.264 / HEVC / AV1 分辨率: 1080p / 4K 码率: 多少 Mbps 播放方式: Direct / Direct Stream / Transcode 如果是 Direct，并且原片是高码率 4K 或 HEVC，手机卡顿很常见。可以在手机播放界面把质量从“自动”改成固定码率，例如：\n1080p - 10 Mbps 720p - 4 Mbps 这样会强制 Jellyfin 转码，降低手机端和网络压力。转码后再观察：\n1. 手机是否不卡了 2. NAS CPU 是否飙高 3. Jellyfin 日志里是否出现 ffmpeg 转码错误 查看日志：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose logs -f 如果强制降低质量后不卡，说明主要问题是原片码率、手机解码能力或 Wi-Fi 带宽。后续可以考虑开启硬件转码。\n这种情况可以直接按下面思路处理：\n局域网手机播放 1080p: 先试 8~12 Mbps 公网手机播放 1080p: 先试 4~8 Mbps 移动网络播放: 先试 2~4 Mbps 4K 原盘或 Remux: 不建议手机 Direct，优先转码或准备 1080p 版本 如果只是偶尔手机看，可以在客户端播放时手动降低质量。如果经常在手机上看，建议开启硬件转码，或者为高码率影片额外准备一份 1080p / H.264 / 8Mbps 左右的版本。\n实际测试时可以按手机稳定性重新定档。如果 1.5 Mbps 稳定，而 3 Mbps 还需要观察，就先把手机端固定在：\n720p 1.5 Mbps 然后再逐步测试：\n720p 3 Mbps 1080p 4 Mbps 1080p 8 Mbps 哪一档连续播放 10~15 分钟不卡，就先用哪一档。不要只看能否起播，手机端真正的问题往往出现在几分钟后的 HLS 分片获取和缓冲。\n如果降低质量后更卡，说明 NAS 正在软件转码但性能不够。此时要么开启 /dev/dri 硬件转码，要么准备适合手机播放的低码率版本。\n还有一个实用办法是更换手机端播放器。Jellyfin 手机 App 里可以尝试：\n使用集成播放器 使用外部播放器，例如 VLC 关闭或更换复杂字幕 ASS/SSA 特效字幕、PGS 图形字幕在某些客户端上会触发额外处理，低性能手机上容易卡。测试时可以先关闭字幕，确认是不是字幕导致。\niPhone 自动模式播放一段时间后卡住 如果 iPhone 在“自动”质量下播放到 1 分钟左右卡住，但手动降低码率后正常，通常可以这样判断：\n自动模式: iPhone 直接播放原片，显示 Direct 降低码率: Jellyfin 转成 HLS 分段流，显示 Transcode 这说明问题主要出在 iPhone 直播放这条链路上，可能是原片封装、60fps、音视频轨道、关键帧、Safari/Web 播放器兼容性或网络缓冲策略导致的。降低码率后变成 HLS 转码流，客户端压力和兼容性问题都会小很多。\n可以在 NAS 上看 Jellyfin 日志确认是否转码：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose logs -f 如果日志里的 ffmpeg 命令包含：\n-codec:v:0 libx264 说明当前是 CPU 软件转码。开启硬件转码后，日志里通常会出现 qsv 或 vaapi 相关参数。\n处理建议：\n1. iPhone 客户端不要长期使用自动质量，先固定 1080p 8~12 Mbps 2. 使用 Jellyfin App 时尝试切换集成播放器或外部播放器 3. 关闭复杂字幕测试 4. 开启 QNAP 的 /dev/dri 硬件转码 5. 对特别不兼容的视频，额外准备一份 H.264 / AAC / 1080p 版本 电脑和平板不卡，手机卡 如果同一个视频在电脑和平板上不卡，但华为手机和 iPhone 都卡，基本可以先排除 NAS 磁盘、Jellyfin 容器和整体网络吞吐问题。排查重点放到手机端：\n手机是否走浏览器播放，而不是 Jellyfin App 手机是否连到 2.4G Wi-Fi 或弱信号区域 手机是否启用了省电模式、低数据模式、私有地址、代理或 VPN 手机播放信息是否在 Direct / Transcode 之间变化 是否通过 HTTPS 反向代理访问，而电脑和平板用的是内网直连 建议固定一个测试矩阵，不要同时改多个变量：\n电脑: http://192.168.3.200:8096 自动质量 平板: http://192.168.3.200:8096 自动质量 手机: http://192.168.3.200:8096 1080p 8 Mbps 手机: http://192.168.3.200:8096 720p 4 Mbps 手机: https://jellyfin.jihw.top 1080p 8 Mbps 如果手机直连内网不卡、域名访问卡，问题在反向代理、DNS、路由器回流或 HTTPS 链路。\n如果手机直连内网也卡，但电脑和平板不卡，优先处理手机客户端：\n1. 不用 Safari / 手机浏览器，改用 Jellyfin App 2. Jellyfin App 中尝试切换集成播放器或外部播放器 3. 外部播放器优先测试 VLC 4. 关闭字幕 5. 固定质量，不使用自动 QSV 生效后，日志中会出现：\n-init_hw_device qsv -codec:v:0 h264_qsv 如果日志已经是 h264_qsv，但手机仍然卡，就不要继续纠结“硬件转码有没有打开”。下一步应该固定手机质量、换播放器、绕过反向代理测试。\n还有一种容易误判的情况：Jellyfin 后台已经开启 QSV，但手机客户端在“自动”质量下仍然选择 Direct。这时日志里只会看到 MediaInfoHelper，不会出现新的 ffmpeg 命令。也就是说硬件转码虽然已经配置好了，但这次播放根本没有用上。\n判断方法很简单：播放时打开手机端“播放信息”：\nDirect 没有用转码，QSV 不参与 Transcode 正在转码，才会用到 QSV/VAAPI 如果手机端仍然显示 Direct，就把质量固定到低于原片的值，例如：\n1080p 8 Mbps 720p 4 Mbps 如果固定质量后日志仍然没有 ffmpeg 命令，说明客户端没有真正触发转码，继续降低质量或换播放器测试。\n如果固定质量后日志出现 h264_qsv，但仍然在 1~2 分钟后卡住，优先怀疑这个视频文件本身的封装、时间戳、关键帧或手机端播放器兼容性。可以额外准备一份手机友好的版本：\nH.264 AAC 1080p 或 720p 30fps MP4 faststart 如果日志里反复出现：\nCurrent HLS implementation doesn't support non-keyframe breaks but one is requested Stopping ffmpeg process with q command 说明手机端在请求 HLS 分片或跳转位置时，服务端需要不断停掉当前转码任务并从新的时间点重新生成分片。这类问题通常不是 NAS 性能不足，而是手机播放器、HLS 分片、源文件关键帧间隔或封装兼容性叠在一起导致的。\n实测如果 1.5 Mbps 稳定，而 3 Mbps 仍然在 2~3 分钟附近卡住，就先把手机端固定在稳定档位：\n720p 1.5 Mbps 然后再考虑长期方案：\n1. 手机端使用外部播放器 VLC / Infuse 2. 避免使用自动质量 3. 关闭字幕测试 4. 给问题视频转一份手机友好版本 5. 用 30fps、固定关键帧间隔、H.264、AAC、MP4 faststart DLNA 和局域网发现 本文默认使用桥接网络，并映射了：\n8096/tcp Web 页面和客户端访问 7359/udp 局域网发现 Jellyfin 官方说明 Docker 默认是 bridge 网络，如果要使用 DLNA，通常需要 host 网络。QNAP 上使用 host 网络可能和 NAS 自身服务端口冲突，建议先不用 DLNA，优先通过浏览器、手机 App、电视客户端手动填写服务器地址：\nhttp://192.168.3.200:8096 Nginx Proxy Manager 反向代理 如果要通过域名访问，例如：\nhttps://jellyfin.jihw.top 可以用 Nginx Proxy Manager 反代到 Jellyfin：\nDomain Names: jellyfin.jihw.top Scheme: http Forward Hostname/IP: 192.168.3.200 Forward Port: 8096 SSL: Let's Encrypt Force SSL: 开启 Websockets Support: 开启 同时把 compose.yaml 里的 JELLYFIN_PublishedServerUrl 改成最终访问地址：\nenvironment: TZ: Asia/Shanghai JELLYFIN_PublishedServerUrl: https://jellyfin.jihw.top 修改后重启：\ndocker compose down docker compose up -d 不要把没有 HTTPS 的 Jellyfin 直接暴露到公网。公网访问建议走 HTTPS、强密码、反向代理、VPN 或内网穿透访问控制。\n常用维护命令 进入部署目录：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose 停止：\ndocker compose stop 启动：\ndocker compose start 重启：\ndocker compose restart 查看状态：\ndocker compose ps 查看日志：\ndocker compose logs -f 备份和恢复 Jellyfin 最重要的是配置目录：\n/share/CACHEDEV2_DATA/DockerDatas/jellyfin/config 缓存目录可以不备份：\n/share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache 停机备份：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose stop tar czf /share/CACHEDEV2_DATA/DockerDatas/jellyfin-backup-$(date +%F).tgz \\ /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config \\ /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose/compose.yaml docker compose start 媒体文件目录一般体积很大，建议通过 QNAP 自己的快照、HBS、外置硬盘或异地备份单独处理。\n恢复时保持目录路径一致，然后启动：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose up -d 升级 升级前先备份 config 目录。然后修改 compose.yaml 中的镜像版本，例如从：\nimage: jellyfin/jellyfin:10.11.11 改成目标版本。\n拉取并重建：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose pull docker compose up -d 查看日志确认启动正常：\ndocker compose logs -f 不要跨多个大版本直接升级。升级前先看 Jellyfin Releases，确认目标版本是否有数据库迁移、插件兼容性或安全修复说明。\n卸载 只删除容器，保留配置和媒体文件：\ncd /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose docker compose down 确认不再需要 Jellyfin 后，再删除配置和缓存：\nrm -rf /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config rm -rf /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache rm -rf /share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose 不要误删媒体目录：\n/share/CACHEDEV2_DATA/Media 小结 QNAP NAS 上部署 Jellyfin 最稳的方式是：Container Station 提供 Docker 环境，实际部署用 Docker Compose 管理。\n普通使用只需要 8096/tcp，局域网发现可以加 7359/udp。硬件转码需要确认 /dev/dri 是否存在，并且要单独测试。公网访问不要直接暴露 HTTP，建议通过 Nginx Proxy Manager 配 HTTPS 反向代理。\n","permalink":"/coding/docker-jellyfin-qnap/","summary":"Jellyfin 是一个开源媒体服务器，可以用来管理电影、电视剧、音乐和照片，并通过浏览器、电视盒子、手机 App 访问。\n本文记录在 QNAP NAS 上用 Docker 部署 Jellyfin 的方式。部署方式优先使用 Docker Compose，Container Station 用来查看容器状态和日志。\n官方文档入口：\nJellyfin Docker 安装文档 Jellyfin Docker 下载页 Jellyfin GitHub Releases QNAP Container Station 3 版本说明 本文固定使用 Jellyfin：\njellyfin/jellyfin:10.11.11 不要直接使用 jellyfin/jellyfin:latest。Jellyfin 官方说明 latest 会跟随最新稳定版，包括大版本和小版本变化。家庭媒体库长期运行时，建议固定到具体版本，升级前先备份配置。\n官方容器镜像常见标签含义：\nlatest 最新稳定版，浮动 10 最新 10.Y.Z，浮动 10.11 最新 10.11.Z，浮动 10.11.11 固定具体版本 部署规划 本文假设 QNAP NAS 的 IP 是：\n192.168.3.200 Jellyfin 访问地址：\nhttp://192.168.3.200:8096 目录规划：\n/share/CACHEDEV2_DATA/DockerDatas/jellyfin/compose 放 compose.yaml /share/CACHEDEV2_DATA/DockerDatas/jellyfin/config 放 Jellyfin 配置 /share/CACHEDEV2_DATA/DockerDatas/jellyfin/cache 放 Jellyfin 缓存和转码临时文件 /share/CACHEDEV2_DATA/Media 放媒体文件 如果你的 QNAP 默认存储池不是 CACHEDEV2_DATA，需要按实际路径修改。可以通过下面命令查看默认数据卷：\n","title":"QNAP NAS 使用 Docker 部署 Jellyfin"},{"content":"Harbor 是一个企业级镜像仓库，不只是一个 registry 容器。它包含 Portal、Core、Registry、Jobservice、PostgreSQL、Redis、Nginx、日志服务，以及可选的 Trivy 漏洞扫描服务。\n所以先说结论：\nHarbor 不适合用一条 docker run 完整部署。 官方单机部署方式是 Docker Compose。 如果只需要单容器 registry，可以用 registry:2，但那不是 Harbor。 Harbor 官方文档也把单机部署方式写成 Docker Compose，把 Kubernetes 部署方式写成 Helm。本文记录在一台 Linux 主机上部署 Harbor 的流程，重点使用官方安装包生成的 Docker Compose 文件。\n官方文档入口：\nHarbor Installation Prerequisites Download the Harbor Installer Configure the Harbor YML File Run the Installer Script Harbor Releases QNAP Container Station 3 版本说明 本文固定使用 Harbor：\nv2.14.4 不要使用 latest 这种浮动标签。Harbor 是多组件系统，升级涉及数据库、配置模板、镜像版本和迁移逻辑，不能只靠重新拉镜像解决。\n如果官方 release 页面已经有更新版本，可以把下面命令里的 HARBOR_VERSION 替换成目标版本。升级生产环境前要先看对应版本的 release notes 和 upgrade 文档。\n部署规划 本文假设部署在一台 Linux 主机上：\nHarbor 域名: harbor.example.com 安装目录: /opt/harbor-installer 数据目录: /data/harbor HTTP 端口: 80 HTTPS 端口: 443 把 harbor.example.com 替换成自己的域名或内网 DNS 名称。不要把 hostname 写成 localhost、127.0.0.1 或 0.0.0.0，否则其他 Docker 客户端无法按正常 registry 地址访问。\n资源建议按官方最低要求起步：\nCPU: 最低 2 Core，推荐 4 Core+ Memory: 最低 4GB，推荐 8GB+ Disk: 最低 40GB，推荐 160GB+ 如果要长期保存构建镜像，磁盘要按镜像数量、标签数量和清理策略单独规划。\n前置条件 主机需要先安装：\nDocker Engine Docker Compose Plugin OpenSSL curl tar 验证版本：\ndocker version docker compose version openssl version 如果 Docker 还没装，可以先参考本站的 Docker 入门：Ubuntu 安装与基础配置。\ndocker run 方式的边界 Harbor 不能像下面这样部署：\ndocker run -d --name harbor -p 80:80 goharbor/harbor:latest 原因很简单：Harbor 没有这样一个官方单体镜像。完整 Harbor 需要多个容器、共享配置、内部证书、数据库、Redis、Nginx 反向代理、任务服务和 registry 存储目录。手工把这些组件全部翻译成一堆 docker run 命令，不但容易漏配置，也不利于后续升级和迁移。\n如果只是想临时跑一个最简单的 Docker Registry，可以用下面的命令，但它不是 Harbor，没有 Harbor 的 Web UI、项目权限、审计、复制、漏洞扫描等功能：\ndocker run -d \\ --name registry \\ --restart unless-stopped \\ -p 5000:5000 \\ -v /opt/registry/data:/var/lib/registry \\ registry:2.8.3 验证普通 registry：\ndocker pull busybox:1.36.1 docker tag busybox:1.36.1 localhost:5000/library/busybox:1.36.1 docker push localhost:5000/library/busybox:1.36.1 如果目标是 Harbor，继续使用下面的 Docker Compose 方式。\nDocker Compose 部署 Harbor 创建目录：\nsudo mkdir -p /opt/harbor-installer /data/harbor sudo chown -R \"$USER:$USER\" /opt/harbor-installer /data/harbor cd /opt/harbor-installer 下载固定版本的在线安装包：\nexport HARBOR_VERSION=v2.14.4 curl -L -o harbor-online-installer-${HARBOR_VERSION}.tgz \\ https://github.com/goharbor/harbor/releases/download/${HARBOR_VERSION}/harbor-online-installer-${HARBOR_VERSION}.tgz tar xzf harbor-online-installer-${HARBOR_VERSION}.tgz --strip-components=1 在线安装包体积小，安装时会从镜像仓库拉取 Harbor 组件镜像。如果部署机器不能访问外网，改用同版本的 harbor-offline-installer-${HARBOR_VERSION}.tgz。\n复制配置文件：\ncp harbor.yml.tmpl harbor.yml 编辑配置：\nvim harbor.yml 内网测试环境可以先使用 HTTP，核心配置类似下面这样：\nhostname: harbor.example.com http: port: 80 # https: # port: 443 # certificate: /your/certificate/path # private_key: /your/private/key/path harbor_admin_password: change-me-to-a-strong-admin-password database: password: change-me-to-a-strong-db-password data_volume: /data/harbor 注意事项：\nharbor_admin_password 只在第一次初始化时生效，后续要在 Web 页面里修改管理员密码。 database.password 不要使用默认值，生产环境必须改成强密码。 data_volume 是 Harbor 数据目录，删除这里会丢失镜像、数据库和任务数据。 HTTP 只适合内网测试或隔离环境，生产环境应该启用 HTTPS。 如果要启用 HTTPS，把 https 配置取消注释，并填入证书和私钥路径：\nhostname: harbor.example.com https: port: 443 certificate: /opt/harbor-installer/certs/harbor.example.com.crt private_key: /opt/harbor-installer/certs/harbor.example.com.key 证书可以来自可信 CA，也可以是自签证书。自签证书需要额外分发到所有 Docker 客户端，否则 docker login、docker push 会因为证书不受信任失败。\n安装并启动 Harbor：\ncd /opt/harbor-installer sudo ./install.sh --with-trivy 如果暂时不需要漏洞扫描，可以不带 Trivy：\nsudo ./install.sh 安装脚本会根据 harbor.yml 生成 docker-compose.yml 和组件配置，然后用 Docker Compose 启动 Harbor。\n查看容器状态：\ncd /opt/harbor-installer docker compose ps 查看日志：\ndocker compose logs -f 正常情况下可以看到类似这些容器：\nnginx harbor-core harbor-portal harbor-jobservice harbor-db redis registry registryctl harbor-log trivy-adapter 在 QNAP NAS 中安装 Harbor QNAP NAS 上可以通过 Container Station 运行 Docker 和 Docker Compose 应用，但 Harbor 不是普通的单个 Compose YAML 就能手工写完的应用。它需要先执行官方安装包里的 install.sh 或 prepare，生成 docker-compose.yml、common/config 和各组件配置。\n所以在 QNAP 上推荐这条路线：\n1. 在 QTS / QuTS hero 的 App Center 安装 Container Station 2. 开启 SSH 3. 通过 SSH 登录 NAS 4. 在 NAS 上运行 Harbor 官方安装包 5. 用 Container Station 查看容器状态和日志 不要直接在 Container Station 的“创建应用程序”里粘贴网上随便找的 Harbor Compose。Harbor 官方生成的 docker-compose.yml 会引用安装目录下的相对路径，例如 ./common/config/...。如果只复制 YAML，不复制同一目录下的配置文件，容器大概率启动失败。\nQNAP 前置检查 先确认 NAS 型号和资源。Harbor 更适合 x86_64 的 QNAP NAS，例如 Intel 或 AMD CPU 机型。很多 ARM NAS 不适合直接跑 Harbor，容易遇到镜像架构不匹配、性能不足或组件镜像不可用的问题。\n通过 SSH 登录 QNAP 后检查架构：\nuname -m 常见结果：\nx86_64 适合继续部署 aarch64 ARM64，先不要按本文直接部署 Harbor armv7l ARM 32 位，不建议部署 Harbor 检查内存和磁盘：\nfree -h df -h 建议至少：\nCPU: 2 Core 起步，推荐 4 Core+ Memory: 4GB 起步，推荐 8GB+ Disk: 单独预留 160GB+ 更稳妥 如果 NAS 还运行 Plex、虚拟机、数据库、下载器等服务，建议给 Harbor 留出更高余量。\n安装 Container Station 在 QNAP Web 管理页面操作：\n1. 打开 App Center 2. 搜索 Container Station 3. 安装并启动 Container Station Container Station 3 支持用 Docker Compose 创建多容器应用。Harbor 虽然最终也是 Compose 应用，但它需要官方安装脚本生成配置，所以本文不走纯 GUI 创建方式。\n开启 SSH 在 QNAP Web 管理页面打开：\n控制台与管理工具 -\u003e Telnet / SSH -\u003e 允许 SSH 连接 不同 QTS / QuTS hero 版本菜单名称可能略有差异。开启后从电脑连接 NAS：\nssh admin@\u003cNAS_IP\u003e 把 \u003cNAS_IP\u003e 替换成 NAS 的内网地址。\n准备 QNAP 数据目录 QNAP 的数据卷路径可能是 /share/CACHEDEV1_DATA、/share/CACHEDEV2_DATA 或其他名称。可以先查看默认存储池挂载点：\ngetcfg SHARE_DEF defVolMP -f /etc/config/def_share.info 假设输出是：\n/share/CACHEDEV1_DATA 创建 Harbor 目录：\nexport NAS_DATA_ROOT=/share/CACHEDEV2_DATA mkdir -p ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-installer mkdir -p ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-data cd ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-installer 这里分成两个目录：\nharbor-installer 放 Harbor 安装包、harbor.yml、docker-compose.yml 和 common/config harbor-data 放 Harbor 数据，包括 registry、数据库、任务数据等 下载 Harbor 安装包 使用和前文一致的固定版本：\nexport HARBOR_VERSION=v2.14.4 curl -L -o harbor-online-installer-${HARBOR_VERSION}.tgz \\ https://github.com/goharbor/harbor/releases/download/${HARBOR_VERSION}/harbor-online-installer-${HARBOR_VERSION}.tgz tar xzf harbor-online-installer-${HARBOR_VERSION}.tgz --strip-components=1 如果 NAS 访问 GitHub 很慢，可以先在电脑下载同版本安装包，再通过 File Station、SCP 或 SMB 上传到 ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-installer。\n让 SSH 识别 Docker 命令 QNAP 上经常会遇到这种情况：Container Station 已经安装，Web 页面里也能创建容器，但 SSH 登录后执行 Harbor 安装脚本却提示：\nNeed to install docker(20.10.10+) first and run this script again. 同时前面可能还有几行：\ntput: command not found tput 只是 Harbor 安装脚本用来输出彩色文字的命令，缺失时一般只影响显示。真正导致安装中断的是 SSH 环境找不到 docker 命令。\n先检查当前 SSH 环境：\nwhich docker docker version 如果没有输出，查找 Container Station 自带的 Docker：\nfind /share -name docker -type f 2\u003e/dev/null | grep -Ei 'container.*station|/\\.qpkg/' find /share -name docker-compose -type f 2\u003e/dev/null | grep -Ei 'container.*station|/\\.qpkg/' 常见路径类似：\n/share/CACHEDEV2_DATA/.qpkg/container-station/bin/docker /share/CACHEDEV2_DATA/.qpkg/container-station/bin/docker-compose 把这个目录临时加入 PATH：\nexport PATH=export PATH=/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin:$PATH 再次验证：\ndocker version docker compose version 如果 docker compose version 不可用，但 docker-compose version 可用，说明这台 QNAP 提供的是老的 Compose 命令。Harbor 新版本更推荐 docker compose，如果安装脚本仍然识别失败，可以先升级 Container Station，或者用后面生成好的 docker-compose.yml 通过 docker-compose 管理。\n确认路径没问题后，可以写入当前用户的 shell 配置，避免下次 SSH 登录又找不到 Docker：\necho 'export PATH=/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin:$PATH' \u003e\u003e ~/.profile source ~/.profile 如果你的 Docker 实际路径不是 CACHEDEV2_DATA，要按 find 命令查到的结果修改上面的路径。\n配置 harbor.yml 复制模板：\ncp harbor.yml.tmpl harbor.yml 编辑：\nvi harbor.yml QNAP 内网测试可以先用 HTTP，并避免占用 QNAP 管理页面常用端口。示例：\nhostname: harbor-nas.example.com http: port: 8088 # https: # port: 8443 # certificate: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.crt # private_key: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.key harbor_admin_password: change-me-to-a-strong-admin-password database: password: change-me-to-a-strong-db-password data_volume: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-data 几个要点：\nhostname 建议写 NAS 的内网域名，例如 harbor-nas.example.com，并在 DNS 或客户端 hosts 中解析到 NAS IP。 如果没有域名，也可以写 NAS IP，但后续换 IP 会比较麻烦。 http.port 不建议直接用 80，避免和 QNAP Web 服务冲突。 如果启用 HTTPS，也不建议直接用 443，可以先用 8443，再按实际网络环境决定是否反向代理。 data_volume 必须写 QNAP 上真实存在的绝对路径。 如果执行安装时报错：\nError happened in config validation... ERROR:root:Error: The protocol is https but attribute ssl_cert is not set 说明 Harbor 认为当前配置启用了 HTTPS，但没有找到证书配置。先检查 harbor.yml：\ngrep -nE '^(hostname|http:|https:| port:| certificate:| private_key:)' harbor.yml 内网测试只用 HTTP 时，配置应该类似这样：\nhostname: harbor-nas.example.com http: port: 8088 # https: # port: 8443 # certificate: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.crt # private_key: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.key 注意 hostname 只写域名或 IP，不要写成下面这样：\nhostname: https://harbor-nas.example.com 如果确实要启用 HTTPS，就必须取消 https 小节注释，并填入真实存在的证书和私钥路径：\nhostname: harbor-nas.example.com https: port: 8443 certificate: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.crt private_key: /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/harbor-nas.example.com.key 证书文件可以用下面命令确认：\nls -l /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer/certs/ 没有证书时先不要启用 HTTPS，等 Harbor 能通过 HTTP 正常启动后，再补证书和 HTTPS 配置。\n启动 Harbor 进入安装目录执行：\ncd ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-installer ./install.sh --with-trivy 如果 NAS 内存只有 4GB，建议先不启用 Trivy：\n./install.sh 启动完成后查看状态：\ndocker compose ps 如果 QNAP 的 Docker Compose 命令不可用，也可以试：\ndocker-compose ps 查看日志：\ndocker compose logs -f 或者在 Container Station 里打开应用和容器详情查看日志。通过 SSH 启动的容器通常也会显示在 Container Station 的容器列表中。\n访问 Harbor 浏览器访问：\nhttp://harbor-nas.example.com:8088 或直接使用 NAS IP：\nhttp://\u003cNAS_IP\u003e:8088 默认用户名：\nadmin 密码是 harbor.yml 里的 harbor_admin_password。\n使用 Nginx Proxy Manager 反代 Harbor 如果 Harbor 在 QNAP 上监听的是：\nhttp://192.168.3.200:8088 想通过 Nginx Proxy Manager 访问：\nhttps://harbor.jihw.top 先确认域名完全一致。比如 harbor.jihw.top 和 harbor.jiw.top 是两个不同域名，少一个字母就不会命中同一个代理规则。\nNginx Proxy Manager 代理服务建议这样配置：\nDomain Names: harbor.jihw.top Scheme: http Forward Hostname/IP: 192.168.3.200 Forward Port: 8088 SSL: Let's Encrypt Force SSL: 开启 HTTP/2 Support: 可开启 Websockets Support: 可开启 然后检查 DNS 是否指向 Nginx Proxy Manager 所在机器，而不是指向 Harbor 所在的 QNAP：\nnslookup harbor.jihw.top 如果 Nginx Proxy Manager 和 Harbor 都在内网，内网 DNS 或 hosts 应该让 harbor.jihw.top 解析到 Nginx Proxy Manager 的 IP。只有访问先到 Nginx Proxy Manager，它才有机会转发到 192.168.3.200:8088。\n还要确认外部访问链路：\n浏览器 -\u003e https://harbor.jihw.top:443 -\u003e Nginx Proxy Manager -\u003e http://192.168.3.200:8088 -\u003e Harbor 如果 Nginx Proxy Manager 本身不监听 443，或者路由器没有把 443 转发到 Nginx Proxy Manager，HTTPS 域名也会打不开。\n如果 Nginx Proxy Manager 和 Harbor 都部署在同一台 QNAP NAS 上，要重点检查 NAS 宿主机的 80 和 443 端口。浏览器访问 https://harbor.jihw.top 时，默认会连到这台 NAS 的 443，只有这个端口被 Nginx Proxy Manager 监听，代理规则才会生效。\n在 NAS SSH 中查看端口监听：\nnetstat -tlnp | grep -E ':(80|443|8088)\\s' 如果系统支持 ss，也可以用：\nss -tlnp | grep -E ':(80|443|8088)\\s' 再查看 Nginx Proxy Manager 容器端口映射：\ndocker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep -Ei 'nginx|proxy|manager|npm' 正常情况下应该能看到类似：\n0.0.0.0:80-\u003e80/tcp 0.0.0.0:443-\u003e443/tcp 0.0.0.0:81-\u003e81/tcp 如果只看到 81，没有 80 和 443，说明 Nginx Proxy Manager 只能打开管理页面，不能接收 HTTP/HTTPS 业务流量。需要回到 Container Station，把 Nginx Proxy Manager 容器的端口映射补上：\n宿主机 80 -\u003e 容器 80 宿主机 443 -\u003e 容器 443 宿主机 81 -\u003e 容器 81 如果 443 已经被 QNAP 管理页面或其他容器占用，Nginx Proxy Manager 就无法绑定宿主机 443。这时有三种处理方式：\n1. 修改 QNAP 管理页面的 HTTPS 端口，让出 443 给 Nginx Proxy Manager 2. 停掉占用 443 的其他服务或容器 3. 把 Nginx Proxy Manager 映射到宿主机 8443，但访问时必须使用 https://harbor.jihw.top:8443 如果要使用标准地址 https://harbor.jihw.top，最终必须保证访问端能连到 Nginx Proxy Manager 的 443。\n从任意客户端测试 443 是否通：\ncurl -vk https://harbor.jihw.top/ 如果返回 Connection refused，一般是 NAS 的 443 没有服务在监听，或没有映射到 Nginx Proxy Manager。\n如果返回超时，通常是 DNS 指错、路由不通、防火墙拦截，或者公网访问时路由器没有把 443 转发到 Nginx Proxy Manager。\n如果能返回证书信息但页面不是 Harbor，说明请求到了别的服务，比如 QNAP 管理页面或另一条代理规则。\n如果浏览器返回：\n504 Gateway Time-out openresty 这通常说明 443 已经能访问到 Nginx Proxy Manager，因为 OpenResty/Nginx 已经返回了错误页。问题不再是“443 没开”，而是 Nginx Proxy Manager 没有成功连到后端 Harbor。\n先在 NAS 上确认 Harbor 宿主机端口可访问：\ncurl -I http://192.168.3.200:8088/ 再找出 Nginx Proxy Manager 容器名：\ndocker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep -Ei 'nginx|proxy|manager|npm' 从 Nginx Proxy Manager 容器内部访问 Harbor：\ndocker exec -it \u003cnpm容器名\u003e sh -lc 'curl -I http://192.168.3.200:8088/ || wget -S -O- http://192.168.3.200:8088/' 把 \u003cnpm容器名\u003e 替换成实际容器名。\n如果 NAS 宿主机上能访问 http://192.168.3.200:8088/，但 Nginx Proxy Manager 容器里访问超时，说明是容器到宿主机 IP 的网络路径不通。可以在 Nginx Proxy Manager 的代理目标里尝试把后端地址改成 Docker 网关地址：\ndocker exec -it \u003cnpm容器名\u003e sh -lc 'ip route | awk \"/default/ {print \\$3}\"' 假设输出是：\n172.17.0.1 则 Nginx Proxy Manager 代理服务可以改成：\nScheme: http Forward Hostname/IP: 172.17.0.1 Forward Port: 8088 如果容器里能正常访问 http://192.168.3.200:8088/，但浏览器仍然是 504，继续检查 Nginx Proxy Manager 这条代理服务：\nDomain Names 是否是 harbor.jihw.top Scheme 是否是 http Forward Hostname/IP 是否是 192.168.3.200 Forward Port 是否是 8088 是否误开了 Websocket 以外的特殊高级配置 SSL 证书是否绑定在这条代理服务上 然后查看 Nginx Proxy Manager 日志：\ndocker logs --tail=100 \u003cnpm容器名\u003e 如果日志里是 upstream timed out，就是 Nginx Proxy Manager 连后端超时。如果是 connect() failed，通常是后端地址或端口不通。\n如果 http://harbor.jihw.top:8088/ 能直接访问 Harbor，说明域名已经能解析到 NAS，Harbor 的 8088 端口也正常。此时 https://harbor.jihw.top 返回 504，通常还是 Nginx Proxy Manager 到后端的代理路径问题，不是 hostname 直接导致的。\n不过 harbor.yml 里的 hostname 仍然要改成最终访问域名，因为它会影响 Harbor 页面里显示的仓库地址、镜像推送地址、Token 服务地址和部分跳转链接：\nhostname: harbor.jihw.top http: port: 8088 如果 Harbor 后面挂了 HTTPS 反向代理，而 Harbor 自己仍然只跑 HTTP，可以在 harbor.yml 里增加 external_url，让 Harbor 明确知道外部访问入口是 HTTPS：\nhostname: harbor.jihw.top http: port: 8088 external_url: https://harbor.jihw.top external_url 要写完整 URL，包括 https://。hostname 仍然只写域名，不要带协议。\n注意：一旦配置了：\nexternal_url: https://harbor.jihw.top 就应该通过 https://harbor.jihw.top 登录 Harbor，不要再用 http://192.168.3.200:8088 或 http://harbor.jihw.top:8088 登录。否则浏览器里的 Cookie、Origin、CSRF Token 会和 Harbor 认为的外部地址不一致，可能出现：\nFORBIDDEN CSRF token invalid 如果 Nginx Proxy Manager 还没调通，需要继续用 8088 直连排查，就先不要配置 external_url，或者临时改成直连地址：\nhostname: harbor.jihw.top http: port: 8088 external_url: http://harbor.jihw.top:8088 等 https://harbor.jihw.top 反代确认可用后，再改回：\nexternal_url: https://harbor.jihw.top 修改后重新生成配置并启动：\ncd /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer docker compose down vi harbor.yml ./prepare docker compose up -d 如果安装时启用了 Trivy，则使用：\n./prepare --with-trivy docker compose up -d Harbor 后台的项目地址、镜像推送地址、重定向链接都会受 hostname 影响。反代后如果页面能打开但登录跳转、镜像推送或复制地址异常，优先检查这里。\n如果 Nginx Proxy Manager 能打开页面但推送大镜像失败，可以在该代理服务的高级配置里增加：\nclient_max_body_size 0; proxy_request_buffering off; Docker 客户端配置 如果 QNAP Harbor 使用 HTTP，推送镜像的 Docker 客户端要配置 insecure registry：\n{ \"insecure-registries\": [\"harbor-nas.example.com:8088\"] } 然后重启客户端机器上的 Docker：\nsudo systemctl restart docker 登录验证：\ndocker login harbor-nas.example.com:8088 docker pull busybox:1.36.1 docker tag busybox:1.36.1 harbor-nas.example.com:8088/library/busybox:1.36.1 docker push harbor-nas.example.com:8088/library/busybox:1.36.1 注意：如果 Harbor 地址带端口，镜像标签里也必须带端口。\nQNAP 上的维护命令 后续维护仍然建议通过 SSH 在安装目录里执行：\ncd /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer docker compose ps docker compose logs -f docker compose stop docker compose start docker compose restart 修改 harbor.yml 后重新生成配置：\ncd /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer docker compose down vi harbor.yml ./prepare --with-trivy docker compose up -d 没有启用 Trivy 时：\n./prepare docker compose up -d QNAP 注意事项 在 NAS 上跑 Harbor 要特别注意这些点：\n不要把 Harbor 数据目录放到临时目录或系统分区，应该放到 QNAP 存储池里的共享目录。 不要随手删除 ${NAS_DATA_ROOT}/DockerDatas/harbor/harbor-data，这里是镜像仓库和数据库数据。 不要把 HTTP Harbor 直接暴露到公网，公网访问必须启用 HTTPS，并配合强密码、防火墙、反向代理或 VPN。 如果 QNAP 自动休眠硬盘，Harbor 首次拉取或推送镜像时可能会变慢。 如果 NAS 资源紧张，先不要启用 Trivy 扫描。 QNAP 系统升级、Container Station 升级前，建议先停止 Harbor 并备份 harbor.yml 和数据目录。 配置 Docker 客户端访问 浏览器打开：\nhttp://harbor.example.com 默认管理员用户名：\nadmin 密码是 harbor.yml 里的 harbor_admin_password。第一次登录后建议马上修改密码。\n如果 Harbor 使用 HTTP，Docker 客户端需要把它配置成 insecure registry。编辑客户端机器上的 Docker 配置：\nsudo vim /etc/docker/daemon.json 示例：\n{ \"insecure-registries\": [\"harbor.example.com\"] } 如果文件里已经有其他配置，不要直接覆盖，要把 insecure-registries 合并进去。\n重启 Docker：\nsudo systemctl restart docker HTTPS 且证书可信时，不需要配置 insecure-registries。\n推送镜像验证 先在 Harbor Web 页面创建一个项目，例如：\nlibrary 登录 Harbor：\ndocker login harbor.example.com 拉取一个固定版本测试镜像：\ndocker pull busybox:1.36.1 打标签：\ndocker tag busybox:1.36.1 harbor.example.com/library/busybox:1.36.1 推送：\ndocker push harbor.example.com/library/busybox:1.36.1 再从 Harbor 拉取验证：\ndocker rmi harbor.example.com/library/busybox:1.36.1 docker pull harbor.example.com/library/busybox:1.36.1 如果推送失败，优先检查：\ndocker compose ps docker compose logs -f nginx docker compose logs -f harbor-core docker compose logs -f registry 常用维护命令 进入安装目录：\ncd /opt/harbor-installer 停止 Harbor：\ndocker compose stop 启动 Harbor：\ndocker compose start 重启 Harbor：\ndocker compose restart 删除容器和网络，但保留数据：\ndocker compose down 重新创建并启动：\ndocker compose up -d 修改 harbor.yml 后，需要重新生成配置：\ncd /opt/harbor-installer docker compose down vim harbor.yml sudo ./prepare --with-trivy docker compose up -d 如果没有安装 Trivy，则执行：\nsudo ./prepare docker compose up -d 备份和恢复注意事项 Harbor 的关键数据在：\n/data/harbor /opt/harbor-installer/harbor.yml 简单备份可以先停机，再打包数据目录和配置文件：\ncd /opt/harbor-installer docker compose stop sudo tar czf /root/harbor-backup-$(date +%F).tgz \\ /data/harbor \\ /opt/harbor-installer/harbor.yml docker compose start 这种方式会带来短暂停机。生产环境要结合数据库一致性、对象存储、镜像垃圾回收策略和定期恢复演练来设计备份方案。\n恢复时要保证 Harbor 版本、harbor.yml、数据目录和证书路径一致。不要在没有备份的情况下直接删除 /data/harbor。\n升级原则 升级前先备份：\ncd /opt/harbor-installer docker compose stop 然后阅读目标版本的官方 upgrade 文档和 release notes。Harbor 升级不是简单把镜像标签改成新版本，通常需要使用新版本安装包里的迁移和 prepare 流程。\n建议原则：\n1. 先备份 /data/harbor 和 harbor.yml 2. 阅读目标版本 release notes 3. 按官方支持的升级路径逐版本升级 4. 升级后验证登录、推送、拉取、扫描和项目权限 卸载 Harbor 只删除容器，保留镜像仓库数据：\ncd /opt/harbor-installer docker compose down 如果确认不再需要 Harbor，并且已经完成备份，再删除数据目录：\nsudo rm -rf /data/harbor sudo rm -rf /opt/harbor-installer 这一步会删除 Harbor 数据和安装配置，执行前一定要确认备份可用。\n小结 Harbor 的正确打开方式不是手写一堆 docker run，而是使用官方安装包生成并管理 Docker Compose 配置。\ndocker run registry:2.8.3 适合临时测试普通镜像仓库；Harbor 适合需要 Web 管理、项目隔离、权限控制、审计、复制和漏洞扫描的场景。生产环境部署 Harbor 时，重点关注 HTTPS、管理员密码、数据目录、备份恢复和版本升级路径。\n","permalink":"/coding/docker-harbor/","summary":"Harbor 是一个企业级镜像仓库，不只是一个 registry 容器。它包含 Portal、Core、Registry、Jobservice、PostgreSQL、Redis、Nginx、日志服务，以及可选的 Trivy 漏洞扫描服务。\n所以先说结论：\nHarbor 不适合用一条 docker run 完整部署。 官方单机部署方式是 Docker Compose。 如果只需要单容器 registry，可以用 registry:2，但那不是 Harbor。 Harbor 官方文档也把单机部署方式写成 Docker Compose，把 Kubernetes 部署方式写成 Helm。本文记录在一台 Linux 主机上部署 Harbor 的流程，重点使用官方安装包生成的 Docker Compose 文件。\n官方文档入口：\nHarbor Installation Prerequisites Download the Harbor Installer Configure the Harbor YML File Run the Installer Script Harbor Releases QNAP Container Station 3 版本说明 本文固定使用 Harbor：\nv2.14.4 不要使用 latest 这种浮动标签。Harbor 是多组件系统，升级涉及数据库、配置模板、镜像版本和迁移逻辑，不能只靠重新拉镜像解决。\n如果官方 release 页面已经有更新版本，可以把下面命令里的 HARBOR_VERSION 替换成目标版本。升级生产环境前要先看对应版本的 release notes 和 upgrade 文档。\n","title":"使用 Docker 部署 Harbor 镜像仓库"},{"content":"这篇记录一个比较常见的内网场景：目标服务器不能访问公网，但需要安装一个三主节点 Kubernetes 高可用集群。\n本文使用 kubeadm 部署，三台机器都作为 control-plane，etcd 采用 stacked 模式，也就是每个控制平面节点本地运行一个 etcd。API Server 前面用 HAProxy + Keepalived 提供一个固定 VIP。\n参考官方文档时，优先看这些页面：\nInstalling kubeadm Creating Highly Available Clusters with kubeadm Container runtimes Kubernetes releases Install Calico networking and network policy for on-premises deployments 环境规划 本文固定版本，避免离线环境里因为浮动版本导致重复部署结果不一致。\nOS: Ubuntu Server 24.04 amd64 Kubernetes: v1.36.1 kubelet/kubeadm: 1.36.1-1.1 容器运行时: containerd CNI: Calico v3.32.0 Pod CIDR: 10.244.0.0/16 Service CIDR: 10.96.0.0/12 API VIP: 192.168.3.217:8443 节点规划：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip 这里把 API 高可用入口放在 192.168.3.217:8443，而不是 6443。原因是 HAProxy 和 kube-apiserver 都运行在三台 master 上，kube-apiserver 会占用本机 6443，HAProxy 监听 8443 可以避免端口冲突。\n三主节点能解决的是控制平面高可用：kube-apiserver、controller-manager、scheduler 和 etcd 在单台 master 故障时还能继续工作。它不等于业务高可用。业务应用、Ingress、数据库、Redis、存储卷和监控告警还要另外做高可用设计。\n端口和权限边界 这些端口至少要在三台 master 之间互通：\n端口 协议 说明 8443 TCP HAProxy 对外提供的 Kubernetes API VIP 入口 6443 TCP 每台 master 本地 kube-apiserver 2379-2380 TCP etcd 客户端和 peer 通信 10250 TCP kubelet API 10257 TCP kube-controller-manager 10259 TCP kube-scheduler 179 TCP Calico BGP，全互联默认模式会用到 IPIP 4 IP 协议号 Calico 默认 IPIP overlay VRRP 112 IP 协议号 Keepalived 漂移 VIP 安全注意事项：\n8443 是 Kubernetes API 入口，只建议暴露在内网或运维 VPN 内，不要直接暴露到公网。 admin.conf 等价于集群管理员凭据，不要随便复制给普通用户。 三节点 stacked etcd 只能容忍 1 台 master 故障，连续丢 2 台 master 时 etcd 会失去多数派。 Keepalived 只负责 VIP 漂移，不负责业务数据恢复。 离线包里包含集群镜像和安装包，建议放在内网制品库或受控目录，不要放到公开下载地址。 一、在在线机器准备离线包 准备一台能访问公网的打包机。打包机要尽量和离线节点保持一致：\n相同 CPU 架构: amd64 相同 Ubuntu 大版本: 24.04 如果离线节点是 Ubuntu 22.04，就用 Ubuntu 22.04 打包机重新执行下面步骤。不要用 24.04 打包出来的 deb 包直接装到 22.04 上。\n1. 设置变量 在在线打包机执行：\nexport K8S_VERSION=\"v1.36.1\" export K8S_MINOR=\"v1.36\" export K8S_DEB_VERSION=\"1.36.1-1.1\" export CALICO_VERSION=\"v3.32.0\" export BUNDLE_DIR=\"$HOME/k8s-offline-${K8S_VERSION}\" mkdir -p \"${BUNDLE_DIR}\"/{debs,images,manifests,checksums} 2. 配置 Kubernetes apt 源 sudo apt-get update sudo apt-get install -y ca-certificates curl gpg sudo mkdir -p /etc/apt/keyrings curl -fsSL \"https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key\" \\ | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo \"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/ /\" \\ | sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt-get update 3. 下载 deb 安装包 先把需要的包下载到 apt 缓存，再复制到离线包目录：\nsudo apt-get install -y --download-only \\ containerd \\ haproxy \\ keepalived \\ socat \\ conntrack \\ ebtables \\ ethtool \\ ipset \\ ipvsadm \\ curl \\ tar \\ kubelet=\"${K8S_DEB_VERSION}\" \\ kubeadm=\"${K8S_DEB_VERSION}\" \\ kubectl=\"${K8S_DEB_VERSION}\" cp /var/cache/apt/archives/*.deb \"${BUNDLE_DIR}/debs/\" 为了后面能用 kubeadm config images list 生成镜像清单，在线打包机可以临时安装 kubeadm：\nsudo apt-get install -y kubeadm=\"${K8S_DEB_VERSION}\" 如果这一步提示依赖无法下载，先不要继续做离线包。说明打包机 apt 源或系统版本不一致，需要先修好。\n4. 下载 Calico YAML 这里固定下载 Calico v3.32.0 的 manifest，不使用 latest 或分支上的浮动 YAML。Calico 官方更推荐用 Tigera Operator 管理安装和升级；本文为了离线包简单可控，使用版本固定的 calico.yaml，适合小规模内网集群。\ncurl -fsSLo \"${BUNDLE_DIR}/manifests/calico.yaml\" \\ \"https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/calico.yaml\" sed -i \\ -e 's/# - name: CALICO_IPV4POOL_CIDR/- name: CALICO_IPV4POOL_CIDR/' \\ -e 's/# value: \"192.168.0.0\\/16\"/ value: \"10.244.0.0\\/16\"/' \\ \"${BUNDLE_DIR}/manifests/calico.yaml\" 检查 Pod 网段，必须和后面 kubeadm 配置里的 podSubnet 一致：\ngrep -n \"CALICO_IPV4POOL_CIDR\\\\|10.244.0.0/16\" \"${BUNDLE_DIR}/manifests/calico.yaml\" Calico manifest 默认示例常见为 192.168.0.0/16，但本文节点网段是 192.168.3.0/24，会发生重叠，所以这里显式改成 10.244.0.0/16。后面 kubeadm 配置里也会写成这个网段。如果你要改成别的网段，需要同时改 kubeadm 的 podSubnet 和 Calico manifest 里的 CALICO_IPV4POOL_CIDR。\n5. 生成镜像清单 kubeadm config images list --kubernetes-version \"${K8S_VERSION}\" \\ \u003e \"${BUNDLE_DIR}/images/kubeadm-images.txt\" awk '$1==\"image:\" {print $2}' \"${BUNDLE_DIR}/manifests/calico.yaml\" \\ | tr -d '\"' \\ \u003e \"${BUNDLE_DIR}/images/calico-images.txt\" cat \"${BUNDLE_DIR}/images/kubeadm-images.txt\" \\ \"${BUNDLE_DIR}/images/calico-images.txt\" \\ | sort -u \\ \u003e \"${BUNDLE_DIR}/images/all-images.txt\" cat \"${BUNDLE_DIR}/images/all-images.txt\" 不要手工猜镜像版本，以 kubeadm config images list 和固定的 CNI YAML 为准。\n6. 拉取并导出镜像 在线打包机需要有 containerd，并且 ctr 命令可用：\nsudo systemctl enable --now containerd while read -r image; do sudo ctr images pull \"${image}\" done \u003c \"${BUNDLE_DIR}/images/all-images.txt\" sudo ctr images export \\ \"${BUNDLE_DIR}/images/k8s-images-${K8S_VERSION}.tar\" \\ $(cat \"${BUNDLE_DIR}/images/all-images.txt\") 导出完成后生成校验文件：\ncd \"${BUNDLE_DIR}\" sha256sum debs/*.deb images/*.tar manifests/*.yml \u003e checksums/SHA256SUMS 7. 打包 cd \"$(dirname \"${BUNDLE_DIR}\")\" tar -czf \"k8s-offline-${K8S_VERSION}-ubuntu2404-amd64.tar.gz\" \\ \"$(basename \"${BUNDLE_DIR}\")\" 把这个文件复制到三台离线 master：\nscp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.214:/opt/ scp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.215:/opt/ scp k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz root@192.168.3.216:/opt/ 二、三台 master 安装基础组件 下面的步骤在三台 master 都执行。\n1. 解压离线包 cd /opt sudo tar -xzf k8s-offline-v1.36.1-ubuntu2404-amd64.tar.gz cd /opt/k8s-offline-v1.36.1 sha256sum -c checksums/SHA256SUMS 校验失败不要继续安装，先重新传包。\n2. 安装 deb 包 cd /opt/k8s-offline-v1.36.1 sudo dpkg -i debs/*.deb sudo apt-mark hold kubelet kubeadm kubectl 如果 dpkg 提示缺依赖，说明在线打包时漏包了。不要在离线节点上临时乱补版本，回到在线打包机补齐 deb 包后重新打包。\n3. 设置 hostname 和 hosts 分别在三台机器设置 hostname：\nsudo hostnamectl set-hostname k8s-master1 第二台、第三台分别改为：\nsudo hostnamectl set-hostname k8s-master2 sudo hostnamectl set-hostname k8s-master3 三台都写入 hosts：\nsudo tee -a /etc/hosts \u003e/dev/null \u003c\u003c'EOF' 192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip EOF 确认解析正常：\ngetent hosts k8s-master1 k8s-master2 k8s-master3 k8s-vip 4. 关闭 swap sudo swapoff -a sudo sed -ri '/\\\\sswap\\\\s/s/^#?/#/' /etc/fstab free -h Swap 应该显示为 0B。\n5. 配置内核模块和 sysctl sudo tee /etc/modules-load.d/k8s.conf \u003e/dev/null \u003c\u003c'EOF' overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter sudo tee /etc/sysctl.d/99-kubernetes-cri.conf \u003e/dev/null \u003c\u003c'EOF' net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system 确认：\nlsmod | egrep 'overlay|br_netfilter' sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables 6. 配置 containerd sudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml \u003e/dev/null PAUSE_IMAGE=\"$(kubeadm config images list --kubernetes-version v1.36.1 | grep '/pause:')\" sudo sed -i \"s#sandbox_image = .*#sandbox_image = \\\"${PAUSE_IMAGE}\\\"#\" /etc/containerd/config.toml sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml sudo systemctl enable --now containerd sudo systemctl restart containerd sudo systemctl status containerd --no-pager 确认 CRI 可用：\nsudo crictl info | grep -i runtime 7. 导入离线镜像 cd /opt/k8s-offline-v1.36.1 sudo ctr -n k8s.io images import images/k8s-images-v1.36.1.tar sudo ctr -n k8s.io images ls | egrep 'kube-apiserver|kube-controller|kube-scheduler|etcd|coredns|calico' 镜像必须导入到 k8s.io namespace。kubelet 通过 CRI 使用这个 namespace，导入到默认 namespace 里会导致 kubelet 仍然找不到镜像。\n三、配置 HAProxy 和 Keepalived 下面的步骤仍然在三台 master 都执行。\n1. 配置 HAProxy 备份原配置：\nsudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak 写入 Kubernetes API 负载均衡配置：\nsudo tee /etc/haproxy/haproxy.cfg \u003e/dev/null \u003c\u003c'EOF' global log /dev/log local0 log /dev/log local1 notice daemon maxconn 4096 stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners defaults log global mode tcp option tcplog option dontlognull timeout connect 5s timeout client 1m timeout server 1m frontend kubernetes-apiserver bind *:8443 default_backend kubernetes-apiserver backend kubernetes-apiserver balance roundrobin option tcp-check server k8s-master1 192.168.3.214:6443 check server k8s-master2 192.168.3.215:6443 check server k8s-master3 192.168.3.216:6443 check EOF sudo haproxy -c -f /etc/haproxy/haproxy.cfg sudo systemctl enable --now haproxy sudo systemctl restart haproxy 此时 kube-apiserver 还没有启动，HAProxy 后端检查失败是正常的。只要 HAProxy 服务本身能启动即可。\n2. 配置 Keepalived 先确认网卡名：\nip -br addr 本文假设网卡名是 ens160。如果你的机器是 ens33、eth0 或其他名字，替换下面配置里的 interface ens160。\n在 k8s-master1 执行：\nsudo tee /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' global_defs { router_id k8s-master1 } vrrp_script chk_haproxy { script \"pidof haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state MASTER interface ens160 virtual_router_id 51 priority 120 advert_int 1 authentication { auth_type PASS auth_pass k8s-ha } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF 在 k8s-master2 执行，注意 router_id、state 和 priority：\nsudo tee /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' global_defs { router_id k8s-master2 } vrrp_script chk_haproxy { script \"pidof haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state BACKUP interface ens160 virtual_router_id 51 priority 110 advert_int 1 authentication { auth_type PASS auth_pass k8s-ha } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF 在 k8s-master3 执行：\nsudo tee /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' global_defs { router_id k8s-master3 } vrrp_script chk_haproxy { script \"pidof haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state BACKUP interface ens160 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass k8s-ha } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF 三台都启动：\nsudo systemctl enable --now keepalived sudo systemctl restart keepalived sudo systemctl status keepalived --no-pager 查看 VIP 当前在哪台机器：\nip addr | grep 192.168.3.217 正常情况下，优先级最高的 k8s-master1 会持有 VIP。\n四、初始化第一个 control-plane 下面只在 k8s-master1 执行。\n1. 编写 kubeadm 配置 cat \u003e kubeadm-config.yaml \u003c\u003c'EOF' apiVersion: kubeadm.k8s.io/v1beta4 kind: InitConfiguration localAPIEndpoint: advertiseAddress: 192.168.3.214 bindPort: 6443 nodeRegistration: criSocket: unix:///run/containerd/containerd.sock --- apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration kubernetesVersion: v1.36.1 controlPlaneEndpoint: \"192.168.3.217:8443\" networking: podSubnet: \"10.244.0.0/16\" serviceSubnet: \"10.96.0.0/12\" apiServer: certSANs: - 192.168.3.217 - k8s-vip --- apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration cgroupDriver: systemd EOF 如果你修改了 VIP、Pod 网段或 Kubernetes 版本，这个文件也要同步修改。\n2. 执行 kubeadm init sudo kubeadm init --config kubeadm-config.yaml --upload-certs 成功后会输出两类 join 命令：\nworker 节点 join 命令。 control-plane 节点 join 命令，带 --control-plane 和 --certificate-key。 把 control-plane join 命令保存下来，后面 k8s-master2 和 k8s-master3 要用。\n3. 配置 kubectl mkdir -p \"$HOME/.kube\" sudo cp -i /etc/kubernetes/admin.conf \"$HOME/.kube/config\" sudo chown \"$(id -u):$(id -g)\" \"$HOME/.kube/config\" kubectl get nodes 此时节点可能是 NotReady，因为 CNI 还没有安装。\n4. 安装 Calico kubectl apply -f /opt/k8s-offline-v1.36.1/manifests/calico.yaml 如果这个集群只有三台 master，没有 worker，需要允许 control-plane 节点运行普通业务 Pod：\nkubectl taint nodes --all node-role.kubernetes.io/control-plane- 如果后面会加入独立 worker，生产环境不建议轻易取消 master taint。\n五、加入另外两台 control-plane 先确认 k8s-master2 和 k8s-master3 已经完成前面的基础组件、HAProxy、Keepalived 和镜像导入。\n在 k8s-master2 执行第一台输出的 control-plane join 命令，并追加本机 apiserver 地址：\nsudo kubeadm join 192.168.3.217:8443 \\ --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --control-plane \\ --certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --apiserver-advertise-address 192.168.3.215 在 k8s-master3 执行，地址改为本机 IP：\nsudo kubeadm join 192.168.3.217:8443 \\ --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --control-plane \\ --certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --apiserver-advertise-address 192.168.3.216 如果 join 命令过期，可以在 k8s-master1 重新生成：\nkubeadm token create --print-join-command sudo kubeadm init phase upload-certs --upload-certs 第一条命令生成基础 join 命令，第二条命令生成新的 certificate-key。把两者组合成带 --control-plane --certificate-key 的命令。\n六、验证集群 任意一台 master 上执行：\nkubectl get nodes -o wide kubectl -n kube-system get pods -o wide 期望看到三台节点都是 Ready，kube-system 下的核心 Pod 都是 Running 或 Completed。\n检查 API VIP：\ncurl -k https://192.168.3.217:8443/readyz?verbose 检查 HAProxy 后端：\necho \"show stat\" | sudo socat stdio /run/haproxy/admin.sock 如果没有启用 HAProxy socket，可以直接看日志：\njournalctl -u haproxy -n 100 --no-pager 检查 etcd 健康状态：\nsudo crictl exec \"$(sudo crictl ps --name etcd -q)\" \\ etcdctl \\ --endpoints=https://127.0.0.1:2379 \\ --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt \\ --key=/etc/kubernetes/pki/etcd/server.key \\ endpoint health 检查 VIP 漂移：\nip addr | grep 192.168.3.217 sudo systemctl stop keepalived 然后到另外两台机器查看：\nip addr | grep 192.168.3.217 kubectl get nodes 如果 VIP 已经漂移，并且 kubectl get nodes 仍然可用，说明 API 高可用入口生效。\n测试完成后，在刚才停止 Keepalived 的节点恢复：\nsudo systemctl start keepalived 七、部署一个测试应用 kubectl create deployment nginx --image=nginx:1.27.5 kubectl expose deployment nginx --type=NodePort --port=80 kubectl get pod -o wide kubectl get svc nginx 离线环境里如果没有提前导入 nginx:1.27.5，Pod 会卡在 ImagePullBackOff。这不是 Kubernetes 集群故障，而是业务镜像没有进入离线制品流程。\n真实环境建议把业务镜像推到内网 Harbor 或其他私有镜像仓库，然后在部署 YAML 里使用内网镜像地址。\n清理测试应用：\nkubectl delete svc nginx kubectl delete deployment nginx 八、常见问题 kubeadm init 卡住 先看 kubelet 日志：\njournalctl -u kubelet -f 常见原因：\ncontainerd 没启动。 pause 镜像没有导入到 k8s.io namespace。 SystemdCgroup 没有配置成 true。 HAProxy 或 Keepalived 没启动，导致 192.168.3.217:8443 不通。 节点一直 NotReady 检查 CNI：\nkubectl -n kube-system get pods -o wide kubectl -n kube-system get pods -l k8s-app=calico-node -o wide kubectl -n kube-system get pods -l k8s-app=calico-kube-controllers -o wide 常见原因：\nCalico 镜像没有导入到 k8s.io namespace。 kubeadm 的 podSubnet 不是 10.244.0.0/16，和 Calico IP 池不一致。 防火墙拦截了 179/tcp 或 IPIP 协议号 4。 多网卡环境里 Calico 自动识别错了节点 IP，需要配置 IP_AUTODETECTION_METHOD。 VIP 不漂移 检查 Keepalived：\nsystemctl status keepalived --no-pager journalctl -u keepalived -n 100 --no-pager 常见原因：\ninterface 写错，比如实际网卡是 ens33，配置里写了 ens160。 网络不允许 VRRP 协议。 多个集群用了相同的 virtual_router_id，互相干扰。 本机 HAProxy 停止后被 track_script 降权。 Harbor 通过 Nginx Proxy Manager 反代访问失败 离线或内网集群通常会配一个内网 Harbor，用来存放 Kubernetes 组件镜像和业务镜像。我的环境里 Harbor 跑在 QNAP NAS 上，HTTP 入口是：\nhttp://192.168.3.200:8088 同时用 Nginx Proxy Manager 暴露域名：\nhttps://harbor.jihw.top 一开始遇到两个现象：\n直接访问 http://192.168.3.200:8088 能打开 Harbor，但登录时报 CSRF token invalid。 通过 https://harbor.jihw.top 访问时，Nginx Proxy Manager 返回 502 或 504。 先确认 Harbor 自身是健康的：\ncurl -I http://192.168.3.200:8088/ curl http://192.168.3.200:8088/api/v2.0/ping 如果 /api/v2.0/ping 返回：\nPong 说明 Harbor 服务本身没有坏。再检查 Harbor 容器：\ndocker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}' \\ | grep -E 'harbor|registry|redis|postgres|nginx|portal|core|jobservice' 如果 Harbor 组件都是 healthy，但是 Nginx Proxy Manager 日志里出现：\nupstream timed out while connecting to upstream upstream: \"http://192.168.3.200:8088/\" 问题通常不是 Harbor 密码，也不是证书，而是反代容器访问上游的路径不对。Nginx Proxy Manager 自己也是 Docker 容器，它从容器内部访问 NAS 宿主机的 192.168.3.200:8088，在 QNAP / Container Station 这类环境里可能会被 Docker NAT、hairpin NAT 或网络隔离卡住。\n更稳的做法是：让 Nginx Proxy Manager 容器和 Harbor 的 nginx 容器加入同一个 Docker 网络，然后用容器名访问 Harbor。\n先查看网络：\ndocker network ls docker inspect nginx-manager-nginxManager-1 \\ --format '{{json .NetworkSettings.Networks}}' docker inspect nginx \\ --format '{{json .NetworkSettings.Networks}}' 这里 nginx 是 Harbor 自带的入口容器，不是 Nginx Proxy Manager。我的 Harbor 网络名是：\nharbor-installer_harbor 把 Nginx Proxy Manager 容器接入这个网络：\ndocker network connect harbor-installer_harbor nginx-manager-nginxManager-1 然后在 Nginx Proxy Manager 的 Proxy Host 里把上游从：\nForward Hostname / IP: 192.168.3.200 Forward Port: 8088 改成：\nForward Hostname / IP: nginx Forward Port: 8080 Scheme: http 也就是让访问链路变成：\nbrowser -\u003e https://harbor.jihw.top -\u003e nginx-manager-nginxManager-1 -\u003e http://nginx:8080 -\u003e Harbor 不要让反代容器再绕回宿主机端口：\nnginx-manager-nginxManager-1 -\u003e http://192.168.3.200:8088 否则在 NAS 的 Docker 网络里很容易出现超时。\n如果 Harbor 放在 HTTPS 域名后面，还要在 harbor.yml 里配置外部访问地址：\nhostname: harbor.jihw.top http: port: 8088 external_url: https://harbor.jihw.top 修改后重新生成 Harbor 配置并重启：\ncd /share/CACHEDEV2_DATA/DockerDatas/harbor/harbor-installer ./prepare docker compose down docker compose up -d 最后验证：\ncurl -I https://harbor.jihw.top/ curl https://harbor.jihw.top/api/v2.0/ping 正常应该返回：\nHTTP/1.1 200 OK Pong 还有一个容易误判的点：harbor_admin_password 只在 Harbor 首次初始化 admin 用户时生效。Harbor 初始化完成后，再修改 harbor.yml 里的 harbor_admin_password 不会自动改数据库里的 admin 密码。如果遇到“密码不对”，先用 API 验证认证是否成功，再看浏览器 Network 里是不是实际报了 CSRF token invalid。\n如果希望这个网络连接在重建 Nginx Proxy Manager 容器后仍然存在，需要在 Nginx Proxy Manager 的 compose 文件里声明 Harbor 网络为 external network，例如：\nservices: nginxManager: networks: - default - harbor networks: harbor: external: true name: harbor-installer_harbor 从 Windows Docker Desktop 推送和拉取 Harbor 镜像 Harbor 域名和反代修好后，可以先在本机 Docker Desktop 上做一次最小闭环测试：构建一个不依赖外网基础镜像的 scratch 镜像，推送到 Harbor，再删除本地镜像并从 Harbor 拉回。\n先确认本机能访问 Harbor：\nResolve-DnsName harbor.jihw.top curl.exe https://harbor.jihw.top/api/v2.0/ping 正常返回：\nPong 再确认 Docker Desktop 后端已经启动：\ndocker version docker context ls 如果 docker version 提示：\nfailed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine 说明 Docker Desktop 后端没有启动，先打开 Docker Desktop，等 desktop-linux context 可用后再继续。\n登录 Harbor。不要把密码直接写进脚本或博客里，使用交互输入或环境变量：\n$HARBOR_USER = \"admin\" $HARBOR_PASSWORD = Read-Host \"Harbor password\" -AsSecureString $BSTR = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($HARBOR_PASSWORD) $HARBOR_PASSWORD_PLAIN = [Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) $HARBOR_PASSWORD_PLAIN | docker login harbor.jihw.top -u $HARBOR_USER --password-stdin [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) Remove-Variable HARBOR_PASSWORD_PLAIN 构建一个最小测试镜像：\n$TAG = \"harbor.jihw.top/library/dockerdesktop-push-pull-test:$(Get-Date -Format yyyyMMdd-HHmmss)\" $TMP = Join-Path $env:TEMP \"harbor-test-$(Get-Date -Format yyyyMMddHHmmss)\" New-Item -ItemType Directory -Force -Path $TMP | Out-Null Set-Content -LiteralPath (Join-Path $TMP \"hello.txt\") ` -Value \"hello from Docker Desktop to Harbor at $(Get-Date -Format o)\" ` -Encoding ASCII Set-Content -LiteralPath (Join-Path $TMP \"Dockerfile\") -Value @' FROM scratch COPY hello.txt /hello.txt LABEL org.opencontainers.image.title=\"dockerdesktop-push-pull-test\" '@ -Encoding ASCII docker build -t $TAG $TMP 这里使用 FROM scratch，所以不需要先从 Docker Hub 拉取基础镜像，适合内网或弱网环境做 Harbor 连通性测试。\n推送到 Harbor：\ndocker push $TAG 推送成功时会看到类似输出：\nThe push refers to repository [harbor.jihw.top/library/dockerdesktop-push-pull-test] 20260617-175952: digest: sha256:3670dec3a76e... size: 855 删除本地镜像，再从 Harbor 拉回：\n$LOCAL_ID_BEFORE = docker image inspect $TAG --format '{{.Id}}' docker image rm $TAG docker pull $TAG $LOCAL_ID_AFTER = docker image inspect $TAG --format '{{.Id}}' \"before=$LOCAL_ID_BEFORE\" \"after=$LOCAL_ID_AFTER\" 如果 before 和 after 相同，说明这次流程闭环成功：\nDocker Desktop -\u003e Harbor push -\u003e 删除本地镜像 -\u003e Harbor pull -\u003e 本地恢复镜像 最后可以清理临时目录：\nRemove-Item -LiteralPath $TMP -Recurse -Force 如果 docker login 成功但 docker push 失败，优先检查这些点：\nharbor.jihw.top 是否能从 Windows 解析到 NAS。 Harbor 证书是否是 Docker Desktop 信任的证书；如果是自签证书，需要把 CA 加到 Docker Desktop 信任链。 Harbor 项目是否存在，例如这里使用的是默认 library 项目。 Nginx Proxy Manager 的上游是否是 nginx:8080，并且 NPM 容器已经加入 Harbor 的 Docker 网络。 Harbor 的 external_url 是否是 https://harbor.jihw.top。 join control-plane 失败 检查 token 和 certificate key 是否过期：\nkubeadm token list 重新生成：\nkubeadm token create --print-join-command sudo kubeadm init phase upload-certs --upload-certs 还要确认新节点已经导入所有镜像，否则 join 到一半也会失败。\n九、备份 至少备份这些内容：\n/etc/kubernetes /var/lib/etcd 快照 离线安装包 kubeadm-config.yaml 做 etcd 快照可以在任意健康 master 上执行：\nsudo mkdir -p /backup/k8s sudo crictl exec \"$(sudo crictl ps --name etcd -q)\" \\ etcdctl \\ --endpoints=https://127.0.0.1:2379 \\ --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt \\ --key=/etc/kubernetes/pki/etcd/server.key \\ snapshot save /var/lib/etcd/snapshot.db sudo cp /var/lib/etcd/snapshot.db /backup/k8s/etcd-snapshot-$(date +%F-%H%M%S).db sudo tar -czf /backup/k8s/kubernetes-pki-$(date +%F-%H%M%S).tar.gz /etc/kubernetes 备份文件不要只放在集群节点本机，至少再复制到独立备份机或备份存储。\n十、卸载或重装 如果这是实验环境，需要重装集群，三台 master 都执行：\nsudo kubeadm reset -f sudo systemctl stop kubelet || true sudo systemctl stop haproxy || true sudo systemctl stop keepalived || true sudo rm -rf /etc/kubernetes sudo rm -rf /var/lib/etcd sudo rm -rf /var/lib/kubelet sudo rm -rf /etc/cni/net.d sudo rm -rf /var/lib/cni sudo rm -rf /var/lib/calico sudo rm -rf /var/run/calico sudo rm -rf \"$HOME/.kube\" sudo ip link delete cni0 2\u003e/dev/null || true sudo ip link delete tunl0 2\u003e/dev/null || true 如果机器上有生产业务，不要直接执行这组命令。/var/lib/kubelet、/var/lib/etcd 和 CNI 网络目录会影响当前节点上的 Kubernetes 数据。\n卸载软件包：\nsudo apt-mark unhold kubelet kubeadm kubectl sudo dpkg -r kubelet kubeadm kubectl || true sudo dpkg -r haproxy keepalived containerd || true 总结 离线安装 Kubernetes 的关键不是把在线命令照搬到内网，而是把版本、deb 包、镜像、CNI YAML 和初始化配置全部固定下来。\n这套三主节点方案的边界也要明确：\n控制平面可以容忍 1 台 master 故障。 etcd 采用三节点多数派，不能同时丢 2 台 master。 VIP 只解决 API 入口稳定，不解决业务 Pod、数据库和存储高可用。 后续升级时要重新制作对应版本的离线包，并先在测试集群验证。 ","permalink":"/coding/k8s-offline-ha-install/","summary":"这篇记录一个比较常见的内网场景：目标服务器不能访问公网，但需要安装一个三主节点 Kubernetes 高可用集群。\n本文使用 kubeadm 部署，三台机器都作为 control-plane，etcd 采用 stacked 模式，也就是每个控制平面节点本地运行一个 etcd。API Server 前面用 HAProxy + Keepalived 提供一个固定 VIP。\n参考官方文档时，优先看这些页面：\nInstalling kubeadm Creating Highly Available Clusters with kubeadm Container runtimes Kubernetes releases Install Calico networking and network policy for on-premises deployments 环境规划 本文固定版本，避免离线环境里因为浮动版本导致重复部署结果不一致。\nOS: Ubuntu Server 24.04 amd64 Kubernetes: v1.36.1 kubelet/kubeadm: 1.36.1-1.1 容器运行时: containerd CNI: Calico v3.32.0 Pod CIDR: 10.244.0.0/16 Service CIDR: 10.96.0.0/12 API VIP: 192.168.3.217:8443 节点规划：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip 这里把 API 高可用入口放在 192.168.3.217:8443，而不是 6443。原因是 HAProxy 和 kube-apiserver 都运行在三台 master 上，kube-apiserver 会占用本机 6443，HAProxy 监听 8443 可以避免端口冲突。\n","title":"离线安装 Kubernetes 三主节点高可用集群"},{"content":"如果一定要在金钱和健康之间排一个优先级，我会把健康放在前面。\n不是因为金钱不重要，而是因为健康是使用金钱、时间和机会的前提。一个人没有基本的健康，再多选择都会变窄；一个家庭失去健康保障，几年积累下来的财富也可能很快被消耗掉。\n但这并不等于说“钱不重要”。更准确的说法是：健康和金钱不是对立关系，而是本末关系。健康是根，金钱是工具。根坏了，工具再多也很难真正用起来。\n健康是时间复利的前提 很多人谈复利时，第一反应是投资、收入、资产增长。但复利真正依赖的是时间。\n你要有足够长的时间去学习、工作、试错、积累经验，也要有足够稳定的身体去持续执行。身体一旦垮掉，很多长期计划都会被迫中断。\n年轻时最容易低估这一点。因为熬夜、久坐、饮食混乱带来的后果通常不是马上出现，而是慢慢积累。短期看，好像只是多赚了一点钱、多赶了一点进度；长期看，可能是在透支未来的工作效率、生活质量和抗风险能力。\n所以健康不是“有空再管”的事，而是长期主义里最基础的资产。\n金钱买不到体验本身 钱当然可以买到很多东西。\n你可以花钱买更好的床、更贵的食材、更高级的体检套餐，也可以买到更好的医疗资源和更舒适的生活环境。这些都很重要。\n但钱买不到体验本身。\n你可以买一张好床，但买不了真正安稳的睡眠；可以买一桌好菜，但买不了健康的胃口；可以买一张机票去很远的地方，但如果身体状态很差，旅行也很难变成享受。\n金钱能扩大选择，但健康决定你有没有能力承接这些选择。\n健康是家庭风险的第一道防线 对普通家庭来说，健康问题不仅是个人问题，也是财务问题。\n一次严重疾病、一次长期治疗、一次失去劳动能力，都可能改变一个家庭的现金流和生活节奏。很多时候，真正击穿家庭财务安全的，不是收入低一点，也不是消费多一点，而是突然出现的大额支出和长期照护压力。\n这也是为什么健康管理不是鸡汤，而是一种风险管理。\n规律作息、适度运动、合理饮食、定期体检、必要的保险配置，看起来都不够刺激，但它们本质上是在降低人生里那些高破坏性事件的概率和冲击。\n但不要把健康当成逃避赚钱的借口 把健康放在前面，不等于否定赚钱。\n没有钱，很多健康选择也会变少。收入太低、没有储蓄、没有基本保障，会让人长期处在焦虑和被动里。这种压力本身也会反过来伤害健康。\n所以问题不是“要健康还是要钱”，而是“用什么方式赚钱”。\n如果一种赚钱方式长期依赖熬夜、过度消耗、情绪压榨和不可持续的节奏，那它未必真的划算。你现在赚到的是现金，未来付出的可能是医疗成本、家庭关系、精神状态和恢复时间。\n更好的目标是：赚钱能力要提高，但不能建立在持续损害健康的基础上。\n一个更实用的判断标准 遇到选择时，可以问自己几个问题：\n这件事是在短期辛苦，还是在长期透支？ 我牺牲的健康，未来能不能恢复？恢复成本有多高？ 这笔钱解决的是必要问题，还是只是满足焦虑？ 如果身体出问题，我现在的储蓄、保险和家庭安排能撑多久？ 有没有更温和的方式，既能提高收入，又不持续损害身体？ 这些问题不会直接给出标准答案，但能帮你避免把“拼搏”误认为“透支”，也避免把“重视健康”变成“不敢承担压力”。\n可以从几件小事开始 健康和金钱都不是靠一次决心解决的，而是靠日常系统慢慢变好。\n可以先做几件具体的事：\n每天固定一个最晚睡觉时间，先保护睡眠底线。 每周安排几次低门槛运动，不追求强度，先追求持续。 给自己留一笔应急资金，避免小风险变成大危机。 不把加班、熬夜和牺牲身体当成长期策略。 定期做基础体检，对身体状态有基本了解。 在收入提高后，优先补齐储蓄、保障和健康投入，而不是立刻升级消费。 这些动作都不复杂，但它们会慢慢改变一个人的底盘。\n小结 金钱和健康不是敌人。真正的问题，是不要用前提去换工具。\n健康让你有时间、有精力、有状态去创造价值；金钱让你有安全感、有选择权、有能力照顾自己和家人。两者都重要，但顺序不能颠倒。\n不要用健康去换钱，然后再用钱去换残破的健康。\n更好的做法是：把健康作为人生的第一优先级去经营，把赚钱作为提高选择权的长期能力去建设。这样积累下来的财富，才更可能真正服务于生活，而不是变成修补透支的成本。\n","permalink":"/learning/moneyandhealth/","summary":"如果一定要在金钱和健康之间排一个优先级，我会把健康放在前面。\n不是因为金钱不重要，而是因为健康是使用金钱、时间和机会的前提。一个人没有基本的健康，再多选择都会变窄；一个家庭失去健康保障，几年积累下来的财富也可能很快被消耗掉。\n但这并不等于说“钱不重要”。更准确的说法是：健康和金钱不是对立关系，而是本末关系。健康是根，金钱是工具。根坏了，工具再多也很难真正用起来。\n健康是时间复利的前提 很多人谈复利时，第一反应是投资、收入、资产增长。但复利真正依赖的是时间。\n你要有足够长的时间去学习、工作、试错、积累经验，也要有足够稳定的身体去持续执行。身体一旦垮掉，很多长期计划都会被迫中断。\n年轻时最容易低估这一点。因为熬夜、久坐、饮食混乱带来的后果通常不是马上出现，而是慢慢积累。短期看，好像只是多赚了一点钱、多赶了一点进度；长期看，可能是在透支未来的工作效率、生活质量和抗风险能力。\n所以健康不是“有空再管”的事，而是长期主义里最基础的资产。\n金钱买不到体验本身 钱当然可以买到很多东西。\n你可以花钱买更好的床、更贵的食材、更高级的体检套餐，也可以买到更好的医疗资源和更舒适的生活环境。这些都很重要。\n但钱买不到体验本身。\n你可以买一张好床，但买不了真正安稳的睡眠；可以买一桌好菜，但买不了健康的胃口；可以买一张机票去很远的地方，但如果身体状态很差，旅行也很难变成享受。\n金钱能扩大选择，但健康决定你有没有能力承接这些选择。\n健康是家庭风险的第一道防线 对普通家庭来说，健康问题不仅是个人问题，也是财务问题。\n一次严重疾病、一次长期治疗、一次失去劳动能力，都可能改变一个家庭的现金流和生活节奏。很多时候，真正击穿家庭财务安全的，不是收入低一点，也不是消费多一点，而是突然出现的大额支出和长期照护压力。\n这也是为什么健康管理不是鸡汤，而是一种风险管理。\n规律作息、适度运动、合理饮食、定期体检、必要的保险配置，看起来都不够刺激，但它们本质上是在降低人生里那些高破坏性事件的概率和冲击。\n但不要把健康当成逃避赚钱的借口 把健康放在前面，不等于否定赚钱。\n没有钱，很多健康选择也会变少。收入太低、没有储蓄、没有基本保障，会让人长期处在焦虑和被动里。这种压力本身也会反过来伤害健康。\n所以问题不是“要健康还是要钱”，而是“用什么方式赚钱”。\n如果一种赚钱方式长期依赖熬夜、过度消耗、情绪压榨和不可持续的节奏，那它未必真的划算。你现在赚到的是现金，未来付出的可能是医疗成本、家庭关系、精神状态和恢复时间。\n更好的目标是：赚钱能力要提高，但不能建立在持续损害健康的基础上。\n一个更实用的判断标准 遇到选择时，可以问自己几个问题：\n这件事是在短期辛苦，还是在长期透支？ 我牺牲的健康，未来能不能恢复？恢复成本有多高？ 这笔钱解决的是必要问题，还是只是满足焦虑？ 如果身体出问题，我现在的储蓄、保险和家庭安排能撑多久？ 有没有更温和的方式，既能提高收入，又不持续损害身体？ 这些问题不会直接给出标准答案，但能帮你避免把“拼搏”误认为“透支”，也避免把“重视健康”变成“不敢承担压力”。\n可以从几件小事开始 健康和金钱都不是靠一次决心解决的，而是靠日常系统慢慢变好。\n可以先做几件具体的事：\n每天固定一个最晚睡觉时间，先保护睡眠底线。 每周安排几次低门槛运动，不追求强度，先追求持续。 给自己留一笔应急资金，避免小风险变成大危机。 不把加班、熬夜和牺牲身体当成长期策略。 定期做基础体检，对身体状态有基本了解。 在收入提高后，优先补齐储蓄、保障和健康投入，而不是立刻升级消费。 这些动作都不复杂，但它们会慢慢改变一个人的底盘。\n小结 金钱和健康不是敌人。真正的问题，是不要用前提去换工具。\n健康让你有时间、有精力、有状态去创造价值；金钱让你有安全感、有选择权、有能力照顾自己和家人。两者都重要，但顺序不能颠倒。\n不要用健康去换钱，然后再用钱去换残破的健康。\n更好的做法是：把健康作为人生的第一优先级去经营，把赚钱作为提高选择权的长期能力去建设。这样积累下来的财富，才更可能真正服务于生活，而不是变成修补透支的成本。\n","title":"金钱和健康哪个更重要？"},{"content":"Kuboard v3 是一个 Kubernetes 多集群管理控制面板，可以用来查看和管理工作负载、节点、命名空间、配置、日志等资源。\n本文主线采用 集群外部署：在一台独立的 Ubuntu / Linux 虚拟机上用 Docker 运行 Kuboard Server，再把 Kubernetes 集群导入 Kuboard。\n部署方式选择 官方文档建议优先使用 docker run 方式运行 Kuboard v3。原因是 Kuboard 作为多集群管理界面，独立于任何一个 Kubernetes 集群之外，结构更清晰，排查也更简单。参考：Kuboard v3 安装方式说明。\n两种部署方式的区别：\n部署方式 位置 适合场景 注意事项 集群外部署 Kuboard Server 运行在独立 Linux 主机或虚拟机 推荐方式，适合管理一个或多个 Kubernetes 集群 Kuboard 主机需要能被集群里的 Kuboard Agent 访问 集群内部署 Kuboard Server 作为工作负载运行在 Kubernetes 集群内 临时实验、单集群内网使用 Kuboard 自己依赖被管理集群，集群故障时面板也可能不可用 本文推荐把 Kuboard Server 放在集群外。这样即使某个 Kubernetes 集群异常，Kuboard 主机本身仍然独立存在，后续排查和重新导入集群会更方便。\n和集群高可用的关系 Kuboard 是管理面板，不是 Kubernetes 控制平面组件。它不参与 kube-apiserver、etcd、controller-manager、scheduler 的高可用。\n也就是说：\nKuboard 挂了，不会导致 Kubernetes 集群停止运行。 Kubernetes 集群高可用，不等于 Kuboard 自动高可用。 普通 Kuboard v3 部署通常是单容器实例，Kuboard 自身存在单点故障。 如果 Kubernetes API Server 前面有 HAProxy / Keepalived / kube-vip 等高可用入口，导入集群时优先使用这个稳定入口。 Kuboard 官方从 v3.5.0.0 开始提供 Kuboard 自身的高可用部署模式。它需要多台 Kuboard 服务器、负载均衡、独立 etcd 集群，并且审计日志组件 QuestDB 仍有单点取舍。普通内网管理场景一般不需要做到这一步。参考：Kuboard v3 高可用部署。\n前置条件 本文使用一台独立 Linux 虚拟机作为 Kuboard Server，它不加入 Kubernetes 集群：\nKuboard Server: 192.168.3.212 Kubernetes API: 192.168.3.217:8443 Kuboard Web: http://192.168.3.212:8080 Agent Server: 192.168.3.212:10081 Kubernetes 集群为三主三从，高可用入口为 192.168.3.217：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 vip 192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 要求：\nKuboard Server 已安装 Docker，Docker 版本不低于 19.03。 Kubernetes 集群版本不低于 v1.13。 Kubernetes 集群里的 Pod 能访问 Kuboard Server 的 8080/tcp 和 10081/tcp。 运维电脑能访问 Kuboard Server 的 8080/tcp。 如果 Docker 还没有安装，可以先参考本站的 Docker 入门：Ubuntu 安装与基础配置。\n固定 Kuboard 镜像版本 官方安装命令常使用 eipwork/kuboard:v3 或 swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3。为了避免以后重新部署时拉到不同镜像，本文固定为：\nswr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3.5.2.9 这个地址是 Kuboard 官方文档中给出的华为云 SWR 镜像地址，更适合国内服务器拉取。当前可在 Docker Hub eipwork/kuboard Tags 查看 Kuboard 镜像标签，SWR 镜像通常同步同名标签。\n如果后续要升级 Kuboard，不要直接改成 v3 或 v4。先看官方升级说明，备份 /opt/kuboard/data 后再调整镜像版本。\n创建数据目录 在 Kuboard Server 虚拟机上执行：\nsudo mkdir -p /opt/kuboard/data sudo chown -R \"$USER:$USER\" /opt/kuboard cd /opt/kuboard 启动 Kuboard Server 这里把 Kuboard Web 端口映射到宿主机 8080，把 Agent Server 端口映射到宿主机 10081。\ndocker run -d \\ --restart=unless-stopped \\ --privileged \\ --name=kuboard \\ -p 8080:80/tcp \\ -p 10081:10081/tcp \\ -e KUBOARD_ENDPOINT=\"http://192.168.3.212:8080\" \\ -e KUBOARD_AGENT_SERVER_TCP_PORT=\"10081\" \\ -v /opt/kuboard/data:/data \\ swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3.5.2.9 KUBOARD_ENDPOINT 是给 Kubernetes 集群里的 Kuboard Agent 使用的地址，不能写 127.0.0.1 或 localhost。如果有内网域名，建议写域名，例如：\nKUBOARD_ENDPOINT=\"http://kuboard.jihw.lan:8080\" 如果使用域名，需要确保 Kubernetes 集群里的 Pod 能正确解析这个域名。\n镜像拉取失败处理 如果执行 docker run 时出现下面的报错：\nerror from registry: 这镜像不在白名单. this image is not in the allowlist 通常是 Docker 配置了 DaoCloud 等镜像加速器，而该加速器只允许拉取白名单中的镜像。此时不要继续使用 eipwork/kuboard:v3.5.2.9，改用上面的 SWR 镜像地址：\ndocker pull swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3.5.2.9 如果仍然失败，检查 Docker 镜像加速器配置：\ncat /etc/docker/daemon.json 如果里面配置了不可用或有限制的 registry-mirrors，可以先备份配置，再移除该镜像加速器并重启 Docker：\nsudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak sudo vim /etc/docker/daemon.json sudo systemctl restart docker 重启 Docker 后重新执行 docker pull 或 docker run。\n查看运行状态 docker ps --filter name=kuboard docker logs -f kuboard 浏览器访问：\nhttp://192.168.3.212:8080 默认账号：\n用户名：admin 密码：Kuboard123 首次登录后建议立刻修改默认密码。\n防火墙放行 如果 Kuboard Server 开启了 ufw，可以只允许内网访问：\nsudo ufw allow from 192.168.3.0/24 to any port 8080 proto tcp sudo ufw allow from 192.168.3.0/24 to any port 10081 proto tcp sudo ufw status 不要把 Kuboard 直接暴露到公网。Kuboard 面板具备集群管理能力，公网访问应放在 VPN、堡垒机或可信反向代理之后。\n导入 Kubernetes 集群 登录 Kuboard 后，点击添加集群，常见有两种导入方式：\n使用 kuboard-agent 导入：适合 Kuboard Server 和 Kubernetes 集群网络互通的场景。 使用 kubeconfig 导入：适合已经有稳定 kubeconfig 的场景。 本文推荐使用 kuboard-agent 导入。这个页面里不会让你手动填写 Kubernetes API Server 地址，因为 Agent 会部署到 Kubernetes 集群内部，由 Agent 在集群内访问 Kubernetes API，再和 Kuboard Server 建立连接。\n在 Kuboard Agent 页签中按下面方式填写：\n名称：自定义，例如 homeSpace。 描述：自定义，例如 家空间。 Agent 部署名称：保持默认或自定义，多个 Kuboard 实例导入同一个集群时需要不同名称。 Agent 镜像：优先选择 swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard-agent。 代理设置：本文内网环境留空。 截图里的 代理设置 不是 Kubernetes API Server 地址，而是 Kuboard Agent 访问 Kuboard Agent Server 时使用的 HTTP / SOCKS5 代理。本文的 Kuboard Server 192.168.3.212 和 Kubernetes 集群在同一个 192.168.3.0/24 内网，一般不需要代理，留空即可。\n如果你的网络环境要求 Agent 通过代理才能访问 Kuboard Server，再填写代理设置。官网给的代理格式是：\nhttp://user:passwd@192.168.1.128:8080 socks5://user:passwd@192.168.1.128:8080 没有代理账号密码时，可以写成：\nhttp://192.168.1.128:8080 socks5://192.168.1.128:8080 不要把代理地址写成 https://192.168.3.217:8443。这个地址是 Kubernetes API Server，不是 HTTP / SOCKS5 代理服务器。\n点击确定后，Kuboard 页面会生成一段 kubectl apply 命令。复制到能操作 Kubernetes 集群的机器上执行即可。\n如果改用 KubeConfig 或 Token 页签导入，才需要关心 Kubernetes API Server 地址。对于本文的三主三从高可用集群，kubeconfig 中的 server 建议使用高可用入口：\nhttps://192.168.3.217:8443 不要优先使用单个 master 节点的 6443，否则该 master 节点故障时，Kuboard 访问集群也会跟着失败。\n执行后查看 Agent 状态：\nkubectl -n kuboard get pod,svc -o wide 如果 Agent 一直无法连接，优先检查：\n集群 Pod 是否能访问 http://192.168.3.212:8080。 集群 Pod 是否能访问 192.168.3.212:10081/tcp。 KUBOARD_ENDPOINT 是否写成了 127.0.0.1、localhost 或无法解析的域名。 公网集群和内网 Kuboard 如果 Kubernetes 集群部署在公网，而 Kuboard Server 部署在内网，需要先判断连接方向。\n方式一：使用 KubeConfig 导入 如果公网 Kubernetes API Server 可以从内网 Kuboard Server 访问，推荐使用 KubeConfig 或 Token 页签导入。这样连接方向是：\n内网 Kuboard Server -\u003e 公网 Kubernetes API Server 这种方式不需要把内网 Kuboard 暴露到公网，也不需要公网集群里的 Pod 访问 192.168.3.212。\n在 Kuboard 的 KubeConfig 页签中粘贴 kubeconfig。kubeconfig 里的 server 应该填写公网集群的 API Server 地址，例如：\nclusters: - cluster: certificate-authority-data: \u003cCA证书\u003e server: https://公网API入口:6443 name: public-k8s 如果公网集群也是高可用集群，server 应该使用公网负载均衡、云厂商控制面板提供的 API 地址，或者公网可访问的高可用入口。不要写某一台 master 的临时 IP。\n安全建议：\n只允许 Kuboard Server 的出口公网 IP 访问 Kubernetes API Server。 不要把 Kubernetes API Server 对全网开放。 不要长期使用个人管理员 kubeconfig；生产环境建议给 Kuboard 单独创建账号和权限。 方式二：继续使用 Kuboard Agent 如果继续使用 Kuboard Agent 页签导入，连接方向是：\n公网 Kubernetes 集群里的 Agent Pod -\u003e 内网 Kuboard Server 这种情况下，公网集群里的 Pod 必须能访问 Kuboard Server：\nhttp://Kuboard可达地址:8080 Kuboard可达地址:10081/tcp 如果 Kuboard 仍然只监听内网地址 192.168.3.212，公网集群通常无法访问它，Agent 就会连接失败。解决办法有几种：\n建立 VPN，例如 WireGuard、Tailscale、ZeroTier，让公网集群节点和内网 Kuboard 进入同一个专用网络。 使用公网反向代理或内网穿透，例如 frp、云厂商负载均衡，但必须同时转发 8080/tcp 和 10081/tcp。 把 Kuboard Server 部署到公网集群所在 VPC 或一台公网可控服务器上，再通过防火墙限制访问来源。 如果使用 VPN，假设 Kuboard Server 在 VPN 中的地址是 10.8.0.212，启动 Kuboard 时 KUBOARD_ENDPOINT 要写这个公网集群可达的地址：\ndocker run -d \\ --restart=unless-stopped \\ --privileged \\ --name=kuboard \\ -p 8080:80/tcp \\ -p 10081:10081/tcp \\ -e KUBOARD_ENDPOINT=\"http://10.8.0.212:8080\" \\ -e KUBOARD_AGENT_SERVER_TCP_PORT=\"10081\" \\ -v /opt/kuboard/data:/data \\ swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3.5.2.9 如果 Kuboard 容器已经启动过，可以保留数据目录，删除旧容器后用新的 KUBOARD_ENDPOINT 重新启动：\ndocker stop kuboard docker rm kuboard docker run -d \\ --restart=unless-stopped \\ --privileged \\ --name=kuboard \\ -p 8080:80/tcp \\ -p 10081:10081/tcp \\ -e KUBOARD_ENDPOINT=\"http://10.8.0.212:8080\" \\ -e KUBOARD_AGENT_SERVER_TCP_PORT=\"10081\" \\ -v /opt/kuboard/data:/data \\ swr.cn-east-2.myhuaweicloud.com/kuboard/kuboard:v3.5.2.9 如果使用公网域名，例如 kuboard.example.com，需要确认：\nhttp://kuboard.example.com:8080 能访问 Kuboard Web。 kuboard.example.com:10081/tcp 能从公网 Kubernetes 集群访问。 防火墙只放行可信来源，不要把 Kuboard 面板和 Agent 端口无保护地暴露到全网。 代理设置怎么用 Kuboard Agent 页签里的代理设置只在下面这种场景使用：\nAgent Pod -\u003e HTTP/SOCKS5 代理 -\u003e Kuboard Server 也就是说，代理服务器本身必须能访问 Kuboard Server。如果只是 Kuboard 在内网、代理也访问不到内网 Kuboard，填写代理不会解决问题。\n代理不一定是 SOCKS5，Kuboard 这里支持两类常见格式：\nhttp://...：HTTP 代理。 socks5://...：SOCKS5 代理。 选哪一种取决于你实际搭建或购买的代理服务。如果你自己能控制代理，公网集群访问内网 Kuboard 这种场景更推荐 VPN；如果必须使用代理，SOCKS5 通常更适合转发这类 TCP 连接。\n代理格式示例：\nhttp://user:passwd@代理服务器IP:8080 socks5://user:passwd@代理服务器IP:1080 没有账号密码时：\nhttp://代理服务器IP:8080 socks5://代理服务器IP:1080 填写前先确认三段网络都通：\nAgent Pod 能访问代理服务器 代理服务器能访问 Kuboard Web: http://Kuboard可达地址:8080 代理服务器能访问 Agent Server: Kuboard可达地址:10081/tcp 可以在公网 Kubernetes 集群里起一个临时 Pod 测试 HTTP 代理：\nkubectl run curl-test --rm -it --image=curlimages/curl:8.10.1 -- sh curl -x http://user:passwd@代理服务器IP:8080 http://Kuboard可达地址:8080 测试 SOCKS5 代理：\nkubectl run curl-test --rm -it --image=curlimages/curl:8.10.1 -- sh curl --socks5-hostname user:passwd@代理服务器IP:1080 http://Kuboard可达地址:8080 没有账号密码时去掉 user:passwd@：\ncurl --socks5-hostname 代理服务器IP:1080 http://Kuboard可达地址:8080 8080 测通后，还要确认 10081/tcp 可达。可以用带 nc 的临时镜像测试：\nkubectl run net-test --rm -it --image=busybox:1.36 -- sh nc -vz Kuboard可达地址 10081 本文内网集群场景不需要代理；公网集群管理内网 Kuboard 时，更推荐优先使用 KubeConfig 导入，或者先打通 VPN 后再使用 Agent。\n集群内部署方式 如果只是实验环境，也可以把 Kuboard v3 直接部署到 Kubernetes 集群内部：\nkubectl apply -f https://addons.kuboard.cn/kuboard/kuboard-v3.yaml 部署后通过 NodePort 访问：\nhttp://192.168.3.218:30080 这里也可以换成任意 master 或 worker 节点 IP，例如 192.168.3.214:30080、192.168.3.219:30080。\n默认账号：\n用户名：admin 密码：Kuboard123 查看资源：\nkubectl -n kuboard get pod,svc -o wide 这种方式属于 集群内部署。它的优点是快速，缺点是 Kuboard 依赖当前 Kubernetes 集群本身。如果当前集群控制平面、网络、调度或存储异常，Kuboard 也可能不可用。\n另外，官方文档说明集群内部署会包含 Kuboard 依赖的 etcd，并且 Kuboard v3 的 Deployment 暂时保持单副本。参考：Kuboard v3 Kubernetes 内部安装。\n卸载 集群外 Docker 部署的卸载方式：\ndocker stop kuboard docker rm kuboard 如果确认不再需要 Kuboard 数据，再删除数据目录：\nsudo rm -rf /opt/kuboard 集群内部署的卸载方式：\nkubectl delete -f https://addons.kuboard.cn/kuboard/kuboard-v3.yaml 如果使用了官方默认 hostPath 持久化方式，还需要在 master 节点以及带有 k8s.kuboard.cn/role=etcd 标签的节点上清理：\nsudo rm -rf /usr/share/kuboard 删除数据目录前一定要确认不再需要 Kuboard 中保存的集群配置、用户配置和审计数据。\n小结 日常内网环境建议使用集群外 Docker 部署 Kuboard v3，把它当作独立的多集群控制面板。\nKuboard 的可用性和 Kubernetes 集群高可用是两件事。Kubernetes 高可用负责保证集群控制平面稳定；Kuboard 高可用只负责保证管理面板自身稳定。普通部署下 Kuboard 单点故障不会影响集群运行，但会影响 Web 管理入口。\n","permalink":"/coding/kuboard-v3-control-panel/","summary":"Kuboard v3 是一个 Kubernetes 多集群管理控制面板，可以用来查看和管理工作负载、节点、命名空间、配置、日志等资源。\n本文主线采用 集群外部署：在一台独立的 Ubuntu / Linux 虚拟机上用 Docker 运行 Kuboard Server，再把 Kubernetes 集群导入 Kuboard。\n部署方式选择 官方文档建议优先使用 docker run 方式运行 Kuboard v3。原因是 Kuboard 作为多集群管理界面，独立于任何一个 Kubernetes 集群之外，结构更清晰，排查也更简单。参考：Kuboard v3 安装方式说明。\n两种部署方式的区别：\n部署方式 位置 适合场景 注意事项 集群外部署 Kuboard Server 运行在独立 Linux 主机或虚拟机 推荐方式，适合管理一个或多个 Kubernetes 集群 Kuboard 主机需要能被集群里的 Kuboard Agent 访问 集群内部署 Kuboard Server 作为工作负载运行在 Kubernetes 集群内 临时实验、单集群内网使用 Kuboard 自己依赖被管理集群，集群故障时面板也可能不可用 本文推荐把 Kuboard Server 放在集群外。这样即使某个 Kubernetes 集群异常，Kuboard 主机本身仍然独立存在，后续排查和重新导入集群会更方便。\n和集群高可用的关系 Kuboard 是管理面板，不是 Kubernetes 控制平面组件。它不参与 kube-apiserver、etcd、controller-manager、scheduler 的高可用。\n","title":"搭建 Kuboard v3 Kubernetes 控制面板"},{"content":"第一性原理听起来很高级，但它其实是在提醒我们一件朴素的事：不要只沿着别人给出的答案走，而要回到问题最底层，看看哪些东西是真的不能再拆，哪些只是习惯、经验和假设。\n很多时候，我们不是被问题本身困住，而是被旧答案困住。\n别人说这个行业就是这样，公司一直这么做，大家都这么买，过去都是这么成功的。听多了之后，人很容易把“过去有效”误认为“永远正确”。\n第一性原理的价值，就是把这些自动接受的东西重新拆开。\n什么是第一性原理 第一性原理可以理解成：从最基本的事实和约束出发，而不是从类比、经验、传统做法出发。\n它的思考方式不是：\n别人是怎么做的？ 行业通常怎么做？ 过去成功的人怎么做？ 而是先问：\n这个问题真正要解决什么？ 有哪些事实是确定的？ 有哪些限制是无法绕开的？ 哪些只是我们习惯相信的假设？ 如果从零开始设计，会怎么做？ 类比思考并不是坏事。类比能让人快速借鉴经验，少走弯路。但类比的问题在于，它很容易把别人的前提也一起复制过来。\n第一性原理不是拒绝经验，而是在使用经验之前，先检查经验背后的条件还在不在。\n一个简单例子 假设你想提升收入，常见的类比式思考是：\n别人考证涨薪了，我也去考证。 别人做自媒体赚钱了，我也去做自媒体。 别人开网店赚钱了，我也去开网店。 这些做法不一定错，但它们都是从别人的答案出发。\n如果用第一性原理来拆，问题会变成：\n收入从哪里来？ 收入来自别人愿意为我提供的价值付钱。 别人为什么愿意付钱？ 因为我解决了他们觉得重要的问题。 我现在能解决什么问题？ 这个问题有没有真实需求？ 我用什么方式交付？ 我如何让需要的人知道我能解决？ 这样一拆，重点就从“我要模仿谁”变成了“我到底能解决什么问题”。\n考证、自媒体、开店都只是手段。真正底层的是价值、需求、交付和成交。\n第一性原理和普通思考的区别 普通思考常常从现成答案开始。\n比如：\n我要不要买这门课？ 我要不要转行？ 我要不要创业？ 我要不要换一个城市？ 这些问题表面上很具体，但里面经常藏着很多没有拆开的假设。\n第一性原理会把它们拆成更底层的问题：\n我真正想改善的是什么？ 我现在最大的约束是什么？ 这个选择解决的是根因，还是只是在缓解焦虑？ 它的成本是什么？ 有没有更小的实验能先验证？ 失败后最坏会怎样？ 普通思考追求一个确定答案，第一性原理追求把问题拆到可以判断。\n这很重要。因为现实生活里的大多数问题，都没有标准答案。你不可能通过多问几个人，就得到一个永远不会错的选择。\n真正有用的是提高判断质量。\n为什么第一性原理很难 第一性原理听起来简单，做起来很难，因为它会让人不舒服。\n第一，它要求你承认很多“理所当然”的东西其实没有被验证过。\n比如一个人说“我不适合做销售”，这可能是真的，也可能只是因为他害怕被拒绝。再比如一个人说“这个行业没机会了”，这可能是真的，也可能只是他接触到的圈子没有机会。\n第二，它要求你承担重新判断的责任。\n照着别人做，失败了还可以说“大家都是这么做的”。但如果你从底层重新推导，就不能轻易把责任甩给别人。\n第三，它会暴露真正的限制。\n很多问题拆到最后，会发现不是方法不够，而是自己不愿意行动、不愿意试错、不愿意面对反馈。这个发现不太舒服，但它很有价值。\n怎么使用第一性原理 可以用五个步骤来训练。\n1. 写下问题 不要只在脑子里想。脑子里的问题通常是模糊的，写下来才会变清楚。\n比如不要写：\n我最近很迷茫。 可以改成：\n我想在 6 个月内提高收入，但不知道应该继续提升本职工作，还是尝试副业。 问题越具体，越容易拆。\n2. 拆掉假设 把你默认相信的东西列出来。\n比如：\n提高收入必须换工作。 副业一定比上班自由。 我没有资源，所以做不了生意。 我必须准备充分才能开始。 然后逐个问：\n这是真的事实，还是我的感觉？ 有没有反例？ 这个判断成立需要哪些条件？ 很多限制不是事实，而是未经检查的假设。\n3. 找到底层事实 底层事实通常更朴素，也更难逃避。\n比如关于赚钱，底层事实可能是：\n别人只会为自己需要的价值付钱。 市场反馈比自我感觉更真实。 时间有限，注意力有限，现金流有限。 没有输出和成交，就没有真正的验证。 这些事实听起来没有新鲜感，但它们比“别人怎么做”更可靠。\n4. 重新组合方案 有了底层事实，就可以重新设计方案。\n如果目标是提高收入，不一定只有跳槽、考证、创业这几条路。你可以组合出更小的动作：\n先盘点自己能解决的问题。 找 10 个可能需要这个问题的人聊一聊。 用一周时间做一个最小服务版本。 先成交一个小单，而不是先搭建完整品牌。 根据反馈调整交付方式。 第一性原理不是让人空想宏大创新，而是让人摆脱旧答案，找到更贴近现实的路径。\n5. 用实验验证 推导只是开始，验证才是关键。\n如果一个方案不能被小规模测试，它就很容易变成自我说服。\n比如想转行，不一定马上辞职。可以先访谈行业从业者，投几份简历，做一个小项目，观察自己是否真的喜欢，也观察市场是否真的需要。\n好的决策不是一次想清楚，而是通过小实验不断校准。\n几个常见场景 学习 普通思考会问：“我要不要买这门课？”\n第一性原理会问：\n我学这个是为了解决什么具体问题？ 这个问题现在真的重要吗？ 不买课能不能先用免费资料验证？ 学完后我会输出什么？ 怎么判断这次学习有效？ 如果没有输出，学习很容易变成收藏和安慰。\n工作 普通思考会问：“领导为什么不给我机会？”\n第一性原理会问：\n团队现在最需要解决什么问题？ 我能不能主动承担其中一小块？ 我的能力是否被别人看见？ 我交付的结果是否值得信任？ 机会不是等来的，很多时候是通过解决问题换来的。\n创业和副业 普通思考会问：“现在什么项目赚钱？”\n第一性原理会问：\n谁有痛点？ 这个痛点值不值得付钱？ 我能不能用更低成本解决？ 我如何找到第一批客户？ 怎样用最小成本验证需求？ 赚钱不是追热点，而是发现需求、解决问题、完成交易。\n人际关系 普通思考会问：“他为什么不理解我？”\n第一性原理会问：\n我真正想表达的是什么？ 对方真正关心的是什么？ 我们之间的冲突是事实冲突，还是需求冲突？ 我有没有把期待说清楚？ 很多关系问题，不是缺少道理，而是没有拆清楚各自的需求和边界。\n第一性原理不是万能钥匙 第一性原理很有用，但它不是让人看不起经验，也不是每件小事都要从宇宙起源开始推导。\n如果问题很常规，直接借鉴成熟方案就很好。比如安装软件、报税流程、常见故障排查，这些事情没有必要全部重造。\n真正适合使用第一性原理的，是这些场景：\n别人给出的答案互相矛盾。 旧方法越来越无效。 你发现自己只是在模仿，却不知道为什么。 这个选择成本很高，不能只靠感觉。 你需要创造新的方案，而不是复制旧方案。 第一性原理和经验最好的关系是：先用第一性原理看清底层，再用经验减少试错成本。\n一个自我提问清单 遇到重要问题时，可以问自己：\n我现在要解决的真实问题是什么？ 我是不是把别人的答案当成了自己的答案？ 哪些是事实，哪些是假设？ 这个问题不能再拆的底层约束是什么？ 如果从零开始，我会怎么设计？ 有没有更小、更便宜、更快的验证方式？ 我需要什么反馈，才能知道自己是对是错？ 如果现有做法都不存在，我会怎么做？ 这些问题不会直接给你答案，但会让你的思考更接近问题本身。\n小结 第一性原理不是一种炫技的思考方式，而是一种诚实面对问题的能力。\n它让你少一点“别人都这么做”的惯性，多一点“这件事本质上要解决什么”的清醒。\n很多时候，真正的进步不是找到一个更复杂的方法，而是把问题拆回最简单、最真实的地方。\n从那里重新出发，答案才有可能属于你自己。\n","permalink":"/learning/first-principles/","summary":"第一性原理听起来很高级，但它其实是在提醒我们一件朴素的事：不要只沿着别人给出的答案走，而要回到问题最底层，看看哪些东西是真的不能再拆，哪些只是习惯、经验和假设。\n很多时候，我们不是被问题本身困住，而是被旧答案困住。\n别人说这个行业就是这样，公司一直这么做，大家都这么买，过去都是这么成功的。听多了之后，人很容易把“过去有效”误认为“永远正确”。\n第一性原理的价值，就是把这些自动接受的东西重新拆开。\n什么是第一性原理 第一性原理可以理解成：从最基本的事实和约束出发，而不是从类比、经验、传统做法出发。\n它的思考方式不是：\n别人是怎么做的？ 行业通常怎么做？ 过去成功的人怎么做？ 而是先问：\n这个问题真正要解决什么？ 有哪些事实是确定的？ 有哪些限制是无法绕开的？ 哪些只是我们习惯相信的假设？ 如果从零开始设计，会怎么做？ 类比思考并不是坏事。类比能让人快速借鉴经验，少走弯路。但类比的问题在于，它很容易把别人的前提也一起复制过来。\n第一性原理不是拒绝经验，而是在使用经验之前，先检查经验背后的条件还在不在。\n一个简单例子 假设你想提升收入，常见的类比式思考是：\n别人考证涨薪了，我也去考证。 别人做自媒体赚钱了，我也去做自媒体。 别人开网店赚钱了，我也去开网店。 这些做法不一定错，但它们都是从别人的答案出发。\n如果用第一性原理来拆，问题会变成：\n收入从哪里来？ 收入来自别人愿意为我提供的价值付钱。 别人为什么愿意付钱？ 因为我解决了他们觉得重要的问题。 我现在能解决什么问题？ 这个问题有没有真实需求？ 我用什么方式交付？ 我如何让需要的人知道我能解决？ 这样一拆，重点就从“我要模仿谁”变成了“我到底能解决什么问题”。\n考证、自媒体、开店都只是手段。真正底层的是价值、需求、交付和成交。\n第一性原理和普通思考的区别 普通思考常常从现成答案开始。\n比如：\n我要不要买这门课？ 我要不要转行？ 我要不要创业？ 我要不要换一个城市？ 这些问题表面上很具体，但里面经常藏着很多没有拆开的假设。\n第一性原理会把它们拆成更底层的问题：\n我真正想改善的是什么？ 我现在最大的约束是什么？ 这个选择解决的是根因，还是只是在缓解焦虑？ 它的成本是什么？ 有没有更小的实验能先验证？ 失败后最坏会怎样？ 普通思考追求一个确定答案，第一性原理追求把问题拆到可以判断。\n这很重要。因为现实生活里的大多数问题，都没有标准答案。你不可能通过多问几个人，就得到一个永远不会错的选择。\n真正有用的是提高判断质量。\n为什么第一性原理很难 第一性原理听起来简单，做起来很难，因为它会让人不舒服。\n第一，它要求你承认很多“理所当然”的东西其实没有被验证过。\n比如一个人说“我不适合做销售”，这可能是真的，也可能只是因为他害怕被拒绝。再比如一个人说“这个行业没机会了”，这可能是真的，也可能只是他接触到的圈子没有机会。\n第二，它要求你承担重新判断的责任。\n照着别人做，失败了还可以说“大家都是这么做的”。但如果你从底层重新推导，就不能轻易把责任甩给别人。\n第三，它会暴露真正的限制。\n很多问题拆到最后，会发现不是方法不够，而是自己不愿意行动、不愿意试错、不愿意面对反馈。这个发现不太舒服，但它很有价值。\n怎么使用第一性原理 可以用五个步骤来训练。\n1. 写下问题 不要只在脑子里想。脑子里的问题通常是模糊的，写下来才会变清楚。\n比如不要写：\n我最近很迷茫。 可以改成：\n我想在 6 个月内提高收入，但不知道应该继续提升本职工作，还是尝试副业。 问题越具体，越容易拆。\n","title":"第一性原理：从底层重新思考问题"},{"content":"版本说明 本文使用 Docker 在 Ubuntu 虚拟机中部署 PostgreSQL，并固定镜像版本为：\npostgres:16.14-bookworm 不要使用 postgres:latest、postgres:16 或 postgres:16-bookworm 这类浮动标签。浮动标签后续可能指向新的镜像内容，重装、迁移或重新拉取时就可能出现和当初部署不一致的情况。\npostgres:16.14-bookworm 的含义：\n16.14：固定 PostgreSQL 16 的具体小版本。 bookworm：固定 Debian 12 作为基础系统系列。 镜像标签可以在 Docker Hub PostgreSQL 官方镜像 查看。\n前置条件 Ubuntu 虚拟机中需要先安装 Docker Engine 和 Docker Compose Plugin。如果还没有安装，可以参考本站的 Docker 入门：Ubuntu 安装与基础配置。\n验证 Docker 是否可用：\ndocker version docker compose version 创建部署目录 本文把 PostgreSQL 放在 /opt/postgresql 目录中，数据文件放在同级的 data 目录。\nsudo mkdir -p /opt/postgresql sudo chown -R \"$USER:$USER\" /opt/postgresql cd /opt/postgresql mkdir -p data 创建环境变量文件 cat \u003e .env \u003c\u003c'EOF' POSTGRES_VERSION=16.14-bookworm POSTGRES_CONTAINER_NAME=postgres16 POSTGRES_DB=appdb POSTGRES_USER=appuser POSTGRES_PASSWORD=change-me-to-a-strong-password POSTGRES_PORT=5432 EOF 建议马上修改 POSTGRES_PASSWORD。这个密码只会在第一次初始化数据库时生效，如果数据库目录已经初始化过，后面再改 .env 不会自动修改数据库里的用户密码。\n创建 Docker Compose 文件 cat \u003e compose.yaml \u003c\u003c'EOF' services: postgres: image: postgres:${POSTGRES_VERSION} container_name: ${POSTGRES_CONTAINER_NAME} restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: /var/lib/postgresql/data/pgdata ports: - \"${POSTGRES_PORT}:5432\" volumes: - ./data:/var/lib/postgresql/data healthcheck: test: [\"CMD-SHELL\", \"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB\"] interval: 10s timeout: 5s retries: 5 EOF 这里的 image 最终会解析成 postgres:16.14-bookworm，不会因为官方后续发布 PostgreSQL 17、18 或新的 Debian 基础镜像而自动变化。\n如果只允许本机访问，可以把端口改成只监听 127.0.0.1：\nports: - \"127.0.0.1:${POSTGRES_PORT}:5432\" 启动 PostgreSQL docker compose up -d 查看容器状态：\ndocker compose ps docker logs -f postgres16 第一次启动时，PostgreSQL 会初始化 ./data 目录。看到类似 database system is ready to accept connections 的日志，就说明数据库已经可以连接。\n连接测试 进入容器使用 psql：\ndocker exec -it postgres16 psql -U appuser -d appdb 执行一条 SQL：\nselect version(); 从其他机器连接时，先确认 Ubuntu 虚拟机 IP：\nip addr 然后在客户端连接：\npsql -h \u003cUbuntu虚拟机IP\u003e -p 5432 -U appuser -d appdb 防火墙设置 如果 Ubuntu 开启了 ufw，需要放行 PostgreSQL 端口。建议只允许可信网段访问，例如只允许 192.168.3.0/24：\nsudo ufw allow from 192.168.3.0/24 to any port 5432 proto tcp sudo ufw status 不要直接把数据库端口暴露到公网。如果必须远程访问，优先使用 VPN、SSH 隧道或只放行固定来源 IP。\n修改密码 如果数据库已经初始化，修改 .env 中的 POSTGRES_PASSWORD 不会生效，需要进入数据库执行 SQL：\ndocker exec -it postgres16 psql -U appuser -d appdb alter user appuser with password 'new-strong-password'; 然后再把 .env 中的密码同步改掉，避免后续维护时混淆。\n停止、启动和重启 cd /opt/postgresql docker compose stop docker compose start docker compose restart 停止并删除容器，但保留数据：\ndocker compose down 删除 ./data 目录才会真正删除数据库数据，操作前一定要确认已经备份。\n备份和恢复 备份单个数据库：\ncd /opt/postgresql docker exec postgres16 pg_dump -U appuser -d appdb -Fc \u003e appdb_$(date +%F).dump 恢复到已有数据库：\ncat appdb_2026-06-06.dump | docker exec -i postgres16 pg_restore -U appuser -d appdb --clean --if-exists 如果要恢复到一个全新的库，可以先创建数据库：\ndocker exec -it postgres16 createdb -U appuser appdb_restore cat appdb_2026-06-06.dump | docker exec -i postgres16 pg_restore -U appuser -d appdb_restore 后续升级原则 固定版本部署后，不要随手把镜像改成 postgres:17、postgres:18 或 postgres:latest。\nPostgreSQL 大版本升级不能只改镜像标签直接启动。比如从 16 升到 17，需要先备份，再按 PostgreSQL 官方升级流程处理，常见方式是 pg_dump / pg_restore 或 pg_upgrade。\n如果只是 16 系列的小版本升级，也建议先备份，再把 .env 中的版本从：\nPOSTGRES_VERSION=16.14-bookworm 改成目标版本，然后重新拉取和启动：\ndocker compose pull docker compose up -d 本文为了部署命令长期稳定，默认不自动升级镜像版本。\n","permalink":"/coding/docker-postgresql-ubuntu/","summary":"版本说明 本文使用 Docker 在 Ubuntu 虚拟机中部署 PostgreSQL，并固定镜像版本为：\npostgres:16.14-bookworm 不要使用 postgres:latest、postgres:16 或 postgres:16-bookworm 这类浮动标签。浮动标签后续可能指向新的镜像内容，重装、迁移或重新拉取时就可能出现和当初部署不一致的情况。\npostgres:16.14-bookworm 的含义：\n16.14：固定 PostgreSQL 16 的具体小版本。 bookworm：固定 Debian 12 作为基础系统系列。 镜像标签可以在 Docker Hub PostgreSQL 官方镜像 查看。\n前置条件 Ubuntu 虚拟机中需要先安装 Docker Engine 和 Docker Compose Plugin。如果还没有安装，可以参考本站的 Docker 入门：Ubuntu 安装与基础配置。\n验证 Docker 是否可用：\ndocker version docker compose version 创建部署目录 本文把 PostgreSQL 放在 /opt/postgresql 目录中，数据文件放在同级的 data 目录。\nsudo mkdir -p /opt/postgresql sudo chown -R \"$USER:$USER\" /opt/postgresql cd /opt/postgresql mkdir -p data 创建环境变量文件 cat \u003e .env \u003c\u003c'EOF' POSTGRES_VERSION=16.14-bookworm POSTGRES_CONTAINER_NAME=postgres16 POSTGRES_DB=appdb POSTGRES_USER=appuser POSTGRES_PASSWORD=change-me-to-a-strong-password POSTGRES_PORT=5432 EOF 建议马上修改 POSTGRES_PASSWORD。这个密码只会在第一次初始化数据库时生效，如果数据库目录已经初始化过，后面再改 .env 不会自动修改数据库里的用户密码。\n","title":"Ubuntu 虚拟机使用 Docker 安装固定版本 PostgreSQL"},{"content":"前面已经把 GitLab 和 SonarQube 都部署到了 Kubernetes 里：\nGitLab https://gitlab.jihw.top SonarQube https://sonarqube.jihw.top Ingress 192.168.3.230 这篇记录两者配合使用的流程：代码放在 GitLab，流水线由 GitLab Runner 执行，扫描结果上传到 SonarQube。\n本文用这个前端项目作为检测示例：\nD:\\workspace\\jihw\\qianduoduo\\frontend 它是一个 Vite + Vue 项目，关键文件是：\npackage.json package-lock.json vite.config.js src/ 当前 package.json 里已有：\n{ \"scripts\": { \"build\": \"vite build\", \"dev\": \"vite --host 0.0.0.0\" } } 所以 CI 里可以先执行 npm ci 和 npm run build，确认前端至少能正常构建，然后再执行 SonarQube 扫描。\n整体流程 1. GitLab 创建项目 2. 把 frontend 代码推到 GitLab 3. SonarQube 创建项目并生成 Token 4. GitLab 项目配置 CI/CD Variables 5. 提交 sonar-project.properties 6. 提交 .gitlab-ci.yml 7. GitLab Runner 执行流水线 8. SonarQube 查看质量报告 确认 GitLab Runner GitLab 本身只负责管理流水线，真正执行 job 的是 Runner。\n先在 GitLab 页面确认 Runner：\nAdmin Area -\u003e CI/CD -\u003e Runners 或者进入具体项目：\nProject -\u003e Settings -\u003e CI/CD -\u003e Runners 如果没有 Runner，先安装一个 Kubernetes Runner。示例：\nhelm repo add gitlab https://charts.gitlab.io/ helm repo update helm upgrade --install gitlab-runner gitlab/gitlab-runner \\ -n gitlab \\ --set gitlabUrl=https://gitlab.jihw.top \\ --set runnerToken='你的 Runner Token' \\ --set rbac.create=true 这里使用 Kubernetes executor，所以建议一开始就打开 rbac.create=true。否则 Runner 虽然能注册成功，也能从 GitLab 接到 job，但真正执行时可能会因为 ServiceAccount 权限不足而失败。\n典型错误如下：\nsecrets is forbidden: User \"system:serviceaccount:gitlab:default\" cannot create resource \"secrets\" 如果已经安装过 Runner，可以用下面的方式补上 RBAC：\nhelm upgrade gitlab-runner gitlab/gitlab-runner \\ -n gitlab \\ --reuse-values \\ --set rbac.create=true 还要注意 Runner 的标签。比如创建 Runner 时给它设置了 qianduoduo-frontend 标签，而 .gitlab-ci.yml 里的 job 没写 tags，默认情况下这个 Runner 不一定会接未打标签的 job。\n有两种处理方式。\n方式一是在 GitLab 页面允许 Runner 执行未打标签的 job：\nProject -\u003e Settings -\u003e CI/CD -\u003e Runners -\u003e 编辑 Runner 勾选 Run untagged jobs 方式二是在 .gitlab-ci.yml 里给 job 明确加上标签：\nbuild: stage: build tags: - qianduoduo-frontend sonarqube: stage: scan tags: - qianduoduo-frontend Runner 能访问 https://sonarqube.jihw.top 时，CI 里可以直接使用这个域名。\n如果 DNS 没配好，也可以在 CI 变量里把 SonarQube 地址写成集群内 Service 地址。\n常见两种写法：\nSONAR_HOST_URL=https://sonarqube.jihw.top 或者：\nSONAR_HOST_URL=http://sonarqube-sonarqube.sonarqube.svc.cluster.local:9000 创建 GitLab 项目 在 GitLab 里创建一个项目，例如：\nqianduoduo-frontend 然后在本地项目目录执行：\ncd D:\\workspace\\jihw\\qianduoduo\\frontend git init git remote add origin https://gitlab.jihw.top/root/qianduoduo-frontend.git git add . git commit -m \"init frontend\" git branch -M main git push -u origin main 如果这个目录已经是 Git 仓库，只需要确认 remote 指向 GitLab：\ngit remote -v 不要把 node_modules 推到 GitLab。建议 .gitignore 至少包含：\nnode_modules/ dist/ web_dist/ .sonar/ coverage/ 配置免密推送 直接使用 HTTPS remote 时，第一次 git push 会要求输入 GitLab 账号和密码。GitLab 通常不建议直接用登录密码推代码，更推荐使用 Personal Access Token。\n当前这套 GitLab 部署里，Web Ingress 已经可用：\nhttps://gitlab.jihw.top GitLab Shell 本身是集群内 ClusterIP：\ngitlab-gitlab-shell.gitlab.svc.cluster.local:22 如果要从 Windows 本机使用 SSH 免密推送，需要额外把 22 端口暴露到集群外。当前已经通过 ingress-nginx 的 TCP 转发把 192.168.3.230:22 转发到了 GitLab Shell。\n暴露命令如下：\nhelm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm repo update ingress-nginx cat \u003c\u003c'EOF' | helm upgrade ingress-nginx ingress-nginx/ingress-nginx \\ -n ingress-nginx \\ --version 4.15.1 \\ -f - \\ --timeout 5m controller: ingressClassResource: default: true containerPort: \"22-tcp\": 22 service: type: LoadBalancer ports: \"22-tcp\": 22 targetPorts: \"22-tcp\": \"22-tcp\" tcp: \"22\": gitlab/gitlab-gitlab-shell:22 EOF 确认：\nkubectl -n ingress-nginx get svc ingress-nginx-controller -o wide kubectl -n ingress-nginx get cm ingress-nginx-tcp -o yaml 期望看到：\ningress-nginx-controller LoadBalancer ... 192.168.3.230 80:.../TCP,443:.../TCP,22:.../TCP Windows 上测试：\nTest-NetConnection gitlab.jihw.top -Port 22 ssh -T git@gitlab.jihw.top 如果 SSH key 已经添加到 GitLab，应该看到类似：\nWelcome to GitLab, @root! 方案一：HTTPS + Token 不想暴露 SSH 端口时，也可以继续用：\nHTTPS remote + Personal Access Token + Git Credential Manager 记住凭据 在 GitLab 创建 Token：\n头像 -\u003e Preferences -\u003e Access Tokens 创建一个 Project 或 Personal Access Token，勾选：\nread_repository write_repository 如果还要通过 API 操作项目，可以额外勾选：\napi 然后确认当前 remote 是 HTTPS：\ncd D:\\workspace\\jihw\\qianduoduo\\frontend git remote -v 如果不是，改成：\ngit remote set-url origin https://gitlab.jihw.top/root/qianduoduo-frontend.git 第一次推送：\ngit push -u origin main 提示输入时：\nUsername: GitLab 用户名，例如 root Password: 粘贴刚生成的 Token，不是 GitLab 登录密码 Windows 上 Git for Windows 默认带 Git Credential Manager。第一次输入后，它会把凭据保存到 Windows 凭据管理器，后面再 git push 就不需要重复输入。\n可以确认 credential helper：\ngit config --global credential.helper 如果没有配置，可以启用：\ngit config --global credential.helper manager 如果输错过 Token，可以到这里删除旧凭据：\n控制面板 -\u003e 凭据管理器 -\u003e Windows 凭据 找到 git:https://gitlab.jihw.top 相关条目删除，然后重新 git push。\n方案二：SSH Key SSH key 是更传统的免密方式，前提是上面的 GitLab SSH 入口已经暴露给 Windows 本机访问。\n先生成 SSH key：\nssh-keygen -t ed25519 -C \"rx@gitlab.jihw.top\" 查看公钥：\ncat $env:USERPROFILE\\.ssh\\id_ed25519.pub 复制公钥，添加到 GitLab：\n头像 -\u003e Preferences -\u003e SSH Keys 当前已经把 GitLab Shell 暴露为：\nssh://git@gitlab.jihw.top:22 就可以把 remote 改成 SSH：\ngit remote set-url origin git@gitlab.jihw.top:root/qianduoduo-frontend.git 测试：\nssh -T git@gitlab.jihw.top git push 如果 SSH 端口不是 22，而是例如 2222，remote 要写成：\ngit remote set-url origin ssh://git@gitlab.jihw.top:2222/root/qianduoduo-frontend.git 如果没有额外暴露 GitLab Shell，直接配置 SSH key 仍然会连不上。这时先用 HTTPS + Token 更稳。\n创建 SonarQube 项目 登录 SonarQube：\nhttps://sonarqube.jihw.top 创建项目：\nProjects -\u003e Create Project -\u003e Manually 示例：\nProject display name: qianduoduo-frontend Project key: qianduoduo-frontend Main branch: main 创建完成后生成扫描 Token。注意不要去这个页面：\nAdministration -\u003e Configuration -\u003e Authentication -\u003e GitLab 也就是类似这个地址：\nhttps://sonarqube.jihw.top/admin/settings?category=authentication\u0026tab=gitlab 那个页面是配置“使用 GitLab 账号登录 SonarQube”的，不是生成 CI 扫描 Token 的地方。\n扫描 Token 通常在当前登录用户的账号设置里生成：\n右上角头像 -\u003e My Account -\u003e Security -\u003e Generate Tokens Token 类型可以选择：\nProject Analysis Token Token 只显示一次，生成后马上复制。 配置 GitLab 变量 进入 GitLab 项目：\nProject -\u003e Settings -\u003e CI/CD -\u003e Variables 添加：\nSONAR_HOST_URL = https://sonarqube.jihw.top SONAR_TOKEN = SonarQube 里生成的 Token 建议：\nSONAR_TOKEN 勾选 Masked SONAR_TOKEN 勾选 Protected 取决于分支策略 SONAR_HOST_URL 不需要 Masked 如果 Runner 在集群内，并且还没给 DNS 配 sonarqube.jihw.top，可以先用 Service 地址：\nSONAR_HOST_URL = http://sonarqube-sonarqube.sonarqube.svc.cluster.local:9000 添加 sonar-project.properties 在 D:\\workspace\\jihw\\qianduoduo\\frontend 下创建：\nsonar-project.properties 内容：\nsonar.projectKey=qianduoduo-frontend sonar.projectName=qianduoduo-frontend sonar.sourceEncoding=UTF-8 sonar.sources=src sonar.tests= sonar.exclusions=node_modules/**,dist/**,web_dist/**,coverage/**,.sonar/** sonar.javascript.lcov.reportPaths=coverage/lcov.info 当前项目还没有单元测试和覆盖率文件，所以 coverage/lcov.info 不存在也没关系。后面接入 Vitest 或其他测试框架后，再让 CI 生成覆盖率即可。\n如果暂时不想配置覆盖率，也可以先删掉这一行：\nsonar.javascript.lcov.reportPaths=coverage/lcov.info 添加 .gitlab-ci.yml 在项目根目录创建：\n.gitlab-ci.yml 基础版本：\nstages: - build - scan build: stage: build image: node:22-alpine cache: key: files: - package-lock.json paths: - .npm/ script: - npm ci --cache .npm --prefer-offline - npm run build - mkdir -p web_dist - cp -r ../web_dist/. web_dist/ artifacts: when: always expire_in: 1 week paths: - web_dist/ sonarqube: stage: scan image: name: sonarsource/sonar-scanner-cli:latest entrypoint: [\"\"] variables: SONAR_USER_HOME: \"${CI_PROJECT_DIR}/.sonar\" GIT_DEPTH: \"0\" cache: key: \"${CI_JOB_NAME}\" paths: - .sonar/cache script: - sonar-scanner -Dsonar.host.url=\"${SONAR_HOST_URL}\" -Dsonar.token=\"${SONAR_TOKEN}\" needs: - build allow_failure: false 这里分成两个 job：\nbuild 先确认 Vite 项目能构建 sonarqube 再上传代码质量扫描结果 vite.config.js 里现在写的是：\nbuild: { outDir: '../web_dist', emptyOutDir: true } 所以 CI 产物路径写成：\nscript: - npm run build - mkdir -p web_dist - cp -r ../web_dist/. web_dist/ artifacts: paths: - web_dist/ 如果以后把构建输出改回项目内的 dist，这里也要同步改成：\nartifacts: paths: - dist/ 推送并触发流水线 提交配置：\ncd D:\\workspace\\jihw\\qianduoduo\\frontend git add sonar-project.properties .gitlab-ci.yml .gitignore git commit -m \"ci: add sonarqube scan\" git push 在 GitLab 查看流水线：\nProject -\u003e Build -\u003e Pipelines 正常情况会看到：\nbuild passed sonarqube passed 然后回到 SonarQube 项目页面，就能看到：\nBugs Vulnerabilities Security Hotspots Code Smells Duplications Quality Gate 让质量门禁卡住流水线 如果希望 SonarQube Quality Gate 不通过时，GitLab pipeline 直接失败，可以加：\nsonarqube: variables: SONAR_USER_HOME: \"${CI_PROJECT_DIR}/.sonar\" GIT_DEPTH: \"0\" SONAR_SCANNER_OPTS: \"-Dsonar.qualitygate.wait=true\" 或者直接在命令里写：\nscript: - sonar-scanner -Dsonar.host.url=\"${SONAR_HOST_URL}\" -Dsonar.token=\"${SONAR_TOKEN}\" -Dsonar.qualitygate.wait=true 这样扫描完成后，scanner 会等待 SonarQube 计算质量门禁结果。\n如果 Quality Gate 是 Failed，GitLab job 也会失败。\n合并请求里的用法 对于日常开发，更推荐只在 merge request 和 main 分支扫描：\nsonarqube: stage: scan image: name: sonarsource/sonar-scanner-cli:latest entrypoint: [\"\"] variables: SONAR_USER_HOME: \"${CI_PROJECT_DIR}/.sonar\" GIT_DEPTH: \"0\" script: - sonar-scanner -Dsonar.host.url=\"${SONAR_HOST_URL}\" -Dsonar.token=\"${SONAR_TOKEN}\" -Dsonar.qualitygate.wait=true rules: - if: '$CI_PIPELINE_SOURCE == \"merge_request_event\"' - if: '$CI_COMMIT_BRANCH == \"main\"' 这样可以避免每个临时分支都频繁扫描。\n常见问题 流水线一直 running 或 pending 先看 Runner 是否在线：\nProject -\u003e Settings -\u003e CI/CD -\u003e Runners 如果 Runner 在线，但 job 仍然一直 pending，优先检查标签是否匹配。\n这次遇到的问题是：Runner 标签是 qianduoduo-frontend，并且 run_untagged=false；但是 pipeline 里的 job 没有写 tags，所以 GitLab 不会把 job 分配给这个 Runner。\n修复方式二选一：\n1. 在 Runner 设置里打开 Run untagged jobs 2. 或者在 .gitlab-ci.yml 的每个 job 里添加 tags: [qianduoduo-frontend] 如果 job 已经被 Runner 接走，但很快失败，继续看 Runner 日志：\nkubectl -n gitlab logs deploy/gitlab-runner --tail=200 如果看到下面的权限错误：\nsecrets is forbidden: User \"system:serviceaccount:gitlab:default\" cannot create resource \"secrets\" 说明 Kubernetes Runner 缺少 RBAC 权限。用 Helm 打开 RBAC：\nhelm upgrade gitlab-runner gitlab/gitlab-runner \\ -n gitlab \\ --reuse-values \\ --set rbac.create=true 等待 Runner 重启：\nkubectl -n gitlab rollout status deploy/gitlab-runner 然后回到 GitLab 页面重试失败的 job 或 pipeline。修复后，build 和 sonarqube job 应该都会被 Runner 接走并执行。\nRunner 无法连接 SonarQube 先在 Runner job 里临时加：\nscript: - curl -I \"${SONAR_HOST_URL}\" - sonar-scanner ... 如果域名不通，优先确认：\nsonarqube.jihw.top 是否解析到 192.168.3.230 Runner Pod 是否能访问 ingress-nginx 是否需要改用 Kubernetes Service 地址 集群内 Service 地址一般更稳：\nhttp://sonarqube-sonarqube.sonarqube.svc.cluster.local:9000 Token 错误 如果日志里有：\nNot authorized You're not authorized to analyze this project 检查：\nSONAR_TOKEN 是否复制完整 Token 是否属于有项目分析权限的用户 sonar.projectKey 是否和 SonarQube 项目一致 node_modules 被扫描 确认 sonar-project.properties 里排除了：\nsonar.exclusions=node_modules/**,dist/**,web_dist/**,coverage/**,.sonar/** 构建成功但扫描失败 分开看两个 job：\nbuild 失败 先修 npm ci / npm run build sonarqube 失败 再看 SonarQube 地址、Token、projectKey 不要把构建和扫描混在一个 job 里排查。分开以后，问题会清楚很多。\n最小检查清单 GitLab 项目已经有 frontend 代码 GitLab Runner 在线 Runner 标签和 .gitlab-ci.yml 的 tags 匹配，或 Runner 允许 untagged job Kubernetes Runner 已开启 RBAC SonarQube 项目 key 是 qianduoduo-frontend GitLab Variables 已配置 SONAR_HOST_URL 和 SONAR_TOKEN 项目根目录有 sonar-project.properties 项目根目录有 .gitlab-ci.yml 流水线 build 通过 流水线 sonarqube 通过 SonarQube 页面能看到扫描结果 到这里，GitLab 和 SonarQube 就串起来了。后面可以继续补单元测试和覆盖率，让质量门禁从“能扫”升级到“能拦住低质量代码”。\n","permalink":"/coding/gitlab-sonarqube-ci/","summary":"前面已经把 GitLab 和 SonarQube 都部署到了 Kubernetes 里：\nGitLab https://gitlab.jihw.top SonarQube https://sonarqube.jihw.top Ingress 192.168.3.230 这篇记录两者配合使用的流程：代码放在 GitLab，流水线由 GitLab Runner 执行，扫描结果上传到 SonarQube。\n本文用这个前端项目作为检测示例：\nD:\\workspace\\jihw\\qianduoduo\\frontend 它是一个 Vite + Vue 项目，关键文件是：\npackage.json package-lock.json vite.config.js src/ 当前 package.json 里已有：\n{ \"scripts\": { \"build\": \"vite build\", \"dev\": \"vite --host 0.0.0.0\" } } 所以 CI 里可以先执行 npm ci 和 npm run build，确认前端至少能正常构建，然后再执行 SonarQube 扫描。\n整体流程 1. GitLab 创建项目 2. 把 frontend 代码推到 GitLab 3. SonarQube 创建项目并生成 Token 4. GitLab 项目配置 CI/CD Variables 5. 提交 sonar-project.properties 6. 提交 .gitlab-ci.yml 7. GitLab Runner 执行流水线 8. SonarQube 查看质量报告 确认 GitLab Runner GitLab 本身只负责管理流水线，真正执行 job 的是 Runner。\n","title":"GitLab 接入 SonarQube 做前端代码质量检测"},{"content":"很多人离开学校很多年，身上仍然保留着一种“学生思维”。\n这里说的学生思维，不是指爱学习，也不是指保持好奇心。真正的问题在于：一个人习惯用学校里的规则理解社会，以为世界会像考试一样，有标准答案、有明确范围、有老师评分、有努力就一定能换来分数。\n但现实生活不是考卷。现实没有标准答案，也很少有人因为你“态度认真”就给你高分。别人更关心的是：你能不能解决问题，能不能创造价值，能不能对结果负责。\n什么是学生思维 学生思维的核心，是把自己放在一个被安排、被评价、被保护的位置上。\n它通常有几种表现：\n等别人给任务：不知道主动发现问题，只等领导、客户、家人把要求讲清楚。 追求标准答案：遇到复杂问题时，总想问“到底应该怎么做”，而不是自己判断、试错和承担后果。 过度在意评价：把别人的一句批评看得很重，却不太关心事情本身有没有推进。 用努力替代结果：觉得“我已经很认真了”，就应该被认可，但现实更看重交付。 害怕不确定性：只愿意做有教程、有模板、有明确回报的事，一旦没有参考答案就停住。 学生思维最隐蔽的地方在于，它看起来很勤奋、很听话、很谦虚，但底层其实是在逃避主动性。\n学生思维的危害 1. 容易陷入“准备好了再开始” 学生时代做题，通常要先学完知识点，再去考试。于是很多人进入社会后，也习惯先把资料看完、课程学完、工具配齐，然后才开始行动。\n结果是，真正该做的事一直被推迟。\n比如想做自媒体，先买课、整理选题、研究账号定位、看几十个爆款拆解，却迟迟不发第一篇内容。想做副业，先收藏一堆项目资料，反复比较哪个更稳，却从来没有真正去联系客户、发布商品或测试需求。\n现实里，很多能力不是学会了才开始，而是开始之后才学会。\n2. 会把“被认可”误认为“有价值” 学校里，老师表扬、考试高分、排名靠前，都会让人确认自己是优秀的。可进入社会后，评价体系变了。\n工作中，领导不一定因为你加班到很晚就认可你；客户也不会因为你很辛苦就付钱。别人愿意给你机会或报酬，往往是因为你解决了真实问题。\n如果一直用“我有没有被夸”来判断自己的价值，就很容易情绪化。别人一句否定，就觉得自己不行；别人暂时没反馈，就开始焦虑。\n真正成熟的判断应该是：我交付的东西有没有用？有没有让事情变好？有没有产生结果？\n3. 会让人逃避责任 学生做错题，最多扣分；现实做错选择，可能要承担时间、金钱、机会和关系上的成本。\n学生思维强的人，常常会下意识寻找一个“老师”来替自己做决定。比如选工作时问别人哪个行业一定有前途，做投资时问别人买什么一定赚钱，谈合作时希望对方把所有风险都保证清楚。\n可现实里，没有人能替你承担全部后果。成年人真正需要练的，不是找到一个永远正确的人，而是在信息不完整的情况下做判断，并且为结果负责。\n4. 会限制赚钱能力 赚钱不是考试，不是谁知识点背得多，谁就一定赚得多。\n很多人学了很多课程、看了很多商业文章，却迟迟赚不到钱，原因往往不是不聪明，而是还停留在学生式学习里：只输入，不输出；只研究，不成交；只追求理解，不面对市场反馈。\n市场不会给“学习态度”打分。市场只会问：你提供的东西，别人愿不愿意付钱？\n一个人如果总想等自己更专业、更有资格、更被认可之后再开始赚钱，就会错过很多低成本试错的机会。\n现实生活里的几个例子 例子一：职场新人只等安排 一个新人入职后，领导让他整理客户资料。他整理得很认真，但只完成了表格录入，没有发现客户信息缺失、重复、分类混乱这些问题。\n当领导问为什么没有顺手优化时，他说：“你没让我做这个。”\n这就是典型的学生思维：只完成题目要求，不关心真实目标。\n职场里更成熟的做法是，先完成基础任务，再主动补一句：“我发现这里有几个问题，是否需要我顺手整理一下？”主动性不一定要很夸张，关键是开始从“完成作业”转向“解决问题”。\n例子二：学了很多副业课程，却没有一个客户 有人想做副业，于是买了剪辑课、AI 课、运营课、商业课。每天都在学习，笔记写得很满，但半年过去，一单也没有成交。\n问题不在于学习没用，而是学习没有进入现实闭环。\n如果目标是赚钱，就不能只问“我学会了什么”，还要问：\n我能帮谁解决什么具体问题？ 我有没有把服务发出去？ 我有没有和潜在客户聊过？ 别人拒绝我的原因是什么？ 只有进入真实交易，反馈才会变得有效。\n例子三：总想等别人给确定答案 有些人在做选择时，会不断问朋友、同事、博主：“我到底要不要转行？”“这个城市适不适合我？”“这个项目能不能做？”\n问建议当然没问题，但如果问到最后只是想得到一个不会出错的答案，就会越问越焦虑。\n现实选择通常不是判断题，而是开放题。更实际的方式，是把大选择拆成小实验：先访谈几个行业内的人，先投十份简历，先接一个小单，先用一个月观察成本和收益。\n不要幻想一次想清楚人生。很多路是走出几步之后，才知道该不该继续。\n例子四：把批评当成否定自己 在学校里，老师批改作业时，红叉常常意味着“你错了”。进入社会后，批评更多时候只是信息反馈。\n比如你写了一份方案，领导说“不够具体”。学生思维会马上翻译成：“他觉得我能力不行。”成熟一点的理解是：“这份方案还不能支持决策，需要补充预算、时间表、风险和执行步骤。”\n把批评从“人格评价”还原成“问题信息”，人就会轻松很多，也会进步得更快。\n怎么跳出学生思维 跳出学生思维，不是变得油滑，也不是放弃学习，而是把学习从“求认可”变成“求结果”。\n可以从几件小事开始：\n凡事多问一句真实目标是什么，而不是只看表面任务。 少问有没有标准答案，多问有哪些可行方案。 把学习和输出绑定，学完立刻做一个小作品、小服务或小实验。 接受不确定性，用小成本试错替代长时间空想。 把评价当反馈，不把每一次否定都理解成对自己的审判。 学生思维不是一种罪，它只是过去环境留下的习惯。它曾经帮助我们适应学校，但未必适合社会。\n成年人真正要学的，是在没有标准答案的世界里，主动发现问题，做出选择，承担结果，然后继续修正。\n这才是从“等别人给分”，走向“自己创造价值”。\n","permalink":"/learning/student-thinking/","summary":"很多人离开学校很多年，身上仍然保留着一种“学生思维”。\n这里说的学生思维，不是指爱学习，也不是指保持好奇心。真正的问题在于：一个人习惯用学校里的规则理解社会，以为世界会像考试一样，有标准答案、有明确范围、有老师评分、有努力就一定能换来分数。\n但现实生活不是考卷。现实没有标准答案，也很少有人因为你“态度认真”就给你高分。别人更关心的是：你能不能解决问题，能不能创造价值，能不能对结果负责。\n什么是学生思维 学生思维的核心，是把自己放在一个被安排、被评价、被保护的位置上。\n它通常有几种表现：\n等别人给任务：不知道主动发现问题，只等领导、客户、家人把要求讲清楚。 追求标准答案：遇到复杂问题时，总想问“到底应该怎么做”，而不是自己判断、试错和承担后果。 过度在意评价：把别人的一句批评看得很重，却不太关心事情本身有没有推进。 用努力替代结果：觉得“我已经很认真了”，就应该被认可，但现实更看重交付。 害怕不确定性：只愿意做有教程、有模板、有明确回报的事，一旦没有参考答案就停住。 学生思维最隐蔽的地方在于，它看起来很勤奋、很听话、很谦虚，但底层其实是在逃避主动性。\n学生思维的危害 1. 容易陷入“准备好了再开始” 学生时代做题，通常要先学完知识点，再去考试。于是很多人进入社会后，也习惯先把资料看完、课程学完、工具配齐，然后才开始行动。\n结果是，真正该做的事一直被推迟。\n比如想做自媒体，先买课、整理选题、研究账号定位、看几十个爆款拆解，却迟迟不发第一篇内容。想做副业，先收藏一堆项目资料，反复比较哪个更稳，却从来没有真正去联系客户、发布商品或测试需求。\n现实里，很多能力不是学会了才开始，而是开始之后才学会。\n2. 会把“被认可”误认为“有价值” 学校里，老师表扬、考试高分、排名靠前，都会让人确认自己是优秀的。可进入社会后，评价体系变了。\n工作中，领导不一定因为你加班到很晚就认可你；客户也不会因为你很辛苦就付钱。别人愿意给你机会或报酬，往往是因为你解决了真实问题。\n如果一直用“我有没有被夸”来判断自己的价值，就很容易情绪化。别人一句否定，就觉得自己不行；别人暂时没反馈，就开始焦虑。\n真正成熟的判断应该是：我交付的东西有没有用？有没有让事情变好？有没有产生结果？\n3. 会让人逃避责任 学生做错题，最多扣分；现实做错选择，可能要承担时间、金钱、机会和关系上的成本。\n学生思维强的人，常常会下意识寻找一个“老师”来替自己做决定。比如选工作时问别人哪个行业一定有前途，做投资时问别人买什么一定赚钱，谈合作时希望对方把所有风险都保证清楚。\n可现实里，没有人能替你承担全部后果。成年人真正需要练的，不是找到一个永远正确的人，而是在信息不完整的情况下做判断，并且为结果负责。\n4. 会限制赚钱能力 赚钱不是考试，不是谁知识点背得多，谁就一定赚得多。\n很多人学了很多课程、看了很多商业文章，却迟迟赚不到钱，原因往往不是不聪明，而是还停留在学生式学习里：只输入，不输出；只研究，不成交；只追求理解，不面对市场反馈。\n市场不会给“学习态度”打分。市场只会问：你提供的东西，别人愿不愿意付钱？\n一个人如果总想等自己更专业、更有资格、更被认可之后再开始赚钱，就会错过很多低成本试错的机会。\n现实生活里的几个例子 例子一：职场新人只等安排 一个新人入职后，领导让他整理客户资料。他整理得很认真，但只完成了表格录入，没有发现客户信息缺失、重复、分类混乱这些问题。\n当领导问为什么没有顺手优化时，他说：“你没让我做这个。”\n这就是典型的学生思维：只完成题目要求，不关心真实目标。\n职场里更成熟的做法是，先完成基础任务，再主动补一句：“我发现这里有几个问题，是否需要我顺手整理一下？”主动性不一定要很夸张，关键是开始从“完成作业”转向“解决问题”。\n例子二：学了很多副业课程，却没有一个客户 有人想做副业，于是买了剪辑课、AI 课、运营课、商业课。每天都在学习，笔记写得很满，但半年过去，一单也没有成交。\n问题不在于学习没用，而是学习没有进入现实闭环。\n如果目标是赚钱，就不能只问“我学会了什么”，还要问：\n我能帮谁解决什么具体问题？ 我有没有把服务发出去？ 我有没有和潜在客户聊过？ 别人拒绝我的原因是什么？ 只有进入真实交易，反馈才会变得有效。\n例子三：总想等别人给确定答案 有些人在做选择时，会不断问朋友、同事、博主：“我到底要不要转行？”“这个城市适不适合我？”“这个项目能不能做？”\n问建议当然没问题，但如果问到最后只是想得到一个不会出错的答案，就会越问越焦虑。\n现实选择通常不是判断题，而是开放题。更实际的方式，是把大选择拆成小实验：先访谈几个行业内的人，先投十份简历，先接一个小单，先用一个月观察成本和收益。\n不要幻想一次想清楚人生。很多路是走出几步之后，才知道该不该继续。\n例子四：把批评当成否定自己 在学校里，老师批改作业时，红叉常常意味着“你错了”。进入社会后，批评更多时候只是信息反馈。\n比如你写了一份方案，领导说“不够具体”。学生思维会马上翻译成：“他觉得我能力不行。”成熟一点的理解是：“这份方案还不能支持决策，需要补充预算、时间表、风险和执行步骤。”\n把批评从“人格评价”还原成“问题信息”，人就会轻松很多，也会进步得更快。\n怎么跳出学生思维 跳出学生思维，不是变得油滑，也不是放弃学习，而是把学习从“求认可”变成“求结果”。\n可以从几件小事开始：\n凡事多问一句真实目标是什么，而不是只看表面任务。 少问有没有标准答案，多问有哪些可行方案。 把学习和输出绑定，学完立刻做一个小作品、小服务或小实验。 接受不确定性，用小成本试错替代长时间空想。 把评价当反馈，不把每一次否定都理解成对自己的审判。 学生思维不是一种罪，它只是过去环境留下的习惯。它曾经帮助我们适应学校，但未必适合社会。\n","title":"警惕学生思维"},{"content":"这篇记录把三台新的 VMware Ubuntu 虚拟机加入现有 Kubernetes 集群，作为普通 worker 节点使用。\n目标节点：\n192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 现有控制面：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip Kubernetes API 入口继续使用：\n192.168.3.217:8443 这里有一个关键前提：这三台是 worker，不是 control-plane，所以不要在它们上面配置 Keepalived、HAProxy、etcd，也不要执行带 --control-plane 的 join 命令。\n一、先切换 VMware 网卡为 VMXNET3 之前集群里出现过 VMware e1000 网卡在高流量下卡死的问题，所以新 worker 节点一开始就切到 vmxnet3。\n先关闭虚拟机，然后在 VMware 界面里把网卡类型改为 VMXNET3。如果界面里没有这个选项，也可以直接编辑虚拟机的 .vmx 文件。\n找到类似配置：\nethernet0.virtualDev = \"e1000\" 改成：\nethernet0.virtualDev = \"vmxnet3\" 保存后启动虚拟机。\n启动后检查网卡驱动：\nsudo apt update sudo apt install -y ethtool ip -br addr ethtool -i ens160 期望看到：\ndriver: vmxnet3 切换到 VMXNET3 后，Ubuntu 里的网卡名很可能从 ens33 变成 ens160。后面所有配置都按 ens160 写，如果实际网卡名不同，就替换成自己的。\n二、配置固定 IP 三台 worker 都建议使用固定 IP。下面以 192.168.3.218 为例，另外两台把 IP 和 hostname 改掉即可。\n先备份 netplan 配置：\nsudo cp /etc/netplan/*.yaml /tmp/ ls /etc/netplan/ 编辑 netplan：\nsudo vim /etc/netplan/00-installer-config.yaml 示例配置：\nnetwork: version: 2 ethernets: ens160: dhcp4: false addresses: - 192.168.3.218/24 routes: - to: default via: 192.168.3.1 nameservers: addresses: - 192.168.3.1 - 223.5.5.5 - 8.8.8.8 应用配置：\nsudo netplan try sudo netplan apply 检查网络：\nip addr show ens160 ip route ping -c 4 192.168.3.217 ping -c 4 www.baidu.com 三台节点分别使用：\nk8s-worker1 192.168.3.218/24 k8s-worker2 192.168.3.219/24 k8s-worker3 192.168.3.220/24 gateway 192.168.3.1 interface ens160 如果虚拟机是从旧模板克隆出来的，可以检查是否还有旧网卡名：\ngrep -R \"ens33\" /etc/netplan /etc/systemd /etc/NetworkManager 2\u003e/dev/null 如果搜到 ens33，改成当前实际网卡名，比如 ens160。\n三、设置 hostname 和 hosts 在 192.168.3.218 上执行：\nsudo hostnamectl set-hostname k8s-worker1 在 192.168.3.219 上执行：\nsudo hostnamectl set-hostname k8s-worker2 在 192.168.3.220 上执行：\nsudo hostnamectl set-hostname k8s-worker3 三台 worker 都写入相同的 /etc/hosts：\nsudo tee -a /etc/hosts \u003e/dev/null \u003c\u003c'EOF' # k8s hosts begin 192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip 192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 # k8s hosts end EOF 是的，三台 master 节点也建议补上 worker 的 hosts 映射。这样在 master 上执行排查命令时，可以直接使用 k8s-worker1、k8s-worker2、k8s-worker3 这些 hostname。\n在 k8s-master1、k8s-master2、k8s-master3 上分别执行：\nsudo tee -a /etc/hosts \u003e/dev/null \u003c\u003c'EOF' # k8s workers begin 192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 # k8s workers end EOF 验证：\nhostname ping -c 2 k8s-vip ping -c 2 k8s-worker1 ping -c 2 k8s-worker2 ping -c 2 k8s-worker3 四、关闭 swap 并配置内核参数 三台 worker 都执行。\n关闭 swap：\nsudo swapoff -a sudo cp /etc/fstab /etc/fstab.bak.$(date +%Y%m%d%H%M%S) sudo sed -ri '/[[:space:]]swap[[:space:]]/ s/^([^#])/#\\1/' /etc/fstab 加载 Kubernetes 需要的内核模块：\nsudo tee /etc/modules-load.d/k8s.conf \u003e/dev/null \u003c\u003c'EOF' overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter 配置 sysctl：\nsudo tee /etc/sysctl.d/k8s.conf \u003e/dev/null \u003c\u003c'EOF' net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system 检查：\nfree -h lsmod | egrep 'overlay|br_netfilter' sysctl net.ipv4.ip_forward 五、安装 containerd 三台 worker 都执行。\nsudo apt update sudo apt install -y containerd ca-certificates curl gpg apt-transport-https 生成默认配置，并启用 systemd cgroup：\nsudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml \u003e/dev/null sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml sudo systemctl restart containerd sudo systemctl enable containerd sudo systemctl status containerd --no-pager 确认：\nsystemctl is-active containerd sudo ctr version 六、安装 kubeadm、kubelet、kubectl 版本要和现有集群保持一致。先在任意 master 上查看：\nkubectl get nodes kubectl version 我这套文档里当前按 v1.36 写：\nK8S_VERSION=v1.36 三台 worker 都执行：\nsudo mkdir -p -m 755 /etc/apt/keyrings curl -fsSL \"https://pkgs.k8s.io/core:/stable:/${K8S_VERSION}/deb/Release.key\" \\ | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo \"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_VERSION}/deb/ /\" \\ | sudo tee /etc/apt/sources.list.d/kubernetes.list \u003e/dev/null sudo apt update sudo apt install -y kubelet kubeadm kubectl sudo apt-mark hold kubelet kubeadm kubectl sudo systemctl enable --now kubelet # 如果集群实际不是 `v1.36`，把 `K8S_VERSION` 改成当前集群对应小版本仓库，例如 `v1.35` sudo apt install -y --allow-change-held-packages kubelet kubeadm kubectl 如果集群实际不是 v1.36，把 K8S_VERSION 改成当前集群对应的小版本仓库，例如 v1.35。\n安装完成后，给 crictl 写入默认 runtime endpoint：\n# 安装工具 sudo apt install -y cri-tools sudo tee /etc/crictl.yaml \u003e/dev/null \u003c\u003c'EOF' runtime-endpoint: unix:///run/containerd/containerd.sock image-endpoint: unix:///run/containerd/containerd.sock timeout: 10 debug: false EOF 确认 containerd 可以被 CRI 工具访问：\nsudo crictl info | grep runtimeType 七、固定 kubelet 的 node-ip 如果机器只有一张网卡，这一步通常不是必须的。但为了避免 kubelet 选错 IP，建议显式指定。\n192.168.3.218 执行：\nNODE_IP=192.168.3.218 sudo mkdir -p /etc/systemd/system/kubelet.service.d sudo tee /etc/systemd/system/kubelet.service.d/20-node-ip.conf \u003e/dev/null \u003c\u003cEOF [Service] Environment=\"KUBELET_EXTRA_ARGS=--node-ip=${NODE_IP}\" EOF sudo systemctl daemon-reload sudo systemctl restart kubelet 192.168.3.219 和 192.168.3.220 分别把 NODE_IP 改成自己的 IP。\n八、生成 worker join 命令 在任意 master 上执行：\nkubeadm token create --print-join-command 输出类似：\nkubeadm join 192.168.3.217:8443 \\ --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 如果输出里不是 192.168.3.217:8443，先确认当前集群的 controlPlaneEndpoint：\nkubectl -n kube-system get configmap kubeadm-config -o yaml | grep controlPlaneEndpoint 普通 worker 的 join 命令不要带这些参数：\n--control-plane --certificate-key 建议补上 containerd 的 CRI socket：\nsudo kubeadm join 192.168.3.217:8443 \\ --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --cri-socket=unix:///run/containerd/containerd.sock 在 192.168.3.218、192.168.3.219、192.168.3.220 三台 worker 上分别执行这条命令。\n九、检查节点状态 回到任意 master，检查节点：\nkubectl get nodes -o wide 期望看到：\nk8s-master1 Ready control-plane ... 192.168.3.214 k8s-master2 Ready control-plane ... 192.168.3.215 k8s-master3 Ready control-plane ... 192.168.3.216 k8s-worker1 Ready \u003cnone\u003e ... 192.168.3.218 k8s-worker2 Ready \u003cnone\u003e ... 192.168.3.219 k8s-worker3 Ready \u003cnone\u003e ... 192.168.3.220 给 worker 补一个角色标签，方便显示：\nkubectl label node k8s-worker1 node-role.kubernetes.io/worker= kubectl label node k8s-worker2 node-role.kubernetes.io/worker= kubectl label node k8s-worker3 node-role.kubernetes.io/worker= 再看：\nkubectl get nodes -o wide 如果集群已经切到 Calico，可以检查 Calico 相关 Pod 是否正常：\nkubectl get pods -A -o wide | egrep 'calico|tigera|k8s-worker' 也可以直接跑一个测试 Pod：\nkubectl run test-nginx --image=nginx:alpine --restart=Never kubectl get pod test-nginx -o wide kubectl delete pod test-nginx 十、Longhorn 节点准备 如果 worker 后面要承载 Longhorn 卷，三台 worker 还要安装 Longhorn 常用依赖。\nsudo apt update sudo apt install -y open-iscsi nfs-common sudo systemctl enable --now iscsid 检查：\nsystemctl is-active iscsid 然后在 Longhorn UI 或命令行里确认新节点已经出现，并根据实际磁盘规划决定是否允许调度副本。\n十一、常见问题 1. 节点一直 NotReady 先看 kubelet：\nsudo systemctl status kubelet --no-pager sudo journalctl -u kubelet -n 100 --no-pager 再看 CNI：\nkubectl get pods -A -o wide | egrep 'calico|flannel|cni' 如果 CNI Pod 没有在新 worker 上起来，节点通常会保持 NotReady。\n2. kubeadm join 连接不上 API 在 worker 上确认能访问 VIP：\nping -c 4 192.168.3.217 nc -vz 192.168.3.217 8443 如果 8443 不通，回 master 检查 HAProxy 和 Keepalived：\nsystemctl is-active haproxy keepalived ip addr | grep 192.168.3.217 3. VMXNET3 后 IP 不见了 大概率是网卡名变化导致 netplan 还写着旧的 ens33。\nip -br link grep -R \"ens33\" /etc/netplan /etc/NetworkManager 2\u003e/dev/null 把配置里的旧网卡名改成当前实际网卡名，比如 ens160，再执行：\nsudo netplan apply 4. 节点曾经加入过其他集群 如果这台 worker 不是全新机器，先清理旧 kubeadm 状态：\nsudo kubeadm reset -f sudo rm -rf /etc/cni/net.d /var/lib/cni /var/lib/kubelet/pki sudo systemctl restart containerd kubelet 然后重新执行 join。\n最后检查清单 三台 worker 加入完成后，至少检查这些：\nkubectl get nodes -o wide kubectl get pods -A -o wide | grep k8s-worker kubectl top nodes VMXNET3 检查：\nfor iface in ens160; do ethtool -i \"$iface\" | grep driver done 预期结果：\n三台 worker 都是 Ready 三台 worker InternalIP 分别是 192.168.3.218、192.168.3.219、192.168.3.220 网卡驱动是 vmxnet3 Pod 能调度到新 worker kubectl top nodes 能看到新节点指标 到这里，三台新虚拟机就已经以普通 worker 身份加入集群。后续可以根据用途再给它们打标签，比如通用业务、GitLab、数据库、存储或监控节点。\n","permalink":"/coding/k8s-add-workers-vmxnet3/","summary":"这篇记录把三台新的 VMware Ubuntu 虚拟机加入现有 Kubernetes 集群，作为普通 worker 节点使用。\n目标节点：\n192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 现有控制面：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip Kubernetes API 入口继续使用：\n192.168.3.217:8443 这里有一个关键前提：这三台是 worker，不是 control-plane，所以不要在它们上面配置 Keepalived、HAProxy、etcd，也不要执行带 --control-plane 的 join 命令。\n一、先切换 VMware 网卡为 VMXNET3 之前集群里出现过 VMware e1000 网卡在高流量下卡死的问题，所以新 worker 节点一开始就切到 vmxnet3。\n先关闭虚拟机，然后在 VMware 界面里把网卡类型改为 VMXNET3。如果界面里没有这个选项，也可以直接编辑虚拟机的 .vmx 文件。\n找到类似配置：\nethernet0.virtualDev = \"e1000\" 改成：\nethernet0.virtualDev = \"vmxnet3\" 保存后启动虚拟机。\n启动后检查网卡驱动：\nsudo apt update sudo apt install -y ethtool ip -br addr ethtool -i ens160 期望看到：\n","title":"Kubernetes 添加 Worker 节点并切换 VMXNET3"},{"content":"前面已经部署了 SonarQube，用来做代码质量检测。SonarQube 单独使用也可以，但它和 GitLab 配合起来价值更大：\nGitLab 负责代码托管、Merge Request、CI/CD SonarQube 负责代码质量扫描、漏洞检查、质量门禁 GitLab CI 在提交或合并时触发 sonar-scanner 扫描结果回传 SonarQube，再决定是否允许继续发布 这篇文章记录在 Kubernetes 集群里部署 GitLab 的路线。当前集群已有：\nKubernetes 三主节点 Longhorn 存储 ingress-nginx MetalLB cert-manager CloudNativePG SonarQube 计划使用的访问域名：\nhttps://gitlab.jihw.top https://registry.jihw.top 其中：\ngitlab.jihw.top GitLab Web / Git over HTTP registry.jihw.top GitLab Container Registry GitLab 适合放在 K8s 里吗 可以放，但要先知道它比 SonarQube 重很多。\nGitLab 不只是一个 Web 服务，它包含：\nWebService / Rails Sidekiq 后台任务 Gitaly Git 仓库存储服务 Toolbox 备份和维护工具 Container Registry KAS / GitLab Agent Server PostgreSQL Redis / Valkey 对象存储 GitLab Runner 所以 GitLab 的 Kubernetes 部署更像一个平台，而不是一个普通 Deployment。\n如果只是个人或很小的团队，最省心的方式其实是单独一台虚拟机安装 GitLab Omnibus。\n如果已经有稳定的 Kubernetes、Ingress、证书、存储、备份体系，再用 Helm Chart 部署 GitLab 才比较合适。\n和 SonarQube 如何配合 GitLab 和 SonarQube 通常这样配合：\n1. 开发者 push 代码到 GitLab 2. GitLab CI 触发流水线 3. 流水线执行构建、测试、sonar-scanner 4. 扫描结果上传到 SonarQube 5. SonarQube 计算 Quality Gate 6. GitLab Merge Request 里展示检查结果 GitLab 侧需要：\n项目仓库 .gitlab-ci.yml CI/CD Variables Runner SonarQube 侧需要：\n项目 Project Token Quality Gate GitLab ALM Integration 先部署 GitLab，后面再把 SonarQube 接进去。\n部署方式选择 GitLab 官方提供 Helm Chart：\nhelm repo add gitlab https://charts.gitlab.io/ helm repo update helm search repo gitlab/gitlab 需要注意：GitLab Helm Chart 近几年变化比较大。GitLab 19.0 起，官方更推荐 Gateway API；同时 PostgreSQL、Redis、对象存储这类依赖不应该再依赖 chart 内置组件做长期运行。\n当前集群已经有 ingress-nginx，所以本文选择：\n关闭 GitLab chart 自带的 Ingress Controller 关闭 GitLab chart 内置 cert-manager 沿用现有 ingress-nginx 沿用现有 cert-manager 使用 Longhorn 做持久化存储 外部 PostgreSQL / Redis / 对象存储按长期部署规划 这比“一条 helm install 装全部”麻烦一些，但边界更清楚，也更容易备份和维护。\n资源建议 GitLab 很吃资源。学习环境建议至少：\nCPU: 4 core 起步 Memory: 8Gi 起步 Disk: 100Gi 起步 长期使用建议：\nCPU: 8 core+ Memory: 16Gi+ Disk: 按仓库、镜像、构建产物容量规划 如果节点资源不够，GitLab 会表现为：\nPod Pending Pod OOMKilled Web 页面很慢 Sidekiq 堆积 Git push / clone 超时 当前不考虑 GitLab 高可用，只准备放在一台专用 worker 上。推荐这台虚拟机配置：\n最低能跑：4 vCPU / 8GB RAM / 200GB SSD 推荐配置：8 vCPU / 16GB RAM / 500GB SSD 如果 Runner 也跑在这台机器上：8 vCPU+ / 24GB~32GB RAM / 500GB~1TB SSD 我更推荐直接给：\n8 vCPU 16GB RAM 500GB SSD 这样 GitLab 本体、Gitaly、Registry 和少量后台任务会舒服很多。GitLab Runner 后续最好单独规划，避免构建任务和 GitLab 本体抢 CPU、内存和磁盘 IO。\n单独 worker 节点方案 这次目标是不做 GitLab 高可用，只让 GitLab 跑在一台专用 worker 节点上。\n整体步骤：\n1. 准备一台新虚拟机 2. 安装 containerd、kubeadm、kubelet 3. 加入当前 Kubernetes 集群 4. 给节点打 label 5. GitLab values 里用 nodeSelector 固定调度 6. 使用 longhorn-retain 保存数据 准备虚拟机 建议系统：\nUbuntu Server 22.04 LTS 或 24.04 LTS 建议磁盘：\n/var/lib/containerd 镜像和容器数据 /var/lib/kubelet Pod 挂载目录 /var/lib/longhorn Longhorn 磁盘目录 如果只有一块 500GB 系统盘，也可以先全部放在同一块盘上。后续如果仓库和镜像增长很快，再给 Longhorn 单独加数据盘。\n基础设置：\nswapoff -a sed -i '/ swap / s/^/#/' /etc/fstab cat \u003c\u003c'EOF' \u003e/etc/modules-load.d/k8s.conf overlay br_netfilter EOF modprobe overlay modprobe br_netfilter cat \u003c\u003c'EOF' \u003e/etc/sysctl.d/99-kubernetes-cri.conf net.bridge.bridge-nf-call-iptables=1 net.ipv4.ip_forward=1 net.bridge.bridge-nf-call-ip6tables=1 EOF sysctl --system 安装 containerd、kubeadm、kubelet 的步骤要和现有集群版本保持一致。可以先在当前集群查看版本：\nkubectl get nodes kubectl version 加入 Kubernetes 集群 在任意控制平面节点上生成 join 命令：\nkubeadm token create --print-join-command 它会输出类似：\nkubeadm join 192.168.3.217:6443 \\ --token \u003ctoken\u003e \\ --discovery-token-ca-cert-hash sha256:\u003chash\u003e 在新 worker 节点上执行这条 join 命令。\n回到控制平面确认：\nkubectl get nodes -o wide 这里实际加入的 GitLab worker 是 192.168.3.218，Kubernetes 节点名是：\nk8s-worker1 给节点打 label 回到 master 节点，或者任意一台已经配置好 kubeconfig 的机器，给这台 worker 打专用标签：\nkubectl label node k8s-worker1 workload=gitlab --overwrite 确认：\nkubectl get nodes --show-labels | grep gitlab 后面 GitLab values 里会使用：\nglobal: nodeSelector: workload: gitlab 这样 GitLab chart 里的主要组件都会被调度到这个节点。\n暂时不要加 taint 如果想让这台节点只跑 GitLab，理论上可以加 taint：\nkubectl taint node k8s-worker1 dedicated=gitlab:NoSchedule 但第一次部署不建议马上加。原因是 GitLab chart 组件很多，外部依赖也多，如果 toleration 没有覆盖到所有 Pod，容易出现部分组件一直 Pending。\n更稳的做法：\n1. 先只加 label 2. 用 global.nodeSelector 固定 GitLab 到这台节点 3. 部署成功并观察稳定 4. 确认所有 GitLab 组件 tolerations 配置后，再考虑 taint 当前目标只是让 GitLab 跑在单独 worker 上，nodeSelector 已经够用。\n创建命名空间 kubectl create namespace gitlab 准备 StorageClass GitLab 里最重要的数据是 Git 仓库，也就是 Gitaly 的数据卷。它不应该使用 Delete 回收策略。\n前面部署 SonarQube 时已经创建过：\nlonghorn-retain 如果还没有，可以创建：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-retain provisioner: driver.longhorn.io allowVolumeExpansion: true reclaimPolicy: Retain volumeBindingMode: Immediate parameters: numberOfReplicas: \"3\" staleReplicaTimeout: \"30\" fsType: ext4 EOF 确认：\nkubectl get storageclass 外部依赖规划 GitLab 长期运行建议把这些依赖拆出来：\nPostgreSQL GitLab 元数据、用户、权限、CI 状态 Redis/Valkey 缓存、队列、会话 对象存储 artifacts、LFS、uploads、packages、registry、backup 本文先把部署路线写清楚。实际生产时建议：\nPostgreSQL 使用 CloudNativePG Redis/Valkey 使用独立 Helm Chart 或专门的 Redis/Valkey 集群 对象存储 使用 MinIO、Garage、Ceph RGW 或云厂商 S3 不要让 GitLab、业务系统、SonarQube 共用同一个 database 和 user。最低要求是独立 database、独立 user；更好的方式是独立 PostgreSQL Cluster。\nPostgreSQL 准备思路 如果用 CloudNativePG，可以单独给 GitLab 创建 PostgreSQL：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: gitlab-postgresql namespace: gitlab spec: instances: 1 storage: size: 50Gi storageClass: longhorn-retain bootstrap: initdb: database: gitlabhq_production owner: gitlab EOF 等待 Ready：\nkubectl -n gitlab get cluster kubectl -n gitlab get pods -l cnpg.io/cluster=gitlab-postgresql -o wide kubectl -n gitlab get secret | grep gitlab-postgresql GitLab 连接地址类似：\ngitlab-postgresql-rw.gitlab.svc.cluster.local:5432 注意：GitLab 新版本对数据库拆分、CI database、连接池等有更多配置项。正式部署前要按当前 chart 版本的官方文档确认 PostgreSQL 配置。\nRedis / Valkey 准备思路 Redis 或 Valkey 用来支撑缓存、队列和会话。\n可以单独部署一个 Redis：\nhelm repo add bitnami https://charts.bitnami.com/bitnami helm repo update helm upgrade --install gitlab-redis bitnami/redis \\ -n gitlab \\ --set architecture=standalone \\ --set auth.enabled=true \\ --set auth.password='change-this-redis-password' \\ --set master.persistence.enabled=true \\ --set master.persistence.storageClass=longhorn-retain \\ --set master.persistence.size=10Gi 长期环境不要把密码直接写在命令里，应该使用 Secret 或 values 文件管理。\nRedis 地址类似：\ngitlab-redis-master.gitlab.svc.cluster.local:6379 对象存储准备思路 GitLab 有很多文件类数据：\nCI artifacts LFS 对象 用户上传文件 Packages Container Registry Terraform state 备份文件 这些更适合放对象存储，而不是全部压在 Pod 本地卷里。\n可以使用：\nMinIO Garage Ceph RGW 阿里云 OSS / AWS S3 / 其他 S3 兼容存储 对象存储至少要规划这些 bucket：\ngitlab-artifacts gitlab-lfs gitlab-uploads gitlab-packages gitlab-registry gitlab-backups gitlab-tmp GitLab Chart 的对象存储配置比较长，建议单独维护 gitlab-object-storage.yaml，不要把 access key 写进博客或 Git 仓库。\n准备 GitLab root 密码 先创建初始 root 密码 Secret：\nkubectl -n gitlab create secret generic gitlab-root-password \\ --from-literal=password='change-this-root-password' 后续登录：\nusername: root password: 上面 Secret 里的 password 准备 values.yaml 创建 gitlab-values.yaml。\n下面是这次实际安装使用的基础骨架。当前复用 new-api 命名空间里已有的 PostgreSQL 和 Redis，所以 GitLab 只单独创建自己的数据库、数据库密码 Secret、root 密码 Secret。\n因为当前还没有独立对象存储，先临时启用 chart 内置 MinIO，并使用 Longhorn 持久化。Registry 暂时关闭，等后面对象存储规划好后再打开。\nglobal: edition: ce nodeSelector: workload: gitlab hosts: domain: jihw.top https: true gitlab: name: gitlab.jihw.top registry: name: registry.jihw.top ingress: enabled: true class: nginx configureCertmanager: false annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod nginx.ingress.kubernetes.io/proxy-body-size: \"0\" tls: enabled: true initialRootPassword: secret: gitlab-root-password key: password psql: host: newapi-postgres-rw.new-api.svc.cluster.local port: 5432 database: gitlabhq_production username: gitlab password: secret: gitlab-postgresql key: password redis: host: redis-master-proxy.new-api.svc.cluster.local port: 6379 auth: enabled: true secret: gitlab-redis key: redis-password minio: enabled: true kas: enabled: false upgradeCheck: enabled: false gatewayApi: enabled: false nginx-ingress: enabled: false installCertmanager: false certmanager: install: false prometheus: install: false gitlab-runner: install: false postgresql: install: false redis: install: false minio: persistence: storageClass: longhorn-retain size: 20Gi registry: enabled: false gitlab: kas: enabled: false minReplicas: 1 maxReplicas: 1 gitlab-shell: minReplicas: 1 maxReplicas: 1 gitlab-pages: enabled: false webservice: minReplicas: 1 maxReplicas: 1 workerTimeout: 120 resources: requests: cpu: 300m memory: 1Gi sidekiq: minReplicas: 1 maxReplicas: 1 resources: requests: cpu: 200m memory: 768Mi gitaly: persistence: storageClass: longhorn-retain size: 20Gi resources: requests: cpu: 200m memory: 512Mi toolbox: replicas: 1 几个重点：\nglobal.edition=ce 使用 Community Edition global.nodeSelector 固定 GitLab 组件到专用 worker 节点 gatewayApi.enabled=false 沿用现有 ingress-nginx nginx-ingress.enabled=false 不安装 chart 自带 Ingress Controller installCertmanager=false 不安装 chart 自带 cert-manager postgresql.install=false 使用 new-api 命名空间已有的 PostgreSQL redis.install=false 使用 new-api 命名空间已有的 Redis global.minio.enabled=true 临时启用 chart 内置 MinIO 作为对象存储 registry.enabled=false 暂时不启用 Container Registry global.kas.enabled=false 暂时不启用 KAS，降低单 worker 资源压力 gitaly.persistence 使用 longhorn-retain，保护 Git 仓库数据 cert-manager.io/cluster-issuer 要写成当前集群真实存在的 ClusterIssuer：\nkubectl get clusterissuer 当前集群可用的是：\nletsencrypt-alidns-prod letsencrypt-alidns-staging 安装 GitLab helm upgrade --install gitlab gitlab/gitlab \\ -n gitlab \\ -f gitlab-values.yaml GitLab 组件很多，第一次启动会比较久：\nkubectl -n gitlab get pods -w 确认 GitLab Pod 是否都调度到了专用 worker：\nkubectl -n gitlab get pods -o wide 正常情况下，GitLab 相关 Pod 的 NODE 应该都是：\nk8s-worker1 如果有 Pod 没有调度到这个节点，检查：\nkubectl get node k8s-worker1 --show-labels kubectl -n gitlab describe pod \u003cpod-name\u003e 重点看是否有：\nnode(s) didn't match Pod's node affinity/selector Insufficient cpu Insufficient memory pod has unbound immediate PersistentVolumeClaims 查看 Ingress：\nkubectl -n gitlab get ingress kubectl -n gitlab get certificate 查看 PVC：\nkubectl -n gitlab get pvc kubectl get pv | grep gitlab 配置 DNS Ingress/MetalLB 当前入口地址是：\n192.168.3.230 DNS 需要解析：\ngitlab.jihw.top -\u003e 192.168.3.230 registry.jihw.top -\u003e 192.168.3.230 测试：\ncurl -I https://gitlab.jihw.top curl -I https://registry.jihw.top/v2/ Container Registry 未登录时返回 401 Unauthorized 是正常的，说明入口已经通了。\n首次登录 打开：\nhttps://gitlab.jihw.top 账号：\nusername: root password: gitlab-root-password Secret 中的 password 查看密码：\nkubectl -n gitlab get secret gitlab-root-password \\ -o jsonpath='{.data.password}' | base64 -d 首次登录后建议马上：\n1. 修改 root 密码 2. 创建自己的管理员账号 3. 关闭公开注册 4. 配置 SMTP 邮件 5. 配置备份策略 关闭公开注册：\nAdmin Area -\u003e Settings -\u003e General -\u003e Sign-up restrictions 安装 GitLab Runner GitLab 本身负责代码托管和 CI 管理，真正跑流水线的是 Runner。\n可以单独部署 Runner：\nhelm repo add gitlab https://charts.gitlab.io/ helm repo update helm upgrade --install gitlab-runner gitlab/gitlab-runner \\ -n gitlab \\ --set gitlabUrl=https://gitlab.jihw.top \\ --set runnerToken='你的 Runner Token' Runner Token 在 GitLab 页面获取：\nAdmin Area -\u003e CI/CD -\u003e Runners 或者项目级：\nProject -\u003e Settings -\u003e CI/CD -\u003e Runners 长期使用建议给 Runner 单独写 gitlab-runner-values.yaml，限制并发、资源和可用命名空间。\n接入 SonarQube GitLab 部署好后，可以在项目里加 .gitlab-ci.yml。\n示例：\nstages: - test sonarqube: stage: test image: name: sonarsource/sonar-scanner-cli:latest entrypoint: [\"\"] variables: SONAR_USER_HOME: \"${CI_PROJECT_DIR}/.sonar\" GIT_DEPTH: \"0\" cache: key: \"${CI_JOB_NAME}\" paths: - .sonar/cache script: - sonar-scanner -Dsonar.projectKey=\"${CI_PROJECT_PATH_SLUG}\" -Dsonar.projectName=\"${CI_PROJECT_PATH}\" -Dsonar.sources=. -Dsonar.host.url=\"${SONAR_HOST_URL}\" -Dsonar.token=\"${SONAR_TOKEN}\" 在 GitLab 项目里配置 CI/CD Variables：\nSONAR_HOST_URL=https://sonarqube.jihw.top SONAR_TOKEN=SonarQube 项目 Token 如果 SonarQube 和 GitLab 都在同一个内网 Kubernetes 集群里，也可以用内网地址：\nSONAR_HOST_URL=http://sonarqube-sonarqube.sonarqube.svc.cluster.local:9000 但对人类查看和回调集成来说，建议仍然配置 SonarQube 的公网或内网 HTTPS 域名。\n常用排查 Pod 没启动 kubectl -n gitlab get pods -o wide kubectl -n gitlab describe pod \u003cpod-name\u003e kubectl -n gitlab logs \u003cpod-name\u003e 重点看：\n资源不足 PVC 未 Bound 镜像拉取失败 PostgreSQL 连接失败 Redis 连接失败 对象存储配置错误 证书未签发 如果使用专用 worker，还要检查 Pod 是否被 nodeSelector 卡住：\nkubectl get node k8s-worker1 --show-labels kubectl -n gitlab describe pod \u003cpod-name\u003e | grep -A5 -i events 如果看到：\nnode(s) didn't match Pod's node affinity/selector 说明节点标签和 values 里的 global.nodeSelector 对不上。\nIngress 不通 kubectl -n gitlab get ingress kubectl -n gitlab describe ingress kubectl -n gitlab get certificate kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide curl -kI https://gitlab.jihw.top 如果证书不签发，先确认 ClusterIssuer：\nkubectl get clusterissuer Git push 慢或失败 检查：\nkubectl -n gitlab get pods | grep gitaly kubectl -n gitlab get pvc | grep gitaly kubectl -n gitlab logs \u003cgitaly-pod\u003e Gitaly 是 Git 仓库的核心组件。它的 PVC 不要随便删。\nPVC 回收策略 GitLab 的关键数据建议使用 Retain：\nkubectl -n gitlab get pvc kubectl get pv | grep gitlab kubectl get pv \u003cpv-name\u003e -o jsonpath='{.spec.persistentVolumeReclaimPolicy}' 如果发现重要 PV 是 Delete：\nkubectl patch pv \u003cpv-name\u003e \\ -p '{\"spec\":{\"persistentVolumeReclaimPolicy\":\"Retain\"}}' 备份建议 GitLab 备份比普通应用复杂，要覆盖：\nPostgreSQL 数据库 Gitaly Git 仓库数据 对象存储 bucket Secrets Helm values 建议：\n1. CloudNativePG 定期备份 PostgreSQL 2. Longhorn Snapshot / Backup 保护 Gitaly PVC 3. 对象存储开启版本控制或单独备份 4. 保存 gitlab-values.yaml 5. 保存关键 Secret 6. 升级 GitLab 前先备份 这次虽然不考虑 GitLab 高可用，只部署在一台 worker 上，但备份仍然必须做。单 worker 只是不做应用层高可用，不代表可以接受数据丢失。\n如果这台 worker 故障：\nGitLab 服务会不可用 Longhorn 如果有健康副本，数据还有机会在其他节点恢复 如果没有备份，误删、数据库损坏、对象存储丢失仍然很难救 不要把 Retain 当成备份。Retain 只能降低误删 PVC 时的数据清理风险，不能替代数据库备份和对象存储备份。\n升级建议 GitLab 升级要比 SonarQube 更谨慎。\n建议：\n1. 阅读当前版本到目标版本的升级说明 2. 先备份数据库、Gitaly、对象存储 3. 先在测试环境验证 chart values 4. 小版本逐步升级，不要跨太多版本 5. 升级后检查 migrations、Sidekiq、Gitaly、Registry 查看当前 Helm release：\nhelm -n gitlab list helm -n gitlab get values gitlab 升级：\nhelm repo update helm -n gitlab upgrade gitlab gitlab/gitlab \\ -f gitlab-values.yaml 参考 GitLab Helm Chart 文档：https://docs.gitlab.com/charts/ GitLab Helm Chart 安装文档：https://docs.gitlab.com/charts/installation/ GitLab 外部 Ingress 文档：https://docs.gitlab.com/charts/advanced/external-ingress/ GitLab 外部 PostgreSQL 文档：https://docs.gitlab.com/charts/advanced/external-db/ GitLab 外部 Redis 文档：https://docs.gitlab.com/charts/advanced/external-redis/ GitLab 外部对象存储文档：https://docs.gitlab.com/charts/advanced/external-object-storage/ GitLab Runner Helm Chart 文档：https://docs.gitlab.com/runner/install/kubernetes/ Kubernetes Ingress 文档：https://kubernetes.io/docs/concepts/services-networking/ingress/ ","permalink":"/coding/k8s-gitlab/","summary":"前面已经部署了 SonarQube，用来做代码质量检测。SonarQube 单独使用也可以，但它和 GitLab 配合起来价值更大：\nGitLab 负责代码托管、Merge Request、CI/CD SonarQube 负责代码质量扫描、漏洞检查、质量门禁 GitLab CI 在提交或合并时触发 sonar-scanner 扫描结果回传 SonarQube，再决定是否允许继续发布 这篇文章记录在 Kubernetes 集群里部署 GitLab 的路线。当前集群已有：\nKubernetes 三主节点 Longhorn 存储 ingress-nginx MetalLB cert-manager CloudNativePG SonarQube 计划使用的访问域名：\nhttps://gitlab.jihw.top https://registry.jihw.top 其中：\ngitlab.jihw.top GitLab Web / Git over HTTP registry.jihw.top GitLab Container Registry GitLab 适合放在 K8s 里吗 可以放，但要先知道它比 SonarQube 重很多。\nGitLab 不只是一个 Web 服务，它包含：\nWebService / Rails Sidekiq 后台任务 Gitaly Git 仓库存储服务 Toolbox 备份和维护工具 Container Registry KAS / GitLab Agent Server PostgreSQL Redis / Valkey 对象存储 GitLab Runner 所以 GitLab 的 Kubernetes 部署更像一个平台，而不是一个普通 Deployment。\n","title":"Kubernetes 部署 GitLab 代码托管平台"},{"content":"SonarQube 是一个代码质量检测平台，可以用来做静态代码扫描、漏洞检查、重复代码检测、测试覆盖率展示和质量门禁。\n日常开发里，它最常见的用法是：\n开发提交代码 CI 执行 sonar-scanner 或 Maven/Gradle 扫描 扫描结果上传到 SonarQube SonarQube 根据规则和质量门禁判断是否通过 这篇文章记录在 Kubernetes 集群里部署 SonarQube Community Build。当前集群已经有：\nKubernetes 三主节点 Longhorn 存储 ingress-nginx MetalLB cert-manager CloudNativePG 所以本文采用的部署方式是：\nSonarQube Community Build 官方 Helm Chart 外部 PostgreSQL Longhorn 持久化 ingress-nginx HTTPS 访问 访问域名示例：\nhttps://sonarqube.jihw.top SonarQube 能做什么 SonarQube 的核心能力包括：\nBugs 可能导致运行错误的问题 Vulnerabilities 安全漏洞 Security Hotspots 需要人工确认的安全风险点 Code Smells 可维护性问题 Duplications 重复代码 Coverage 单元测试覆盖率 Quality Gate 质量门禁 它适合接入 CI/CD，而不是只当成一个偶尔打开的网站。\n比较推荐的流程是：\n1. 在 SonarQube 创建项目 2. 为项目生成 Token 3. 在 CI 里执行代码扫描 4. 让质量门禁决定是否允许合并或发布 部署前注意事项 SonarQube 不是一个很轻量的组件。它包含 Web、Compute Engine、搜索引擎等内部模块，还依赖 PostgreSQL。\n至少准备：\nCPU: 2 core 起步 Memory: 4Gi 起步 Disk: 20Gi 起步 DB: PostgreSQL 如果只是学习和小团队使用，可以先按本文资源配置跑起来。正式团队使用时，建议单独评估 CPU、内存、数据库备份和磁盘容量。\n还有一个重要前置条件：SonarQube 的搜索组件需要节点内核参数支持。\n在每个可能运行 SonarQube Pod 的节点上设置：\ncat \u003c\u003c'EOF' \u003e/etc/sysctl.d/99-sonarqube.conf vm.max_map_count=524288 fs.file-max=131072 EOF sysctl --system 确认：\nsysctl vm.max_map_count sysctl fs.file-max 如果这些参数不满足，SonarQube Pod 可能会启动失败或不断重启。\n创建命名空间 kubectl create namespace sonarqube 准备 Retain 类型 StorageClass SonarQube 自身和 PostgreSQL 都是有状态组件，不建议使用 Delete 回收策略。\n如果 PVC 被误删，Delete 策略会连底层卷一起删除；Retain 策略会保留 PV 和底层数据，给人工恢复留下机会。\n当前集群使用 Longhorn，可以创建一个专门给重要数据使用的 StorageClass：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-retain provisioner: driver.longhorn.io allowVolumeExpansion: true reclaimPolicy: Retain volumeBindingMode: Immediate parameters: numberOfReplicas: \"3\" staleReplicaTimeout: \"30\" fsType: ext4 EOF 查看：\nkubectl get storageclass 如果只是测试环境，也可以继续使用默认的 longhorn。但数据库这类数据卷更推荐 Retain。\n部署 PostgreSQL SonarQube 正式使用时应该使用外部 PostgreSQL，不建议依赖临时内置数据库。\n当前集群已经使用 CloudNativePG，所以这里用 CloudNativePG 创建一个 PostgreSQL：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: sonarqube-postgresql namespace: sonarqube spec: instances: 1 storage: size: 10Gi storageClass: longhorn-retain bootstrap: initdb: database: sonarqube owner: sonar EOF 等待 PostgreSQL Ready：\nkubectl -n sonarqube get cluster kubectl -n sonarqube get pods -l cnpg.io/cluster=sonarqube-postgresql -o wide CloudNativePG 会自动创建应用账号 Secret，通常名字是：\nsonarqube-postgresql-app 查看：\nkubectl -n sonarqube get secret | grep sonarqube-postgresql 确认数据库连接信息：\nkubectl -n sonarqube get secret sonarqube-postgresql-app \\ -o jsonpath='{.data.jdbc-uri}' | base64 -d SonarQube 连接 PostgreSQL 的地址是：\njdbc:postgresql://sonarqube-postgresql-rw.sonarqube.svc.cluster.local:5432/sonarqube 准备 Helm 仓库 添加 SonarSource 官方 Helm 仓库：\nhelm repo add sonarqube https://SonarSource.github.io/helm-chart-sonarqube helm repo update 查看 chart：\nhelm search repo sonarqube 准备 values.yaml 创建 sonarqube-values.yaml：\ncommunity: enabled: true monitoringPasscode: \"change-this-passcode\" initSysctl: enabled: false service: type: ClusterIP persistence: enabled: true storageClass: longhorn-retain size: 20Gi jdbcOverwrite: enabled: true jdbcUrl: \"jdbc:postgresql://sonarqube-postgresql-rw.sonarqube.svc.cluster.local:5432/sonarqube\" jdbcUsername: \"sonar\" jdbcSecretName: \"sonarqube-postgresql-app\" jdbcSecretPasswordKey: \"password\" resources: requests: cpu: 500m memory: 2Gi limits: cpu: \"2\" memory: 4Gi plugins: install: - \"https://github.com/xuhuisheng/sonar-l10n-zh/releases/download/sonar-l10n-zh-plugin-26.5/sonar-l10n-zh-plugin-26.5.jar\" # 可选：把 SonarQube 固定到一个稳定节点，减少 RWO 卷跨节点挂载。 # nodeSelector: # kubernetes.io/hostname: k8s-master2 ingress: enabled: true ingressClassName: nginx annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod nginx.ingress.kubernetes.io/proxy-body-size: \"100m\" hosts: - name: sonarqube.jihw.top path: / pathType: Prefix tls: - secretName: sonarqube-tls hosts: - sonarqube.jihw.top 几个重点：\ncommunity.enabled=true 表示启用免费版 Community Build initSysctl.enabled=false 表示前面已经在节点上手动配置 sysctl jdbcOverwrite 指向外部 PostgreSQL persistence 使用 Longhorn 持久化 SonarQube 数据 plugins.install 安装中文语言包 ingressClassName=nginx 对应当前 ingress-nginx cert-manager.io/cluster-issuer 让 cert-manager 自动签发证书 cert-manager.io/cluster-issuer 要写成当前集群真实存在的 ClusterIssuer。可以先查看：\nkubectl get clusterissuer 当前集群里可用的是：\nletsencrypt-alidns-prod letsencrypt-alidns-staging monitoringPasscode 不要照抄示例值，实际部署时换成随机字符串。\n可以生成一个：\nopenssl rand -hex 24 安装 SonarQube 执行 Helm 安装：\nhelm upgrade --install sonarqube sonarqube/sonarqube \\ -n sonarqube \\ -f sonarqube-values.yaml 查看资源：\nkubectl -n sonarqube get pods -o wide kubectl -n sonarqube get pvc kubectl -n sonarqube get svc kubectl -n sonarqube get ingress 等待 SonarQube 就绪：\nkubectl -n sonarqube rollout status statefulset/sonarqube-sonarqube 如果 StatefulSet 名字不同，先查看：\nkubectl -n sonarqube get statefulset 安装中文语言包 SonarQube 默认界面是英文。要变成中文，需要安装中文语言包插件 sonar-l10n-zh。\n当前部署的 SonarQube 镜像是：\nsonarqube:26.5.0.122743-community 所以中文包使用 26.5 版本：\nsonar-l10n-zh-plugin-26.5.jar 如果一开始没有在 sonarqube-values.yaml 里写 plugins.install，也可以后面用 Helm 追加：\nhelm -n sonarqube upgrade sonarqube sonarqube/sonarqube \\ --reuse-values \\ --set 'plugins.install[0]=https://github.com/xuhuisheng/sonar-l10n-zh/releases/download/sonar-l10n-zh-plugin-26.5/sonar-l10n-zh-plugin-26.5.jar' 等待 Pod 重启并就绪：\nkubectl -n sonarqube get pods -w kubectl -n sonarqube logs sonarqube-sonarqube-0 --tail=100 日志里看到下面这类信息，说明 SonarQube 已经启动完成：\nSonarQube is operational 然后刷新页面：\nhttps://sonarqube.jihw.top 如果还是英文，可以退出重新登录，或者清理浏览器缓存后再试。\n注意：插件版本要和 SonarQube 版本匹配。版本不匹配时，轻则页面语言不生效，重则 SonarQube 启动失败。升级 SonarQube 前，要同步检查中文插件是否有对应版本。\n配置域名 Ingress/MetalLB 当前入口地址是：\n192.168.3.230 确保 DNS 解析：\nsonarqube.jihw.top -\u003e 192.168.3.230 如果只是内网测试，也可以先在本机 hosts 里加：\n192.168.3.230 sonarqube.jihw.top 访问：\nhttps://sonarqube.jihw.top 首次登录 默认账号通常是：\nusername: admin password: admin 首次登录后会要求修改密码。\n建议修改后马上做三件事：\n1. 创建一个普通管理员账号 2. 生成项目扫描 Token 3. 配置质量门禁 创建项目 进入 SonarQube 后：\n1. Projects 2. Create Project 3. 选择 Manually 4. 输入 Project key 和 Display name 5. 生成 Token 6. 按页面提示选择扫描方式 项目 key 建议用稳定名称：\nnew-api jhonlife backend-service 不要用会频繁变化的分支名或临时目录名。\n扫描代码 使用 sonar-scanner 在代码目录下创建 sonar-project.properties：\nsonar.projectKey=jhonlife sonar.projectName=jhonlife sonar.sources=. sonar.host.url=https://sonarqube.jihw.top 执行扫描：\nsonar-scanner \\ -Dsonar.token=你的项目Token 如果本机没有安装 sonar-scanner，可以用容器跑：\ndocker run --rm \\ -e SONAR_HOST_URL=\"https://sonarqube.jihw.top\" \\ -e SONAR_TOKEN=\"你的项目Token\" \\ -v \"$PWD:/usr/src\" \\ sonarsource/sonar-scanner-cli Maven 项目 Maven 项目可以直接执行：\nmvn clean verify sonar:sonar \\ -Dsonar.host.url=https://sonarqube.jihw.top \\ -Dsonar.token=你的项目Token GitHub Actions 示例 name: SonarQube on: push: branches: - main pull_request: jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarqube.jihw.top 在 GitHub 仓库里配置 Secret：\nSONAR_TOKEN=项目 Token 如果 SonarQube 只在内网访问，GitHub Actions 无法直接连到它。内网环境可以改用自建 Runner，或者在内网 CI 里执行扫描。\n常用排查 Pod 一直启动失败 查看日志和事件：\nkubectl -n sonarqube get pods kubectl -n sonarqube describe pod \u003csonarqube-pod\u003e kubectl -n sonarqube logs \u003csonarqube-pod\u003e 重点检查：\nvm.max_map_count 是否满足要求 内存是否不足 PostgreSQL 是否 Ready JDBC 地址、用户名、密码是否正确 PVC 是否 Bound 数据库连不上 检查 PostgreSQL：\nkubectl -n sonarqube get cluster kubectl -n sonarqube get svc | grep postgresql kubectl -n sonarqube get secret sonarqube-postgresql-app 检查 SonarQube values 里的 JDBC：\njdbcUrl jdbcUsername jdbcSecretName jdbcSecretPasswordKey Ingress 访问失败 检查：\nkubectl -n sonarqube get ingress kubectl -n sonarqube describe ingress sonarqube-sonarqube kubectl -n sonarqube get certificate kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 如果证书还没签发完成，可以先用：\ncurl -kI https://sonarqube.jihw.top 这次实际部署时一开始把 ClusterIssuer 写成了不存在的 letsencrypt-prod，证书事件里出现：\nReferenced \"ClusterIssuer\" not found: clusterissuer.cert-manager.io \"letsencrypt-prod\" not found 修正 Ingress 注解：\nkubectl -n sonarqube annotate ingress sonarqube-sonarqube \\ cert-manager.io/cluster-issuer=letsencrypt-alidns-prod \\ --overwrite 为了避免下次 helm upgrade 又改回错误值，还要同步 Helm release：\nhelm -n sonarqube upgrade sonarqube sonarqube/sonarqube \\ --reuse-values \\ --set-string 'ingress.annotations.cert-manager\\.io/cluster-issuer=letsencrypt-alidns-prod' 如果之前已经生成了失败的 CertificateRequest，可以删除 Certificate 让 cert-manager 重新从 Ingress 创建：\nkubectl -n sonarqube delete certificate sonarqube-tls --ignore-not-found kubectl -n sonarqube delete certificaterequest,order,challenge --all --ignore-not-found 重新确认：\nkubectl -n sonarqube get certificate,certificaterequest,order,challenge curl -I https://sonarqube.jihw.top 最终证书状态应该是：\ncertificate.cert-manager.io/sonarqube-tls True sonarqube-tls letsencrypt-alidns-prod Longhorn Multi-Attach 这次部署过程中还遇到过一个 Longhorn 卷挂载事件：\nMulti-Attach error for volume \"pvc-122749df-7e75-49e6-a3e2-3e05cded31d4\" Volume is already exclusively attached to one node and can't be attached to another 这个卷对应的是：\nPVC: sonarqube/sonarqube-sonarqube PV: pvc-122749df-7e75-49e6-a3e2-3e05cded31d4 原因是 SonarQube 使用的是 ReadWriteOnce 卷。RWO 卷同一时间只能被一个节点以读写方式挂载。\n实际事件链路是：\n1. sonarqube-sonarqube-0 第一次被调度到 k8s-master1 2. Longhorn 卷挂载到了 k8s-master1 3. 后面 Pod 因配置调整被重建 4. 新 Pod 被调度到 k8s-master2 5. 旧节点上的卷还没完全 detach 6. attachdetach-controller 报 Multi-Attach 7. 等 Longhorn detach 完成后，卷成功 attach 到 k8s-master2 这类短暂 Multi-Attach 在有状态应用重建、节点切换、Helm upgrade 后比较常见。只要旧 Pod 已经退出，Longhorn 能正常 detach，通常等几十秒到几分钟会自动恢复。\n这里不是 Kubernetes 不想“先分离旧卷再挂新卷”，而是它不能在不确认旧 Pod 已经完全退出、文件系统已经 unmount、Longhorn engine 已经安全关闭的情况下强行 detach。对 SonarQube、PostgreSQL 这类有状态应用来说，强行 detach 有数据损坏风险。\n每次修改 Helm values 后都容易看到这个报错，是因为：\n1. Helm upgrade 触发 StatefulSet 更新 2. 旧 Pod 退出需要时间 3. Longhorn detach 需要时间 4. 新 Pod 可能被调度到另一个节点 5. 新节点 attach 卷时，旧节点还没完全释放 6. Kubernetes 为保护 RWO 卷，先报 Multi-Attach 只要后面出现：\nSuccessfulAttachVolume 并且 Pod 最终变成 1/1 Running，就说明已经自动恢复。\n排查命令：\nkubectl -n sonarqube describe pod sonarqube-sonarqube-0 kubectl -n sonarqube get events --sort-by=.lastTimestamp kubectl -n sonarqube get pvc -o wide kubectl get pv pvc-122749df-7e75-49e6-a3e2-3e05cded31d4 -o wide kubectl -n longhorn-system get volumes.longhorn.io pvc-122749df-7e75-49e6-a3e2-3e05cded31d4 -o wide 这次恢复后的状态是：\npod/sonarqube-sonarqube-0 1/1 Running k8s-master2 pvc/sonarqube-sonarqube Bound RWO longhorn-retain longhorn volume attached healthy k8s-master2 处理方式按风险从低到高：\n1. 先等 1 到 3 分钟，观察是否自动 SuccessfulAttachVolume 2. 确认旧 Pod 是否已经 Terminating 完成 3. 如果旧 Pod 卡住，可以删除旧 Pod，让 StatefulSet 重新创建 4. 确认 Longhorn 里卷是否仍挂在旧节点 5. 只有确认旧节点没有业务进程使用该卷时，才考虑在 Longhorn UI 里手动 detach 常用恢复命令：\nkubectl -n sonarqube get pod -o wide kubectl -n sonarqube delete pod sonarqube-sonarqube-0 kubectl -n sonarqube describe pod sonarqube-sonarqube-0 不要为了处理 Multi-Attach 直接删除 PVC。PVC 是数据入口，删除 PVC 可能导致 PV 释放，甚至触发底层数据清理。\n如果想减少 SonarQube 在节点间来回漂移，可以给 SonarQube 设置 nodeSelector 或 affinity，让它固定调度到某个稳定节点。但这会降低节点故障时自动迁移的灵活性。\n比如当前 SonarQube 最后稳定运行在 k8s-master2，可以在 sonarqube-values.yaml 里加：\nnodeSelector: kubernetes.io/hostname: k8s-master2 然后升级：\nhelm -n sonarqube upgrade sonarqube sonarqube/sonarqube \\ -f sonarqube-values.yaml 如果是安装插件、升级版本、改动较大的配置，可以走更稳的停机升级流程：\nkubectl -n sonarqube scale statefulset sonarqube-sonarqube --replicas=0 kubectl -n sonarqube wait --for=delete pod/sonarqube-sonarqube-0 --timeout=180s kubectl -n longhorn-system get volumes.longhorn.io pvc-122749df-7e75-49e6-a3e2-3e05cded31d4 -o wide helm -n sonarqube upgrade sonarqube sonarqube/sonarqube \\ -f sonarqube-values.yaml kubectl -n sonarqube get pods -w 总结一下：\n短暂 Multi-Attach 后自动恢复：可以忽略 每次都想减少报错：给 SonarQube 加 nodeSelector 固定节点 大版本升级或插件升级：先 scale 到 0，等卷 detach，再 helm upgrade 不要用 RWX 绕过这个问题，SonarQube 单实例 RWO 更合适 不要删除 PVC PVC 不要随便删 查看 PVC 和 PV：\nkubectl -n sonarqube get pvc kubectl get pv | grep sonarqube 确认回收策略：\nkubectl get pv \u003cpv-name\u003e -o jsonpath='{.spec.persistentVolumeReclaimPolicy}' 数据库和 SonarQube 数据卷建议是：\nRetain 如果发现重要 PV 是 Delete，可以改成 Retain：\nkubectl patch pv \u003cpv-name\u003e \\ -p '{\"spec\":{\"persistentVolumeReclaimPolicy\":\"Retain\"}}' 备份建议 SonarQube 需要备份两类数据：\nPostgreSQL 数据库 SonarQube 持久化目录 其中最重要的是 PostgreSQL。建议：\n1. 用 CloudNativePG 做数据库备份 2. 用 Longhorn Snapshot / Backup 保护卷 3. 升级 SonarQube 前先备份数据库和 PVC 4. 不要把 Retain 当成备份，它只能降低误删风险 卸载 如果只是卸载 SonarQube 应用：\nhelm -n sonarqube uninstall sonarqube 确认 PVC 是否保留：\nkubectl -n sonarqube get pvc kubectl get pv | grep sonarqube 如果使用的是 Retain，删除 PVC 后底层 PV 仍会保留，需要人工确认后再清理。\n不要在没有备份的情况下直接删除数据库 PVC。\n参考 SonarQube Community Build Kubernetes 部署文档：https://docs.sonarsource.com/sonarqube-community-build/server-installation/on-kubernetes-or-openshift/ SonarQube Helm Chart：https://artifacthub.io/packages/helm/sonarqube/sonarqube SonarQube Helm Chart GitHub：https://github.com/SonarSource/helm-chart-sonarqube SonarQube 中文语言包：https://github.com/xuhuisheng/sonar-l10n-zh SonarQube 中文语言包插件页：https://www.sonarplugins.com/l10nzh Kubernetes Ingress 文档：https://kubernetes.io/docs/concepts/services-networking/ingress/ Kubernetes PV 文档：https://kubernetes.io/docs/concepts/storage/persistent-volumes/ ","permalink":"/coding/k8s-sonarqube/","summary":"SonarQube 是一个代码质量检测平台，可以用来做静态代码扫描、漏洞检查、重复代码检测、测试覆盖率展示和质量门禁。\n日常开发里，它最常见的用法是：\n开发提交代码 CI 执行 sonar-scanner 或 Maven/Gradle 扫描 扫描结果上传到 SonarQube SonarQube 根据规则和质量门禁判断是否通过 这篇文章记录在 Kubernetes 集群里部署 SonarQube Community Build。当前集群已经有：\nKubernetes 三主节点 Longhorn 存储 ingress-nginx MetalLB cert-manager CloudNativePG 所以本文采用的部署方式是：\nSonarQube Community Build 官方 Helm Chart 外部 PostgreSQL Longhorn 持久化 ingress-nginx HTTPS 访问 访问域名示例：\nhttps://sonarqube.jihw.top SonarQube 能做什么 SonarQube 的核心能力包括：\nBugs 可能导致运行错误的问题 Vulnerabilities 安全漏洞 Security Hotspots 需要人工确认的安全风险点 Code Smells 可维护性问题 Duplications 重复代码 Coverage 单元测试覆盖率 Quality Gate 质量门禁 它适合接入 CI/CD，而不是只当成一个偶尔打开的网站。\n比较推荐的流程是：\n1. 在 SonarQube 创建项目 2. 为项目生成 Token 3. 在 CI 里执行代码扫描 4. 让质量门禁决定是否允许合并或发布 部署前注意事项 SonarQube 不是一个很轻量的组件。它包含 Web、Compute Engine、搜索引擎等内部模块，还依赖 PostgreSQL。\n","title":"Kubernetes 部署 SonarQube 代码检测平台"},{"content":"上一篇已经把 Headlamp 部署到了 Kubernetes 集群里，这一篇专门记录日常怎么用它。\nHeadlamp 可以理解成一个 Kubernetes Web 控制台：它本身不改变 Kubernetes 的工作方式，只是把 kubectl get、kubectl describe、kubectl logs、编辑 YAML、查看事件这些操作做成了网页界面。\n这篇文章重点解决一个最常见的问题：怎么在 Headlamp 里给应用扩缩容。\n先说结论：\n不能直接“扩容某一个 Pod”。 应该扩容 Deployment、StatefulSet 或 ReplicaSet 这类工作负载的 replicas。 Pod 是这些控制器根据 replicas 自动创建出来的结果。 比如 new-api 如果是一个 Deployment，那么扩容时应该把 Deployment/new-api 的 spec.replicas 从 1 改成 2 或 3，Kubernetes 会自动创建新的 Pod。\n登录 Headlamp 打开 Headlamp 地址：\nhttps://headlamp.jihw.top 如果使用 Token 登录，可以在服务器上生成临时 Token：\nkubectl -n headlamp create token headlamp-admin --duration=24h 如果之前已经创建了长期 Token Secret，也可以直接取长期 Token：\nkubectl -n headlamp get secret headlamp-admin-token \\ -o jsonpath='{.data.token}' | base64 -d 把输出的 Token 粘贴到 Headlamp 登录页即可。\n如果登录后很多资源看不到，通常是 RBAC 权限不够。可以在服务器上检查当前 ServiceAccount 是否有查看和修改工作负载的权限：\nkubectl auth can-i get deployments -A \\ --as=system:serviceaccount:headlamp:headlamp-admin kubectl auth can-i patch deployments -A \\ --as=system:serviceaccount:headlamp:headlamp-admin 如果要让这个账号具备完整集群管理权限，可以绑定 cluster-admin：\nkubectl create clusterrolebinding headlamp-admin-cluster-admin \\ --serviceaccount=headlamp:headlamp-admin \\ --clusterrole=cluster-admin \\ --dry-run=client -o yaml | kubectl apply -f - 注意：cluster-admin 权限非常大，只适合可信内网或个人实验环境。对外暴露 Headlamp 时，应该给不同用户配置更小的 RBAC 权限。\n认识几个常用页面 登录后，日常最常用的是这几类资源：\nNodes 查看节点状态、CPU、内存、Pod 分布 Namespaces 切换命名空间 Workloads 查看 Deployment、StatefulSet、DaemonSet、Job、Pod Services 查看 Service 暴露方式和 Endpoints Ingresses 查看域名入口 ConfigMaps 查看配置 Secrets 查看密钥资源 Events 查看资源事件 排查应用时，建议先切到对应命名空间。\n比如排查 new-api：\nnamespace: new-api workload: Deployment/new-api pod: new-api-xxxxx service: new-api ingress: new-api 或对应业务入口 在 Headlamp 里可以按下面顺序看：\n1. 看 Deployment 是否 Available 2. 看 Pod 是否 Running / Ready 3. 看 Pod Events 是否有 FailedMount、ImagePullBackOff、Readiness probe failed 4. 看 Pod Logs 是否有应用启动错误 5. 看 Service 是否有 Endpoints 6. 看 Ingress 是否指向正确 Service 这套顺序和命令行排查是一致的，只是换成了图形界面。\n新建资源 Headlamp 里新建 Kubernetes 资源，本质上就是向 Kubernetes API 提交 YAML。\n不同版本的 Headlamp 按钮位置可能略有差异，常见入口是：\n1. 进入对应资源页面，比如 Namespaces、Deployments、Services 2. 点击 Create、+、Apply YAML 或右上角 Actions 菜单 3. 粘贴或编辑 YAML 4. 点击 Apply / Save / Create 5. 回到资源列表确认状态 如果页面里没有看到单独的创建按钮，就找全局的 Create resource、Apply YAML 或 YAML 编辑入口。Headlamp 最稳定的创建方式就是提交 YAML。\n创建前先确认当前登录账号有权限。比如当前使用的是 headlamp-admin：\nkubectl auth can-i create namespaces \\ --as=system:serviceaccount:headlamp:headlamp-admin kubectl auth can-i create deployments -n new-api \\ --as=system:serviceaccount:headlamp:headlamp-admin kubectl auth can-i create services -n new-api \\ --as=system:serviceaccount:headlamp:headlamp-admin kubectl auth can-i create ingresses -n new-api \\ --as=system:serviceaccount:headlamp:headlamp-admin 如果输出是 no，Headlamp 页面里就算有按钮，也会创建失败。\n新建 Namespace Namespace 是集群级资源，不属于任何命名空间。\n在 Headlamp 里进入 Namespaces，点击创建入口，提交：\napiVersion: v1 kind: Namespace metadata: name: demo 创建后，左侧或顶部命名空间选择器里应该能看到 demo。\n命令行确认：\nkubectl get namespace demo 新建 ConfigMap ConfigMap 用来保存普通配置，不适合放密码。\n进入 ConfigMaps，选择命名空间 demo，提交：\napiVersion: v1 kind: ConfigMap metadata: name: nginx-demo-config namespace: demo data: APP_ENV: \"dev\" APP_NAME: \"nginx-demo\" 命令行确认：\nkubectl -n demo get configmap nginx-demo-config 新建 Secret Secret 用来保存密码、Token、连接串等敏感配置。\n手写 YAML 时建议用 stringData，这样不用自己先 base64：\napiVersion: v1 kind: Secret metadata: name: nginx-demo-secret namespace: demo type: Opaque stringData: USERNAME: \"admin\" PASSWORD: \"change-me\" 保存后 Kubernetes 会自动把 stringData 转成 data。\n命令行确认：\nkubectl -n demo get secret nginx-demo-secret 注意：不要把真实生产密码直接写进博客或公开 Git 仓库。长期配置建议配合 External Secrets、Sealed Secrets、SOPS 之类的方案。\n新建 Pod Pod 可以直接创建，但日常不推荐这么做。直接创建的 Pod 坏了不会自动重建，也不方便扩缩容。\n如果只是测试，可以在 Pods 页面提交：\napiVersion: v1 kind: Pod metadata: name: nginx-demo-pod namespace: demo labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.27 ports: - containerPort: 80 命令行确认：\nkubectl -n demo get pod nginx-demo-pod -o wide 正式应用建议创建 Deployment，而不是直接创建 Pod。\n新建 Deployment Deployment 是最常用的无状态应用资源。它可以滚动更新、自动重建 Pod，也可以修改 replicas 做扩缩容。\n进入 Workloads -\u003e Deployments，选择命名空间 demo，提交：\napiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo namespace: demo spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.27 ports: - containerPort: 80 envFrom: - configMapRef: name: nginx-demo-config - secretRef: name: nginx-demo-secret 这里最关键的是 selector.matchLabels 和 template.metadata.labels 要一致：\nselector.matchLabels.app = nginx-demo template.metadata.labels.app = nginx-demo 否则 Deployment 管不住自己创建的 Pod。\n命令行确认：\nkubectl -n demo get deployment nginx-demo kubectl -n demo get pods -l app=nginx-demo -o wide 新建 Service Service 用来给 Pod 提供稳定访问入口。\n进入 Services，选择命名空间 demo，提交：\napiVersion: v1 kind: Service metadata: name: nginx-demo namespace: demo spec: type: ClusterIP selector: app: nginx-demo ports: - name: http port: 80 targetPort: 80 这里最关键的是 Service 的 selector 要能匹配 Pod 的 label：\nService selector: app=nginx-demo Pod label: app=nginx-demo 如果 Service 没有 Endpoints，通常就是 selector 没匹配到 Pod，或者 Pod 还没 Ready。\n命令行确认：\nkubectl -n demo get service nginx-demo kubectl -n demo get endpoints nginx-demo 新建 Ingress 和 IngressRule 在 Kubernetes 里，IngressRule 不是一个单独创建的资源。它是 Ingress 资源里的 spec.rules 配置。\n也就是说，在 Headlamp 里要新建的是 Ingress，规则写在 YAML 里面。\n进入 Ingresses，选择命名空间 demo，提交：\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-demo namespace: demo annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: ingressClassName: nginx tls: - hosts: - demo.jihw.top secretName: nginx-demo-tls rules: - host: demo.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: nginx-demo port: number: 80 这段里真正的 IngressRule 是：\nrules: - host: demo.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: nginx-demo port: number: 80 几个重点：\ningressClassName 要和集群里的 IngressClass 对上，当前常见是 nginx host 要解析到 Ingress/MetalLB 地址 backend.service.name 要指向同命名空间里的 Service backend.service.port.number 要对应 Service 暴露的 port tls.secretName 是证书 Secret，cert-manager 可以自动创建 当前集群里 Ingress/MetalLB 地址是：\n192.168.3.230 所以如果要让 demo.jihw.top 能访问，需要 DNS 或本地 hosts 指向这个地址。\n命令行确认：\nkubectl -n demo get ingress nginx-demo kubectl -n demo describe ingress nginx-demo kubectl -n demo get certificate curl -kI https://demo.jihw.top 一次性创建一组资源 Headlamp 的 YAML 创建入口通常可以提交多段 YAML，用 --- 分隔。\n顺序建议是：\n1. Namespace 2. ConfigMap / Secret 3. Deployment 4. Service 5. Ingress 示例：\napiVersion: v1 kind: Namespace metadata: name: demo --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo namespace: demo spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.27 ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-demo namespace: demo spec: selector: app: nginx-demo ports: - name: http port: 80 targetPort: 80 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-demo namespace: demo spec: ingressClassName: nginx rules: - host: demo.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: nginx-demo port: number: 80 创建完成后，回到 Headlamp 里按这个顺序确认：\nNamespace 是否存在 Deployment 是否 Available Pod 是否 Running / Ready Service 是否有 Endpoints Ingress 是否有 Address 域名是否能访问 查看 Pod 状态 进入 Workloads 里的 Pods 页面，选择对应命名空间。\n重点看这几列：\nStatus Pod 当前阶段，比如 Running、Pending、CrashLoopBackOff Ready 容器是否通过 readinessProbe Restarts 容器重启次数 Age Pod 创建时间 Node Pod 被调度到哪个节点 如果 Pod 不是 Ready，点进 Pod 详情页。\n详情页里重点看：\nContainers 每个容器的状态 Logs 容器日志 Events 调度、拉镜像、挂载卷、健康检查事件 YAML 当前资源完整配置 常见问题可以这样判断：\nPending 多半是调度、资源、PVC 或网络问题 ImagePullBackOff 镜像地址、镜像密钥或仓库访问问题 CrashLoopBackOff 应用进程启动后退出 Running 但不 Ready readinessProbe 失败，或者应用依赖没连上 FailedMount PVC、Longhorn、Secret、ConfigMap 挂载异常 常用插件 Headlamp 的一个特点是支持插件。插件不是 Kubernetes 必需组件，它们主要是给 Headlamp 增加更细的资源页面、图表或操作入口。\n不要一口气把所有插件都装上。比较好的方式是：集群里已经安装了什么组件，就给 Headlamp 补对应插件。\n结合当前集群，比较值得关注的是这些：\nprometheus 在工作负载详情页显示 Prometheus 指标图表 cert-manager 查看和管理 Certificate、Issuer、ClusterIssuer 等证书资源 MetalLB 查看和管理 MetalLB 地址池、L2Advertisement 等资源 opencost 查看工作负载成本，适合后续做资源成本分析 plugin-catalog 插件目录，方便在 Headlamp Desktop 里一键安装插件 app-catalog Helm 应用目录，主要适合 Headlamp Desktop 使用 flux 如果后续使用 Flux GitOps，可以在 Headlamp 里查看 Flux 状态 keda 如果后续使用 KEDA 自动伸缩，可以查看 ScaledObject 等资源 Gatekeeper 如果后续使用 OPA Gatekeeper，可以查看策略和违规项 Trivy 如果安装了 Trivy Operator，可以查看镜像漏洞和合规扫描结果 对现在这套集群来说，优先级可以这样排：\n1. prometheus 2. cert-manager 3. MetalLB 4. opencost 5. flux / keda / Gatekeeper / Trivy 按需再装 prometheus 插件最有用，因为集群里已经有 kube-prometheus-stack。装好后，在 Deployment、Pod 等详情页里可以直接看到资源指标，排查扩容是否有效会更直观。\ncert-manager 插件也很适合当前环境，因为 Headlamp、Grafana、new-api 这些入口都依赖证书。通过插件可以更方便地看证书是否 Ready、是否快过期、Issuer 是否正常。\nMetalLB 插件适合裸金属或家庭内网集群。当前 Ingress/MetalLB 地址是：\n192.168.3.230 如果以后遇到 LoadBalancer IP 分配异常，MetalLB 插件会比只看 YAML 更直观。\n需要注意：插件一般只是 UI 扩展，不会替你安装 Prometheus、cert-manager、MetalLB、KEDA 这些后端组件。比如 prometheus 插件要显示图表，集群里本身就要有 Prometheus；cert-manager 插件要有内容，集群里必须已经安装 cert-manager CRD。\n插件安装思路 Headlamp 插件的安装方式和运行模式有关。\n如果使用的是 Headlamp Desktop，通常可以通过 plugin-catalog 插件在界面里安装插件：\n1. 打开 Headlamp Desktop 2. 进入 Plugins 或 Plugin Catalog 3. 搜索 prometheus、cert-manager 等插件 4. 安装后重启或刷新 Headlamp 如果 Headlamp 是部署在集群里的 Web 版本，插件安装通常要通过 Headlamp 的部署配置完成。也就是说，不是在 Kubernetes 资源页面里随便点一下就一定能装好，而是要调整 Headlamp Helm values、容器镜像或插件挂载目录。\n当前集群的 Headlamp 是 Helm 部署的：\nhelm upgrade --install headlamp headlamp/headlamp \\ -n headlamp 所以后续如果要在集群内 Headlamp 固定启用插件，建议单独维护一个 values.yaml，把插件配置也纳入 Helm 管理。这样 Headlamp 重装或升级时，插件不会丢。\n安装插件前先确认 Headlamp 版本：\nkubectl -n headlamp get deployment headlamp -o jsonpath='{.spec.template.spec.containers[0].image}' 再看官方插件仓库或 Artifact Hub 里对应插件的安装说明。插件和 Headlamp 版本不匹配时，最常见的问题是页面加载失败、菜单不出现、或者浏览器控制台报前端错误。\n插件和扩缩容的关系 扩缩容本身不依赖插件。只要 Headlamp 登录账号有权限修改 Deployment 或 StatefulSet，就可以改 spec.replicas。\n插件的作用是让扩缩容之后的观察更舒服：\nprometheus 看 CPU、内存、请求量变化 opencost 看扩容后成本变化 keda 看自动伸缩对象和触发器状态 cert-manager 看扩容涉及的新入口证书是否正常 MetalLB 看 LoadBalancer 地址是否正常 所以第一次学习 Headlamp 扩缩容时，不需要先纠结插件。先会改 Deployment 的 replicas，再用 prometheus 这类插件补观察能力。\nHeadlamp 里如何扩缩容 扩缩容要进入工作负载页面，而不是 Pod 页面。\n以 new-api 为例：\n1. 左侧进入 Workloads 2. 打开 Deployments 3. 命名空间选择 new-api 4. 点进 Deployment/new-api 5. 找到页面里的 Scale、Replicas、Actions 或右上角菜单 6. 修改 replicas 数量 7. 保存 不同 Headlamp 版本的按钮名称可能略有差异。有的版本会直接提供 Scale 操作，有的版本会把操作放在右上角的菜单里。\n如果页面里没有明显的扩缩容按钮，可以用编辑 YAML 的方式：\n1. 进入 Deployment/new-api 详情页 2. 打开 YAML 或 Edit 3. 找到 spec.replicas 4. 把值改成需要的副本数 5. 保存或 Apply 例如把 new-api 扩成 3 个副本：\napiVersion: apps/v1 kind: Deployment metadata: name: new-api namespace: new-api spec: replicas: 3 保存后回到 Deployment 详情页，观察：\nDesired 期望副本数 Current 当前副本数 Ready 已 Ready 副本数 Available 可用副本数 也可以在服务器上用命令确认：\nkubectl -n new-api get deployment new-api kubectl -n new-api get pods -o wide 如果缩容，比如从 3 缩到 1，也是修改同一个 spec.replicas：\nspec: replicas: 1 哪些资源可以扩缩容 常见资源里，扩缩容的逻辑如下：\nDeployment 可以扩缩容，最常见 StatefulSet 可以扩缩容，但要注意有状态应用的数据和启动顺序 ReplicaSet 可以扩缩容，但通常应该改它上层的 Deployment DaemonSet 不按 replicas 扩缩容，每个匹配节点运行一个 Pod Job 不适合用 replicas 扩缩容 CronJob 按计划创建 Job，不是常规副本扩缩容 Pod 不能直接扩缩容 所以在 Headlamp 里看到 Pod 页面时，不要在 Pod 上找扩容按钮。应该先确认这个 Pod 属于哪个控制器。\n可以在 Pod 详情页里看 Owner References，常见链路是：\nPod -\u003e ReplicaSet -\u003e Deployment 这时真正要修改的是最上层的 Deployment。\n有 HPA 时要小心 如果应用配置了 HPA，手动修改 replicas 可能很快又被 HPA 改回去。\n检查是否有 HPA：\nkubectl -n new-api get hpa 如果有 HPA，扩缩容应该优先调整 HPA 的范围：\nspec: minReplicas: 2 maxReplicas: 5 否则会出现这种情况：\n你在 Headlamp 里把 Deployment 改成 3 个副本 过一会儿 HPA 根据指标又把它调回 1 个或调到其他数量 扩容后怎么确认成功 扩容不等于立刻可用。保存 replicas 后，要继续确认新的 Pod 是否 Ready。\n在 Headlamp 里看：\nDeployment Available 是否为 True Pods 是否都 Running / Ready Events 是否有异常 Service Endpoints 是否包含新 Pod Ingress 访问是否正常 命令行可以这样确认：\nkubectl -n new-api rollout status deployment/new-api kubectl -n new-api get pods -o wide kubectl -n new-api get endpoints curl -kI https://k8s-ai.jihw.top 如果扩容后 Pod 起不来，按这个顺序看：\n1. Pod Events 2. Pod Logs 3. PVC 是否 Bound 4. Secret / ConfigMap 是否存在 5. Service 是否有 Endpoints 6. 节点资源是否足够 推荐的日常操作习惯 Headlamp 很适合用来观察集群，但生产环境里建议保留命令行校验。\n我的习惯是：\nHeadlamp 看全局状态和资源详情 kubectl 做最终确认和批量操作 Git / Helm / YAML 保存长期配置 如果只是临时把 new-api 从 1 个副本扩成 2 个副本，用 Headlamp 操作很方便。\n如果这是一个长期配置，最好把对应 Helm values 或 Kubernetes YAML 也改掉，否则下次重新部署时，副本数可能又回到旧值。\n参考 Headlamp 官方文档：https://headlamp.dev/docs/ Headlamp 插件页面：https://headlamp.dev/plugins/ Headlamp 官方插件仓库：https://github.com/headlamp-k8s/plugins Headlamp GitHub：https://github.com/kubernetes-sigs/headlamp Kubernetes Deployment 文档：https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ Kubernetes Service 文档：https://kubernetes.io/docs/concepts/services-networking/service/ Kubernetes Ingress 文档：https://kubernetes.io/docs/concepts/services-networking/ingress/ Kubernetes ConfigMap 文档：https://kubernetes.io/docs/concepts/configuration/configmap/ Kubernetes Secret 文档：https://kubernetes.io/docs/concepts/configuration/secret/ kubectl scale 文档：https://kubernetes.io/docs/reference/kubectl/generated/kubectl_scale/ ","permalink":"/coding/headlamp-basic-usage/","summary":"上一篇已经把 Headlamp 部署到了 Kubernetes 集群里，这一篇专门记录日常怎么用它。\nHeadlamp 可以理解成一个 Kubernetes Web 控制台：它本身不改变 Kubernetes 的工作方式，只是把 kubectl get、kubectl describe、kubectl logs、编辑 YAML、查看事件这些操作做成了网页界面。\n这篇文章重点解决一个最常见的问题：怎么在 Headlamp 里给应用扩缩容。\n先说结论：\n不能直接“扩容某一个 Pod”。 应该扩容 Deployment、StatefulSet 或 ReplicaSet 这类工作负载的 replicas。 Pod 是这些控制器根据 replicas 自动创建出来的结果。 比如 new-api 如果是一个 Deployment，那么扩容时应该把 Deployment/new-api 的 spec.replicas 从 1 改成 2 或 3，Kubernetes 会自动创建新的 Pod。\n登录 Headlamp 打开 Headlamp 地址：\nhttps://headlamp.jihw.top 如果使用 Token 登录，可以在服务器上生成临时 Token：\nkubectl -n headlamp create token headlamp-admin --duration=24h 如果之前已经创建了长期 Token Secret，也可以直接取长期 Token：\nkubectl -n headlamp get secret headlamp-admin-token \\ -o jsonpath='{.data.token}' | base64 -d 把输出的 Token 粘贴到 Headlamp 登录页即可。\n","title":"Headlamp 基本使用：查看资源和扩缩容"},{"content":"这篇文章记录一次真实的 Kubernetes CNI 迁移：把现有集群的网络插件从 Flannel 切换到 Calico。\n这不是只执行两条命令就结束的事情。CNI 切换后，旧 Pod 可能还挂着旧网络；如果集群里有 Longhorn、PostgreSQL、Redis、Ingress、Prometheus 这些组件，还要按顺序恢复存储、数据库、业务和监控。\n这次最后的完成标准是：\n所有 Pod 都 Ready 所有 Deployment 都 Available metrics-server 可以正常返回 kubectl top nodes new-api 和 Grafana 入口可以访问 Longhorn 业务卷恢复 healthy 最终验证结果：\nkubectl wait --for=condition=Ready pod --all -A --timeout=120s kubectl wait --for=condition=Available deployment --all -A --timeout=120s kubectl top nodes 输出里 93 个 Pod 全部 Ready，所有 Deployment 都 Available，kubectl top nodes 正常返回。\n业务入口也恢复：\nhttps://k8s-ai.jihw.top HTTP/2 200 https://grafana.jihw.top HTTP/2 302 -\u003e /login 当前集群 当前集群是三主节点 kubeadm 集群：\nk8s-master1 192.168.3.214 k8s-master2 192.168.3.215 k8s-master3 192.168.3.216 API VIP 192.168.3.217 kubeadm 初始化时使用的 Pod CIDR 是：\n10.244.0.0/16 这个网段很重要。Flannel 常见默认 Pod 网段就是 10.244.0.0/16。已有集群迁移时，Calico 的 IPPool 要继续使用这个 CIDR，不要随手改成 192.168.0.0/16 或其他新网段。\n当前集群里还有这些关键组件：\nLonghorn CloudNativePG PostgreSQL Redis HA + redis-master-proxy ingress-nginx MetalLB cert-manager kube-prometheus-stack Headlamp new-api 这也决定了迁移完成后不能只看 kubectl get nodes，还要看所有命名空间的 Pod。\n为什么换 Calico Flannel 的优点是简单，适合快速把 Pod 网络跑起来。它主要解决 Pod 跨节点通信问题。\nCalico 更适合后续生产化：\n支持 Kubernetes NetworkPolicy，可以控制 Pod 之间的访问。 支持 VXLAN、IPIP、BGP 等更多网络模式。 有更完整的网络排错和可观测能力。 后面可以继续探索 eBPF、全局网络策略等能力。 如果只是学习 Kubernetes，Flannel 够用。如果想继续做安全隔离和网络治理，Calico 更值得切换。\n这里也顺手纠正一下：fannel 应该写作 flannel。\n最重要的结论 全新集群最简单：kubeadm init 后直接安装 Calico，不安装 Flannel。\n已有集群也能迁移，但必须安排维护窗口，因为 CNI 切换会影响 Pod 网络。尤其是使用 Longhorn 时，迁移完成后要优先恢复 Longhorn 自身组件，否则 PostgreSQL、Redis 这类依赖 PVC 的业务会继续不可用。\n这次真实故障链路是：\nnew-api 无法访问 | v new-api Service 没有 endpoints | v new-api Pod CrashLoopBackOff | v new-api 连接 PostgreSQL / DNS 超时 | v PostgreSQL / Redis NotReady | v Longhorn CSI 和卷状态异常 | v Longhorn instance-manager 还保留旧 Pod IP | v Longhorn engine / replica 之间 no route to host 所以恢复顺序不能反过来。不要一上来反复重启 new-api。应该先确认 Calico，再恢复 Longhorn，再恢复数据库和 Redis，最后恢复业务和监控。\n全新集群怎么做 如果集群还没正式跑业务，建议直接重建。\n初始化 Kubernetes 时继续指定 Pod CIDR：\nsudo kubeadm init \\ --control-plane-endpoint \"192.168.3.217:8443\" \\ --apiserver-advertise-address=192.168.3.214 \\ --pod-network-cidr=10.244.0.0/16 \\ --upload-certs 不要再安装 Flannel：\n# 不再执行这个 kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml 安装 Tigera Operator：\nkubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/tigera-operator.yaml 下载 Calico 自定义资源：\ncurl -O https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/custom-resources.yaml 把 custom-resources.yaml 里的 IP 池改成当前 Pod CIDR：\napiVersion: operator.tigera.io/v1 kind: Installation metadata: name: default spec: calicoNetwork: ipPools: - blockSize: 26 cidr: 10.244.0.0/16 encapsulation: VXLANCrossSubnet natOutgoing: Enabled nodeSelector: all() 应用：\nkubectl create -f custom-resources.yaml 等待 Calico 就绪：\nkubectl -n calico-system get pods -o wide kubectl get tigerastatus kubectl get nodes 已有集群迁移总流程 这次已有集群迁移按这个顺序收口：\n1. 备份资源和 etcd 2. 确认 Pod CIDR 和 NetworkPolicy 3. 删除 Flannel 4. 清理每台节点上的 Flannel CNI 残留 5. 安装 Calico 6. 验证 Calico 和 DNS 7. 恢复 Longhorn 8. 恢复 PostgreSQL / Redis 9. 恢复 new-api 和 Ingress 10. 恢复 metrics-server / cert-manager / Prometheus operator 等旧 Pod 11. 全集群 Pod Ready 检查 不要在业务高峰期做。\n迁移前备份 先导出资源清单：\nmkdir -p ~/k8s-backup/flannel-to-calico kubectl get all -A -o yaml \u003e ~/k8s-backup/flannel-to-calico/all.yaml kubectl get cm,secret,ingress,svc,pvc,pv -A -o yaml \u003e ~/k8s-backup/flannel-to-calico/common-resources.yaml kubectl get nodes -o wide \u003e ~/k8s-backup/flannel-to-calico/nodes.txt kubectl get pods -A -o wide \u003e ~/k8s-backup/flannel-to-calico/pods.txt 备份 Flannel 资源：\nkubectl -n kube-flannel get all -o yaml \u003e ~/k8s-backup/flannel-to-calico/flannel.yaml kubectl -n kube-flannel get cm -o yaml \u003e\u003e ~/k8s-backup/flannel-to-calico/flannel.yaml 备份 NetworkPolicy：\nkubectl get networkpolicy -A -o yaml \u003e ~/k8s-backup/flannel-to-calico/networkpolicy.yaml 这一点很关键。Flannel 本身不实现 Kubernetes NetworkPolicy，Calico 会实现。也就是说，如果集群里以前已经创建过 NetworkPolicy，但因为 Flannel 没有执行，所以没有产生实际限制；换成 Calico 以后，这些策略可能会突然生效。\n备份 etcd kubeadm 默认把 etcd 跑成静态 Pod，宿主机上不一定安装了 etcdctl。如果直接执行：\nsudo ETCDCTL_API=3 etcdctl snapshot save /root/etcd-snapshot-before-calico.db \\ --endpoints=https://127.0.0.1:2379 \\ --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt \\ --key=/etc/kubernetes/pki/etcd/server.key 可能会报：\nsudo: etcdctl：找不到命令 说明只是宿主机没有 etcdctl，不代表 etcd 有问题。\n更推荐直接使用 etcd 容器里自带的 etcdctl。注意不要写 sh -c，有些 etcd 镜像非常精简，容器里没有 sh，会报：\nexec: \"sh\": executable file not found in $PATH 直接执行 etcdctl：\nETCD_CONTAINER_ID=$(sudo crictl ps --name etcd -q | head -n 1) sudo crictl exec \"$ETCD_CONTAINER_ID\" etcdctl snapshot save /var/lib/etcd/etcd-snapshot-before-calico.db \\ --endpoints=https://127.0.0.1:2379 \\ --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt \\ --key=/etc/kubernetes/pki/etcd/server.key sudo cp /var/lib/etcd/etcd-snapshot-before-calico.db /root/etcd-snapshot-before-calico.db sudo ls -lh /root/etcd-snapshot-before-calico.db 如果提示找不到 etcdctl，再尝试完整路径：\nsudo crictl exec \"$ETCD_CONTAINER_ID\" /usr/local/bin/etcdctl snapshot save /var/lib/etcd/etcd-snapshot-before-calico.db \\ --endpoints=https://127.0.0.1:2379 \\ --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt \\ --key=/etc/kubernetes/pki/etcd/server.key 如果 crictl 也不可用，先确认容器运行时：\nsudo crictl ps | grep etcd sudo ctr -n k8s.io containers list | grep etcd 也可以安装客户端：\nsudo apt update sudo apt install -y etcd-client 迁移前检查 确认 Flannel：\nkubectl get ns | grep flannel kubectl -n kube-flannel get pods -o wide kubectl -n kube-flannel get ds 确认节点和 Pod：\nkubectl get nodes -o wide kubectl get pods -A -o wide 确认 kubeadm 配置：\nkubectl -n kube-system get cm kubeadm-config -o yaml 确认当前 NetworkPolicy：\nkubectl get networkpolicy -A 删除 Flannel 如果当初用官方 manifest 安装 Flannel，可以先尝试：\nkubectl delete -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml 也可以删除当前集群里的 Flannel 资源：\nkubectl -n kube-flannel delete ds kube-flannel-ds kubectl delete ns kube-flannel 确认 Flannel Pod 消失：\nkubectl get pods -A | grep -i flannel 清理 Flannel CNI 残留 下面操作要在每台节点执行：\nsudo systemctl stop kubelet sudo rm -f /etc/cni/net.d/*flannel* sudo rm -f /etc/cni/net.d/10-flannel.conflist sudo ip link delete flannel.1 2\u003e/dev/null || true sudo ip link delete cni0 2\u003e/dev/null || true sudo rm -rf /var/lib/cni/networks/cni0 sudo rm -rf /var/lib/cni/results sudo systemctl start kubelet 如果节点上还有其他 CNI 配置文件，先看清楚再删：\nls -l /etc/cni/net.d/ 安装 Calico 安装 Tigera Operator：\nkubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/tigera-operator.yaml 下载自定义资源：\ncurl -O https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/custom-resources.yaml 修改 custom-resources.yaml：\napiVersion: operator.tigera.io/v1 kind: Installation metadata: name: default spec: calicoNetwork: ipPools: - blockSize: 26 cidr: 10.244.0.0/16 encapsulation: VXLANCrossSubnet natOutgoing: Enabled nodeSelector: all() 应用：\nkubectl create -f custom-resources.yaml 等待 Calico：\nkubectl -n calico-system get pods -o wide kubectl get tigerastatus 这次看到的 Calico 状态是：\napiserver True calico True goldmane True ippools True tiers True whisker True 如果 tigerastatus 都是 Available，说明 Calico 这层基本正常。\n先验证新 Pod DNS 切换后先创建临时 Pod 验证 DNS，不要马上判断业务问题都是 Calico 问题。\nkubectl run dns-test --image=busybox:1.36 --restart=Never -- sleep 3600 kubectl wait --for=condition=Ready pod/dns-test --timeout=60s kubectl exec dns-test -- nslookup kubernetes.default.svc.cluster.local kubectl exec dns-test -- nslookup newapi-postgres-rw.new-api.svc.cluster.local kubectl delete pod dns-test --wait=false 这次新建的临时 Pod DNS 是正常的，可以解析：\nkubernetes.default.svc.cluster.local newapi-postgres-rw.new-api.svc.cluster.local 所以后面 new-api 里的 DNS 超时，不是 Calico 整体坏了，而是旧 Pod 和 Longhorn 状态还没恢复。\n真实故障：new-api 不能访问 迁移后重启了 new-api：\nkubectl -n new-api rollout restart deployment/new-api 但是访问仍然失败。\n先看资源：\nkubectl -n new-api get pods,svc,endpoints,ingress -o wide 当时看到：\nnew-api Pod CrashLoopBackOff new-api Service endpoints 为空 newapi-postgres-rw endpoints 为空 redis-ha endpoints 为空 这说明入口不是第一现场。Ingress 可以转发，但 Service 后面没有 Ready Pod。\n看 new-api 日志：\nkubectl -n new-api logs deploy/new-api --tail=120 --all-containers=true 关键错误：\nfailed to connect to user=newapi database=newapi lookup newapi-postgres-rw.new-api.svc.cluster.local on 10.96.0.10:53: i/o timeout 继续看 PostgreSQL、Redis 和 Longhorn：\nkubectl -n new-api get pods -o wide kubectl -n longhorn-system get pods -o wide kubectl get csidriver 当时事件里看到：\ndriver name driver.longhorn.io not found in the list of registered CSI drivers volume is not ready for workloads Multi-Attach error MountVolume.MountDevice failed 这说明真正卡住的是存储层。\n恢复 Longhorn 先看 Longhorn 组件：\nkubectl -n longhorn-system get pods -o wide kubectl -n longhorn-system get ds,deploy -o wide kubectl -n longhorn-system get volumes.longhorn.io -o wide 当时 longhorn-manager、longhorn-csi-plugin 都不健康，业务卷卡在：\ndetaching unknown not ready for workloads 先重启 Longhorn 控制组件：\nkubectl -n longhorn-system rollout restart \\ daemonset/longhorn-manager \\ daemonset/longhorn-csi-plugin \\ deployment/longhorn-driver-deployer \\ deployment/csi-attacher \\ deployment/csi-provisioner \\ deployment/csi-resizer \\ deployment/csi-snapshotter 等待恢复：\nkubectl -n longhorn-system rollout status daemonset/longhorn-manager --timeout=180s kubectl -n longhorn-system rollout status daemonset/longhorn-csi-plugin --timeout=180s kubectl -n longhorn-system rollout status deployment/longhorn-driver-deployer --timeout=180s 然后继续看 instance-manager：\nkubectl -n longhorn-system get pods -l longhorn.io/component=instance-manager -o wide 这次关键问题是：instance-manager 还保留旧 Pod IP，例如：\n10.244.1.169 10.244.2.152 Longhorn engine 和 replica 互相访问时报：\nno route to host read: connection timed out 删除旧 instance-manager Pod，让 Longhorn 重新创建：\nkubectl -n longhorn-system delete pod instance-manager-旧Pod名 --wait=false 等新 Pod 拿到 Calico 新 IP：\nkubectl -n longhorn-system get pods -l longhorn.io/component=instance-manager -o wide 这次新 IP 类似：\n10.244.159.x 10.244.224.x 10.244.135.x 再看卷：\nkubectl -n longhorn-system get volumes.longhorn.io -o wide 目标是业务卷变成：\nattached healthy 恢复 PostgreSQL 和 Redis Longhorn 恢复后，再处理依赖 PVC 的业务。不要在 Longhorn 还卡着时反复删 Pod。\n查看状态：\nkubectl -n new-api get pods,svc,endpoints -o wide kubectl -n new-api get clusters.postgresql.cnpg.io -o wide 这次在 Longhorn 恢复后，重新创建 PostgreSQL 和 Redis Pod：\nkubectl -n new-api delete pod newapi-postgres-1 newapi-postgres-2 newapi-postgres-3 --wait=false kubectl -n new-api delete pod redis-ha-node-0 redis-ha-node-1 redis-ha-node-2 --wait=false 然后等待：\nkubectl -n new-api get pods -o wide kubectl -n new-api get endpoints kubectl -n new-api get clusters.postgresql.cnpg.io -o wide 恢复后的目标：\nnewapi-postgres-1 1/1 Running newapi-postgres-2 1/1 Running newapi-postgres-3 1/1 Running redis-ha-node-0 2/2 Running redis-ha-node-1 2/2 Running redis-ha-node-2 2/2 Running CloudNativePG 最终状态：\nCluster in healthy state PostgreSQL 和 Redis 的 endpoints 也要恢复：\nnewapi-postgres-rw 10.244.x.x:5432 redis-ha 10.244.x.x:6379,26379 恢复 new-api 数据库和 Redis 恢复后，再重启代理和业务：\nkubectl -n new-api rollout restart deployment/redis-master-proxy deployment/new-api 等待：\nkubectl -n new-api rollout status deployment/redis-master-proxy --timeout=180s kubectl -n new-api rollout status deployment/new-api --timeout=240s 看 endpoints：\nkubectl -n new-api get pods,svc,endpoints,ingress -o wide 恢复后的目标：\nnew-api 3 个 Pod 都是 1/1 Running new-api endpoints 有 3 个 Pod IP redis-master-proxy endpoints 有 2 个 Pod IP newapi-postgres-rw endpoints 有 primary Pod IP 验证入口：\ncurl -kI --connect-timeout 5 https://k8s-ai.jihw.top curl -kI --connect-timeout 5 -H 'Host: k8s-ai.jihw.top' https://192.168.3.230 这次恢复后返回：\nHTTP/2 200 x-new-api-version: v1.0.0-rc.10 恢复 Ingress 这次 new-api 恢复后，Ingress 还有旧 Pod 不 Ready，所以重启 ingress-nginx：\nkubectl -n ingress-nginx rollout restart deployment/ingress-nginx-controller kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s kubectl -n ingress-nginx get pods,endpoints -o wide 目标：\ningress-nginx-controller 3/3 Running ingress-nginx-controller endpoints 有 3 个 Pod 恢复监控和证书组件 业务恢复不代表迁移完成。后面检查全集群 Pod 时，又发现这些旧 Pod 还在 CrashLoop：\nmetrics-server cert-manager kube-prometheus-stack-operator kube-prometheus-stack-kube-state-metrics 它们也都是切 CNI 前创建的旧 Pod，保留旧 Pod IP 后会出现探针超时、连接 API Server 超时等问题。\n滚动重启：\nkubectl -n kube-system rollout restart deployment/metrics-server kubectl -n cert-manager rollout restart deployment/cert-manager kubectl -n monitoring rollout restart deployment/kube-prometheus-stack-operator kubectl -n monitoring rollout restart deployment/kube-prometheus-stack-kube-state-metrics 等待：\nkubectl -n kube-system rollout status deployment/metrics-server --timeout=180s kubectl -n cert-manager rollout status deployment/cert-manager --timeout=180s kubectl -n monitoring rollout status deployment/kube-prometheus-stack-operator --timeout=180s kubectl -n monitoring rollout status deployment/kube-prometheus-stack-kube-state-metrics --timeout=180s metrics-server 恢复后，验证：\nkubectl top nodes 这次返回：\nNAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) k8s-master1 611m 15% 5250Mi 67% k8s-master2 443m 11% 3831Mi 49% k8s-master3 447m 11% 4419Mi 56% 清理 Longhorn 其他旧 Pod 为了避免 Longhorn 后续继续被旧 IP 绊住，这次又重启了剩余 Longhorn 组件：\nkubectl -n longhorn-system rollout restart daemonset/engine-image-ei-c9fa6d45 kubectl -n longhorn-system rollout restart deployment/longhorn-ui 等待：\nkubectl -n longhorn-system rollout status daemonset/engine-image-ei-c9fa6d45 --timeout=180s kubectl -n longhorn-system rollout status deployment/longhorn-ui --timeout=180s 确认所有 Longhorn Pod 都是新 IP：\nkubectl -n longhorn-system get pods -o wide 最终验证 最终不要只看某个业务能不能访问，要用全集群检查收口。\n所有 Pod Ready：\nkubectl wait --for=condition=Ready pod --all -A --timeout=120s 所有 Deployment Available：\nkubectl wait --for=condition=Available deployment --all -A --timeout=120s 检查 Deployment、StatefulSet、DaemonSet：\nkubectl get deploy,sts,ds -A 检查 Longhorn 卷：\nkubectl -n longhorn-system get volumes.longhorn.io -o wide 目标是业务相关卷都 healthy：\nattached healthy 检查 metrics-server：\nkubectl top nodes 检查业务入口：\ncurl -kI --connect-timeout 5 https://k8s-ai.jihw.top curl -kI --connect-timeout 5 https://grafana.jihw.top 这次最终结果：\nALL_PODS_READY ALL_DEPLOYMENTS_AVAILABLE new-api: HTTP/2 200 Grafana: HTTP/2 302 -\u003e /login 到这里才算从 Flannel 正式切到了 Calico。\n常见问题 节点一直 NotReady 先看 kubelet：\nsudo journalctl -u kubelet -n 100 --no-pager 再看 CNI 配置：\nls -l /etc/cni/net.d/ 正常情况下应该能看到 Calico 配置，例如：\n10-calico.conflist Calico Pod 起不来 看 operator 和 Calico 组件：\nkubectl -n tigera-operator get pods kubectl -n tigera-operator logs -l k8s-app=tigera-operator --tail=100 kubectl -n calico-system get pods -o wide kubectl -n calico-system describe pod Pod名称 kubectl get tigerastatus 新 Pod DNS 正常，旧 Pod DNS 超时 这次就遇到了这种情况。\n新创建的 dns-test 可以正常解析 Service，但旧业务 Pod 仍然 DNS 超时。这通常说明不是 CoreDNS 整体坏了，而是旧 Pod、旧 CNI 网络命名空间、Longhorn instance-manager 或依赖组件没有重建干净。\n处理方式是按层重启：\nCoreDNS Longhorn 数据库和 Redis 业务 Deployment 监控和证书组件 Service 没有 endpoints 先看后端 Pod 是否 Ready：\nkubectl -n 命名空间 get pods,svc,endpoints -o wide kubectl -n 命名空间 describe pod Pod名称 kubectl -n 命名空间 logs Pod名称 --tail=100 Service 没有 endpoints 通常不是 Service 自己坏了，而是 selector 匹配的 Pod 没有 Ready。\nLonghorn volume 卡在 detaching 先看 Longhorn：\nkubectl -n longhorn-system get volumes.longhorn.io -o wide kubectl -n longhorn-system get pods -l longhorn.io/component=instance-manager -o wide kubectl -n longhorn-system logs -l app=longhorn-manager -c longhorn-manager --tail=200 如果日志里有：\nno route to host read: connection timed out 并且 instance-manager 还在旧 IP，删除旧 instance-manager Pod，让 Longhorn 重建。\n回滚到 Flannel 如果 Calico 安装失败，并且短时间内无法修好，可以回滚。\n先删除 Calico：\nkubectl delete -f custom-resources.yaml kubectl delete -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/tigera-operator.yaml 每台节点清理 Calico CNI 残留：\nsudo systemctl stop kubelet sudo rm -f /etc/cni/net.d/*calico* sudo ip link delete tunl0 2\u003e/dev/null || true sudo ip link delete vxlan.calico 2\u003e/dev/null || true sudo rm -rf /var/lib/cni/networks/k8s-pod-network sudo rm -rf /var/lib/cni/results sudo systemctl start kubelet 重新安装 Flannel：\nkubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml 然后按同样思路重启 CoreDNS、Longhorn、业务和监控组件。\n这次经验 如果只是刚搭好的实验集群，我更倾向于重建：\nkubeadm reset 重新 kubeadm init 直接安装 Calico 重新加入其他节点 重新部署业务 如果集群里已经有 Longhorn、PostgreSQL、Redis、Ingress、Prometheus、业务服务，就不要把 CNI 迁移理解成“删 Flannel、装 Calico”。真正的工作是迁移后的恢复顺序：\nCalico Ready CoreDNS 正常 Longhorn healthy 数据库和 Redis Ready 业务 endpoints 恢复 Ingress 可访问 metrics-server 和 Prometheus 组件恢复 所有 Pod Ready 这些都验证通过，迁移才算完成。\n","permalink":"/coding/k8s-flannel-to-calico/","summary":"这篇文章记录一次真实的 Kubernetes CNI 迁移：把现有集群的网络插件从 Flannel 切换到 Calico。\n这不是只执行两条命令就结束的事情。CNI 切换后，旧 Pod 可能还挂着旧网络；如果集群里有 Longhorn、PostgreSQL、Redis、Ingress、Prometheus 这些组件，还要按顺序恢复存储、数据库、业务和监控。\n这次最后的完成标准是：\n所有 Pod 都 Ready 所有 Deployment 都 Available metrics-server 可以正常返回 kubectl top nodes new-api 和 Grafana 入口可以访问 Longhorn 业务卷恢复 healthy 最终验证结果：\nkubectl wait --for=condition=Ready pod --all -A --timeout=120s kubectl wait --for=condition=Available deployment --all -A --timeout=120s kubectl top nodes 输出里 93 个 Pod 全部 Ready，所有 Deployment 都 Available，kubectl top nodes 正常返回。\n业务入口也恢复：\nhttps://k8s-ai.jihw.top HTTP/2 200 https://grafana.jihw.top HTTP/2 302 -\u003e /login 当前集群 当前集群是三主节点 kubeadm 集群：\n","title":"Kubernetes 从 Flannel 切换到 Calico"},{"content":"这篇文章不是某一个组件的安装教程，而是用来解释我当前这套 Kubernetes 系统为什么这样搭。\n如果只跟着命令敲，很容易变成“能跑，但不知道为什么”。这篇文章的目标是：看完以后，我能知道每一层解决什么问题、为什么要这么选、好处是什么、哪里会踩坑。以后重新搭建 Kubernetes 集群时，可以按同样的思路独立设计。\n当前架构 当前集群是 3 台虚拟机组成的三主节点 Kubernetes：\nk8s-master1 192.168.3.214 k8s-master2 192.168.3.215 k8s-master3 192.168.3.216 API VIP 192.168.3.217 Ingress VIP 192.168.3.231 MetalLB IP 192.168.3.230 整体访问路径可以先这样理解：\n浏览器 | | 访问 https://k8s-ai.jihw.top v DNS 解析到入口 VIP | v Keepalived + HAProxy | v Ingress NodePort | v ingress-nginx-controller | v new-api Service | v new-api Pod | +--\u003e CloudNativePG PostgreSQL | +--\u003e redis-master-proxy | v Redis Sentinel 当前 master Kubernetes API 的访问路径是另一条：\nkubectl / kubelet / 集群组件 | v API VIP 192.168.3.217:8443 | v HAProxy | v 三台 kube-apiserver:6443 这两条路径要分清楚：\n192.168.3.217 主要服务 Kubernetes 控制面，也就是 API Server。 192.168.3.231 主要服务业务入口，也就是 HTTP/HTTPS 访问。 192.168.3.230 是 MetalLB 分配给 ingress-nginx Service 的地址。 先理解高可用分层 高可用不是装了 3 台 Kubernetes 主节点就结束。三主节点只解决控制面一部分问题。\n我把整套系统拆成几层：\n1. 虚拟机和网络层 2. Kubernetes 控制面 3. 入口负载均衡层 4. Ingress 和域名证书层 5. 业务应用层 6. 数据库和缓存层 7. 存储层 8. 监控和运维入口层 每一层都可能有单点。\n例如：\n3 个 API Server 都正常，但 Ingress 只有 1 个 Pod，业务域名仍然会挂。 new-api 有 3 个副本，但 PostgreSQL 是单实例，数据库节点挂了业务仍然不可用。 PostgreSQL 做了主从，但存储卷无法重新挂载，数据库仍然可能恢复慢。 业务全部正常，但 Grafana 和 Headlamp 是单副本，运维入口仍然会断。 所以高可用要一层一层补。\n虚拟机和网络层 当前是在 VMware 虚拟机里搭建集群。之前遇到过网卡相关问题，网卡类型从 e1000 调整为 vmxnet3 后，系统内网卡名从 ens33 变成了 ens160。\n这个决定的原因：\nvmxnet3 是 VMware 优化过的虚拟网卡，性能和稳定性通常更好。 压测时网卡稳定性很重要，虚拟网卡异常会让节点看起来像突然失联。 Keepalived 依赖具体网卡名，如果网卡名变了，配置必须同步修改。 需要记住：\n网卡类型变化 -\u003e Linux 网卡名可能变化 -\u003e keepalived.conf 里的 interface 要跟着改 -\u003e NetworkManager 连接配置也可能要跟着改 检查命令：\nip addr nmcli connection show grep -R \"ens33\\|ens160\" /etc/keepalived /etc/NetworkManager 2\u003e/dev/null 经验结论：底层网络不稳时，Kubernetes 上层所有组件都会表现得很奇怪。先把网卡、IP、DNS、时间同步这些基础打牢，再谈应用高可用。\n控制面高可用 当前 3 台节点都是 control-plane：\nk8s-master1 k8s-master2 k8s-master3 控制面核心组件包括：\netcd kube-apiserver kube-controller-manager kube-scheduler 为什么要 3 台，而不是 2 台？\n因为 etcd 需要多数派。3 个 etcd 节点时，挂掉 1 个，还剩 2 个，仍然是多数派。\n3 个 etcd 节点 挂 0 个：3/3，可用 挂 1 个：2/3，可用 挂 2 个：1/3，不可用 如果只有 2 个 etcd 节点：\n2 个 etcd 节点 挂 1 个：1/2，不是多数派，不可用 所以三主节点是合理的入门 HA 规模。\n但是要注意：控制面可用，不等于业务一定可用。控制面负责调度、管理和 API，业务是否可访问还要看 Ingress、Service、Pod、数据库、存储。\n检查控制面：\nkubectl get nodes -o wide kubectl -n kube-system get pods -o wide API 入口：Keepalived 和 HAProxy kubectl 和集群组件需要一个稳定的 API Server 地址。如果直接写某一台机器的 6443，那台机器挂了，kubectl 就访问不了。\n所以这里使用：\nKeepalived 提供 VIP HAProxy 转发到多个 kube-apiserver 设计思路：\nkubectl | v 192.168.3.217:8443 | v HAProxy | +--\u003e 192.168.3.214:6443 +--\u003e 192.168.3.215:6443 +--\u003e 192.168.3.216:6443 好处：\nkubectl 永远访问同一个地址。 某个 API Server 挂了，HAProxy 会把它摘掉。 当前持有 VIP 的节点挂了，Keepalived 可以把 VIP 漂移到其他节点。 为什么不是只用 DNS 轮询？\nDNS 轮询不能很好判断后端是否健康，也不能快速漂移 VIP。Keepalived + HAProxy 更适合家庭/内网环境里做一个简单可靠的入口。\n检查命令：\nip addr | grep 192.168.3.217 systemctl status keepalived systemctl status haproxy journalctl -u haproxy --since \"10 minutes ago\" --no-pager 业务入口：Ingress 和 NodePort 业务域名不是直接访问 Pod，而是走 Ingress。\n例如：\nhttps://k8s-ai.jihw.top https://grafana.jihw.top https://headlamp.jihw.top 访问路径：\n浏览器 | v 入口 VIP / HAProxy | v Ingress NodePort | v ingress-nginx-controller | v 对应业务 Service 为什么要 Ingress？\nService 只适合集群内部访问。 Ingress 可以按域名和路径转发。 Ingress 可以统一处理 HTTPS。 多个应用可以共用 80/443 入口。 之前关闭 master3 后，业务域名访问失败，原因不是 new-api 挂了，而是 ingress-nginx-controller 只有 1 个副本，并且刚好在 master3。\n修复方式：\ningress-nginx-controller replicas = 3 使用 topologySpreadConstraints 尽量分散 经验结论：入口层也必须高可用。业务 Pod 高可用，但入口 Pod 单副本，域名仍然会挂。\n检查命令：\nkubectl -n ingress-nginx get deploy,pods,svc -o wide kubectl get ingress -A curl -k -I https://k8s-ai.jihw.top/ MetalLB 的作用 当前 ingress-nginx Service 是 LoadBalancer 类型，并由 MetalLB 分配内网 IP：\n192.168.3.230 为什么内网 Kubernetes 需要 MetalLB？\n在云厂商里，Service type=LoadBalancer 会自动创建云负载均衡。但家庭内网或裸机环境没有云负载均衡，Kubernetes 自己不知道去哪里申请外部 IP。\nMetalLB 的作用就是在裸机/内网环境中提供 LoadBalancer IP。\n简单理解：\n云上：Service LoadBalancer -\u003e 云厂商负载均衡 内网：Service LoadBalancer -\u003e MetalLB 分配局域网 IP 检查命令：\nkubectl -n metallb-system get pods -o wide kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 证书：cert-manager HTTPS 证书由 cert-manager 管理。\n好处：\n不用手动申请和续期证书。 Ingress 上写注解，cert-manager 自动创建 TLS Secret。 证书快过期时自动续期。 当前使用阿里云 DNS 验证来签发证书。DNS 验证的好处是：\n不要求应用临时暴露 HTTP 校验路径。 内网服务也可以申请公网可信证书。 对多个子域名更方便。 检查命令：\nkubectl get certificate -A kubectl get clusterissuer kubectl -n cert-manager get pods -o wide 存储：Longhorn Kubernetes 的 Pod 可以随时重建，所以数据不能只放在容器本地。数据库、Grafana、Prometheus 这类有状态组件都需要 PVC。\n当前使用 Longhorn 作为存储系统。\n为什么用 Longhorn？\n它适合家庭/小集群。 可以把多个节点磁盘做成分布式块存储。 PVC 可以跟随 Pod 在节点之间重新挂载。 有 UI，便于观察卷状态。 但 Longhorn 不是魔法。要理解几个关键点：\nRWO: 同一时间通常只能被一个节点读写挂载 RWX: 可以被多个节点同时读写，通常需要 NFS/share-manager replica: Longhorn 卷副本，用来抵抗磁盘或节点故障 这次故障演练里，Grafana 恢复慢，就是因为它使用 SQLite + RWO PVC。旧 Pod 没完全释放卷时，新 Pod 不能在别的节点立刻挂载。\n所以：\n无状态应用可以直接多副本。 有状态应用不能盲目多副本。 数据库要用数据库自己的高可用方案，不只是靠 Longhorn 卷副本。 检查命令：\nkubectl get storageclass kubectl -n longhorn-system get pods -o wide kubectl -n longhorn-system get nodes.longhorn.io -o wide kubectl -n longhorn-system get volumes.longhorn.io -o wide 当前还需要继续关注：Longhorn 节点 Ready 状态、卷副本是否健康、节点恢复时是否有重建压力。\nnew-api 应用层 new-api 是当前主要业务应用。\n最初它是单副本，单副本的问题很直接：\nPod 所在节点挂了 -\u003e new-api 不可访问 现在 new-api 改成 3 副本：\nnew-api replicas = 3 好处：\n某一个 Pod 挂了，还有其他 Pod。 某一个节点挂了，只要还有其他节点上的 Pod，业务仍然可以访问。 Service 会自动只把流量发给 Ready 的 Pod。 为什么 new-api 要尽量无状态？\n因为无状态应用最适合多副本。多个 Pod 之间不需要共享本地数据，业务数据放到 PostgreSQL 和 Redis。\n设计原则：\n应用本地不保存关键业务状态 业务数据放 PostgreSQL 缓存和临时状态放 Redis 日志后续交给统一日志系统 检查命令：\nkubectl -n new-api get deploy new-api kubectl -n new-api get pods -l app=new-api -o wide kubectl -n new-api get endpoints new-api curl -k https://k8s-ai.jihw.top/api/status PostgreSQL：CloudNativePG PostgreSQL 不能简单地把单实例改成 3 个 Pod。数据库有主从、复制、故障切换、数据一致性这些问题。\n当前使用 CloudNativePG 做 PostgreSQL 高可用。\n架构：\nnewapi-postgres-1 newapi-postgres-2 newapi-postgres-3 其中 1 个是 primary 其他是 replica CloudNativePG 提供几个重要 Service：\nnewapi-postgres-rw 写入口，指向当前 primary newapi-postgres-ro 只读入口，指向 replica newapi-postgres-r 所有实例入口 为什么用 -rw Service？\n因为主库可能切换。如果应用直接连某个 Pod 名，一旦主库换了，应用还会连旧主库。-rw Service 会自动指向当前 primary。\n好处：\n主库故障时可以自动切换。 应用不需要知道哪个 Pod 是主库。 PostgreSQL 高可用逻辑由专业 Operator 管理。 注意：\nPostgreSQL HA 不能替代备份 Longhorn 卷副本不能替代数据库复制 主从切换期间应用可能会短暂重连 检查命令：\nkubectl -n new-api get cluster newapi-postgres kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide kubectl -n new-api get svc | grep newapi-postgres Redis：Sentinel + master 代理 Redis 也不能只保留单实例。最初的旧 Redis 是：\nredis-master-0 它的问题是：所在节点挂了，new-api 就连不上 Redis。\n现在 Redis 后端使用 Sentinel：\nredis-ha-node-0 redis-ha-node-1 redis-ha-node-2 每个 redis-ha-node Pod 里有两个容器：\nredis sentinel Sentinel 的作用：\n监控当前 Redis master。 判断 master 是否故障。 在故障时选出新的 master。 但是当前 calciumion/new-api:latest 镜像对 Redis Sentinel 支持不够直接。它会把 REDIS_CONN_STRING 当普通 Redis URL 解析。\n所以加了一个代理：\nredis-master-proxy 访问路径：\nnew-api | v redis-master-proxy | v 当前 Redis master redis-master-proxy 使用 HAProxy 检查 Redis 节点的 role:master，只把流量转发给当前 master。\n为什么 proxy 是 2 个副本，不是 3 个？\n因为 proxy 只是代理层，不存数据。2 个副本已经可以避免代理单点：\nproxy-1 挂了，还有 proxy-2 proxy-2 挂了，还有 proxy-1 真正的数据高可用由 3 个 redis-ha-node 承担。\n检查命令：\nkubectl -n new-api get statefulset redis-ha-node kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide kubectl -n new-api get deploy redis-master-proxy kubectl -n new-api get secret new-api-secret -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d echo 平台组件 平台组件是用来运维集群的，不一定是业务本身，但它们也需要考虑可用性。\n当前主要有：\nHeadlamp Prometheus Grafana Alertmanager metrics-server Headlamp Headlamp 是 Kubernetes Web UI，适合看 Pod、Service、Ingress、日志和事件。\n它是无状态服务，所以可以直接 2 副本。\n好处：\n一个 Headlamp Pod 挂了，还有另一个。 某个节点挂了，仍然可以通过页面观察集群。 metrics-server kubectl top nodes 和 HPA 依赖 metrics-server。\n如果 metrics-server 挂了，会看到：\nMetrics API not available 所以 metrics-server 也适合至少 2 副本。\nGrafana Grafana 当前是单副本，因为它使用 SQLite 和 RWO PVC。\n为什么不直接 2 副本？\n因为两个 Grafana 同时访问同一个 SQLite 数据库不安全，RWO PVC 也不能跨节点同时挂载。\n当前策略：\nGrafana 单副本 Recreate 更新策略 放宽启动探针 后续如果要真 HA，改用外部 PostgreSQL 所以这里要理解一个重要原则：\n无状态组件可以直接多副本 有状态组件要先解决数据一致性和存储访问方式 为什么资源会紧张 当前每台机器大约 2 核、4GB 内存级别。三主节点上同时跑：\nKubernetes 控制面 Longhorn Ingress new-api PostgreSQL Redis Prometheus Grafana Headlamp cert-manager MetalLB 这对小虚拟机来说已经比较重。\n故障演练时，如果关掉一台节点，剩下两台会承接更多 Pod，CPU 很容易接近 100%。这时很多组件不是配置错，而是健康探针超时。\n常见表现：\nReadiness probe failed: context deadline exceeded Liveness probe failed: context deadline exceeded kubectl top nodes 显示 CPU 很高 Longhorn 节点 Ready 反复变化 建议资源：\n每台至少 4 vCPU 每台至少 8GB 内存 磁盘预留足够空间给 Longhorn 如果资源有限，要做取舍：\n先保证业务和数据库。 监控组件减少保留时间和资源请求。 Grafana 保持单副本快速恢复。 清理不用的旧 Redis、旧 PostgreSQL。 从零搭建顺序 以后重新搭建时，可以按这个顺序来。\n1. 准备虚拟机 先准备 3 台机器：\n固定 IP 统一主机名 关闭 swap 配置时间同步 选择稳定网卡类型 确保 ssh key 可登录 为什么先做这些？\n因为 Kubernetes 对网络、时间和主机名很敏感。底层不稳，上层排错会非常痛苦。\n2. 安装容器运行时和 Kubernetes 安装：\ncontainerd kubeadm kubelet kubectl 用 kubeadm 初始化第一个 control-plane，再加入另外两个 control-plane。\n目标结果：\nkubectl get nodes 能看到 3 个节点 Ready。\n3. 安装 CNI CNI 负责 Pod 网络。当前使用 flannel。\n没有 CNI 时，Pod 之间无法正常通信，CoreDNS 也可能起不来。\n检查：\nkubectl -n kube-flannel get pods -o wide kubectl -n kube-system get pods -o wide 4. 做 API Server 高可用入口 安装并配置：\nKeepalived HAProxy 让 kubectl 和 kubelet 统一访问 VIP。\n5. 安装 MetalLB 和 ingress-nginx MetalLB 负责内网 LoadBalancer IP。\ningress-nginx 负责域名 HTTP/HTTPS 转发。\n从一开始就把 ingress-nginx 设置为多副本，否则入口会成为单点。\n6. 安装 cert-manager 让证书自动签发和续期。\n域名访问要尽量一开始就走 HTTPS，这样后面应用迁移时不用反复改入口。\n7. 安装 Longhorn 安装存储系统，让 PVC 可以动态创建。\n安装后重点检查：\nkubectl get storageclass kubectl -n longhorn-system get pods -o wide kubectl -n longhorn-system get nodes.longhorn.io -o wide 8. 部署业务应用 先部署单副本验证能跑，再改多副本。\n部署应用时要问自己：\n应用是否无状态？ 是否依赖本地文件？ 是否可以多副本同时运行？ 健康检查是否合理？ 资源 requests/limits 是否设置？ 9. 部署数据库和缓存高可用 PostgreSQL 使用 CloudNativePG。\nRedis 使用 Sentinel，并为 new-api 增加 redis-master-proxy。\n注意：数据库高可用不是“Pod 数量多”这么简单，要用合适的 Operator 或数据库自身机制。\n10. 部署监控和运维工具 安装：\nmetrics-server Headlamp kube-prometheus-stack 然后根据是否有状态决定副本数：\nHeadlamp：无状态，可以 2 副本。 metrics-server：可以 2 副本。 Grafana：当前 SQLite + RWO，只做单副本快速恢复。 Prometheus：后续再考虑多副本和长期存储。 11. 做故障演练 不要等真正故障时才知道哪里没做 HA。\n按顺序演练：\n删除一个 new-api Pod 关闭一个 Redis master Pod 删除 PostgreSQL primary Pod 关闭一个 Ingress Controller Pod 关闭一台虚拟机 每次演练后检查：\nkubectl get nodes -o wide kubectl get pods -A -o wide kubectl top nodes kubectl -n longhorn-system get volumes.longhorn.io -o wide curl -k https://k8s-ai.jihw.top/api/status 独立搭建时的判断方法 以后遇到一个新组件，可以按这几个问题判断怎么部署：\n1. 它是无状态还是有状态？ 2. 它是否需要 PVC？ 3. PVC 是 RWO 还是 RWX？ 4. 它能不能多副本同时写同一个数据？ 5. 它是否需要专门的 Operator？ 6. 它挂了会影响业务，还是只影响运维观察？ 7. 它的入口是 Service、Ingress，还是 APIService？ 8. 它的健康检查是否会在高负载时误杀容器？ 几个经验规则：\n无状态服务优先用 Deployment，多副本。 数据库优先用成熟 Operator，不要自己手搓主从。 只有一个 RWO PVC 的组件，不要直接多副本。 Ingress Controller、metrics-server、Headlamp 这类平台入口要多副本。 Prometheus、Grafana 这类监控组件要区分“快速恢复”和“真正 HA”。 Longhorn 卷健康和节点资源压力会影响大量组件。 当前架构的不足 这套架构已经能让 new-api、PostgreSQL、Redis、Ingress 具备基础高可用，但还不是完美的生产架构。\n当前主要不足：\n1. 虚拟机资源偏紧，故障切换时 214/215 容易 CPU 打满。 2. Longhorn 节点状态需要继续稳定，尤其是节点恢复后的 Ready 状态。 3. Grafana 还不是真正多副本 HA。 4. Prometheus 目前仍是单实例 StatefulSet。 5. 备份体系还需要继续完善。 后续优先级：\n第一优先级：Longhorn 健康和资源扩容 第二优先级：PostgreSQL 定时备份和恢复演练 第三优先级：Prometheus/Grafana 监控告警完善 第四优先级：继续做完整故障演练 总结 这套 Kubernetes 架构的核心思想是：\n控制面用三主保证管理能力 入口层用 Keepalived + HAProxy + ingress-nginx 保证域名访问 应用层用 Deployment 多副本保证服务可用 数据库用 CloudNativePG 保证 PostgreSQL 主从切换 缓存用 Redis Sentinel + master proxy 保证 Redis master 切换 存储用 Longhorn 提供 PVC 和卷副本 监控和运维工具按有无状态分别处理 真正理解以后，就不会只问“这个 Pod 为什么不是 3 个”，而是会先判断它属于哪一层、是不是存数据、能不能多副本、挂了影响什么。\n这才是独立搭建 Kubernetes 集群最重要的能力。\n","permalink":"/coding/k8s-architecture-guide/","summary":"这篇文章不是某一个组件的安装教程，而是用来解释我当前这套 Kubernetes 系统为什么这样搭。\n如果只跟着命令敲，很容易变成“能跑，但不知道为什么”。这篇文章的目标是：看完以后，我能知道每一层解决什么问题、为什么要这么选、好处是什么、哪里会踩坑。以后重新搭建 Kubernetes 集群时，可以按同样的思路独立设计。\n当前架构 当前集群是 3 台虚拟机组成的三主节点 Kubernetes：\nk8s-master1 192.168.3.214 k8s-master2 192.168.3.215 k8s-master3 192.168.3.216 API VIP 192.168.3.217 Ingress VIP 192.168.3.231 MetalLB IP 192.168.3.230 整体访问路径可以先这样理解：\n浏览器 | | 访问 https://k8s-ai.jihw.top v DNS 解析到入口 VIP | v Keepalived + HAProxy | v Ingress NodePort | v ingress-nginx-controller | v new-api Service | v new-api Pod | +--\u003e CloudNativePG PostgreSQL | +--\u003e redis-master-proxy | v Redis Sentinel 当前 master Kubernetes API 的访问路径是另一条：\n","title":"Kubernetes 集群架构总览"},{"content":"这篇文章用来长期记录我的 Kubernetes 高可用建设过程。\n高可用不是只把 Kubernetes 主节点做成 3 台就结束了。三主节点主要解决的是控制面高可用，也就是 API Server、etcd、Controller Manager、Scheduler 这些组件在单节点故障时还能继续工作。业务是否高可用，还要继续看服务副本、调度分散、数据库主从、Redis、存储卷、Ingress、负载均衡和监控告警。\n当前集群节点：\nk8s-master1 192.168.3.214 k8s-master2 192.168.3.215 k8s-master3 192.168.3.216 VIP 192.168.3.217 高可用分层 我现在把 Kubernetes 高可用拆成几层来看：\n控制面高可用：3 个 control-plane 节点，etcd 保持多数派。 入口高可用：Keepalived 提供 VIP，HAProxy 转发到多个 API Server 和 Ingress NodePort。 服务高可用：业务 Deployment 多副本，副本分散到不同节点。 数据库高可用：PostgreSQL、Redis 不能只是单实例。 存储高可用：Longhorn 卷副本需要分散，节点失联后能恢复。 监控高可用：Prometheus、Grafana、Alertmanager 要能发现异常并保留证据。 三主节点的边界 三主节点断 1 个，Kubernetes 控制面理论上应该还能用。因为 3 个 etcd 节点掉 1 个以后，还剩 2 个节点，仍然满足多数派。\n但是控制面可用不等于业务可用。比如 new-api、PostgreSQL、Redis 如果都是单实例，或者都依赖同一个 ReadWriteOnce PVC，那么节点失联后业务 Pod 可能无法立刻在其他节点恢复。\n这次压测后 192.168.3.215 失联时，集群里看到：\nk8s-master1 Ready k8s-master2 NotReady k8s-master3 Ready 控制面没有整体崩溃，kubectl 仍然能查询资源。但是 new-api、PostgreSQL、Redis 和部分 Longhorn 卷受到影响，这说明当前只是控制面有 HA，业务和存储还没有完整 HA。\n故障记录：192.168.3.215 网卡失联 现象 压测后，192.168.3.215 无法 SSH：\nssh: connect to host 192.168.3.215 port 22: Connection timed out 从其他节点访问 215：\nping -c 4 192.168.3.215 ip neigh show 192.168.3.215 结果类似：\nDestination Host Unreachable 192.168.3.215 dev ens33 INCOMPLETE 这说明问题不是 SSH 进程挂了，也不是 kubelet 单独异常，而是同网段二层 ARP 都找不到这台机器的网卡。\nKubernetes 中 k8s-master2 最后心跳大概在：\nNode LastHeartbeatTime: 2026-06-01 20:56:40 Lease RenewTime: 2026-06-01 20:59:56 NodeNotReady: 2026-06-01 21:01:09 HAProxy 也在这个时间附近把 k8s-master2 后端判定为 DOWN：\n2026-06-01 21:00:14 Server kubernetes-apiserver/k8s-master2 is DOWN 2026-06-01 21:00:14 Server ingress_http_nodeport/k8s-master2 is DOWN 2026-06-01 21:00:14 Server ingress_https_nodeport/k8s-master2 is DOWN 重启后排查 重启 192.168.3.215 后先确认节点恢复：\nkubectl get nodes -o wide 然后查看上一次启动周期的日志：\njournalctl --list-boots last -x | head journalctl -b -1 --since \"2026-06-01 20:50:00\" --until \"2026-06-01 21:05:00\" -p warning journalctl -k -b -1 --since \"2026-06-01 20:50:00\" --until \"2026-06-01 21:05:00\" 关键日志：\n2026-06-01 21:00:05 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang 2026-06-01 21:00:07 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang 2026-06-01 21:00:09 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang 这条日志持续重复到重启前。它表示网卡发送队列卡死。\n查看网卡型号和驱动：\nlspci -nn | egrep -i 'ethernet|network|vmware' ethtool -i ens33 当时结果是：\nEthernet controller: Intel Corporation 82545EM Gigabit Ethernet Controller driver: e1000 interface: ens33 这是 VMware 里的 E1000 仿真网卡。结论是：这次 192.168.3.215 失联的直接原因不是 Kubernetes，而是 VMware 虚拟网卡 e1000 在网络流量下出现 Detected Tx Unit Hang，导致节点从网络层消失。\n修复方向 把 VMware 虚拟机网卡类型从 E1000 改成 VMXNET3。\n如果 UI 里看不到网卡类型，可以关闭虚拟机后修改 .vmx 文件：\nethernet0.virtualDev = \"vmxnet3\" 原来如果是：\nethernet0.virtualDev = \"e1000\" 就改成：\nethernet0.virtualDev = \"vmxnet3\" 启动后检查驱动：\nethtool -i ens160 期望看到：\ndriver: vmxnet3 网卡名变化 修改为 VMXNET3 后，Ubuntu 中网卡名可能从 ens33 变成 ens160。\n这时需要检查所有写死旧网卡名的配置：\ngrep -R \"ens33\" /etc/keepalived /etc/haproxy /etc/netplan /etc/kubernetes /etc/systemd /etc/NetworkManager 2\u003e/dev/null 常见需要修改：\n/etc/netplan/*.yaml /etc/keepalived/keepalived.conf /etc/NetworkManager/system-connections/*.nmconnection Keepalived 中如果有：\nvrrp_instance VI_1 { interface ens33 } 改成：\nvrrp_instance VI_1 { interface ens160 } 如果有 track_interface，也要一起改：\ntrack_interface { ens160 } NetworkManager 中也可能残留旧网卡名，例如：\n/etc/NetworkManager/system-connections/有线连接 1.nmconnection:interface-name=ens33 推荐用 nmcli 修改，而不是直接编辑文件：\n# 查看所有连接 nmcli connection show nmcli connection modify \"有线连接 1\" connection.interface-name ens160 nmcli connection reload nmcli connection up \"有线连接 1\" 如果看到原本 ens160 挂在 有线连接 2 上，切换后变成：\nNAME TYPE DEVICE 有线连接 1 ethernet ens160 有线连接 2 ethernet -- 说明当前活动连接已经切到 有线连接 1。为了避免重启后又自动切回 有线连接 2，可以固定自动连接优先级：\nnmcli connection modify \"有线连接 1\" connection.autoconnect yes connection.autoconnect-priority 100 nmcli connection modify \"有线连接 2\" connection.autoconnect no 再确认 有线连接 1 的 IP、网关和 DNS 是否正确：\nnmcli connection show \"有线连接 1\" | egrep \"interface-name|ipv4.method|ipv4.addresses|ipv4.gateway|ipv4.dns|autoconnect\" ip addr show ens160 ip route 如果虚拟机所在宿主机有两个物理网口，需要注意：虚拟机内部的 ens160 只代表虚拟机里的网卡，不决定桥接到宿主机哪个物理网口。桥接到哪个物理网口，要在 VMware 的虚拟网络设置里配置，例如 VMnet0 桥接到指定宿主机网卡。Ubuntu 里只需要保证 ens160 配好固定 IP，Keepalived 也指向 ens160。\n改完后检查：\nnetplan apply systemctl restart keepalived systemctl status keepalived ip addr show ens160 ip addr | grep 192.168.3.217 三台主节点建议一台一台改。每改完一台，都确认：\nkubectl get nodes -o wide systemctl is-active kubelet containerd keepalived haproxy ip addr | grep 192.168.3.217 入口高可用 new-api、PostgreSQL、Redis 都做成高可用以后，还要检查入口层。否则业务 Pod 和数据库都还活着，但域名仍然可能无法访问。\n这次关闭 k8s-master3 / 192.168.3.216 后，k8s-master1 立刻出现：\nhaproxy[1067]: backend ingress_http_nodeport has no server available! haproxy[1067]: backend ingress_https_nodeport has no server available! 原因是 ingress-nginx-controller 只有 1 个副本，而且刚好运行在 k8s-master3：\nkubectl -n ingress-nginx get deploy ingress-nginx-controller kubectl -n ingress-nginx get pods -o wide kubectl -n ingress-nginx get svc ingress-nginx-controller -o yaml | grep -E \"type:|externalTrafficPolicy|nodePort\" 当唯一的 Ingress Controller 所在节点关闭后，Service Endpoints 被摘掉，HAProxy 检查的 NodePort 后端就全部不可用：\nHAProxy VIP: 192.168.3.231:80/443 Ingress NodePort: 32467/http, 32419/https Ingress Pod: 只有 1 个，并且在 k8s-master3 这说明入口层仍然是单点。修复思路是把 ingress-nginx-controller 扩成多个副本，并尽量分散到不同节点。\n现场恢复 Ingress 如果已经发生故障，可以先用 kubectl patch 现场恢复：\ncat \u003c\u003c'EOF' | kubectl -n ingress-nginx patch deploy ingress-nginx-controller \\ --type merge \\ --patch-file /dev/stdin { \"spec\": { \"replicas\": 3, \"template\": { \"spec\": { \"topologySpreadConstraints\": [ { \"maxSkew\": 1, \"topologyKey\": \"kubernetes.io/hostname\", \"whenUnsatisfiable\": \"ScheduleAnyway\", \"labelSelector\": { \"matchLabels\": { \"app.kubernetes.io/component\": \"controller\", \"app.kubernetes.io/instance\": \"ingress-nginx\", \"app.kubernetes.io/name\": \"ingress-nginx\" } } } ], \"affinity\": { \"podAntiAffinity\": { \"preferredDuringSchedulingIgnoredDuringExecution\": [ { \"weight\": 100, \"podAffinityTerm\": { \"topologyKey\": \"kubernetes.io/hostname\", \"labelSelector\": { \"matchLabels\": { \"app.kubernetes.io/component\": \"controller\", \"app.kubernetes.io/instance\": \"ingress-nginx\", \"app.kubernetes.io/name\": \"ingress-nginx\" } } } } ] } } } } } } EOF 等待 Ingress Controller 恢复：\nkubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=300s kubectl -n ingress-nginx get pods -o wide kubectl -n ingress-nginx get endpoints ingress-nginx-controller -o wide 检查 HAProxy 是否重新把 214、215 的 NodePort 标记为 UP：\njournalctl -u haproxy --since \"5 minutes ago\" --no-pager | grep ingress 期望看到类似：\nServer ingress_http_nodeport/k8s-master1 is UP Server ingress_https_nodeport/k8s-master1 is UP Server ingress_http_nodeport/k8s-master2 is UP Server ingress_https_nodeport/k8s-master2 is UP 最后验证域名：\ncurl -k -I https://k8s-ai.jihw.top/ curl -k https://k8s-ai.jihw.top/api/status 持久化 Ingress 配置 上面的 kubectl patch 是现场恢复手段。因为 ingress-nginx 是 Helm 安装的，后续 helm upgrade 可能覆盖手工 patch，所以要把配置写进 Helm values。\n创建 ingress-nginx-ha-values.yaml：\ncat \u003c\u003c'EOF' \u003e ingress-nginx-ha-values.yaml controller: # Ingress Controller 至少 2 个副本；三节点集群可以设置为 3。 replicaCount: 3 # 让多个 Ingress Controller 尽量分散到不同节点。 topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app.kubernetes.io/component: controller app.kubernetes.io/instance: ingress-nginx app.kubernetes.io/name: ingress-nginx affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: app.kubernetes.io/component: controller app.kubernetes.io/instance: ingress-nginx app.kubernetes.io/name: ingress-nginx service: # 保持 Cluster，任意节点的 NodePort 都可以转发到可用的 Ingress Pod。 externalTrafficPolicy: Cluster EOF 用 Helm 持久化：\nhelm upgrade ingress-nginx ingress-nginx/ingress-nginx \\ -n ingress-nginx \\ --reuse-values \\ -f ingress-nginx-ha-values.yaml 如果当前机器没有 ingress-nginx Helm repo，先添加：\nhelm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm repo update 再次确认：\nkubectl -n ingress-nginx get deploy ingress-nginx-controller kubectl -n ingress-nginx get pods -o wide kubectl -n ingress-nginx get svc ingress-nginx-controller -o yaml | grep externalTrafficPolicy curl -k -I https://k8s-ai.jihw.top/ 服务高可用 当前 new-api 部署是单副本：\nDeployment/new-api replicas: 1 Service/new-api ClusterIP Ingress/new-api k8s-ai.jihw.top 它依赖：\nPostgreSQL: postgresql.new-api.svc.cluster.local:5432 Redis: redis-master.new-api.svc.cluster.local:6379 PVC: new-api-data 所以 new-api 服务高可用要分两步做：\n第一步：先让 new-api 应用本身多副本、多节点分散。 第二步：再继续做 PostgreSQL 和 Redis 高可用。 注意：只做第一步时，如果 PostgreSQL 或 Redis 所在节点挂掉，new-api 仍然可能不可用。服务高可用只是先解决 new-api Pod 单点，不等于数据库高可用。\n先检查 PVC 当前部署里 new-api 挂载了 new-api-data：\nvolumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: new-api-data 先查看这个 PVC 的访问模式：\nkubectl get pvc new-api-data -n new-api kubectl describe pvc new-api-data -n new-api 如果是：\nACCESS MODES: RWO 就不能直接把 new-api 改成跨节点多副本。ReadWriteOnce 卷同一时间只能被一个节点以读写方式挂载。强行 replicas: 3 后，副本一旦分散到 214、215、216，可能出现：\nMulti-Attach error for volume \"new-api-data\" Volume is already exclusively attached to one node 处理方式有两个：\n推荐方式：让 new-api 尽量无状态化，不再挂载 /data PVC，业务状态放 PostgreSQL 和 Redis。 备选方式：把 new-api-data 改成支持 ReadWriteMany 的共享存储，例如 Longhorn RWX/NFS，再给多个副本共享挂载。 我当前优先选择第一种：new-api 应用层先无状态化，多副本通过 PostgreSQL 和 Redis 共享业务状态。日志继续使用 emptyDir，应用重启后日志不作为持久数据依赖。\n备份当前配置 修改前先导出现有配置：\nkubectl -n new-api get deploy new-api -o yaml \u003e new-api-deploy-before-ha.yaml kubectl -n new-api get svc new-api -o yaml \u003e new-api-svc-before-ha.yaml kubectl -n new-api get ingress new-api -o yaml \u003e new-api-ingress-before-ha.yaml 更新 new-api Deployment 下面这个版本做了几件事：\n1. replicas 改成 3。 2. 移除 /data PVC，让 new-api 先按无状态服务运行。 3. 用 topologySpreadConstraints 尽量分散到 3 个节点。 4. 用 podAntiAffinity 避免多个副本挤在同一个节点。 5. 增加 startupProbe，避免节点重启后 PostgreSQL/Redis 还没好时 new-api 反复被 liveness 杀掉。 6. 保留 readinessProbe，让未就绪的 Pod 不进入 Service endpoints。 应用：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: apps/v1 kind: Deployment metadata: name: new-api namespace: new-api spec: # 副本数改为 3，目标是任意 1 台节点故障时仍有其他副本可服务。 replicas: 3 # 保留原来的标签选择器，Service 仍然通过 app=new-api 找到这些 Pod。 selector: matchLabels: app: new-api # 控制滚动更新节奏，避免升级时一次性停掉太多副本。 strategy: type: RollingUpdate rollingUpdate: # 更新过程中最多额外创建 1 个 Pod。 maxSurge: 1 # 更新过程中至少保留原有可用副本，不主动减少可用容量。 maxUnavailable: 0 template: metadata: labels: app: new-api spec: # 尽量把 new-api 副本分散到不同节点。 # 当 3 个节点都可调度时，3 个副本会尽量落到 3 台机器上。 topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: new-api # 软性反亲和：尽量不要把多个 new-api 副本放在同一个节点。 # preferred 是软约束，资源紧张时仍允许调度，避免 Pod 一直 Pending。 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: app: new-api containers: - name: new-api image: calciumion/new-api:latest imagePullPolicy: IfNotPresent # 保留原来的启动参数，把日志写到 /app/logs。 args: - \"--log-dir\" - \"/app/logs\" ports: - name: http containerPort: 3000 # 继续从 ConfigMap 读取普通配置，例如 TZ、超时时间等。 envFrom: - configMapRef: name: new-api-config # 继续从 Secret 读取敏感配置，例如 SQL_DSN、REDIS_CONN_STRING。 - secretRef: name: new-api-secret env: # 把 Pod 名注入给应用，方便日志和排查区分副本。 - name: NODE_NAME valueFrom: fieldRef: fieldPath: metadata.name volumeMounts: # 日志目录使用 emptyDir。Pod 重建后日志会丢失， # 后续应通过 Loki/Promtail 或其他日志系统集中采集。 - name: logs mountPath: /app/logs # startupProbe 只在容器启动阶段生效。 # 节点重启后 PostgreSQL/Redis 可能比 new-api 慢，这里给应用更长启动窗口。 startupProbe: httpGet: path: /api/status port: 3000 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 36 # readinessProbe 决定 Pod 是否加入 Service endpoints。 # 如果 /api/status 失败，Ingress/Service 不会把新流量打到这个副本。 readinessProbe: httpGet: path: /api/status port: 3000 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 # livenessProbe 用来处理应用运行中卡死的情况。 # 有 startupProbe 后，liveness 会等 startupProbe 成功后才开始。 livenessProbe: httpGet: path: /api/status port: 3000 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 # 先给一个保守的资源请求，帮助调度器做容量判断。 # 后续根据压测和监控再调整。 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 1000m memory: 512Mi volumes: # emptyDir 的生命周期跟随 Pod。 # 这里只用于应用日志，不承载业务状态。 - name: logs emptyDir: {} EOF 如果确认 new-api 必须使用 /data 持久目录，就不要直接使用上面的无状态版本。应先把 new-api-data 改成 RWX 存储，再保留下面的挂载：\n# 只有在 new-api-data 支持 ReadWriteMany 时，才建议多副本共享挂载。 volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: new-api-data 确认 Service Service 不需要大改。它根据 app: new-api 自动把流量负载到所有 Ready 副本：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: Service metadata: name: new-api namespace: new-api spec: # ClusterIP 只在集群内部暴露，外部访问仍走 Ingress。 type: ClusterIP # 选择所有带 app=new-api 标签的 Pod。 selector: app: new-api ports: - name: http # Service 端口。 port: 3000 # Pod 容器端口。 targetPort: 3000 EOF 更严格地分散到三台节点 如果只使用下面这种配置：\ntopologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: [] 它表达的是“尽量分散”，不是“必须分散”。所以 Kubernetes 可能接受这种结果：\nk8s-master1 2 个 new-api Pod k8s-master2 0 个 new-api Pod k8s-master3 1 个 new-api Pod 如果想让 3 个 new-api 副本尽量变成：\nk8s-master1 1 个 k8s-master2 1 个 k8s-master3 1 个 可以把 podAntiAffinity 改成硬约束：同一个节点上不允许调度两个 app=new-api Pod。\n应用下面的 Deployment。重点是 requiredDuringSchedulingIgnoredDuringExecution：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: apps/v1 kind: Deployment metadata: name: new-api namespace: new-api spec: # 3 个副本，对应 3 台 master 节点。 replicas: 3 selector: matchLabels: app: new-api strategy: type: RollingUpdate rollingUpdate: # 允许滚动更新时多创建 1 个 Pod。 maxSurge: 1 # 更新过程中不主动减少可用副本。 maxUnavailable: 0 template: metadata: labels: app: new-api spec: affinity: podAntiAffinity: # 硬反亲和：同一台节点上不能同时存在两个 app=new-api Pod。 # topologyKey 使用 kubernetes.io/hostname，表示按节点维度分散。 requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: new-api topologyKey: kubernetes.io/hostname # 拓扑分散也改成硬约束。 # maxSkew=1 表示各节点之间的副本数量差距最多为 1。 # DoNotSchedule 表示如果无法满足分散要求，就不要把 Pod 调度上去。 topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: new-api containers: - name: new-api image: calciumion/new-api:latest imagePullPolicy: IfNotPresent args: - \"--log-dir\" - \"/app/logs\" ports: - name: http containerPort: 3000 envFrom: - configMapRef: name: new-api-config - secretRef: name: new-api-secret env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: metadata.name volumeMounts: - name: logs mountPath: /app/logs startupProbe: httpGet: path: /api/status port: 3000 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 36 readinessProbe: httpGet: path: /api/status port: 3000 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 livenessProbe: httpGet: path: /api/status port: 3000 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 1000m memory: 512Mi volumes: - name: logs emptyDir: {} EOF 注意硬反亲和的代价：\n1. 如果只有 2 台节点可调度，而 replicas=3，第 3 个 Pod 会 Pending。 2. 如果某台节点资源不足，Pod 也可能 Pending。 3. 已经运行的 Pod 不会因为规则变化自动搬家，需要触发重建。 因此这个策略适合当前这种 3 节点、3 副本的场景。任意 1 台节点挂掉时，剩下 2 个副本仍然可用；但 Kubernetes 不会在剩余 2 台节点上补出第 3 个副本，因为硬反亲和不允许同节点放两个 new-api。\n应用后检查 rollout：\nkubectl -n new-api rollout status deploy/new-api --timeout=300s kubectl -n new-api get pods -o wide 如果发现还是 2/1/0，说明旧 Pod 没有被重新调度。可以重启 Deployment 触发重新创建：\nkubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s kubectl -n new-api get pods -o wide 期望结果：\nnew-api-xxx 1/1 Running k8s-master1 new-api-yyy 1/1 Running k8s-master2 new-api-zzz 1/1 Running k8s-master3 如果有 Pod Pending，查看原因：\nkubectl -n new-api describe pod \u003cpending-pod-name\u003e kubectl describe node k8s-master1 kubectl describe node k8s-master2 kubectl describe node k8s-master3 常见原因：\ndidn't match pod anti-affinity rules Insufficient cpu Insufficient memory node(s) had untolerated taint 确认 Ingress Ingress 也不需要因为多副本而变化。Ingress 访问 Service，Service 再转发到多个 new-api Pod：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: new-api namespace: new-api annotations: # 使用已经创建好的 Let's Encrypt + 阿里云 DNS ClusterIssuer。 cert-manager.io/cluster-issuer: letsencrypt-alidns-prod spec: # 使用 ingress-nginx。 ingressClassName: nginx # HTTPS 证书 Secret，由 cert-manager 自动创建和续期。 tls: - hosts: - k8s-ai.jihw.top secretName: k8s-ai-jihw-top-tls rules: - host: k8s-ai.jihw.top http: paths: - path: / pathType: Prefix backend: service: # 后端仍然指向 new-api Service。 name: new-api port: number: 3000 EOF 增加 PodDisruptionBudget PDB 可以避免手动维护、驱逐、升级时一次性赶走太多 new-api 副本。它不能阻止机器突然宕机，但能减少人为操作导致的中断。\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: new-api namespace: new-api spec: # 至少保持 2 个 new-api 副本可用。 # 如果 replicas 暂时只设置 2，这里可以改成 minAvailable: 1。 minAvailable: 2 # 只约束 app=new-api 的 Pod。 selector: matchLabels: app: new-api EOF 检查发布结果 kubectl -n new-api rollout status deploy/new-api --timeout=300s kubectl -n new-api get pods -o wide kubectl -n new-api get endpoints new-api kubectl -n new-api get endpointslice -l kubernetes.io/service-name=new-api 期望看到 3 个 new-api Pod 分散在不同节点：\nnew-api-xxx 1/1 Running k8s-master1 new-api-yyy 1/1 Running k8s-master2 new-api-zzz 1/1 Running k8s-master3 测试集群内访问：\nkubectl -n new-api run curl-test \\ --rm -it \\ --restart=Never \\ --image=curlimages/curl -- \\ curl -sS http://new-api.new-api.svc.cluster.local:3000/api/status 测试外部域名：\ncurl -I https://k8s-ai.jihw.top 故障演练 确认当前 new-api 副本分布：\nkubectl -n new-api get pods -o wide 如果要模拟 k8s-master2 下线，可以先用非破坏性的方式把节点 cordon：\nkubectl cordon k8s-master2 然后删除该节点上的 new-api Pod，让 Deployment 在其他节点补副本：\nkubectl -n new-api delete pod -l app=new-api --field-selector spec.nodeName=k8s-master2 观察：\nkubectl -n new-api get pods -o wide -w kubectl -n new-api rollout status deploy/new-api --timeout=300s curl -I https://k8s-ai.jihw.top 演练结束后恢复调度：\nkubectl uncordon k8s-master2 真正关机测试前，先确认：\nkubectl -n longhorn-system get volumes.longhorn.io -o wide kubectl -n new-api get pods -o wide kubectl get nodes -o wide 如果 PostgreSQL 和 Redis 仍然是单实例，并且刚好在要关机的节点上，那么 new-api 多副本也不能保证业务可用。数据库高可用要放到下一阶段继续做。\n数据库高可用 当前数据库和缓存仍然是单点：\npostgresql-0 k8s-master2 / 192.168.3.215 redis-master-0 k8s-master2 / 192.168.3.215 即使 new-api 已经有 3 个副本，只要 192.168.3.215 宕机，PostgreSQL 和 Redis 就会一起不可用，new-api 仍然无法正常服务。\n当前 Helm release：\npostgresql bitnami/postgresql standalone redis bitnami/redis standalone 数据库高可用不要直接在旧实例上硬改。我的迁移原则是：\n1. 先备份。 2. 新建一套 HA 数据库/Redis。 3. 验证新实例可用。 4. 暂停 new-api 写入。 5. 做最终数据同步。 6. 切换 new-api Secret。 7. 验证通过后再考虑清理旧实例。 已经做过一次备份，保存在 k8s-master1 / 192.168.3.214：\n/root/k8s-ha-backup-20260602-153911/newapi-postgresql.dump /root/k8s-ha-backup-20260602-153911/redis-master.rdb 迁移前备份 正式切换前还要再做一次最终备份。先在 k8s-master1 创建备份目录：\nTS=$(date +%Y%m%d-%H%M%S) DIR=/root/k8s-ha-backup-$TS mkdir -p \"$DIR\" echo \"$DIR\" 备份 PostgreSQL：\nPGPASSWORD_VALUE=$(kubectl -n new-api get secret postgresql \\ -o jsonpath='{.data.password}' | base64 -d) kubectl -n new-api exec postgresql-0 -- bash -lc \\ \"PGPASSWORD='$PGPASSWORD_VALUE' pg_dump -U newapi -d newapi --format=custom --file=/tmp/newapi.dump\" kubectl -n new-api cp postgresql-0:/tmp/newapi.dump \"$DIR/newapi-postgresql.dump\" kubectl -n new-api exec postgresql-0 -- rm -f /tmp/newapi.dump 备份 Redis：\nREDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl -n new-api exec redis-master-0 -- bash -lc \\ \"redis-cli -a '$REDIS_PASSWORD_VALUE' --rdb /tmp/redis-master.rdb\" kubectl -n new-api cp redis-master-0:/tmp/redis-master.rdb \"$DIR/redis-master.rdb\" kubectl -n new-api exec redis-master-0 -- rm -f /tmp/redis-master.rdb 检查备份文件：\nls -lh \"$DIR\" PostgreSQL HA 方案 PostgreSQL 不建议只依赖 Longhorn 卷副本来当数据库 HA。Longhorn 解决的是存储副本，不等于 PostgreSQL 主从复制和主库故障转移。\n这里优先选择 CloudNativePG：\nCloudNativePG Operator -\u003e newapi-postgres Cluster -\u003e 3 个 PostgreSQL 实例 -\u003e 自动主从管理和故障转移 -\u003e newapi-postgres-rw Service 始终指向当前可写主库 参考：\nCloudNativePG 安装文档：https://cloudnative-pg.io/documentation/current/installation_upgrade/ CloudNativePG Bootstrap 文档：https://cloudnative-pg.io/documentation/current/bootstrap/ CloudNativePG Service 管理文档：https://cloudnative-pg.io/documentation/current/service_management/ CloudNativePG Backup/Recovery 文档：https://cloudnative-pg.io/documentation/current/backup_recovery/ CloudNativePG GitHub Release：https://github.com/cloudnative-pg/cloudnative-pg/releases 安装 Operator 安装 CloudNativePG Operator。版本要以 GitHub Release 页面为准，当前记录使用 1.29.1：\nkubectl apply --server-side -f \\ https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yaml 等待 Operator 正常：\nkubectl -n cnpg-system get pods kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager --timeout=300s kubectl get crd | grep postgresql.cnpg.io 可选安装 cnpg kubectl 插件，方便后续查看集群状态：\ncurl -sSfL https://github.com/cloudnative-pg/cloudnative-pg/raw/main/hack/install-cnpg-plugin.sh | sh -s -- -b /usr/local/bin kubectl cnpg version 如果不想安装插件也没关系，常规 kubectl get cluster、kubectl describe cluster 已经够用。\n创建业务用户 Secret 为了减少切换变量，先复用当前 Bitnami PostgreSQL 的 newapi 用户密码。这样后面 SQL_DSN 只需要把 host 从旧的：\npostgresql.new-api.svc.cluster.local 改成新的：\nnewapi-postgres-rw.new-api.svc.cluster.local 创建 CloudNativePG 使用的 basic-auth Secret：\nPG_APP_PASSWORD=$(kubectl -n new-api get secret postgresql \\ -o jsonpath='{.data.password}' | base64 -d) kubectl create secret generic newapi-cnpg-app \\ -n new-api \\ --type=kubernetes.io/basic-auth \\ --from-literal=username='newapi' \\ --from-literal=password=\"$PG_APP_PASSWORD\" \\ --dry-run=client -o yaml | kubectl apply -f - 确认 Secret 存在：\nkubectl -n new-api get secret newapi-cnpg-app 创建 3 实例集群 创建 3 实例 PostgreSQL 集群：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: newapi-postgres namespace: new-api spec: # 3 个 PostgreSQL 实例。任意 1 个实例所在节点故障时，仍有多数实例可用。 # CloudNativePG 会管理主从复制、主库选举和故障转移。 instances: 3 # 使用 PostgreSQL 18，和当前 Bitnami PostgreSQL 18 保持同一大版本，减少迁移兼容性风险。 # 实际生产中建议固定到明确小版本镜像，而不是长期使用浮动大版本。 imageName: ghcr.io/cloudnative-pg/postgresql:18 # 初始化业务库和业务用户。 # CloudNativePG 会创建 newapi 数据库，并把 owner 设置为 newapi。 bootstrap: initdb: database: newapi owner: newapi secret: name: newapi-cnpg-app # 每个 PostgreSQL 实例一个独立 PVC。 # Longhorn 负责每个 PVC 的底层副本，CloudNativePG 负责数据库复制和主从切换。 storage: storageClass: longhorn size: 10Gi # 启用 Pod 反亲和，让 3 个数据库实例尽量分散到不同节点。 # podAntiAffinityType=required 表示同一节点不放两个 PostgreSQL 实例。 affinity: enablePodAntiAffinity: true topologyKey: kubernetes.io/hostname podAntiAffinityType: required # 升级或维护时优先保障主库可用。 primaryUpdateStrategy: unsupervised # PostgreSQL 参数先保持保守，后续根据监控和压测再调。 postgresql: parameters: max_connections: \"200\" shared_buffers: \"256MB\" EOF 检查集群：\nkubectl -n new-api get cluster kubectl -n new-api describe cluster newapi-postgres kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide kubectl -n new-api get svc | grep newapi-postgres 期望看到类似服务：\nnewapi-postgres-rw 当前主库写入口 newapi-postgres-ro 只读副本入口 newapi-postgres-r 所有实例入口 其中 new-api 后续只应该连接 newapi-postgres-rw，因为它始终指向当前可写主库。\n等待 3 个实例都 Ready：\nkubectl -n new-api wait cluster/newapi-postgres \\ --for=condition=Ready \\ --timeout=600s kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide 如果有 Pod Pending，重点看是不是硬反亲和导致无法调度：\nkubectl -n new-api describe pod \u003cpending-pod-name\u003e 导入旧 PostgreSQL 数据 先用已有备份做一次演练恢复。正式切换前，还要停 new-api 后再做最终备份。\n创建临时恢复 Pod：\nkubectl -n new-api run pg-restore \\ --image=postgres:18-alpine \\ --restart=Never \\ --command -- sleep 3600 把备份文件拷进去。这里的 $DIR 是备份目录，例如 /root/k8s-ha-backup-20260602-153911：\nkubectl -n new-api cp \"$DIR/newapi-postgresql.dump\" pg-restore:/tmp/newapi-postgresql.dump kubectl -n new-api cp \"/root/k8s-ha-backup-20260602-153911/newapi-postgresql.dump\" pg-restore:/tmp/newapi-postgresql.dump 恢复到 CloudNativePG 的写入口：\nCNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \\ -o jsonpath='{.data.password}' | base64 -d) kubectl -n new-api exec pg-restore -- sh -lc \\ \"PGPASSWORD='$CNPG_PASSWORD' pg_restore \\ -h newapi-postgres-rw.new-api.svc.cluster.local \\ -p 5432 \\ -U newapi \\ -d newapi \\ --clean --if-exists \\ --no-owner \\ /tmp/newapi-postgresql.dump\" 这里加 --no-owner 是为了避免旧实例和新实例对象 owner 细节不一致时恢复失败。当前 owner 都是 newapi，不加通常也可以。\n测试连接：\nkubectl -n new-api exec pg-restore -- sh -lc \\ \"PGPASSWORD='$CNPG_PASSWORD' psql \\ -h newapi-postgres-rw.new-api.svc.cluster.local \\ -U newapi \\ -d newapi \\ -c 'select now();'\" 清理临时 Pod：\nkubectl -n new-api delete pod pg-restore 正式切换 new-api 到 CloudNativePG 正式切换时要先暂停 new-api，避免旧 PostgreSQL 继续产生新写入：\nkubectl -n new-api scale deploy/new-api --replicas=0 kubectl -n new-api rollout status deploy/new-api --timeout=120s || true 暂停后重新做最终备份：\nTS=$(date +%Y%m%d-%H%M%S) DIR=/root/k8s-ha-backup-final-$TS mkdir -p \"$DIR\" PGPASSWORD_VALUE=$(kubectl -n new-api get secret postgresql \\ -o jsonpath='{.data.password}' | base64 -d) kubectl -n new-api exec postgresql-0 -- bash -lc \\ \"PGPASSWORD='$PGPASSWORD_VALUE' pg_dump -U newapi -d newapi --format=custom --file=/tmp/newapi-final.dump\" kubectl -n new-api cp postgresql-0:/tmp/newapi-final.dump \"$DIR/newapi-postgresql-final.dump\" kubectl -n new-api exec postgresql-0 -- rm -f /tmp/newapi-final.dump 把最终备份恢复到 CloudNativePG：\nkubectl -n new-api run pg-restore-final \\ --image=postgres:18-alpine \\ --restart=Never \\ --command -- sleep 3600 kubectl -n new-api cp \"$DIR/newapi-postgresql-final.dump\" \\ pg-restore-final:/tmp/newapi-postgresql-final.dump CNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \\ -o jsonpath='{.data.password}' | base64 -d) kubectl -n new-api exec pg-restore-final -- sh -lc \\ \"PGPASSWORD='$CNPG_PASSWORD' pg_restore \\ -h newapi-postgres-rw.new-api.svc.cluster.local \\ -p 5432 \\ -U newapi \\ -d newapi \\ --clean --if-exists \\ --no-owner \\ /tmp/newapi-postgresql-final.dump\" kubectl -n new-api delete pod pg-restore-final 更新 new-api-secret。为了避免手动重新填写 SESSION_SECRET 和 CRYPTO_SECRET，先从旧 Secret 读出来：\nSESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SESSION_SECRET}' | base64 -d) CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d) REDIS_CONN_STRING_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d) kubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET=\"$SESSION_SECRET_VALUE\" \\ --from-literal=CRYPTO_SECRET=\"$CRYPTO_SECRET_VALUE\" \\ --from-literal=SQL_DSN=\"postgresql://newapi:$CNPG_PASSWORD@newapi-postgres-rw.new-api.svc.cluster.local:5432/newapi\" \\ --from-literal=REDIS_CONN_STRING=\"$REDIS_CONN_STRING_VALUE\" \\ --dry-run=client -o yaml | kubectl apply -f - 恢复 new-api：\nkubectl -n new-api scale deploy/new-api --replicas=3 kubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 验证 new-api 连接的是新 PostgreSQL：\nkubectl -n new-api get pods -o wide kubectl -n new-api logs deploy/new-api --tail=100 kubectl -n new-api exec deploy/new-api -- wget -qO- http://127.0.0.1:3000/api/status curl -k https://k8s-ai.jihw.top/api/status 确认 Secret 已经切到新 host：\nkubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SQL_DSN}' | base64 -d 期望包含：\nnewapi-postgres-rw.new-api.svc.cluster.local CloudNativePG 故障演练 查看当前主库：\nkubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide kubectl -n new-api get cluster newapi-postgres 如果安装了插件，可以更直观地看：\nkubectl cnpg status newapi-postgres -n new-api 删除当前 primary Pod 模拟主库故障：\nkubectl -n new-api delete pod \u003c当前primary-pod-name\u003e 观察故障转移：\nkubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide -w kubectl -n new-api get cluster newapi-postgres 业务验证：\ncurl -k https://k8s-ai.jihw.top/api/status 如果 newapi-postgres-rw 正常漂到新的主库，new-api 不需要改配置。\nCloudNativePG 回滚 如果切换后发现异常，先把 new-api 停掉：\nkubectl -n new-api scale deploy/new-api --replicas=0 把 new-api-secret 的 SQL_DSN 改回旧 PostgreSQL：\nSESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SESSION_SECRET}' | base64 -d) CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d) REDIS_CONN_STRING_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d) OLD_PG_PASSWORD=$(kubectl -n new-api get secret postgresql \\ -o jsonpath='{.data.password}' | base64 -d) kubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET=\"$SESSION_SECRET_VALUE\" \\ --from-literal=CRYPTO_SECRET=\"$CRYPTO_SECRET_VALUE\" \\ --from-literal=SQL_DSN=\"postgresql://newapi:$OLD_PG_PASSWORD@postgresql.new-api.svc.cluster.local:5432/newapi\" \\ --from-literal=REDIS_CONN_STRING=\"$REDIS_CONN_STRING_VALUE\" \\ --dry-run=client -o yaml | kubectl apply -f - kubectl -n new-api scale deploy/new-api --replicas=3 kubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 清理旧 PostgreSQL 切换稳定运行一段时间后，再考虑清理旧 postgresql Helm release 和旧 PVC。清理前必须确认：\n1. new-api 已经连接 newapi-postgres-rw。 2. CloudNativePG 3 个实例正常。 3. 最近一次最终备份文件存在，并且至少做过一次恢复验证。 4. 已经不打算回滚到旧 postgresql release。 先确认 new-api 当前 SQL_DSN：\nkubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SQL_DSN}' | base64 -d 期望包含：\nnewapi-postgres-rw.new-api.svc.cluster.local 确认 CloudNativePG 状态：\nkubectl -n new-api get cluster newapi-postgres kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide kubectl -n new-api get svc | grep newapi-postgres 确认旧 PostgreSQL 当前还在：\nhelm list -n new-api | grep postgresql kubectl -n new-api get pods,svc,pvc | grep postgresql 先只卸载旧 Helm release，不手动删 PVC：\nhelm uninstall postgresql -n new-api 卸载后检查旧 Pod 和 Service 是否消失：\nkubectl -n new-api get pods,svc | grep postgresql || true kubectl -n new-api get pvc | grep postgresql || true 通常 StatefulSet 的 PVC 会保留下来，例如：\ndata-postgresql-0 建议先保留这个 PVC 一段时间，给它打个标记，避免误删：\nkubectl -n new-api label pvc data-postgresql-0 app.kubernetes.io/archived=true --overwrite kubectl -n new-api annotate pvc data-postgresql-0 \\ archive-note=\"old bitnami postgresql pvc, keep temporarily after CloudNativePG migration\" \\ --overwrite 等确认 CloudNativePG 稳定运行、备份可恢复、并且不再需要旧数据盘后，再删除旧 PVC：\nkubectl -n new-api delete pvc data-postgresql-0 删除 PVC 会触发底层 Longhorn volume 回收，是否真正删除取决于 StorageClass 的 reclaimPolicy。删除前先确认：\nkubectl get storageclass longhorn -o yaml | grep reclaimPolicy kubectl -n longhorn-system get volumes.longhorn.io -o wide | grep pvc-25ed515e || true 如果暂时不确定，就不要删 PVC。保留旧 PVC 比误删数据库更安全。\nRedis HA 方案 new-api 官方集群部署文档提到，Redis 可以使用 Cluster 或 Sentinel。不过当前实际使用的 calciumion/new-api:latest 镜像会把 REDIS_CONN_STRING 按普通 Redis URL 解析，直接把 Sentinel 地址写进去会启动失败。因此这里后端仍然使用 Redis Sentinel 做故障转移，new-api 前面再加一个 redis-master-proxy，让 new-api 只看到普通 Redis URL。\n参考：\nNew API Cluster Deployment：https://docs.newapi.ai/en/docs/installation/deployment-methods/cluster-deployment Bitnami Redis values：https://github.com/bitnami/charts/blob/main/bitnami/redis/values.yaml Redis Sentinel 官方文档：https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/ 这里优先使用 Redis Sentinel：\nRedis master + replicas + Sentinel -\u003e Sentinel 负责发现主库故障并触发主从切换 -\u003e redis-master-proxy 只转发到当前 master -\u003e new-api 使用普通 Redis URL 连接 redis-master-proxy 当前 Redis 是 Bitnami 单实例：\nredis-master-0 k8s-master2 / 192.168.3.215 Service redis-master.new-api.svc.cluster.local:6379 PVC redis-data-redis-master-0 Redis HA 迁移原则：\n1. 不直接覆盖旧 redis release。 2. 新建 redis-ha release。 3. 验证 Sentinel 能找到 master。 4. 再切换 new-api Secret。 5. 旧 Redis 和旧 PVC 先保留，确认稳定后再清理。 迁移前备份 Redis Redis 对 new-api 来说主要是缓存和性能增强，但切换前仍然建议导出一份 RDB，保留回滚证据。\nTS=$(date +%Y%m%d-%H%M%S) DIR=/root/k8s-ha-backup-redis-$TS mkdir -p \"$DIR\" REDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl -n new-api exec redis-master-0 -- bash -lc \\ \"redis-cli -a '$REDIS_PASSWORD_VALUE' --rdb /tmp/redis-master.rdb\" kubectl -n new-api cp redis-master-0:/tmp/redis-master.rdb \"$DIR/redis-master.rdb\" kubectl -n new-api exec redis-master-0 -- rm -f /tmp/redis-master.rdb ls -lh \"$DIR\" 如果只是缓存数据，后续可以不导入到新 Redis，让新 Redis 从空库启动。new-api 的核心业务数据在 PostgreSQL，不应该依赖 Redis 作为主数据源。\n准备 Redis HA values 先从旧 Redis Secret 读取密码。这里选择复用旧密码，减少 new-api 切换变量：\nREDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \\ -o jsonpath='{.data.redis-password}' | base64 -d) 创建一个带注释的 values 文件。这里不要使用单引号包裹 EOF，让 shell 能把 $REDIS_PASSWORD_VALUE 写入 values 文件：\ncat \u003c\u003cEOF \u003e redis-ha-values.yaml # 使用主从复制架构，而不是 standalone 单实例。 architecture: replication auth: # 开启 Redis 密码认证。 enabled: true # Sentinel 也使用同一个认证密码。 sentinel: true # 复用旧 Redis 密码，变量来自 REDIS_PASSWORD_VALUE。 password: \"$REDIS_PASSWORD_VALUE\" # Redis 主容器镜像。 # 不直接使用 registry-1.docker.io，是为了避免 Docker Hub 匿名拉取限流。 # Kubernetes 使用 containerd 拉镜像，节点上执行 docker login 不一定会被 containerd 使用。 image: registry: public.ecr.aws repository: bitnami/redis tag: latest master: persistence: # master 持久化数据，避免 Pod 重建后数据丢失。 enabled: true storageClass: longhorn size: 5Gi # master 尽量不要和其他 Redis Pod 落在同一个节点。 podAntiAffinityPreset: hard replica: # Sentinel 模式下，Bitnami chart 会创建 redis-ha-node StatefulSet。 # replicaCount=3 表示创建 3 个 redis-ha-node Pod。 # 每个 Pod 里都有 redis 和 sentinel 两个容器，master 会在这些节点中选出。 replicaCount: 3 persistence: # replica 也持久化，故障切换后可以继续承接角色。 enabled: true storageClass: longhorn size: 5Gi # replica 之间也尽量分散到不同节点。 podAntiAffinityPreset: hard sentinel: # 开启 Sentinel。Sentinel 会监控 master，并在故障时触发 failover。 enabled: true # Sentinel 容器镜像，同样切到 public.ecr.aws，避免 Docker Hub 429。 image: registry: public.ecr.aws repository: bitnami/redis-sentinel tag: latest # Sentinel 使用这个 masterSet 名称管理主从切换。 masterSet: mymaster # 3 个 Sentinel 中至少 2 个认为 master 故障，才触发故障转移。 quorum: 2 # master 被认为不可达的时间。家庭集群先用默认值即可。 # downAfterMilliseconds: 60000 # 故障转移超时时间。先使用默认值即可。 # failoverTimeout: 180000 EOF 确认 values 文件已生成。不要把包含真实密码的文件提交到 Git：\ngrep -n \"password:\" redis-ha-values.yaml 安装 redis-ha 为了降低风险，不直接改旧的 redis release，先安装新的 redis-ha release：\nhelm upgrade --install redis-ha \\ oci://registry-1.docker.io/bitnamicharts/redis \\ --namespace new-api \\ -f redis-ha-values.yaml 如果遇到 Docker Hub 未认证拉取限制：\ntoomanyrequests: You have reached your unauthenticated pull rate limit 优先确认 redis-ha-values.yaml 里已经把 Redis 和 Sentinel 镜像切到了 public.ecr.aws。如果仍然要从 Docker Hub 拉 chart，可以再登录 Docker Hub：\nhelm registry login registry-1.docker.io helm pull oci://registry-1.docker.io/bitnamicharts/redis 注意：helm registry login 只解决 Helm 拉 chart 的认证；Pod 镜像由 kubelet/containerd 拉取，普通的 docker login 不会自动让 Kubernetes 使用 Docker Hub 凭据。\n如果某个节点拉取失败，而其他节点正常，先不要急着判断节点网络不同。很可能是其他节点已经有本地镜像缓存：\ncrictl images | grep bitnami kubectl -n new-api describe pod \u003credis-ha-node-x\u003e | tail -n 80 例如 214、215 上已经存在 registry-1.docker.io/bitnami/redis 和 registry-1.docker.io/bitnami/redis-sentinel 缓存，所以 Pod 可以直接启动；216 没有对应缓存时，就会继续访问 Docker Hub 或镜像加速器，从而遇到 429 Too Many Requests 或镜像加速器 403 Forbidden。\n等待 Redis HA 启动：\nkubectl -n new-api get statefulset redis-ha-node -o wide kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide kubectl -n new-api get svc | grep redis-ha kubectl -n new-api get pvc | grep redis-ha 期望 Redis HA Pod 分散到不同节点。注意：开启 Sentinel 后，Bitnami chart 创建的是 redis-ha-node，不是 redis-ha-master：\nredis-ha-node-0 2/2 Running k8s-master1 redis-ha-node-1 2/2 Running k8s-master2 redis-ha-node-2 2/2 Running k8s-master3 2/2 表示这个 Pod 中的 redis 容器和 sentinel 容器都已经 Running。\n如果之前按旧配置装成了 2 个节点，可以先确认：\nkubectl -n new-api get statefulset redis-ha-node 如果看到：\nredis-ha-node 2/2 说明当前只有 2 个 Redis/Sentinel 节点，不适合作为三节点高可用。应把 redis-ha-values.yaml 中的 replica.replicaCount 改为 3，然后执行：\nhelm upgrade redis-ha \\ oci://registry-1.docker.io/bitnamicharts/redis \\ --namespace new-api \\ -f redis-ha-values.yaml 如果 Helm 拉 chart 被 Docker Hub 限流，临时扩容可以先执行：\nkubectl -n new-api scale statefulset redis-ha-node --replicas=3 但这只是临时修正，后续仍然要把 Helm values 改成 replicaCount: 3，否则下次 Helm upgrade 可能又回到 2 个。\n验证 Sentinel 查看 Sentinel Service。开启 Sentinel 后，Bitnami Redis 通常会暴露 26379：\nkubectl -n new-api get svc | grep redis-ha 查看 Sentinel 是否能发现 master：\nREDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl -n new-api run redis-ha-test \\ --rm -it \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster 如果返回 master 地址和端口，说明 Sentinel 正常。\n也可以看更详细的 master 信息：\nkubectl -n new-api run redis-ha-test \\ --rm -it \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL master mymaster 测试通过 Sentinel 找到的 master 是否能写入。先取 master 地址：\nMASTER_INFO=$(kubectl -n new-api run redis-ha-test \\ --rm -i \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster) echo \"$MASTER_INFO\" 输出通常是两行，第一行是 master 地址，第二行是端口。\n如果只是验证 Redis 密码和连通性，可以随便选一个 redis-ha-node-* Pod。注意：REDIS_HA_PASSWORD 是宿主机 shell 里的变量，进入 Pod 后不会自动存在。Bitnami Redis 容器里的密码挂载在 /opt/bitnami/redis/secrets/redis-password：\nkubectl -n new-api exec redis-ha-node-0 -c redis -- sh -lc \\ '/opt/bitnami/redis/bin/redis-cli --no-auth-warning \\ -a \"$(cat /opt/bitnami/redis/secrets/redis-password)\" ping' 期望返回：\nPONG 如果要测试写入，必须写到当前 master。不能随便选一个 Redis Pod 写入，因为 replica 是只读的。\nREDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) MASTER_HOST=$(kubectl -n new-api exec redis-ha-node-0 -c sentinel -- \\ redis-cli --no-auth-warning --raw \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster | sed -n '1p') echo \"$MASTER_HOST\" kubectl -n new-api exec redis-ha-node-0 -c redis -- \\ /opt/bitnami/redis/bin/redis-cli --no-auth-warning \\ -h \"$MASTER_HOST\" \\ -a \"$REDIS_HA_PASSWORD\" \\ set newapi:ha:test ok kubectl -n new-api exec redis-ha-node-0 -c redis -- \\ /opt/bitnami/redis/bin/redis-cli --no-auth-warning \\ -h \"$MASTER_HOST\" \\ -a \"$REDIS_HA_PASSWORD\" \\ get newapi:ha:test 期望返回：\nOK ok 给 new-api 准备 Redis master 代理 实际验证时需要注意：calciumion/new-api:latest 当前会把 REDIS_CONN_STRING 当作普通 Redis URL 解析。如果直接配置成：\nredis://redis-ha.new-api.svc.cluster.local:26379/0 new-api 会把 Sentinel 端口当成普通 Redis 端口去 PING，容易出现：\nRedis ping test failed: NOAUTH Authentication required. 如果改成纯 Sentinel 地址列表，例如：\nredis-ha-node-0.redis-ha-headless.new-api.svc.cluster.local:26379,... new-api 又会报：\nfailed to parse Redis connection string: redis: invalid URL scheme 所以这里加一个 redis-master-proxy。它用 HAProxy 检查 3 个 Redis 节点的 role:master，只把流量转发给当前 master。new-api 继续使用普通 Redis URL，Redis master 变更时由 HAProxy 自动切换后端。\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: ConfigMap metadata: name: redis-master-proxy-config namespace: new-api data: haproxy.cfg.tpl: | global log stdout format raw local0 defaults mode tcp log global timeout connect 5s timeout client 1m timeout server 1m frontend redis_front bind *:6379 default_backend redis_master backend redis_master mode tcp option tcp-check # 使用 Redis 密码做健康检查。 tcp-check connect tcp-check send AUTH\\ __REDIS_PASSWORD__\\r\\n tcp-check expect string +OK # 只把 role:master 的 Redis 节点标记为可用。 tcp-check send INFO\\ replication\\r\\n tcp-check expect string role:master tcp-check send QUIT\\r\\n server redis-0 redis-ha-node-0.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1 server redis-1 redis-ha-node-1.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1 server redis-2 redis-ha-node-2.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1 --- apiVersion: apps/v1 kind: Deployment metadata: name: redis-master-proxy namespace: new-api spec: # 两个代理副本，避免代理自己变成单点。 replicas: 2 selector: matchLabels: app: redis-master-proxy template: metadata: labels: app: redis-master-proxy spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: redis-master-proxy containers: - name: haproxy image: public.ecr.aws/docker/library/haproxy:2.9-alpine imagePullPolicy: IfNotPresent ports: - name: redis containerPort: 6379 command: - sh - -ec - | # Redis 密码来自 redis-ha Secret，避免明文写死到 ConfigMap。 REDIS_PASSWORD=\"$(cat /opt/redis-secret/redis-password)\" awk -v pass=\"$REDIS_PASSWORD\" '{gsub(/__REDIS_PASSWORD__/, pass)}1' /config/haproxy.cfg.tpl \u003e /tmp/haproxy.cfg exec haproxy -f /tmp/haproxy.cfg readinessProbe: tcpSocket: port: 6379 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 6 livenessProbe: tcpSocket: port: 6379 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 6 resources: requests: cpu: 20m memory: 32Mi limits: cpu: 200m memory: 128Mi volumeMounts: - name: config mountPath: /config - name: redis-password mountPath: /opt/redis-secret readOnly: true volumes: - name: config configMap: name: redis-master-proxy-config - name: redis-password secret: secretName: redis-ha --- apiVersion: v1 kind: Service metadata: name: redis-master-proxy namespace: new-api spec: type: ClusterIP selector: app: redis-master-proxy ports: - name: redis port: 6379 targetPort: 6379 EOF 等待代理启动：\nkubectl -n new-api rollout status deploy/redis-master-proxy --timeout=240s kubectl -n new-api get pods -l app=redis-master-proxy -o wide 这里 redis-master-proxy 设置为 2 个副本是有意的。它只是代理层，不是 Redis 数据节点：\nnew-api -\u003e redis-master-proxy -\u003e 当前 Redis master 真正的 Redis 高可用由 redis-ha-node StatefulSet 承担，应该是 3 个 Redis/Sentinel 节点：\nkubectl -n new-api get statefulset redis-ha-node kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide redis-master-proxy 只需要避免自己成为单点，所以 2 个副本已经可以满足：\nproxy-1 故障，还有 proxy-2 proxy-2 故障，还有 proxy-1 三节点家庭集群里，如果节点资源充足，也可以改成 3 个副本：\nkubectl -n new-api scale deploy redis-master-proxy --replicas=3 但当前集群 CPU 压力偏高，2 个代理副本更合适。优先保障 Redis 数据节点、PostgreSQL、Ingress 和存储组件稳定。\n验证代理是否指向当前 master：\nREDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl -n new-api exec redis-ha-node-0 -c redis -- \\ /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \\ -h redis-master-proxy.new-api.svc.cluster.local \\ -a \"$REDIS_HA_PASSWORD\" \\ role kubectl -n new-api exec redis-ha-node-0 -c redis -- \\ /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \\ -h redis-master-proxy.new-api.svc.cluster.local \\ -a \"$REDIS_HA_PASSWORD\" \\ set newapi:proxy:test ok kubectl -n new-api exec redis-ha-node-0 -c redis -- \\ /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \\ -h redis-master-proxy.new-api.svc.cluster.local \\ -a \"$REDIS_HA_PASSWORD\" \\ get newapi:proxy:test 期望看到 role 第一行是：\nmaster 切换 new-api 到 Redis master 代理 先读取当前 new-api Secret，保留 PostgreSQL 配置不变，只替换 Redis 相关配置：\nSESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SESSION_SECRET}' | base64 -d) CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d) SQL_DSN_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SQL_DSN}' | base64 -d) REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) 更新 new-api-secret：\nkubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET=\"$SESSION_SECRET_VALUE\" \\ --from-literal=CRYPTO_SECRET=\"$CRYPTO_SECRET_VALUE\" \\ --from-literal=SQL_DSN=\"$SQL_DSN_VALUE\" \\ --from-literal=REDIS_CONN_STRING=\"redis://:$REDIS_HA_PASSWORD@redis-master-proxy.new-api.svc.cluster.local:6379/0\" \\ --from-literal=REDIS_PASSWORD=\"$REDIS_HA_PASSWORD\" \\ --dry-run=client -o yaml | kubectl apply -f - 这里的含义：\nREDIS_CONN_STRING 指向 redis-master-proxy 的普通 Redis URL，并在 URL 中携带密码 REDIS_PASSWORD 保留 Redis 密码，方便后续脚本或兼容配置读取 重启 new-api，让环境变量生效：\nkubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 验证 new-api：\nkubectl -n new-api get pods -o wide kubectl -n new-api logs deploy/new-api --tail=120 curl -k https://k8s-ai.jihw.top/api/status 如果日志里看到 Redis 连接失败，先把 new-api-secret 打出来确认：\nkubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d echo 期望看到：\nredis://:\u003credis-password\u003e@redis-master-proxy.new-api.svc.cluster.local:6379/0 如果看到 redis-ha.new-api.svc.cluster.local:26379 或纯 Sentinel 地址列表，说明还没有切到代理方式，new-api 可能会因为 Redis 连接串解析失败而无法启动。\nRedis Sentinel 故障演练 先查看当前 master：\nREDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl -n new-api run redis-ha-test \\ --rm -it \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster 再查看 Redis HA Pod 分布：\nkubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide 根据 Sentinel 返回的 master 地址，找到当前 master Pod。比如返回：\nredis-ha-node-1.redis-ha-headless.new-api.svc.cluster.local 6379 当前 master Pod 就是 redis-ha-node-1。\n删除当前 master Pod，模拟故障。Pod 名以实际输出为准：\nkubectl -n new-api delete pod \u003credis-ha-node-master-pod\u003e 观察是否选出新 master：\nkubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide -w 重新查询 master：\nkubectl -n new-api run redis-ha-test \\ --rm -it \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster 最后验证 new-api：\ncurl -k https://k8s-ai.jihw.top/api/status kubectl -n new-api logs deploy/new-api --tail=100 Redis HA 回滚 如果切换 Redis HA 后 new-api 异常，可以把 Secret 改回旧 Redis。\nSESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SESSION_SECRET}' | base64 -d) CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d) SQL_DSN_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SQL_DSN}' | base64 -d) OLD_REDIS_PASSWORD=$(kubectl -n new-api get secret redis \\ -o jsonpath='{.data.redis-password}' | base64 -d) kubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET=\"$SESSION_SECRET_VALUE\" \\ --from-literal=CRYPTO_SECRET=\"$CRYPTO_SECRET_VALUE\" \\ --from-literal=SQL_DSN=\"$SQL_DSN_VALUE\" \\ --from-literal=REDIS_CONN_STRING=\"redis://:$OLD_REDIS_PASSWORD@redis-master.new-api.svc.cluster.local:6379/0\" \\ --dry-run=client -o yaml | kubectl apply -f - kubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 清理旧 Redis Redis HA 稳定运行一段时间后，再清理旧 Redis。\n注意：不要只删除 redis-master-0 这个 Pod。旧 Redis 是通过 Helm 安装的 StatefulSet 管理的，只删除 Pod 后，StatefulSet 会自动把它重新拉起来。\n正确做法是：\n1. 先确认 new-api 已经切到 Redis HA。 2. 再卸载旧的 redis Helm release。 3. 旧 PVC 先保留，用于回滚。 4. 稳定运行一段时间后，再删除旧 PVC。 先确认 new-api 已经指向 Redis master 代理：\nkubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d echo 期望看到：\nredis-master-proxy.new-api.svc.cluster.local:6379 也可以确认旧 Redis 和新 Redis HA 当前都在：\nkubectl -n new-api get pods,svc | grep redis 如果业务已经稳定使用 redis-master-proxy，先只卸载旧 Helm release，不急着删除 PVC：\nhelm uninstall redis -n new-api 再次检查 Redis 相关资源：\nkubectl -n new-api get pods,svc | grep redis || true kubectl -n new-api get pvc | grep redis || true 这时旧的 redis-master-0 Pod 和 redis-master Service 应该已经消失，但旧 PVC 通常会保留，例如：\nredis-data-redis-master-0 建议先给旧 PVC 打标记，明确它是旧单实例 Redis 的数据卷：\nkubectl -n new-api label pvc redis-data-redis-master-0 app.kubernetes.io/archived=true --overwrite kubectl -n new-api annotate pvc redis-data-redis-master-0 \\ archive-note=\"old bitnami standalone redis pvc, keep temporarily after redis-ha migration\" \\ --overwrite 如果后续发现 Redis HA 异常，可以参考上一节回滚，把 new-api Secret 改回旧 Redis；所以刚卸载 release 后不要马上删除 PVC。\n确认稳定运行一段时间、不再需要回滚后，再删除旧 PVC：\nkubectl -n new-api delete pvc redis-data-redis-master-0 可选：一次性切换 new-api 到 PostgreSQL 和 Redis HA 后端 如果 PostgreSQL 的 CloudNativePG 和 Redis Sentinel 都已经验证完成，也可以一次性把 new-api 切到两个 HA 后端。\n如果只是单独做 Redis HA，优先使用上一节 切换 new-api 到 Redis Sentinel，不要重复执行这里。\n一次性切换前先暂停 new-api，避免新旧数据库之间继续产生写入差异：\nkubectl -n new-api scale deploy/new-api --replicas=0 再做一次最终 PostgreSQL 备份并恢复到 CloudNativePG。确认完成后，更新 new-api-secret。\n先读取需要保留或复用的 Secret 值：\nSESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.SESSION_SECRET}' | base64 -d) CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \\ -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d) CNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \\ -o jsonpath='{.data.password}' | base64 -d) REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \\ -o jsonpath='{.data.redis-password}' | base64 -d) 更新 new-api-secret：\nkubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET=\"$SESSION_SECRET_VALUE\" \\ --from-literal=CRYPTO_SECRET=\"$CRYPTO_SECRET_VALUE\" \\ --from-literal=SQL_DSN=\"postgresql://newapi:$CNPG_PASSWORD@newapi-postgres-rw.new-api.svc.cluster.local:5432/newapi\" \\ --from-literal=REDIS_CONN_STRING=\"redis://:$REDIS_HA_PASSWORD@redis-master-proxy.new-api.svc.cluster.local:6379/0\" \\ --from-literal=REDIS_PASSWORD=\"$REDIS_HA_PASSWORD\" \\ --dry-run=client -o yaml | kubectl apply -f - 恢复 new-api 副本：\nkubectl -n new-api scale deploy/new-api --replicas=3 kubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 验证：\nkubectl -n new-api get pods -o wide kubectl -n new-api logs deploy/new-api --tail=100 curl -k https://k8s-ai.jihw.top/api/status 故障演练 PostgreSQL HA 演练：\nkubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide kubectl -n new-api get cluster newapi-postgres 找到当前 primary 所在 Pod 后，删除它模拟故障：\nkubectl -n new-api delete pod \u003ccnpg-primary-pod\u003e 观察是否自动切换：\nkubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide -w kubectl -n new-api exec deploy/new-api -- wget -qO- http://127.0.0.1:3000/api/status Redis Sentinel 演练：\nkubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide # 根据 SENTINEL get-master-addr-by-name mymaster 的返回结果， # 删除当前 master 所在的 redis-ha-node-* Pod。 kubectl -n new-api delete pod \u003credis-ha-node-master-pod\u003e 观察 Sentinel 是否选出新 master：\nkubectl -n new-api run redis-ha-test \\ --rm -it \\ --restart=Never \\ --image=redis:7-alpine \\ -- redis-cli \\ -h redis-ha.new-api.svc.cluster.local \\ -p 26379 \\ -a \"$REDIS_HA_PASSWORD\" \\ SENTINEL get-master-addr-by-name mymaster 最后测试业务域名：\ncurl -k https://k8s-ai.jihw.top/api/status 回滚思路 切换后先保留旧的 Helm release：\npostgresql redis 如果新 HA 后端异常，回滚方式是把 new-api-secret 里的：\nSQL_DSN REDIS_CONN_STRING REDIS_PASSWORD 改回旧 PostgreSQL 和旧 Redis，然后重启 new-api：\nkubectl -n new-api rollout restart deploy/new-api kubectl -n new-api rollout status deploy/new-api --timeout=300s 确认 HA 后端稳定运行一段时间后，再考虑卸载旧实例：\nhelm uninstall postgresql -n new-api helm uninstall redis -n new-api 卸载前必须确认备份可用，并明确是否保留旧 PVC。\n平台组件高可用 业务服务恢复后，还要检查平台组件。关闭 k8s-master3 时，headlamp.jihw.top、grafana.jihw.top 和 kubectl top nodes 都出现过不可用，说明运维入口和指标服务也存在单点。\n先检查当前分布：\nkubectl get pods -A -o wide | grep -Ei \"headlamp|grafana|metrics-server\" kubectl get deploy -A | grep -Ei \"headlamp|grafana|metrics-server\" kubectl get ingress -A | grep -Ei \"headlamp|grafana\" kubectl top nodes Headlamp 高可用 Headlamp 是无状态 Web UI，可以直接扩成 2 个副本，并分散到不同节点。\ncat \u003c\u003c'EOF' | kubectl -n headlamp patch deploy headlamp \\ --type merge \\ --patch-file /dev/stdin { \"spec\": { \"replicas\": 2, \"template\": { \"spec\": { \"topologySpreadConstraints\": [ { \"maxSkew\": 1, \"topologyKey\": \"kubernetes.io/hostname\", \"whenUnsatisfiable\": \"ScheduleAnyway\", \"labelSelector\": { \"matchLabels\": { \"app.kubernetes.io/instance\": \"headlamp\", \"app.kubernetes.io/name\": \"headlamp\" } } } ], \"affinity\": { \"podAntiAffinity\": { \"preferredDuringSchedulingIgnoredDuringExecution\": [ { \"weight\": 100, \"podAffinityTerm\": { \"topologyKey\": \"kubernetes.io/hostname\", \"labelSelector\": { \"matchLabels\": { \"app.kubernetes.io/instance\": \"headlamp\", \"app.kubernetes.io/name\": \"headlamp\" } } } } ] } } } } } } EOF 如果某台节点负载过高，Headlamp 探针可能超时。当前 k8s-master1 上 Longhorn manager 不稳定、CPU 压力也比较高，所以我临时把 Headlamp 限制在 k8s-master2 和 k8s-master3：\ncat \u003c\u003c'EOF' | kubectl -n headlamp patch deploy headlamp \\ --type strategic \\ --patch-file /dev/stdin { \"spec\": { \"template\": { \"spec\": { \"affinity\": { \"nodeAffinity\": { \"requiredDuringSchedulingIgnoredDuringExecution\": { \"nodeSelectorTerms\": [ { \"matchExpressions\": [ { \"key\": \"kubernetes.io/hostname\", \"operator\": \"In\", \"values\": [\"k8s-master2\", \"k8s-master3\"] } ] } ] } }, \"podAntiAffinity\": { \"preferredDuringSchedulingIgnoredDuringExecution\": [ { \"weight\": 100, \"podAffinityTerm\": { \"topologyKey\": \"kubernetes.io/hostname\", \"labelSelector\": { \"matchLabels\": { \"app.kubernetes.io/instance\": \"headlamp\", \"app.kubernetes.io/name\": \"headlamp\" } } } } ] } } } } } } EOF 验证：\nkubectl -n headlamp rollout status deploy/headlamp --timeout=240s kubectl -n headlamp get pods -o wide curl -k -I https://headlamp.jihw.top/ metrics-server 高可用 kubectl top nodes 依赖 metrics-server。如果 metrics-server 只有 1 个副本，并且刚好在故障节点上，Metrics API 会短暂不可用：\nerror: Metrics API not available metrics-server 可以扩成 2 个副本：\ncat \u003c\u003c'EOF' | kubectl -n kube-system patch deploy metrics-server \\ --type merge \\ --patch-file /dev/stdin { \"spec\": { \"replicas\": 2, \"template\": { \"spec\": { \"topologySpreadConstraints\": [ { \"maxSkew\": 1, \"topologyKey\": \"kubernetes.io/hostname\", \"whenUnsatisfiable\": \"ScheduleAnyway\", \"labelSelector\": { \"matchLabels\": { \"k8s-app\": \"metrics-server\" } } } ], \"affinity\": { \"podAntiAffinity\": { \"preferredDuringSchedulingIgnoredDuringExecution\": [ { \"weight\": 100, \"podAffinityTerm\": { \"topologyKey\": \"kubernetes.io/hostname\", \"labelSelector\": { \"matchLabels\": { \"k8s-app\": \"metrics-server\" } } } } ] } } } } } } EOF 验证：\nkubectl -n kube-system rollout status deploy/metrics-server --timeout=240s kubectl -n kube-system get pods -l k8s-app=metrics-server -o wide kubectl get apiservice v1beta1.metrics.k8s.io kubectl top nodes 如果某个节点显示 \u003cunknown\u003e，看 metrics-server 日志：\nkubectl -n kube-system logs deploy/metrics-server --tail=120 常见原因是 metrics-server 暂时抓不到该节点 kubelet：\nFailed to scrape node, timeout to access kubelet 稍等一个采集周期后通常会恢复。如果长期不恢复，再检查目标节点的 kubelet 和 10250 端口。\nGrafana 恢复和高可用限制 Grafana 当前使用：\nSQLite 数据库 RWO PVC: kube-prometheus-stack-grafana StorageClass: longhorn-single 这类配置不能简单把 Grafana 改成多副本。两个 Grafana Pod 同时挂同一个 RWO 卷，或者同时访问同一个 grafana.db，容易出现：\nMulti-Attach error Readiness probe failed grafana.db 锁竞争 所以当前先做“单副本快速恢复”，不是“真正 Grafana 多副本 HA”。\n这次 Grafana 503 的直接原因是：旧 Pod 占着 RWO PVC，新 Pod 被调度到其他节点后卷无法 attach。恢复时先避免滚动双开，把策略改成 Recreate，再让 Grafana 只启动 1 个副本。\ncat \u003c\u003c'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \\ --type merge \\ --patch-file /dev/stdin { \"spec\": { \"strategy\": { \"type\": \"Recreate\", \"rollingUpdate\": null } } } EOF 当前 k8s-master1 在 Longhorn 视角不是 Ready，Grafana 卷不能挂到 master1，所以临时把 Grafana 固定到 k8s-master2：\ncat \u003c\u003c'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \\ --type merge \\ --patch-file /dev/stdin { \"spec\": { \"template\": { \"spec\": { \"nodeSelector\": { \"kubernetes.io/hostname\": \"k8s-master2\" } } } } } EOF Grafana 启动较慢，默认 liveness 太激进会在插件和 Dashboard 加载完成前杀掉容器。放宽探针：\ncat \u003c\u003c'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \\ --type strategic \\ --patch-file /dev/stdin { \"spec\": { \"template\": { \"spec\": { \"containers\": [ { \"name\": \"grafana\", \"livenessProbe\": { \"httpGet\": { \"path\": \"/api/health\", \"port\": \"grafana\" }, \"initialDelaySeconds\": 300, \"timeoutSeconds\": 30, \"periodSeconds\": 10, \"failureThreshold\": 10 }, \"readinessProbe\": { \"httpGet\": { \"path\": \"/api/health\", \"port\": \"grafana\" }, \"initialDelaySeconds\": 30, \"timeoutSeconds\": 10, \"periodSeconds\": 10, \"failureThreshold\": 12 } } ] } } } } EOF 如果已经出现两个 Grafana Pod 抢同一个卷，先缩到 0，再恢复：\nkubectl -n monitoring scale deploy kube-prometheus-stack-grafana --replicas=0 kubectl -n monitoring wait --for=delete pod -l app.kubernetes.io/name=grafana --timeout=240s kubectl -n monitoring scale deploy kube-prometheus-stack-grafana --replicas=1 kubectl -n monitoring rollout status deploy/kube-prometheus-stack-grafana --timeout=420s 验证：\nkubectl -n monitoring get pods -l app.kubernetes.io/name=grafana -o wide kubectl -n monitoring get endpoints kube-prometheus-stack-grafana -o wide curl -k -I https://grafana.jihw.top/ 期望看到：\nHTTP/2 302 location: /login 真正要把 Grafana 做成多副本 HA，建议后续改成：\nGrafana replicas \u003e= 2 Grafana database 使用外部 PostgreSQL Dashboard/数据源通过 ConfigMap 或 GitOps 管理 不再依赖单个 RWO grafana.db PVC 存储高可用 后续记录 Longhorn 的高可用和故障恢复。\n这次 215 失联后出现过：\nMulti-Attach error volume robustness unknown volume faulted volume degraded 需要继续确认：\nLonghorn 卷副本数是否至少为 2 或 3。 卷副本是否分散到不同节点。 节点失联后，卷是否能自动 detach/attach。 数据库类 PVC 的恢复策略是否安全。 压测前检查清单 后续压测前先检查底层状态：\nkubectl get nodes -o wide kubectl get pods -A -o wide kubectl -n longhorn-system get volumes.longhorn.io -o wide kubectl top nodes journalctl -k --since \"10 min ago\" | egrep -i \"e1000|vmxnet3|Tx Unit Hang|oom|panic|I/O error\" 压测时重点观察：\n节点是否 Ready。 Keepalived VIP 是否漂移正常。 HAProxy 是否把某个后端判 DOWN。 Ingress 是否出现 502、503、504。 Longhorn 卷是否 degraded 或 faulted。 new-api、PostgreSQL、Redis 是否出现重启或迁移。 当前结论 这次事故说明：三主节点可以保证 Kubernetes 控制面在单节点故障时继续工作，但不能自动保证业务、数据库、存储也高可用。\n真正的高可用要从底层虚拟化网卡、负载均衡、Kubernetes 调度、业务副本、数据库架构、存储副本和监控告警一起建设。\n","permalink":"/coding/k8s-high-availability/","summary":"这篇文章用来长期记录我的 Kubernetes 高可用建设过程。\n高可用不是只把 Kubernetes 主节点做成 3 台就结束了。三主节点主要解决的是控制面高可用，也就是 API Server、etcd、Controller Manager、Scheduler 这些组件在单节点故障时还能继续工作。业务是否高可用，还要继续看服务副本、调度分散、数据库主从、Redis、存储卷、Ingress、负载均衡和监控告警。\n当前集群节点：\nk8s-master1 192.168.3.214 k8s-master2 192.168.3.215 k8s-master3 192.168.3.216 VIP 192.168.3.217 高可用分层 我现在把 Kubernetes 高可用拆成几层来看：\n控制面高可用：3 个 control-plane 节点，etcd 保持多数派。 入口高可用：Keepalived 提供 VIP，HAProxy 转发到多个 API Server 和 Ingress NodePort。 服务高可用：业务 Deployment 多副本，副本分散到不同节点。 数据库高可用：PostgreSQL、Redis 不能只是单实例。 存储高可用：Longhorn 卷副本需要分散，节点失联后能恢复。 监控高可用：Prometheus、Grafana、Alertmanager 要能发现异常并保留证据。 三主节点的边界 三主节点断 1 个，Kubernetes 控制面理论上应该还能用。因为 3 个 etcd 节点掉 1 个以后，还剩 2 个节点，仍然满足多数派。\n但是控制面可用不等于业务可用。比如 new-api、PostgreSQL、Redis 如果都是单实例，或者都依赖同一个 ReadWriteOnce PVC，那么节点失联后业务 Pod 可能无法立刻在其他节点恢复。\n这次压测后 192.168.3.215 失联时，集群里看到：\n","title":"Kubernetes 高可用实践记录"},{"content":"面试里经常会问 QPS。QPS 是 Queries Per Second，也就是每秒请求数。对 new-api 这种服务来说，压测时不要只看 QPS，还要同时看：\nQPS：每秒能处理多少请求。 平均耗时：请求平均响应时间。 P95/P99：95% 或 99% 请求的响应时间。 错误率：非 2xx/3xx 响应或请求失败占比。 CPU/内存：Pod 和节点是否接近瓶颈。 数据库/Redis：PostgreSQL、Redis 是否被打满。 当前环境：\n外部入口：https://k8s-ai.jihw.top 集群内服务：http://new-api.new-api.svc.cluster.local:3000 命名空间：new-api 监控看板：https://grafana.jihw.top/d/new-api-overview/new-api-e79b91-e68ea7-e79c8b-e69dbf 压测前检查 先确认服务正常：\nkubectl -n new-api get pod,svc,ingress -o wide kubectl -n monitoring get pod 确认 new-api、PostgreSQL、Redis 都是 Running：\nkubectl -n new-api get pod 查看当前资源使用：\nkubectl top nodes kubectl top pods -n new-api 在浏览器打开 Grafana 看板：\nhttps://grafana.jihw.top/d/new-api-overview/new-api-e79b91-e68ea7-e79c8b-e69dbf 压测时重点观察：\nCPU 使用量 by Pod 内存使用量 by Pod 网络接收/发送流量 by Pod 1小时容器重启次数 PVC 使用率 Pod Ready 状态 安装 hey 本文使用 hey 做压测。它简单、轻量，适合先学习 QPS。\n如果在 Windows 本机执行，可以用 Go 安装： 将go安装在c盘默认就会配置好环境变量。 go install github.com/rakyll/hey@latest 确认：\nhey -h 如果 go install 报 Go 标准库相关错误，例如：\n//go:notinheap is not allowed in the standard library empty redeclared in this block lfstackPack redeclared in this block 这通常不是 hey 的问题，而是本机 Go 安装目录损坏，或者新旧 Go 版本文件混在了一起。可以先跳过 Go 安装，直接下载 hey 的 Windows 预编译版本。hey 官方 README 提供了 Windows amd64 下载地址：\nmkdir $HOME\\bin -Force Invoke-WebRequest ` -Uri \"https://storage.googleapis.com/hey-releases/hey_windows_amd64\" ` -OutFile \"$HOME\\bin\\hey.exe\" $env:Path = \"$HOME\\bin;$env:Path\" hey.exe -h 如果想永久加入 PATH：\n[Environment]::SetEnvironmentVariable( \"Path\", \"$HOME\\bin;\" + [Environment]::GetEnvironmentVariable(\"Path\", \"User\"), \"User\" ) 如果不想在本机安装，也可以直接在 Kubernetes 集群里临时运行 hey 容器。\n外部域名压测 外部压测会经过完整链路：\n本机 -\u003e DNS -\u003e MetalLB -\u003e ingress-nginx -\u003e new-api Service -\u003e new-api Pod 先用轻量接口测试。/api/status 适合做健康检查压测：\nhey -z 60s -c 10 https://k8s-ai.jihw.top/api/status 参数含义：\n-z 60s 持续压测 60 秒 -c 10 并发 10 个请求 -n 10000 请求数 输出里重点看：\nRequests/sec Average Slowest Fastest Status code distribution Requests/sec 就是这次压测测出来的 QPS。\n如果接口返回 404 或不适合压测，可以换成 new-api 实际存在的轻量接口，例如：\nhey -z 60s -c 10 https://k8s-ai.jihw.top/v1/models 如果接口需要 API Key：\nhey -z 60s -c 10 ` -H \"Authorization: Bearer 你的_API_KEY\" ` https://k8s-ai.jihw.top/v1/models 集群内 Service 压测 集群内压测会绕过外部 DNS、MetalLB、Ingress，只测 Kubernetes Service 到 Pod 的链路：\nhey Pod -\u003e new-api Service -\u003e new-api Pod 执行：\nkubectl run hey-new-api \\ -n new-api \\ --rm -it \\ --restart=Never \\ --image=rakyll/hey -- \\ -z 60s -c 10 \\ http://new-api.new-api.svc.cluster.local:3000/api/status 如果需要 API Key：\nkubectl run hey-new-api \\ -n new-api \\ --rm -it \\ --restart=Never \\ --image=rakyll/hey -- \\ -z 60s -c 10 \\ -H \"Authorization: Bearer 你的_API_KEY\" \\ http://new-api.new-api.svc.cluster.local:3000/v1/models 外部域名压测和集群内压测可以对比：\n外部 QPS 明显更低：可能瓶颈在 ingress-nginx、网络、DNS、TLS。 集群内 QPS 也低：可能瓶颈在 new-api Pod、PostgreSQL、Redis 或上游模型接口。 压测聊天接口 如果要压测 OpenAI 兼容接口，可以测：\n/v1/chat/completions 示例：\nhey -z 60s -c 5 ` -m POST ` -H \"Content-Type: application/json\" ` -H \"Authorization: Bearer 你的_API_KEY\" ` -d \"{\\\"model\\\":\\\"你的模型名\\\",\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hello\\\"}],\\\"stream\\\":false}\" ` https://k8s-ai.jihw.top/v1/chat/completions 注意：AI 聊天接口的 QPS 不一定高，因为一次请求可能要等待上游模型返回。面试时可以这样说：\nAI 网关不能只看 QPS，还要看平均响应时间、P95/P99、错误率、上游模型限流、Token/s。 如果是流式接口，hey 不太适合精细统计 Token/s，后续可以用 k6 或自己写脚本统计首 token 延迟和完整响应时间。\n阶梯压测 不要一上来就高并发。建议从低到高逐步压：\nhey -z 60s -c 5 https://k8s-ai.jihw.top/api/status hey -z 60s -c 10 https://k8s-ai.jihw.top/api/status hey -z 60s -c 20 https://k8s-ai.jihw.top/api/status hey -z 60s -c 50 https://k8s-ai.jihw.top/api/status 每一轮压测后都记录：\n并发数 QPS 平均耗时 P95/P99 错误率 new-api CPU new-api 内存 PostgreSQL CPU/内存 Redis CPU/内存 如果出现下面情况，就不要继续加压：\n错误率明显上升 P95/P99 延迟突然变高 new-api Pod CPU 接近上限 Pod 重启次数增加 PostgreSQL 或 Redis 资源明显升高 Ingress 返回 502/503/504 压测时查看日志 查看 new-api 日志：\nkubectl -n new-api logs deploy/new-api --tail=100 -f 查看 Ingress 日志：\nkubectl -n ingress-nginx logs deploy/ingress-nginx-controller --tail=100 -f 查看 Pod 事件：\nkubectl -n new-api describe pod -l app=new-api 查看资源：\nkubectl top pod -n new-api kubectl top nodes 结果记录模板 可以按下面格式记录：\n压测时间：2026-06-01 16:45 压测入口：https://k8s-ai.jihw.top/api/status 压测工具：hey 持续时间：60s 并发数：10 QPS：xxx 平均响应时间：xxx ms P95：xxx ms P99：xxx ms 错误率：0% new-api CPU：xxx new-api 内存：xxx PostgreSQL CPU：xxx Redis CPU：xxx 结论： 在并发 10、持续 60 秒时，服务稳定，无错误，P95 延迟可接受。 面试回答示例 可以这样回答：\n我会先区分压测入口，是测 Ingress 外部入口，还是测集群内 Service。 然后用 hey 或 k6 做阶梯压测，例如并发 5、10、20、50，每轮持续 1 到 5 分钟。 压测时不只看 QPS，还会看 P95/P99 延迟、错误率、Pod CPU/内存、PostgreSQL、Redis 和 Ingress 日志。 如果 QPS 上不去，我会通过外部入口压测和集群内 Service 压测做对比，判断瓶颈是在网络/Ingress，还是应用/数据库/缓存。 对于 new-api 这种 AI 网关，还可以补充：\n如果压测的是模型调用接口，我还会关注上游模型限流、首 token 延迟、完整响应耗时和 Token/s，因为这类接口不是普通 CRUD API，QPS 不能单独说明性能。 后续优化方向 当前监控看板主要使用 Kubernetes 基础指标。后续可以继续补：\n给 ingress-nginx 开启 metrics，统计 k8s-ai.jihw.top 的 HTTP QPS、状态码、响应耗时。 给 new-api 暴露应用级 /metrics，统计业务请求数、错误数、模型调用耗时。 使用 k6 编写更接近真实业务的压测脚本。 配置 Alertmanager 告警，例如错误率过高、Pod 重启、PVC 空间不足。 ","permalink":"/coding/k8s-new-api-benchmark/","summary":"面试里经常会问 QPS。QPS 是 Queries Per Second，也就是每秒请求数。对 new-api 这种服务来说，压测时不要只看 QPS，还要同时看：\nQPS：每秒能处理多少请求。 平均耗时：请求平均响应时间。 P95/P99：95% 或 99% 请求的响应时间。 错误率：非 2xx/3xx 响应或请求失败占比。 CPU/内存：Pod 和节点是否接近瓶颈。 数据库/Redis：PostgreSQL、Redis 是否被打满。 当前环境：\n外部入口：https://k8s-ai.jihw.top 集群内服务：http://new-api.new-api.svc.cluster.local:3000 命名空间：new-api 监控看板：https://grafana.jihw.top/d/new-api-overview/new-api-e79b91-e68ea7-e79c8b-e69dbf 压测前检查 先确认服务正常：\nkubectl -n new-api get pod,svc,ingress -o wide kubectl -n monitoring get pod 确认 new-api、PostgreSQL、Redis 都是 Running：\nkubectl -n new-api get pod 查看当前资源使用：\nkubectl top nodes kubectl top pods -n new-api 在浏览器打开 Grafana 看板：\nhttps://grafana.jihw.top/d/new-api-overview/new-api-e79b91-e68ea7-e79c8b-e69dbf 压测时重点观察：\nCPU 使用量 by Pod 内存使用量 by Pod 网络接收/发送流量 by Pod 1小时容器重启次数 PVC 使用率 Pod Ready 状态 安装 hey 本文使用 hey 做压测。它简单、轻量，适合先学习 QPS。\n","title":"k8s-new-api QPS 压测"},{"content":"前面已经安装好了 Kubernetes 三主节点集群、MetalLB、ingress-nginx、cert-manager、Longhorn 和 Headlamp。Headlamp 适合查看和管理 Kubernetes 资源，Prometheus + Grafana 更适合做长期监控、趋势分析和告警。\n本文使用 prometheus-community/kube-prometheus-stack 一次性安装：\nPrometheus Grafana Alertmanager kube-state-metrics node-exporter Prometheus Operator 前置条件 确认 Helm、StorageClass、ingress-nginx 都正常：\nhelm version kubectl get storageclass kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 当前集群已经使用 Longhorn 作为默认存储：\nlonghorn (default) 如果是学习环境，节点磁盘比较小，建议额外创建一个单副本 StorageClass 给监控组件使用。Prometheus 会持续写入时序数据，使用 Longhorn 默认 3 副本时，一个 10Gi 的 Prometheus PVC 实际需要在多个节点上调度多个 10Gi 副本，很容易因为磁盘空间不足而卡住。\n创建学习环境用的单副本 StorageClass：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-single provisioner: driver.longhorn.io allowVolumeExpansion: true reclaimPolicy: Delete volumeBindingMode: Immediate parameters: numberOfReplicas: \"1\" staleReplicaTimeout: \"2880\" fromBackup: \"\" EOF 生产环境更推荐继续使用 Longhorn 默认 3 副本，并给每个节点准备足够的独立数据盘。\nGrafana 域名准备使用：\ngrafana.jihw.top DNS 或本机 hosts 需要解析到 ingress-nginx 的 LoadBalancer IP：\n192.168.3.230 grafana.jihw.top 添加 Helm 仓库 helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update 查看 chart：\nhelm search repo prometheus-community/kube-prometheus-stack 配置 quay.io 镜像加速 kube-prometheus-stack 会从 quay.io 拉取多个镜像，例如 Prometheus、Alertmanager、node-exporter、prometheus-config-reloader。如果网络比较慢，可能会看到 Prometheus Pod 长时间停在：\nPodInitializing Pulling image \"quay.io/prometheus/prometheus:v3.12.0-distroless\" 可以给 containerd 增加 quay.io 镜像加速配置。三台节点都执行：\nsudo mkdir -p /etc/containerd/certs.d/quay.io cat \u003c\u003c'EOF' | sudo tee /etc/containerd/certs.d/quay.io/hosts.toml server = \"https://quay.io\" [host.\"https://quay.m.daocloud.io\"] capabilities = [\"pull\", \"resolve\"] EOF 重启 containerd 让配置生效。建议一台一台执行：\nsudo systemctl restart containerd 验证镜像可以正常拉取：\nsudo crictl pull quay.io/prometheus/prometheus:v3.12.0-distroless 创建配置文件 创建命名空间：\nkubectl create namespace monitoring 创建 values-monitoring.yaml：\ncat \u003e values-monitoring.yaml \u003c\u003c'EOF' grafana: enabled: true adminUser: admin adminPassword: \"ChangeMe_Grafana_2026\" persistence: enabled: true type: pvc storageClassName: longhorn-single accessModes: - ReadWriteOnce size: 5Gi ingress: enabled: true ingressClassName: nginx annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod hosts: - grafana.jihw.top tls: - secretName: grafana-jihw-top-tls hosts: - grafana.jihw.top prometheus: prometheusSpec: retention: 3d storageSpec: volumeClaimTemplate: spec: storageClassName: longhorn-single accessModes: - ReadWriteOnce resources: requests: storage: 5Gi alertmanager: alertmanagerSpec: storage: volumeClaimTemplate: spec: storageClassName: longhorn-single accessModes: - ReadWriteOnce resources: requests: storage: 2Gi EOF adminPassword 建议改成自己的强密码。第一次安装完成后，也可以在 Grafana 页面里修改密码。\n安装 kube-prometheus-stack helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \\ -n monitoring \\ -f values-monitoring.yaml 等待 Pod 启动：\nkubectl -n monitoring get pod -o wide 正常情况下会看到 Prometheus、Grafana、Alertmanager、node-exporter、kube-state-metrics 等组件：\nkubectl -n monitoring get pod kubectl -n monitoring get svc kubectl -n monitoring get pvc 查看证书和 Ingress Grafana 的 HTTPS 证书由 cert-manager 自动申请：\nkubectl -n monitoring get ingress,certificate -o wide 证书正常时可以看到：\ncertificate.cert-manager.io/grafana-jihw-top-tls True grafana-jihw-top-tls 如果还没有变成 True，查看申请过程：\nkubectl -n monitoring get certificaterequest,order,challenge kubectl -n monitoring describe certificate grafana-jihw-top-tls 访问 Grafana 确认本机可以解析并连通：\nnslookup grafana.jihw.top Test-NetConnection grafana.jihw.top -Port 443 浏览器访问：\nhttps://grafana.jihw.top 默认账号：\n用户名：admin 密码：ChangeMe_Grafana_2026 登录后建议立即修改密码。\n查看默认监控面板 kube-prometheus-stack 会自动导入一批 Kubernetes 监控面板。进入 Grafana 后可以查看：\nDashboards -\u003e Kubernetes / Compute Resources / Cluster Dashboards -\u003e Kubernetes / Compute Resources / Namespace Dashboards -\u003e Kubernetes / Compute Resources / Pod Dashboards -\u003e Node Exporter / Nodes Prometheus 数据源也会自动配置好，一般不需要手动添加。\n入门案例：创建一个新手巡检面板 默认的 Kubernetes 面板内容很多，刚开始可能不知道看哪里。可以先创建一个简化版 Dashboard，只看最常用的 6 个指标：\nReady 节点数 Running Pod 数 节点 CPU 使用率 节点内存使用率 根分区磁盘使用率 各命名空间 Running Pod 数 登录 Grafana 后，进入：\nDashboards -\u003e New -\u003e New dashboard 点击 Add visualization，选择 Prometheus 数据源。\n第一个面板可以做 Ready 节点数，查询语句：\nsum(kube_node_status_condition{condition=\"Ready\",status=\"true\"}) 面板类型选择 Stat，标题填写：\nReady 节点数 第二个面板做 Running Pod 数：\nsum(kube_pod_status_phase{phase=\"Running\"}) 面板类型同样选择 Stat。\n第三个面板做 节点 CPU 使用率：\n100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100) 面板类型选择 Time series，单位选择 Percent (0-100)。\n第四个面板做 节点内存使用率：\n(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 第五个面板做 根分区磁盘使用率：\n(1 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"rootfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"rootfs\"})) * 100 第六个面板做 各命名空间 Running Pod 数：\nsum by (namespace) (kube_pod_status_phase{phase=\"Running\"}) 面板类型可以选择 Bar chart。\n我在当前 Grafana 中已经创建了一个示例 Dashboard：\n新手巡检示例 / Kubernetes Overview 可以直接访问：\nhttps://grafana.jihw.top/d/k8s-beginner-overview/0f0711f new-api 业务看板 安装好 new-api 后，也可以为它单独创建一个 Dashboard。当前 new-api 没有单独暴露应用级 /metrics，所以先使用 Kubernetes 指标做运行状态监控。\n我在 Grafana 中已经创建了：\nnew-api 监控看板 访问地址：\nhttps://grafana.jihw.top/d/new-api-overview/new-api-e79b91-e68ea7-e79c8b-e69dbf 这个看板包含：\nnew-api 可用副本：看 Deployment 是否至少有 1 个可用副本。 new-api Running Pod：看 new-api 命名空间当前运行中的 Pod 数。 1小时容器重启次数：如果大于 0，需要检查 Pod 日志和事件。 PVC 最大使用率：看 PostgreSQL、Redis、new-api 数据卷是否接近满盘。 CPU 使用量 by Pod：按 Pod 查看 CPU 消耗。 内存使用量 by Pod：按 Pod 查看内存占用。 网络接收/发送流量 by Pod：看应用、PostgreSQL、Redis 的流量变化。 PVC 使用率：分别查看 data-postgresql-0、redis-data-redis-master-0、new-api-data。 Pod Ready 状态：每个 Pod 为 1 表示 Ready。 常用 PromQL 示例：\nkube_deployment_status_replicas_available{namespace=\"new-api\",deployment=\"new-api\"} sum(increase(kube_pod_container_status_restarts_total{namespace=\"new-api\"}[1h])) sum(rate(container_cpu_usage_seconds_total{namespace=\"new-api\",container!=\"\",image!=\"\"}[5m])) by (pod) sum(container_memory_working_set_bytes{namespace=\"new-api\",container!=\"\",image!=\"\"}) by (pod) (kubelet_volume_stats_used_bytes{namespace=\"new-api\"} / kubelet_volume_stats_capacity_bytes{namespace=\"new-api\"}) * 100 如果后续希望看到 k8s-ai.jihw.top 的 HTTP 请求数、状态码、响应耗时，需要给 ingress-nginx 开启 metrics 并让 Prometheus 采集 ingress-nginx 的指标。\n本地临时访问 Prometheus Prometheus 默认不建议直接暴露到公网或局域网。如果只是临时排查，可以使用端口转发：\nkubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090 --address 0.0.0.0 然后访问：\nhttp://服务器IP:9090 Alertmanager 也可以临时端口转发：\nkubectl -n monitoring port-forward svc/kube-prometheus-stack-alertmanager 9093:9093 --address 0.0.0.0 访问：\nhttp://服务器IP:9093 常用排查命令 查看 Helm 安装状态：\nhelm -n monitoring list helm -n monitoring status kube-prometheus-stack 查看所有组件：\nkubectl -n monitoring get all 查看 PVC：\nkubectl -n monitoring get pvc 查看 Grafana 日志：\nkubectl -n monitoring logs deploy/kube-prometheus-stack-grafana --tail=100 查看 Prometheus 状态：\nkubectl -n monitoring get prometheus kubectl -n monitoring describe prometheus kube-prometheus-stack-prometheus 查看 Prometheus Operator 日志：\nkubectl -n monitoring logs deploy/kube-prometheus-stack-operator --tail=100 查看证书申请状态：\nkubectl -n monitoring get certificate,certificaterequest,order,challenge Prometheus 卡在 Init:0/1 如果 prometheus-kube-prometheus-stack-prometheus-0 一直卡在 Init:0/1：\nkubectl -n monitoring get pod prometheus-kube-prometheus-stack-prometheus-0 -o wide kubectl -n monitoring describe pod prometheus-kube-prometheus-stack-prometheus-0 如果事件中出现：\nAttachVolume.Attach failed volume ... is not ready for workloads 继续查看 Longhorn 卷：\nkubectl -n longhorn-system get volumes.longhorn.io -o wide kubectl -n longhorn-system logs -l app=longhorn-manager --since=10m | grep -i 'insufficient storage\\|not ready\\|failed' 如果日志里出现：\ninsufficient storage 说明不是 Prometheus 镜像问题，而是 Longhorn 没有足够空间创建该 PVC 的副本。解决办法有三个：\n给每个节点扩容磁盘，生产环境推荐这种方式。 降低 Prometheus PVC 容量和保留时间，例如 storage: 5Gi、retention: 3d。PVC 不能原地缩容，已经创建过 10Gi PVC 时，需要删除旧 PVC 后重新安装。 学习环境使用上面创建的 longhorn-single 单副本 StorageClass。 如果是刚安装的测试环境，确认不需要保留监控数据后，可以删除旧 PVC 并重新安装：\nhelm -n monitoring uninstall kube-prometheus-stack kubectl -n monitoring delete pvc --all helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \\ -n monitoring \\ -f values-monitoring.yaml 删除 PVC 会删除 Prometheus、Grafana、Alertmanager 的持久化数据，已经有重要面板或历史监控数据时不要直接执行。\n如果 PVC 和 Longhorn 卷都正常，但 Pod 一直停在 PodInitializing，并且事件里只有：\nPulling image \"quay.io/prometheus/prometheus:v3.12.0-distroless\" 说明多半是 quay.io 镜像拉取慢。按上面的“配置 quay.io 镜像加速”处理，然后重新拉取或等待 kubelet 自动重试即可。\n升级 更新 Helm 仓库：\nhelm repo update 升级：\nhelm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \\ -n monitoring \\ -f values-monitoring.yaml 卸载 卸载 chart：\nhelm -n monitoring uninstall kube-prometheus-stack 如果确认不再需要监控数据，再删除 PVC：\nkubectl -n monitoring delete pvc --all 最后删除命名空间：\nkubectl delete namespace monitoring 生产环境卸载前一定要先确认是否还需要 Prometheus 历史数据和 Grafana 面板配置。\n","permalink":"/coding/k8s-prometheus-grafana/","summary":"前面已经安装好了 Kubernetes 三主节点集群、MetalLB、ingress-nginx、cert-manager、Longhorn 和 Headlamp。Headlamp 适合查看和管理 Kubernetes 资源，Prometheus + Grafana 更适合做长期监控、趋势分析和告警。\n本文使用 prometheus-community/kube-prometheus-stack 一次性安装：\nPrometheus Grafana Alertmanager kube-state-metrics node-exporter Prometheus Operator 前置条件 确认 Helm、StorageClass、ingress-nginx 都正常：\nhelm version kubectl get storageclass kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 当前集群已经使用 Longhorn 作为默认存储：\nlonghorn (default) 如果是学习环境，节点磁盘比较小，建议额外创建一个单副本 StorageClass 给监控组件使用。Prometheus 会持续写入时序数据，使用 Longhorn 默认 3 副本时，一个 10Gi 的 Prometheus PVC 实际需要在多个节点上调度多个 10Gi 副本，很容易因为磁盘空间不足而卡住。\n创建学习环境用的单副本 StorageClass：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-single provisioner: driver.longhorn.io allowVolumeExpansion: true reclaimPolicy: Delete volumeBindingMode: Immediate parameters: numberOfReplicas: \"1\" staleReplicaTimeout: \"2880\" fromBackup: \"\" EOF 生产环境更推荐继续使用 Longhorn 默认 3 副本，并给每个节点准备足够的独立数据盘。\n","title":"k8s-Prometheus 和 Grafana 监控"},{"content":"Headlamp 是 Kubernetes 官方社区维护的 Web UI，适合用来查看工作负载、Service、Ingress、Pod 日志、事件等资源。本文记录在已经安装好 ingress-nginx、MetalLB、cert-manager 的集群里部署 Headlamp，并通过 https://headlamp.jihw.top 访问。\n安装 Headlamp 添加 Helm 仓库并安装：\nhelm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ helm repo update kubectl create namespace headlamp helm upgrade --install headlamp headlamp/headlamp \\ -n headlamp 查看运行状态：\nkubectl -n headlamp get pod,svc -o wide 正常情况下可以看到 Headlamp Pod 为 Running，Service 类型为 ClusterIP：\nkubectl -n headlamp get pod kubectl -n headlamp get svc 创建登录账号 为了先快速验证集群管理功能，可以创建一个 cluster-admin 权限的 ServiceAccount：\nkubectl -n headlamp create serviceaccount headlamp-admin kubectl create clusterrolebinding headlamp-admin-cluster-admin \\ --serviceaccount=headlamp:headlamp-admin \\ --clusterrole=cluster-admin 生成登录 Token：\nkubectl -n headlamp create token headlamp-admin --duration=24h 打开 Headlamp 后选择 Token 登录，把上面命令输出的 Token 粘贴进去即可。\n如果之前已经创建过同名 ClusterRoleBinding/headlamp-admin，需要注意它可能是 Helm 默认创建的绑定，绑定对象不一定是 headlamp-admin 这个 ServiceAccount。可以用下面的命令确认权限：\nkubectl auth can-i list nodes.metrics.k8s.io \\ --as=system:serviceaccount:headlamp:headlamp-admin 如果输出是 no，重新创建一个独立名称的绑定：\nkubectl create clusterrolebinding headlamp-admin-cluster-admin \\ --serviceaccount=headlamp:headlamp-admin \\ --clusterrole=cluster-admin \\ --dry-run=client -o yaml | kubectl apply -f - 创建长期登录 Token kubectl create token 生成的是短期 Token，过期后 Headlamp 会要求重新登录。如果只是内网自用，可以手动创建一个 kubernetes.io/service-account-token 类型的 Secret，让 Kubernetes 为 headlamp-admin 生成长期 Token。\n注意：这里给的是 cluster-admin 权限，只适合可信内网环境，不要暴露到公网。\n先确认 ServiceAccount 和权限绑定已经存在：\nkubectl -n headlamp get serviceaccount headlamp-admin kubectl get clusterrolebinding headlamp-admin-cluster-admin 如果不存在，先创建：\nkubectl -n headlamp create serviceaccount headlamp-admin kubectl create clusterrolebinding headlamp-admin-cluster-admin \\ --serviceaccount=headlamp:headlamp-admin \\ --clusterrole=cluster-admin \\ --dry-run=client -o yaml | kubectl apply -f - 创建长期 Token Secret：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: Secret metadata: name: headlamp-admin-token namespace: headlamp annotations: kubernetes.io/service-account.name: headlamp-admin type: kubernetes.io/service-account-token EOF 等待几秒后获取 Token：\nkubectl -n headlamp get secret headlamp-admin-token \\ -o jsonpath='{.data.token}' | base64 -d 把输出粘贴到 Headlamp 的 Token 登录页。以后如果浏览器丢失登录状态，可以继续用这个 Token 登录。\n确认权限：\nTOKEN=$(kubectl -n headlamp get secret headlamp-admin-token \\ -o jsonpath='{.data.token}' | base64 -d) kubectl auth can-i '*' '*' --all-namespaces --token=\"$TOKEN\" kubectl auth can-i list nodes.metrics.k8s.io --token=\"$TOKEN\" 两个命令都应该输出：\nyes 如果以后要废弃这个长期 Token，删除 Secret 即可：\nkubectl -n headlamp delete secret headlamp-admin-token 配置 HTTPS 访问 这里使用 ingress-nginx 暴露 Headlamp，并使用之前配置好的生产证书签发器 letsencrypt-alidns-prod 自动申请证书。\n创建 Ingress：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: headlamp namespace: headlamp annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod spec: ingressClassName: nginx tls: - hosts: - headlamp.jihw.top secretName: headlamp-jihw-top-tls rules: - host: headlamp.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: headlamp port: number: 80 EOF 查看 Ingress 和证书状态：\nkubectl -n headlamp get ingress,certificate -o wide 证书正常时可以看到：\ncertificate.cert-manager.io/headlamp-jihw-top-tls True headlamp-jihw-top-tls 配置 DNS 当前 ingress-nginx-controller 的 LoadBalancer IP 是 192.168.3.230：\nkubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 所以需要把域名解析到这个 IP：\n192.168.3.230 headlamp.jihw.top 如果是在 Windows 本机测试，可以先确认解析是否正确：\nnslookup headlamp.jihw.top Test-NetConnection headlamp.jihw.top -Port 443 连通后访问：\nhttps://headlamp.jihw.top 需要注意：多个 Ingress 共用同一个 192.168.3.230 是正常的。这个 IP 属于 ingress-nginx-controller 这个 LoadBalancer Service，ingress-nginx 会根据访问的 Host，例如 headlamp.jihw.top、k8s-ai.jihw.top，转发到不同后端服务。\n三主节点集群的 MetalLB 注意事项 如果集群只有控制平面节点，没有单独的 Worker 节点，可能会遇到域名已经解析到 192.168.3.230，但 Windows 访问时报错：\nTCP connect to 192.168.3.230:443 failed Ping to 192.168.3.230 failed with status: DestinationHostUnreachable 原因是控制平面节点默认可能带有这个标签：\nnode.kubernetes.io/exclude-from-external-load-balancers= MetalLB 默认不会在带有该标签的节点上宣告 LoadBalancer IP。如果所有节点都是控制平面节点，就会导致 192.168.3.230 没有真正对局域网宣告。\n删除该标签：\nkubectl label nodes --all node.kubernetes.io/exclude-from-external-load-balancers- 确认 MetalLB 已经分配并宣告 ingress-nginx-controller：\nkubectl get servicel2status -A -o wide 正常情况下可以看到类似输出：\nNAMESPACE NAME ALLOCATED NODE SERVICE NAME SERVICE NAMESPACE metallb-system l2-wpcq7 k8s-master2 ingress-nginx-controller ingress-nginx 此时再从 Windows 测试：\narp -d * ipconfig /flushdns nslookup headlamp.jihw.top Test-NetConnection 192.168.3.230 -Port 443 Test-NetConnection headlamp.jihw.top -Port 443 如果 TcpTestSucceeded 为 True，就可以打开 https://headlamp.jihw.top 访问 Headlamp。\n常用排查命令 查看 Headlamp 资源：\nkubectl -n headlamp get pod,svc,ingress,certificate -o wide 查看 ingress-nginx 的 LoadBalancer IP：\nkubectl -n ingress-nginx get svc ingress-nginx-controller -o wide 查看 MetalLB 是否宣告了 IP：\nkubectl get servicel2status -A -o wide kubectl -n metallb-system logs -l component=speaker --since=10m | grep -E '192.168.3.230|serviceAnnounced|serviceWithdrawn' 在集群内直接测试 Headlamp：\ncurl -kI https://192.168.3.230/ -H 'Host: headlamp.jihw.top' 重新生成登录 Token：\nkubectl -n headlamp create token headlamp-admin --duration=24h 查看长期登录 Token：\nkubectl -n headlamp get secret headlamp-admin-token \\ -o jsonpath='{.data.token}' | base64 -d 登录后提示没有权限 如果 Headlamp 页面突然提示没有权限访问，但 RBAC 没有改过，通常是浏览器里保存的旧 Token 过期了。kubectl create token 生成的是有有效期的 Token，不是永久 Token。\n重新生成一个 24 小时有效的 Token：\nkubectl -n headlamp create token headlamp-admin --duration=24h 确认这个 Token 有权限：\nTOKEN=$(kubectl -n headlamp create token headlamp-admin --duration=24h) kubectl auth can-i '*' '*' --all-namespaces --token=\"$TOKEN\" kubectl auth can-i list nodes.metrics.k8s.io --token=\"$TOKEN\" 两个命令都应该输出：\nyes 然后打开 Headlamp，退出当前登录，重新粘贴新 Token 登录。如果页面一直自动使用旧 Token，可以清理浏览器里 headlamp.jihw.top 的站点数据，或者用无痕窗口重新打开。 中文访问方式\nhttps://headlamp.jihw.top/?lng=zh-tw\n","permalink":"/coding/headlamp/","summary":"Headlamp 是 Kubernetes 官方社区维护的 Web UI，适合用来查看工作负载、Service、Ingress、Pod 日志、事件等资源。本文记录在已经安装好 ingress-nginx、MetalLB、cert-manager 的集群里部署 Headlamp，并通过 https://headlamp.jihw.top 访问。\n安装 Headlamp 添加 Helm 仓库并安装：\nhelm repo add headlamp https://kubernetes-sigs.github.io/headlamp/ helm repo update kubectl create namespace headlamp helm upgrade --install headlamp headlamp/headlamp \\ -n headlamp 查看运行状态：\nkubectl -n headlamp get pod,svc -o wide 正常情况下可以看到 Headlamp Pod 为 Running，Service 类型为 ClusterIP：\nkubectl -n headlamp get pod kubectl -n headlamp get svc 创建登录账号 为了先快速验证集群管理功能，可以创建一个 cluster-admin 权限的 ServiceAccount：\nkubectl -n headlamp create serviceaccount headlamp-admin kubectl create clusterrolebinding headlamp-admin-cluster-admin \\ --serviceaccount=headlamp:headlamp-admin \\ --clusterrole=cluster-admin 生成登录 Token：\n","title":"k8s-Headlamp 可视化面板"},{"content":"Longhorn 是一个 Kubernetes 原生分布式块存储系统。\n你可以把它理解成：\n把每个节点上的本地磁盘组合成一个分布式存储池 PVC 创建出来后，Longhorn 会给这个卷做多个副本 某个节点挂了，只要还有副本，卷还能在其他节点重新挂载 它比 NFS 更适合你现在的目标，因为你想要高可用。\n不过先说明：Longhorn 做的是 存储层高可用，不是数据库自动主从。\n比如 PostgreSQL 用一个 Longhorn PVC，Longhorn 可以保证磁盘卷有多个副本；但同一时刻这个卷还是通常只挂到一个 PostgreSQL Pod。PostgreSQL 自身高可用以后可以再用 CloudNativePG。\nLonghorn 前提要求 Longhorn 官方要求每个节点满足：\nKubernetes \u003e= 1.25 containerd 可用 每个节点安装 open-iscsi，并运行 iscsid RWX 支持需要 NFSv4 client 节点文件系统支持 ext4 或 XFS Longhorn 组件需要 privileged/root 权限 你的环境是 Ubuntu 22.04 + kubeadm，适合安装。\n一、三台节点都安装依赖 在三台节点都执行：\nsudo apt update sudo apt install -y open-iscsi nfs-common jq curl util-linux 启用 iSCSI：\nsudo systemctl enable --now iscsid sudo systemctl start iscsid 加载模块：\nsudo modprobe iscsi_tcp 设置开机加载：\necho iscsi_tcp | sudo tee /etc/modules-load.d/iscsi_tcp.conf 检查：\nsystemctl status iscsid --no-pager which iscsiadm 二、建议准备 Longhorn 数据目录 Longhorn 默认数据目录是： 默认不存在\n/var/lib/longhorn 如果你的系统盘空间够，可以先用默认目录。\n三台都执行：\nsudo mkdir -p /var/lib/longhorn 如果你后面有单独数据盘，更推荐挂载到：\n/var/lib/longhorn 并写入 /etc/fstab，保证重启后自动挂载。\n三、安装 Longhorn 添加 Helm 仓库：\nhelm repo add longhorn https://charts.longhorn.io helm repo update 安装：\nhelm upgrade --install longhorn longhorn/longhorn \\ --namespace longhorn-system \\ --create-namespace \\ --set persistence.defaultClass=true \\ --set persistence.defaultClassReplicaCount=3 \\ --set defaultSettings.defaultReplicaCount=3 \\ --set defaultSettings.defaultDataPath=/var/lib/longhorn 解释：\npersistence.defaultClass=true 把 longhorn 设置为默认 StorageClass。 persistence.defaultClassReplicaCount=3 新建 PVC 默认 3 副本。 defaultSettings.defaultReplicaCount=3 Longhorn 默认卷副本数 3。 defaultSettings.defaultDataPath=/var/lib/longhorn Longhorn 数据存放目录。 四、等待 Longhorn 启动 kubectl get pods -n longhorn-system -o wide 等待全部 Running：\nkubectl wait --for=condition=ready pod \\ --all \\ -n longhorn-system \\ --timeout=600s 如果某些 Pod 一直没好，查看：\nkubectl get pods -n longhorn-system -o wide kubectl describe pod -n longhorn-system Pod名 kubectl logs -n longhorn-system Pod名 五、检查 StorageClass kubectl get storageclass 应该看到：\nlonghorn (default) 如果以后还有别的默认 StorageClass，要保证只有一个 default。\n如果你之前装过 local-path，要取消它默认：\nkubectl patch storageclass local-path \\ -p '{\"metadata\": {\"annotations\":{\"storageclass.kubernetes.io/is-default-class\":\"false\"}}}' || true 六、暴露 Longhorn UI Longhorn 自带 Web UI。建议通过你已经装好的 ingress-nginx 暴露。\n先看 Service：\nkubectl get svc -n longhorn-system 应该有：\nlonghorn-frontend 创建 Ingress：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: longhorn-ui namespace: longhorn-system spec: ingressClassName: nginx rules: - host: longhorn.k8s.local http: paths: - path: / pathType: Prefix backend: service: name: longhorn-frontend port: number: 80 EOF 查 ingress-nginx 的 EXTERNAL-IP：\nkubectl get svc -n ingress-nginx ingress-nginx-controller 假设是：\n192.168.3.230 在 Windows hosts 加：\n192.168.3.230 longhorn.k8s.local 然后浏览器访问：\nhttp://longhorn.k8s.local 注意：Longhorn UI 默认没有认证。家庭内网可以先用，后面要加 Basic Auth 或只内网访问。\n七、创建 PVC 测试 kubectl create namespace storage-test 创建 PVC：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: PersistentVolumeClaim metadata: name: longhorn-test-pvc namespace: storage-test spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 1Gi EOF 创建测试 Pod：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: Pod metadata: name: longhorn-test-pod namespace: storage-test spec: containers: - name: busybox image: busybox:latest command: [\"sh\", \"-c\", \"echo hello-longhorn \u003e /data/test.txt \u0026\u0026 sleep 3600\"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: longhorn-test-pvc EOF 检查：\nkubectl get pod,pvc -n storage-test -o wide kubectl get pv 读取文件：\nkubectl exec -n storage-test longhorn-test-pod -- cat /data/test.txt 应输出：\nhello-longhorn 八、测试存储高可用 查看 Pod 在哪个节点：\nkubectl get pod longhorn-test-pod -n storage-test -o wide 然后可以删除 Pod，让它重建测试卷是否能重新挂载。因为这是普通 Pod，不会自动重建，所以更推荐用 Deployment 测试。\n简单先做删除重建：\nkubectl delete pod longhorn-test-pod -n storage-test 重新创建刚才的 Pod YAML，再读取：\nkubectl exec -n storage-test longhorn-test-pod -- cat /data/test.txt 如果还能读到：\nhello-longhorn 说明数据持久化正常。\n节点级故障测试可以后面再做，别一开始就直接断电数据库节点。\n九、清理测试资源 kubectl delete namespace storage-test 十、Longhorn 对 new-api 的意义 安装 Longhorn 后，后续 PostgreSQL/Redis 的 Helm Chart 可以直接用：\nstorageClass: longhorn 这样数据库 PVC 会由 Longhorn 管理，并默认 3 副本。\n后续我们会这样装：\nPostgreSQL PVC -\u003e Longhorn 3 replicas Redis PVC -\u003e Longhorn 3 replicas new-api Deployment -\u003e 无状态多副本 不过再提醒一次：\nLonghorn 保证卷副本高可用 PostgreSQL 真正的数据库主备高可用以后要用 CloudNativePG 当前阶段先用 Longhorn 作为高可用存储，把 Helm PostgreSQL/Redis 跑起来是合理的。\n官方参考：\nLonghorn 安装要求和安装方式：\nhttps://longhorn.io/docs/latest/deploy/install/ Longhorn Helm 安装与默认配置：\nhttps://longhorn.io/docs/latest/deploy/install/install-with-helm/ Longhorn 默认设置说明：\nhttps://longhorn.io/docs/latest/advanced-resources/deploy/customizing-default-settings/ StorageClass 参数：\nhttps://longhorn.io/docs/latest/references/storage-class-parameters/ ","permalink":"/coding/k8s-longhorn/","summary":"Longhorn 是一个 Kubernetes 原生分布式块存储系统。\n你可以把它理解成：\n把每个节点上的本地磁盘组合成一个分布式存储池 PVC 创建出来后，Longhorn 会给这个卷做多个副本 某个节点挂了，只要还有副本，卷还能在其他节点重新挂载 它比 NFS 更适合你现在的目标，因为你想要高可用。\n不过先说明：Longhorn 做的是 存储层高可用，不是数据库自动主从。\n比如 PostgreSQL 用一个 Longhorn PVC，Longhorn 可以保证磁盘卷有多个副本；但同一时刻这个卷还是通常只挂到一个 PostgreSQL Pod。PostgreSQL 自身高可用以后可以再用 CloudNativePG。\nLonghorn 前提要求 Longhorn 官方要求每个节点满足：\nKubernetes \u003e= 1.25 containerd 可用 每个节点安装 open-iscsi，并运行 iscsid RWX 支持需要 NFSv4 client 节点文件系统支持 ext4 或 XFS Longhorn 组件需要 privileged/root 权限 你的环境是 Ubuntu 22.04 + kubeadm，适合安装。\n一、三台节点都安装依赖 在三台节点都执行：\nsudo apt update sudo apt install -y open-iscsi nfs-common jq curl util-linux 启用 iSCSI：\n","title":"K8s Longhorn"},{"content":"漂亮，nginx Ingress 已经通了。下一步我们做 new-api 部署前准备，目标是先把 K8s 的运维基础补齐，再验证它能访问你已有的 PostgreSQL 和 Redis。\n接下来分 4 步：\n1. 安装 metrics-server 2. 创建 new-api namespace 3. 准备 Secret / ConfigMap 4. 用临时 Pod 测试 PostgreSQL 和 Redis 连通性 1. 安装 metrics-server\n作用：\nmetrics-server 用来采集节点和 Pod 的 CPU、内存指标。 安装后才能用 kubectl top nodes / kubectl top pods。 安装：\nkubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.8.1/components.yaml 家庭自建 kubeadm 集群里，metrics-server 经常因为 kubelet 证书问题启动失败。先检查：\nkubectl get pods -n kube-system | grep metrics 如果 Running，直接测试：\nkubectl top nodes kubectl top pods -A 如果不正常，执行这个 patch：\nkubectl patch deployment metrics-server -n kube-system --type='json' -p='[ { \"op\": \"add\", \"path\": \"/spec/template/spec/containers/0/args/-\", \"value\": \"--kubelet-insecure-tls\" } ]' 等它重启：\nkubectl rollout status deployment metrics-server -n kube-system kubectl top nodes 下面按 Helm 简单版 来装 PostgreSQL 和 Redis。这个版本适合你当前阶段：先让 new-api 在 K8s 里跑起来，后面再升级数据库 HA、备份和 Operator。\n我们会放在同一个 namespace：\nnew-api 架构：\nnew-api Pod -\u003e postgresql.new-api.svc.cluster.local:5432 -\u003e redis-master.new-api.svc.cluster.local:6379 0. 先确认 StorageClass\nPostgreSQL 和 Redis 都需要 PVC。先看：\nkubectl get storageclass 如果有默认 StorageClass，会看到类似：\nlocal-path (default) 或者其他名字。\n如果没有默认 StorageClass，先暂停，把输出发我。因为没有 StorageClass，PostgreSQL/Redis 的 PVC 会一直 Pending。\n1. 创建 namespace\n如果已经创建过，会提示已存在，没关系。\nkubectl create namespace new-api 2. 添加 Bitnami Helm 仓库\nBitnami 官方现在推荐 OCI 方式，不一定需要 helm repo add。我们直接用 OCI：\noci://registry-1.docker.io/bitnamicharts/postgresql oci://registry-1.docker.io/bitnamicharts/redis Bitnami PostgreSQL 官方安装示例也是：\nhelm install my-release oci://registry-1.docker.io/bitnamicharts/postgresql 参考：Bitnami PostgreSQL Helm Chart。\n3. 准备密码\n建议你先生成几个密码：\nopenssl rand -base64 24 openssl rand -base64 24 openssl rand -base64 24 分别用于：\nPostgreSQL postgres 管理员密码 PostgreSQL newapi 用户密码 Redis 密码 假设你准备：\nPOSTGRES_ADMIN_PASSWORD=替换成你的管理员密码 POSTGRES_USER_PASSWORD=替换成你的 newapi 用户密码 REDIS_PASSWORD=替换成你的 Redis 密码 后面命令里手动替换。\n4. 安装 PostgreSQL\n这里创建：\n数据库：newapi 用户：newapi Service：postgresql.new-api.svc.cluster.local 端口：5432 PVC：20Gi 执行：\nhelm upgrade --install postgresql \\ oci://registry-1.docker.io/bitnamicharts/postgresql \\ --namespace new-api \\ --set auth.postgresPassword='替换成POSTGRES_ADMIN_PASSWORD' \\ --set auth.username='newapi' \\ --set auth.password='替换成POSTGRES_USER_PASSWORD' \\ --set auth.database='newapi' \\ --set primary.persistence.enabled=true \\ --set primary.persistence.size=20Gi 说明：\nauth.username/auth.password： 创建业务用户 newapi。 auth.database： 创建业务数据库 newapi。 primary.persistence.size： PostgreSQL 数据盘大小。 检查状态：\nkubectl get pods -n new-api -o wide kubectl get pvc -n new-api kubectl get svc -n new-api 等待 PostgreSQL Running：\nkubectl wait --for=condition=ready pod \\ -l app.kubernetes.io/instance=postgresql \\ -n new-api \\ --timeout=300s 测试连接：\nkubectl run pg-test \\ -n new-api \\ --rm -it \\ --image=postgres:16-alpine \\ --restart=Never \\ --env PGPASSWORD='替换成POSTGRES_USER_PASSWORD' \\ -- psql -h postgresql.new-api.svc.cluster.local -p 5432 -U newapi -d newapi -c 'select 1;' 成功会看到：\n?column? ---------- 1 5. 安装 Redis\n这里先用单实例 Redis：\n架构：standalone Service：redis-master.new-api.svc.cluster.local 端口：6379 PVC：8Gi 执行：\nhelm upgrade --install redis \\ oci://registry-1.docker.io/bitnamicharts/redis \\ --namespace new-api \\ --set architecture=standalone \\ --set auth.enabled=true \\ --set auth.password='替换成REDIS_PASSWORD' \\ --set master.persistence.enabled=true \\ --set master.persistence.size=8Gi 说明：\narchitecture=standalone： 单实例 Redis，学习阶段够用。 auth.password： Redis 访问密码。 master.persistence.size： Redis 数据盘大小。 检查：\nkubectl get pods -n new-api -o wide kubectl get pvc -n new-api kubectl get svc -n new-api 等待 Redis Running：\nkubectl wait --for=condition=ready pod \\ -l app.kubernetes.io/instance=redis \\ -n new-api \\ --timeout=300s 测试连接：\nkubectl run redis-test \\ -n new-api \\ --rm -it \\ --image=redis:7-alpine \\ --restart=Never \\ -- redis-cli -h redis-master.new-api.svc.cluster.local -p 6379 -a '替换成REDIS_PASSWORD' ping 成功会返回：\nPONG 6. 创建 new-api 要用的 Secret\n先生成两个固定密钥：\nopenssl rand -hex 32 openssl rand -hex 32 分别作为：\nSESSION_SECRET CRYPTO_SECRET 创建 Secret：\nkubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET='TV6KasGSCn8lS4h48Jl86rEoQMolzNe5' \\ --from-literal=CRYPTO_SECRET='TV6KasGSCn8lS4h48Jl86rEoQMolzNe5' \\ --from-literal=SQL_DSN='postgresql://newapi:TV6KasGSCn8lS4h48Jl86rEoQMolzNe5@postgresql.new-api.svc.cluster.local:5432/newapi' \\ --from-literal=REDIS_CONN_STRING='redis://:TV6KasGSCn8lS4h48Jl86rEoQMolzNe5@redis-master.new-api.svc.cluster.local:6379/0' \\ --dry-run=client -o yaml | kubectl apply -f - 注意：SQL_DSN 里的密码要和你安装 PostgreSQL 时的：\nauth.password 一致。\n7. 创建 new-api 普通配置 ConfigMap\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: ConfigMap metadata: name: new-api-config namespace: new-api data: TZ: \"Asia/Shanghai\" STREAMING_TIMEOUT: \"300\" MAX_REQUEST_BODY_MB: \"32\" ERROR_LOG_ENABLED: \"true\" EOF 检查：\nkubectl get cm,secret -n new-api 8. 当前阶段完成标准\n执行：\nkubectl get pods -n new-api -o wide kubectl get pvc -n new-api kubectl get svc -n new-api 你应该看到：\npostgresql-0 Running redis-master-0 Running PVC Bound Service postgresql Service redis-master 连接测试：\nselect 1 成功 Redis PONG 成功 到这里，new-api 的数据库和缓存就准备好了。\n9. 常用查看命令\nPostgreSQL 日志：\nkubectl logs -n new-api statefulset/postgresql Redis 日志：\nkubectl logs -n new-api statefulset/redis-master 查看 Helm release：\nhelm list -n new-api 查看 PostgreSQL values：\nhelm get values postgresql -n new-api 查看 Redis values：\nhelm get values redis -n new-api 下一步我们就可以写：\nnew-api Deployment new-api Service new-api Ingress 先 1 副本跑通，然后再改 2-3 副本验证多副本。\n我看了你的博客，整理得挺清楚。现在下一步就是：部署 new-api 本体，也就是创建：\nDeployment Service Ingress 不过先改一个小点：new-api 官方文档里 PostgreSQL 的 SQL_DSN 推荐格式是：\npostgresql://user:password@host:5432/database 你博客里现在写的是：\nhost=postgresql.new-api.svc.cluster.local port=5432 ... 这个格式 PostgreSQL 驱动有时也能识别，但为了贴近 new-api 官方示例，建议改成 URL 格式。官方文档也写了 SQL_DSN、REDIS_CONN_STRING、SESSION_SECRET、CRYPTO_SECRET 这些变量；镜像用的是 calciumion/new-api:latest。参考：New API 环境变量文档、new-api GitHub README。\n假设你的密码变量还在当前 shell 里：\necho $POSTGRES_USER_PASSWORD echo $REDIS_PASSWORD 如果没有，就重新 export 一次。\n一、更新 Secret\nkubectl create secret generic new-api-secret \\ -n new-api \\ --from-literal=SESSION_SECRET='替换成你的SESSION_SECRET' \\ --from-literal=CRYPTO_SECRET='替换成你的CRYPTO_SECRET' \\ --from-literal=SQL_DSN='postgresql://newapi:替换成POSTGRES_USER_PASSWORD@postgresql.new-api.svc.cluster.local:5432/newapi' \\ --from-literal=REDIS_CONN_STRING='redis://:替换成REDIS_PASSWORD@redis-master.new-api.svc.cluster.local:6379/0' \\ --dry-run=client -o yaml | kubectl apply -f - 注意：如果密码里有 @、:、/、#、?、\u0026 这类字符，URL 里可能需要转义。学习阶段建议密码先用字母数字下划线，少踩坑。\n二、创建 new-api 的 PVC\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: PersistentVolumeClaim metadata: name: new-api-data namespace: new-api spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gi EOF 三、部署 new-api\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: apps/v1 kind: Deployment metadata: name: new-api namespace: new-api spec: replicas: 1 selector: matchLabels: app: new-api template: metadata: labels: app: new-api spec: containers: - name: new-api image: calciumion/new-api:latest imagePullPolicy: IfNotPresent args: - \"--log-dir\" - \"/app/logs\" ports: - containerPort: 3000 envFrom: - configMapRef: name: new-api-config - secretRef: name: new-api-secret env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: metadata.name volumeMounts: - name: data mountPath: /data - name: logs mountPath: /app/logs readinessProbe: httpGet: path: /api/status port: 3000 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 livenessProbe: httpGet: path: /api/status port: 3000 initialDelaySeconds: 60 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 volumes: - name: data persistentVolumeClaim: claimName: new-api-data - name: logs emptyDir: {} EOF 四、创建 Service\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: v1 kind: Service metadata: name: new-api namespace: new-api spec: type: ClusterIP selector: app: new-api ports: - name: http port: 3000 targetPort: 3000 EOF 五、创建 Ingress\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: new-api namespace: new-api spec: ingressClassName: nginx rules: - host: k8s-ai.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: new-api port: number: 3000 EOF 六、检查状态\nkubectl get pods -n new-api -o wide kubectl get svc -n new-api kubectl get ingress -n new-api kubectl logs -n new-api deploy/new-api --tail=100 集群内测试：\n如果你的 MetalLB 外部访问还是有问题，就先用 NodePort 访问 ingress-nginx：\ncurl -I -H \"Host: k8s-ai.jihw.top\" http://192.168.3.214:32658 浏览器访问的话，Win11 hosts 暂时写：\n192.168.3.214 k8s-ai.jihw.top 然后打开：\nhttp://k8s-ai.jihw.top:32658 等单副本访问成功、初始化后台成功之后，再考虑扩成 2 副本。现在先别急着多副本，先把 new-api 跑起来。\n是的，要优化成：域名绑定一个会漂移的业务 VIP，而不是绑定某一台节点 IP。\n推荐架构：\nnew-api.k8s.local ↓ 192.168.3.231 业务 VIP，由 keepalived 漂移 ↓ HAProxy ↓ ingress-nginx NodePort 192.168.3.214:32658 192.168.3.215:32658 192.168.3.216:32658 以后 Win11 hosts 只写：\n192.168.3.231 new-api.k8s.local 访问：\nhttp://new-api.k8s.local 即使 192.168.3.214 关闭，VIP 漂移到其他节点，也还能访问。\n一、先确认 NodePort\n在任意 master 执行：\nkubectl get svc -n ingress-nginx ingress-nginx-controller 确认类似：\n80:32658/TCP 443:31847/TCP 下面我按你现在的端口 32658、31847 写。\n二、三个节点都配置 HAProxy\n在 192.168.3.214、192.168.3.215、192.168.3.216 都执行：\nsudo sysctl -w net.ipv4.ip_nonlocal_bind=1 echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/99-haproxy-nonlocal-bind.conf sudo sysctl --system 在文件末尾追加：\nsudo tee -a /etc/haproxy/haproxy.cfg \u003e/dev/null \u003c\u003c'EOF' frontend ingress_http_vip bind 192.168.3.231:80 mode tcp option tcplog default_backend ingress_http_nodeport backend ingress_http_nodeport mode tcp balance roundrobin option tcp-check server k8s-master1 192.168.3.214:32467 check server k8s-master2 192.168.3.215:32467 check server k8s-master3 192.168.3.216:32467 check frontend ingress_https_vip bind 192.168.3.231:443 mode tcp option tcplog default_backend ingress_https_nodeport backend ingress_https_nodeport mode tcp balance roundrobin option tcp-check server k8s-master1 192.168.3.214:32419 check server k8s-master2 192.168.3.215:32419 check server k8s-master3 192.168.3.216:32419 check EOF 检查并重载：\nsudo haproxy -c -f /etc/haproxy/haproxy.cfg sudo systemctl reload haproxy 三、三个节点都配置 keepalived 业务 VIP\n先查看网卡名：\nip route get 192.168.3.1 你会看到类似：\ndev ens33 记住这个网卡名，下面假设是 ens33，如果你的不是，就替换。\n编辑：\n不要删原来的 API VIP 配置，在末尾追加一个新的 vrrp_instance。\nk8s-master1 / 192.168.3.214：\nsudo tee -a /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' vrrp_instance VI_INGRESS { state MASTER interface ens33 virtual_router_id 52 priority 120 advert_int 1 authentication { auth_type PASS auth_pass ingressvip } virtual_ipaddress { 192.168.3.231 } } EOF k8s-master2 / 192.168.3.215：\nsudo tee -a /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' vrrp_instance VI_INGRESS { state BACKUP interface ens33 virtual_router_id 52 priority 110 advert_int 1 authentication { auth_type PASS auth_pass ingressvip } virtual_ipaddress { 192.168.3.231 } } EOF k8s-master3 / 192.168.3.216：\nsudo tee -a /etc/keepalived/keepalived.conf \u003e/dev/null \u003c\u003c'EOF' vrrp_instance VI_INGRESS { state BACKUP interface ens33 virtual_router_id 52 priority 100 advert_int 1 authentication { auth_type PASS auth_pass ingressvip } virtual_ipaddress { 192.168.3.231 } } EOF 然后三个节点都执行：\nsudo systemctl restart keepalived sudo systemctl status keepalived --no-pager 四、测试 VIP\n在任意节点执行：\nip a | grep 192.168.3.231 正常只会有一台机器持有这个 VIP。\n在 Win11 hosts 里改成：\n192.168.3.231 k8s-ai.jihw.top 192.168.3.231 longhorn.k8s.local 然后访问：\nhttp://k8s-ai.jihw.top/setup 测试端口：\nTest-NetConnection 192.168.3.231 -Port 80 高可用测试：\n# 关掉当前持有 192.168.3.231 的那台节点 sudo systemctl stop keepalived 然后在另一台节点看：\nip a | grep 192.168.3.231 如果 VIP 漂移成功，Win11 继续访问：\nhttp://new-api.k8s.local 这套方案比你现在的 192.168.3.214:32658 好很多：域名绑定的是业务 VIP，后端任意一台节点挂掉，HAProxy 会自动跳过；当前持有 VIP 的节点挂掉，keepalived 会把入口漂移到另一台。\n自定义域名证书 可以，下一步分两段做：先把 HTTP 域名访问跑通，再做 Let’s Encrypt HTTPS 证书。\n先确认你现在的入口链路是：\nk8s-ai.jihw.top -\u003e 192.168.3.231 -\u003e HAProxy -\u003e ingress-nginx NodePort -\u003e new-api 一、先改 new-api Ingress 域名\n在 master 节点执行：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: new-api namespace: new-api spec: ingressClassName: nginx rules: - host: k8s-ai.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: new-api port: number: 3000 EOF 检查：\nkubectl get ingress -n new-api 然后在 Win11 测试：\nTest-NetConnection k8s-ai.jihw.top -Port 80 浏览器访问：\nhttp://k8s-ai.jihw.top/setup 如果能打开，说明域名入口已经正常。\n二、安装 cert-manager\n如果你还没装 cert-manager，执行：\nkubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.2/cert-manager.yaml 等待启动：\nkubectl get pods -n cert-manager 三个 Pod 都 Running 后再继续。\n三、安装阿里云 DNS webhook\nhelm repo add cert-manager-alidns-webhook https://devmachine-fr.github.io/cert-manager-alidns-webhook helm repo update helm upgrade --install alidns-webhook \\ cert-manager-alidns-webhook/alidns-webhook \\ -n cert-manager \\ --set groupName=jihw.top 检查：\nkubectl get pods -n cert-manager 四、创建阿里云 DNS AccessKey Secret\n建议在阿里云 RAM 创建专用 AccessKey，先给 AliyunDNSFullAccess，后面熟悉后再收窄权限。\nkubectl create secret generic alidns-secret \\ -n cert-manager \\ --from-literal=access-key-id='你的AccessKeyId' \\ --from-literal=access-key-secret='你的AccessKeySecret' 注意这里放在 cert-manager 命名空间。\n五、创建 Let’s Encrypt Prod ClusterIssuer\n这里直接使用正式证书。注意两点：\n1. email 必须换成真实邮箱，不能写“你的邮箱”这种中文占位。 2. 这里安装的是 devmachine-fr/cert-manager-alidns-webhook 0.3.1， webhook 配置字段要用 accessTokenSecretRef / secretKeySecretRef。 如果字段写成 accessKeyIdRef / accessKeySecretRef，Challenge 会报类似错误：\nfailed to load secret \"cert-manager/\": resource name may not be empty 创建正式证书 Issuer：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-alidns-prod spec: acme: email: 2455191080@qq.com server: https://acme-v02.api.letsencrypt.org/directory privateKeySecretRef: name: letsencrypt-alidns-prod-account-key solvers: - dns01: webhook: groupName: jihw.top solverName: alidns-solver config: regionId: cn-hangzhou accessTokenSecretRef: name: alidns-secret key: access-key-id secretKeySecretRef: name: alidns-secret key: access-key-secret EOF 检查 Issuer：\nkubectl get clusterissuer letsencrypt-alidns-prod 应该看到：\nREADY True 如果不是 True，先看原因：\nkubectl describe clusterissuer letsencrypt-alidns-prod kubectl -n cert-manager logs deploy/cert-manager --tail=100 六、给 Ingress 加 TLS 并申请证书\n下面这个 Ingress 会自动创建名为 k8s-ai-jihw-top-tls 的 Certificate，并把证书保存到同名 Secret。\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: new-api namespace: new-api annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod spec: ingressClassName: nginx tls: - hosts: - k8s-ai.jihw.top secretName: k8s-ai-jihw-top-tls rules: - host: k8s-ai.jihw.top http: paths: - path: / pathType: Prefix backend: service: name: new-api port: number: 3000 EOF 查看申请状态：\nkubectl get certificate -n new-api kubectl get certificaterequest,order,challenge -n new-api kubectl describe certificate k8s-ai-jihw-top-tls -n new-api 正常流程大概是：\nCertificateRequest Approved=True Order pending -\u003e valid Challenge pending -\u003e valid Certificate Ready=True 成功后会看到 TLS Secret：\nkubectl get secret k8s-ai-jihw-top-tls -n new-api 也可以看证书有效期：\nkubectl describe certificate k8s-ai-jihw-top-tls -n new-api | grep -E \"Not Before|Not After|Renewal Time|Ready\" 七、测试 HTTPS\nTest-NetConnection k8s-ai.jihw.top -Port 443 浏览器访问：\nhttps://k8s-ai.jihw.top 再看状态：\nkubectl get certificate -n new-api kubectl describe certificate k8s-ai-jihw-top-tls -n new-api 八、以后申请其他域名证书\n以后只要 cert-manager、alidns-webhook、alidns-secret、letsencrypt-alidns-prod 都已经存在，就不需要重复安装。只需要给新的服务写 Ingress。\n假设要给 api.example.com 申请证书，命名空间是 demo，服务名是 demo-api，端口是 3000：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-api namespace: demo annotations: cert-manager.io/cluster-issuer: letsencrypt-alidns-prod spec: ingressClassName: nginx tls: - hosts: - api.example.com secretName: api-example-com-tls rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: demo-api port: number: 3000 EOF 然后查对应命名空间：\nkubectl get certificate -n demo kubectl get certificaterequest,order,challenge -n demo kubectl describe certificate api-example-com-tls -n demo 九、常见问题排查\n如果 Certificate 一直是 False，先看这几个：\nkubectl get clusterissuer letsencrypt-alidns-prod kubectl describe clusterissuer letsencrypt-alidns-prod kubectl get certificate -A kubectl get certificaterequest,order,challenge -A kubectl describe challenge -n new-api kubectl -n cert-manager logs deploy/cert-manager --tail=200 kubectl -n cert-manager logs deploy/alidns-webhook --tail=200 如果看到：\nReferenced issuer does not have a Ready status condition 说明 ClusterIssuer 还没 Ready，常见原因是邮箱写错，尤其不能写中文占位。\n如果看到：\nfailed to load secret \"cert-manager/\": resource name may not be empty 说明 alidns webhook 字段名写错了。当前这个 webhook 要写：\naccessTokenSecretRef: name: alidns-secret key: access-key-id secretKeySecretRef: name: alidns-secret key: access-key-secret 如果改过 ClusterIssuer 后，旧的 Challenge 一直卡在删除中，可以清理旧资源再让 cert-manager 重建：\nkubectl -n new-api delete challenge --all --wait=false kubectl -n new-api delete order --all --wait=false kubectl -n new-api delete certificaterequest --all --wait=false 如果某个旧 Challenge 有 finalizer 卡住，而且确认它没有成功写入 DNS 记录，可以移除 finalizer：\nkubectl -n new-api patch challenge \u003cchallenge-name\u003e \\ --type=json \\ -p='[{\"op\":\"remove\",\"path\":\"/metadata/finalizers\"}]' DNS-01 成功写入后，可以用下面命令看 TXT 记录传播：\ndig +short TXT _acme-challenge.k8s-ai.jihw.top @223.5.5.5 dig +short TXT _acme-challenge.k8s-ai.jihw.top @8.8.8.8 后续申请其他域名时，优先复用 letsencrypt-alidns-prod，只需要新增或修改对应服务的 Ingress。\n","permalink":"/coding/k8s-new-api/","summary":"漂亮，nginx Ingress 已经通了。下一步我们做 new-api 部署前准备，目标是先把 K8s 的运维基础补齐，再验证它能访问你已有的 PostgreSQL 和 Redis。\n接下来分 4 步：\n1. 安装 metrics-server 2. 创建 new-api namespace 3. 准备 Secret / ConfigMap 4. 用临时 Pod 测试 PostgreSQL 和 Redis 连通性 1. 安装 metrics-server\n作用：\nmetrics-server 用来采集节点和 Pod 的 CPU、内存指标。 安装后才能用 kubectl top nodes / kubectl top pods。 安装：\nkubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.8.1/components.yaml 家庭自建 kubeadm 集群里，metrics-server 经常因为 kubelet 证书问题启动失败。先检查：\nkubectl get pods -n kube-system | grep metrics 如果 Running，直接测试：\n","title":"K8s New Api"},{"content":"ssh配置 安装openssh-server sudo apt update # 安装后会自动开启服务 sudo apt install -y openssh-server # 查看状态 sudo systemctl status ssh sudo systemctl start ssh sudo systemctl enable ssh ssh免密登录 在控制服务器操作的电脑上，执行以下命令\ncd C:\\Users\\Rx\\.ssh ssh-keygen -t rsa -b 4096 -C \"你的注释，比如：win11-to-ubuntu-vm\" # 查看公钥 cat C:\\Users\\Rx/.ssh/id_ed25519.pub -t rsa：指定密钥类型为 RSA -b 4096：指定密钥长度为 4096 位（更安全） -C：添加注释，帮助识别这个密钥的用途 在ubuntu上操作\n# 创建 .ssh 目录（如果不存在） mkdir -p ~/.ssh # 设置正确的权限 chmod 700 ~/.ssh # 将公钥添加到授权文件，必须是完整的字符串 echo \"秘钥\" \u003e\u003e ~/.ssh/authorized_keys # 设置授权文件权限 chmod 600 ~/.ssh/authorized_keys 在win11上操作\nssh -i C:\\Users\\Rx\\.ssh\\私钥名称 root@192.168.3.211 用户配置 启用root账户 # 设置root密码 sudo passwd root # 切换到root用户 su root 开启root账号ssh连接 sudo vim /etc/ssh/sshd_config # 添加 PermitRootLogin yes sudo systemctl restart ssh 休闲版\necho 'PermitRootLogin yes' | sudo tee -a /etc/ssh/sshd_config \u003e/dev/null \\ \u0026\u0026 sudo systemctl restart ssh 如果不小改乱了配置，可以执行以下命令：\nsudo cp /usr/share/openssh/sshd_config /etc/ssh/sshd_config 关闭无人值守模式 原因是 K8s 节点升级应该走维护流程：cordon/drain -\u003e 升级/重启 -\u003e uncordon。Kubernetes 官方节点升级文档也要求升级节点前先 kubectl drain，Ubuntu 官方文档也说明 unattended-upgrades 可自动安装安全更新，甚至可配置自动重启，这对 K8s 节点很容易造成不可控扰动\nsudo systemctl disable --now apt-daily.timer sudo systemctl disable --now apt-daily-upgrade.timer sudo systemctl disable --now apt-daily.service sudo systemctl disable --now apt-daily-upgrade.service sudo systemctl disable --now unattended-upgrades.service #验证 systemctl is-enabled apt-daily.timer systemctl is-active apt-daily.timer systemctl is-enabled apt-daily-upgrade.timer systemctl is-active apt-daily-upgrade.timer systemctl is-enabled unattended-upgrades.service systemctl is-active unattended-upgrades.service 把 VMware 虚拟机网卡类型从 E1000 改成 VMXNET3。\n如果 UI 里看不到网卡类型，可以关闭虚拟机后修改 .vmx 文件：\nethernet0.virtualDev = \"vmxnet3\" 原来如果是：\nethernet0.virtualDev = \"e1000\" 就改成：\nethernet0.virtualDev = \"vmxnet3\" 启动后检查驱动：\nethtool -i ens160 期望看到：\ndriver: vmxnet3 修改网卡 修改为 VMXNET3 后，Ubuntu 中网卡名可能从 ens33 变成 ens160。\n这时需要检查所有写死旧网卡名的配置：\ngrep -R \"ens33\" /etc/keepalived /etc/haproxy /etc/netplan /etc/kubernetes /etc/systemd /etc/NetworkManager 2\u003e/dev/null 常见需要修改：\n/etc/netplan/*.yaml /etc/keepalived/keepalived.conf /etc/NetworkManager/system-connections/*.nmconnection Keepalived 中如果有：\nvrrp_instance VI_1 { interface ens33 } 改成：\nvrrp_instance VI_1 { interface ens160 } 如果有 track_interface，也要一起改：\ntrack_interface { ens160 } NetworkManager 中也可能残留旧网卡名，例如：\n/etc/NetworkManager/system-connections/有线连接 1.nmconnection:interface-name=ens33 推荐用 nmcli 修改，而不是直接编辑文件：\n# 查看所有连接 nmcli connection show nmcli connection modify \"有线连接 1\" connection.interface-name ens160 nmcli connection reload nmcli connection up \"有线连接 1\" 如果看到原本 ens160 挂在 有线连接 2 上，切换后变成：\nNAME TYPE DEVICE 有线连接 1 ethernet ens160 有线连接 2 ethernet -- 说明当前活动连接已经切到 有线连接 1。为了避免重启后又自动切回 有线连接 2，可以固定自动连接优先级：\nnmcli connection modify \"有线连接 1\" connection.autoconnect yes connection.autoconnect-priority 100 nmcli connection modify \"有线连接 2\" connection.autoconnect no 再确认 有线连接 1 的 IP、网关和 DNS 是否正确：\nnmcli connection show \"有线连接 1\" | egrep \"interface-name|ipv4.method|ipv4.addresses|ipv4.gateway|ipv4.dns|autoconnect\" ip addr show ens160 ip route 如果虚拟机所在宿主机有两个物理网口，需要注意：虚拟机内部的 ens160 只代表虚拟机里的网卡，不决定桥接到宿主机哪个物理网口。桥接到哪个物理网口，要在 VMware 的虚拟网络设置里配置，例如 VMnet0 桥接到指定宿主机网卡。Ubuntu 里只需要保证 ens160 配好固定 IP，Keepalived 也指向 ens160。\n改完后检查：\nnetplan apply systemctl restart keepalived systemctl status keepalived ip addr show ens160 ip addr | grep 192.168.3.217 三台主节点建议一台一台改。每改完一台，都确认：\nkubectl get nodes -o wide systemctl is-active kubelet containerd keepalived haproxy ip addr | grep 192.168.3.217 设置主机名修改hostname 在 192.168.3.214：\nsudo hostnamectl set-hostname k8s-master1 在 192.168.3.215：\nsudo hostnamectl set-hostname k8s-master2 在 192.168.3.216：\nsudo hostnamectl set-hostname k8s-master3 三台节点都写入 hosts：\ncat \u003c\u003c'EOF' | sudo tee -a /etc/hosts 192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-api-vip EOF ","permalink":"/coding/ubuntu-getting-started/","summary":"ssh配置 安装openssh-server sudo apt update # 安装后会自动开启服务 sudo apt install -y openssh-server # 查看状态 sudo systemctl status ssh sudo systemctl start ssh sudo systemctl enable ssh ssh免密登录 在控制服务器操作的电脑上，执行以下命令\ncd C:\\Users\\Rx\\.ssh ssh-keygen -t rsa -b 4096 -C \"你的注释，比如：win11-to-ubuntu-vm\" # 查看公钥 cat C:\\Users\\Rx/.ssh/id_ed25519.pub -t rsa：指定密钥类型为 RSA -b 4096：指定密钥长度为 4096 位（更安全） -C：添加注释，帮助识别这个密钥的用途 在ubuntu上操作\n# 创建 .ssh 目录（如果不存在） mkdir -p ~/.ssh # 设置正确的权限 chmod 700 ~/.ssh # 将公钥添加到授权文件，必须是完整的字符串 echo \"秘钥\" \u003e\u003e ~/.ssh/authorized_keys # 设置授权文件权限 chmod 600 ~/.ssh/authorized_keys 在win11上操作\n","title":"Ubuntu Getting Started"},{"content":"三主节点高可用 3 台机器全部做 control-plane 3 个 etcd 组成 stacked etcd HAProxy + Keepalived 提供 API Server 虚拟 IP containerd 作为容器运行时 规划：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip API 入口用：\n192.168.3.217:8443 不用 6443 是因为 HAProxy 和 kube-apiserver 都在同一批机器上，避免端口冲突。\n快捷部署（脚本版） 如果你想先快速把三主节点 HA 集群跑起来，可以直接用我整理好的脚本。脚本放在仓库的 static/k8s 目录，站点发布后访问路径是 /k8s/脚本名。\n脚本默认使用这组地址：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip Kubernetes: v1.36 API 入口: 192.168.3.217:8443 如果 IP、VIP、Kubernetes 版本不一样，先改 k8s_env.sh，或者执行脚本时通过环境变量覆盖：\nMASTER1_IP=192.168.3.214 \\ MASTER2_IP=192.168.3.215 \\ MASTER3_IP=192.168.3.216 \\ VIP_IP=192.168.3.217 \\ K8S_VERSION=v1.36 \\ bash k8s_prepare.sh 1. 下载脚本 如果你在这份文档仓库里，可以直接进入脚本目录：\ncd static/k8s chmod +x *.sh 如果是在三台 Ubuntu 机器上从网站下载：\nmkdir -p ~/k8s-ha cd ~/k8s-ha for file in \\ k8s_env.sh \\ k8s_reset.sh \\ k8s_prepare.sh \\ k8s_init_master1.sh \\ k8s_join_control_plane.sh \\ k8s_post_install.sh \\ k8s_check_ha.sh \\ k8s_shutdown_all.sh do curl -fsSLO \"https://doc.jihw.top/k8s/${file}\" done chmod +x *.sh 2. 可选：清理旧集群 如果这三台机器之前已经跑过 kubeadm 集群，三台都执行：\nYES=yes bash k8s_reset.sh 这一步会删除本机的 Kubernetes、etcd、kubelet、CNI 和 ~/.kube 配置。新机器可以跳过。\n3. 三台机器都执行准备脚本 在 192.168.3.214、192.168.3.215、192.168.3.216 三台都执行：\nbash k8s_prepare.sh 这个脚本会自动做这些事：\n安装基础工具、containerd、kubeadm、kubelet、kubectl 关闭 swap 配置内核模块和 sysctl 写入 hosts 按本机 IP 设置 hostname 配置 HAProxy 配置 Keepalived 如果脚本没有识别出本机 IP，手动指定：\nNODE_IP=192.168.3.214 bash k8s_prepare.sh 如果默认网卡不是脚本自动识别的网卡，也可以指定：\nIFACE=ens33 bash k8s_prepare.sh 4. 初始化第一个 control-plane 只在 192.168.3.214 上执行：\nbash k8s_init_master1.sh 它会执行 kubeadm init，配置当前用户的 kubectl，安装 Flannel，并生成：\n~/k8s-control-plane-join.sh 这个 join 脚本用于把另外两台机器加入 control-plane。\n5. 加入另外两个 control-plane 把 192.168.3.214 上生成的 ~/k8s-control-plane-join.sh 拷到 192.168.3.215 和 192.168.3.216，然后分别执行：\nbash ~/k8s-control-plane-join.sh 如果不想拷贝脚本，也可以在另外两台机器上用 JOIN_COMMAND 传入完整 join 命令：\nJOIN_COMMAND='sudo kubeadm join 192.168.3.217:8443 --token xxxxxx.xxxxxxxxxxxxxxxx --discovery-token-ca-cert-hash sha256:xxxx --control-plane --certificate-key xxxx' \\ bash k8s_join_control_plane.sh 6. 部署测试应用并检查 HA 在任意一台 master 上执行：\nbash k8s_post_install.sh bash k8s_check_ha.sh k8s_post_install.sh 会允许 control-plane 跑业务 Pod，并部署一个 nginx NodePort 服务。\nk8s_check_ha.sh 会检查节点、系统 Pod、etcd、VIP 和 API Server 健康状态。\n确认没问题后，可以手工关掉当前持有 VIP 的机器，再在其他 master 上执行：\nkubectl get nodes ip addr | grep 192.168.3.217 如果 kubectl get nodes 仍然能返回，并且 VIP 漂移到其他机器，说明 API Server HA 生效。\n一、先理解 HA 架构 高可用 K8s 至少要解决三件事：\n1. 多个 kube-apiserver 2. 多个 etcd，且保持多数派 3. 一个稳定的 API 入口 你这 3 台机器会变成：\nk8s-master1: apiserver + scheduler + controller-manager + etcd + haproxy + keepalived k8s-master2: apiserver + scheduler + controller-manager + etcd + haproxy + keepalived k8s-master3: apiserver + scheduler + controller-manager + etcd + haproxy + keepalived 3 个 etcd 可以容忍 宕机 1 台。\n如果 3 台里宕 2 台，etcd 没有多数派，集群就不可用了。\n如果这 3 台虚拟机都在同一台物理机上，那只是“学习 HA”，不是物理层真正 HA。\n二、如果昨天已经初始化过集群，先清理 三台都执行。注意：这会清掉现有 K8s 集群配置。\nsudo kubeadm reset -f sudo rm -rf /etc/kubernetes sudo rm -rf /var/lib/etcd sudo rm -rf /var/lib/kubelet sudo rm -rf /etc/cni/net.d sudo rm -rf /var/lib/cni sudo rm -rf ~/.kube sudo systemctl restart containerd sudo systemctl restart kubelet || true 三、三台机器都执行基础配置 sudo apt update sudo apt install -y apt-transport-https ca-certificates curl gpg vim haproxy keepalived 关闭 swap：\nsudo swapoff -a sudo sed -i.bak '/ swap / s/^/#/' /etc/fstab 配置内核模块：\ncat \u003c\u003cEOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter 配置内核参数：\ncat \u003c\u003cEOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 net.ipv4.ip_nonlocal_bind = 1 EOF sudo sysctl --system 配置 hosts：\ncat \u003c\u003cEOF | sudo tee -a /etc/hosts 192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip EOF 分别设置 hostname：\n在 192.168.3.214：\nsudo hostnamectl set-hostname k8s-master1 在 192.168.3.215：\nsudo hostnamectl set-hostname k8s-master2 在 192.168.3.216：\nsudo hostnamectl set-hostname k8s-master3 四、三台机器安装 containerd sudo apt install -y containerd sudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml \u003e/dev/null sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml sudo systemctl restart containerd sudo systemctl enable containerd 五、三台机器安装 kubeadm/kubelet/kubectl 这里用 Kubernetes v1.36。\nsudo mkdir -p -m 755 /etc/apt/keyrings curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key \\ | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' \\ | sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt update sudo apt install -y kubelet kubeadm kubectl sudo apt-mark hold kubelet kubeadm kubectl sudo systemctl enable --now kubelet 配置镜像加速 在 K8s + containerd 里，不要改 Docker 的 /etc/docker/daemon.json，因为节点上不是 Docker 在拉镜像，而是 containerd 在拉。\n你有两种方式。\n方式一：推荐，给 containerd 配 Docker Hub 镜像加速\n这要在 每个 K8s 节点 都执行：\n# 创建 containerd 的 registry 配置目录 sudo mkdir -p /etc/containerd/certs.d/docker.io # 写入 Docker Hub 镜像加速配置 cat \u003c\u003cEOF | sudo tee /etc/containerd/certs.d/docker.io/hosts.toml server = \"https://registry-1.docker.io\" [host.\"https://docker.m.daocloud.io\"] capabilities = [\"pull\", \"resolve\"] EOF 然后确认 containerd 启用了 config_path：\n# 目前测试不能识别多目录，将默认的docker目录去除才有用 sudo sed -i \"s|config_path = '/etc/containerd/certs.d:/etc/docker/certs.d'|config_path = '/etc/containerd/certs.d'|\" /etc/containerd/config.toml sudo grep -n \"config_path\" /etc/containerd/config.toml sudo systemctl restart containerd sudo systemctl restart kubelet 如果没有 crictl，可以先装：\nsudo apt install -y cri-tools 消除警告\ncat \u003c\u003cEOF | sudo tee /etc/crictl.yaml runtime-endpoint: unix:///run/containerd/containerd.sock image-endpoint: unix:///run/containerd/containerd.sock timeout: 10 debug: false EOF 测试拉取：\nsudo crictl -D pull nginx:latest crictl images #查看镜像日志 sudo journalctl -u containerd -f 六、三台机器配置 HAProxy 三台都写同样配置：\nsudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak cat \u003c\u003cEOF | sudo tee /etc/haproxy/haproxy.cfg global log /dev/log local0 log /dev/log local1 notice daemon maxconn 4096 defaults log global mode tcp option tcplog option dontlognull timeout connect 5s timeout client 50s timeout server 50s frontend kubernetes-apiserver bind *:8443 mode tcp default_backend kubernetes-apiserver backend kubernetes-apiserver mode tcp balance roundrobin option tcp-check server k8s-master1 192.168.3.214:6443 check server k8s-master2 192.168.3.215:6443 check server k8s-master3 192.168.3.216:6443 check EOF sudo systemctl restart haproxy sudo systemctl enable haproxy 七、三台机器配置 Keepalived 先确认网卡名：\nip -o -4 route show to default | awk '{print $5}' 假设输出是：\nens33 如果你的不是 ens33，下面配置里的 ens33 要替换。\n192.168.3.214 上执行 cat \u003c\u003cEOF | sudo tee /etc/keepalived/keepalived.conf vrrp_script chk_haproxy { script \"killall -0 haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state MASTER interface ens33 virtual_router_id 51 priority 101 advert_int 1 authentication { auth_type PASS auth_pass k8sha } unicast_src_ip 192.168.3.214 unicast_peer { 192.168.3.215 192.168.3.216 } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF sudo systemctl restart keepalived sudo systemctl enable keepalived 192.168.3.215 上执行 cat \u003c\u003cEOF | sudo tee /etc/keepalived/keepalived.conf vrrp_script chk_haproxy { script \"killall -0 haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state BACKUP interface ens33 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass k8sha } unicast_src_ip 192.168.3.215 unicast_peer { 192.168.3.214 192.168.3.216 } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF sudo systemctl restart keepalived sudo systemctl enable keepalived 192.168.3.216 上执行 cat \u003c\u003cEOF | sudo tee /etc/keepalived/keepalived.conf vrrp_script chk_haproxy { script \"killall -0 haproxy\" interval 2 weight -20 } vrrp_instance VI_1 { state BACKUP interface ens33 virtual_router_id 51 priority 99 advert_int 1 authentication { auth_type PASS auth_pass k8sha } unicast_src_ip 192.168.3.216 unicast_peer { 192.168.3.214 192.168.3.215 } virtual_ipaddress { 192.168.3.217/24 } track_script { chk_haproxy } } EOF sudo systemctl restart keepalived sudo systemctl enable keepalived 检查 VIP 是否出现：\nip addr | grep 192.168.3.217 只要三台里有一台显示 192.168.3.217，就说明 VIP 正常。\n八、初始化第一个 control-plane 只在 192.168.3.214 执行：\nsudo kubeadm init \\ --control-plane-endpoint \"192.168.3.217:8443\" \\ --upload-certs \\ --apiserver-advertise-address=192.168.3.214 \\ --pod-network-cidr=10.244.0.0/16 \\ --cri-socket=unix:///run/containerd/containerd.sock 初始化成功后，保存输出里的两类 join 命令：\nworker join 命令 control-plane join 命令 你需要的是带这个参数的：\n--control-plane --certificate-key ... 配置 kubectl： 如果也想在其他上面使用 kubectl，再分别在它们上执行这三行\nmkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config 安装 Flannel，只用在一个节点执行：\nkubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml 九、加入另外两个 control-plane 在 192.168.3.215 和 192.168.3.216 上执行 master 初始化输出的命令。\n格式类似这样：\nsudo kubeadm join 192.168.3.217:8443 \\ --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --control-plane \\ --certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --cri-socket=unix:///run/containerd/containerd.sock 如果 join 命令丢了，在 192.168.3.214 重新生成：\nkubeadm token create --print-join-command 重新生成 certificate key：\nsudo kubeadm init phase upload-certs --upload-certs 然后把两者组合起来。\n十、检查集群 在任意 master 上执行：\nkubectl get nodes -o wide 你应该看到：\nk8s-master1 Ready control-plane k8s-master2 Ready control-plane k8s-master3 Ready control-plane 查看系统 Pod：\nkubectl get pods -A -o wide 查看 etcd：\nkubectl get pods -n kube-system -o wide | grep etcd 应该有 3 个 etcd：\netcd-k8s-master1 etcd-k8s-master2 etcd-k8s-master3 十一、允许 control-plane 也跑业务 Pod 因为你这 3 台都是 control-plane，没有普通 worker。学习环境可以允许它们跑业务： 一次性移除所有 Master（Control Plane）节点上的 NoSchedule污点，从而允许普通 Pod 调度到 Master 节点上运行。\nkubectl taint nodes --all node-role.kubernetes.io/control-plane- 十二、测试部署 nginx kubectl create deployment nginx --image=nginx:latest kubectl expose deployment nginx --port=80 --type=NodePort kubectl get pods -o wide kubectl get svc nginx 假设 NodePort 是 31234，就可以访问：\nhttp://192.168.3.214:31234 http://192.168.3.215:31234 http://192.168.3.216:31234 十三、测试高可用 先确认 kubectl 用的是 VIP：\nkubectl config view --minify | grep server 应该类似：\nserver: https://192.168.3.217:8443 然后可以关掉当前持有 VIP 的机器，例如 192.168.3.214：\nsudo poweroff 等几十秒，在其他机器上执行：\nkubectl get nodes 如果还能正常返回，说明 API Server HA 生效。\n检查 VIP 是否漂移：\nip addr | grep 192.168.3.217 十四、虚拟机集群的日常关机和启动 如果 K8s 集群部署在家用服务器或台式机里的虚拟机上，为了省电，晚上可以把虚拟机关掉，第二天再启动。关键点是：不要直接强制断电，尽量让每台虚拟机正常关机。\n对于这套三主节点集群：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip 如果后面又加入了 worker，例如：\n192.168.3.218 k8s-worker1 192.168.3.219 k8s-worker2 192.168.3.220 k8s-worker3 那么日常关机建议按这个顺序：\n先关业务入口和 worker 再关 control-plane/master 启动时反过来：\n先启动 control-plane/master 等 API Server、etcd、CNI 正常 再启动 worker 最后检查业务 Pod 1. 关机前检查集群状态 在任意一台 master 上执行：\nkubectl get nodes -o wide kubectl get pods -A -o wide 如果安装了 Ingress、Longhorn、GitLab、SonarQube 这类有状态或入口组件，也先看一下它们是否健康：\nkubectl get pods -n ingress-nginx -o wide kubectl get pods -n longhorn-system -o wide kubectl get pods -n gitlab -o wide kubectl get pods -n sonarqube -o wide 如果只是学习环境，通常不用把所有 Pod 都手动删掉。Kubernetes 会在节点重新启动后由 kubelet 拉起静态 Pod，并恢复 Deployment、StatefulSet、DaemonSet 管理的工作负载。\n如果正在写入数据库、GitLab、Longhorn 卷，最好先停掉外部访问，等写入结束后再关机。不要在备份、迁移、镜像拉取、卷重建时直接断电。\n2. 不建议对整套集群逐台 drain kubectl drain 适合“只维护一台节点，其他节点继续运行”的场景。比如只重启一个 worker，可以这样：\nkubectl drain k8s-worker1 --ignore-daemonsets --delete-emptydir-data sudo reboot kubectl uncordon k8s-worker1 但是如果晚上要把整套虚拟机集群全部关闭，就不建议把所有节点都 drain 一遍。因为所有节点都会停，Pod 没有地方重新调度，很多 eviction 会卡住，反而增加混乱。\n整套集群关机时，核心原则是：\n让业务停止写入 让每台虚拟机正常执行 shutdown/poweroff 不要强制断电 3. 关闭 worker 节点 如果有 worker，先在每台 worker 上执行：\nsudo shutdown -h now 也可以从 master 上通过 SSH 远程执行：\nssh root@192.168.3.218 \"shutdown -h now\" ssh root@192.168.3.219 \"shutdown -h now\" ssh root@192.168.3.220 \"shutdown -h now\" 等待虚拟机状态变成 powered off。\n4. 使用一键关闭脚本 如果不想每次手动 SSH 到每台机器，可以使用脚本：\nstatic/k8s/k8s_shutdown_all.sh 如果是在网站上下载：\nmkdir -p ~/k8s-ha cd ~/k8s-ha curl -fsSLO https://doc.jihw.top/k8s/k8s_env.sh curl -fsSLO https://doc.jihw.top/k8s/k8s_shutdown_all.sh chmod +x k8s_env.sh k8s_shutdown_all.sh 先看一遍关机计划，不会真正关机：\nbash k8s_shutdown_all.sh --dry-run 确认没问题后，在任意一台 master 上执行：\nYES=yes bash k8s_shutdown_all.sh 脚本默认会按这个顺序执行：\n1. 关闭 worker: 192.168.3.218、192.168.3.219、192.168.3.220 2. 关闭远端 master 3. 最后关闭当前执行脚本的 master 默认 SSH 用户是 root。如果不是 root，可以指定：\nSSH_USER=ubuntu YES=yes bash k8s_shutdown_all.sh 如果 SSH key 不是默认位置，可以指定：\nSSH_KEY=~/.ssh/id_rsa YES=yes bash k8s_shutdown_all.sh 如果还没有 worker，或者只想关闭三台 master：\nYES=yes bash k8s_shutdown_all.sh --no-workers 如果 worker IP 不是默认这三个，用 WORKER_IPS 覆盖：\nWORKER_IPS=\"192.168.3.221 192.168.3.222\" YES=yes bash k8s_shutdown_all.sh 脚本执行前会打印当前节点状态和即将关闭的机器。没有加 YES=yes 时，需要手动输入 SHUTDOWN 才会继续。\n5. 手动关闭 master 节点 worker 关闭后，再关闭三台 master：\nsudo shutdown -h now 如果从 k8s-master1 远程关另外两台，可以先关 master2、master3，最后关自己：\nssh root@192.168.3.215 \"shutdown -h now\" ssh root@192.168.3.216 \"shutdown -h now\" sudo shutdown -h now 三台 master 都关闭后，etcd 会整体停止。只要是正常关机，第二天全部启动后可以恢复。\n6. 第二天启动 master 先启动三台 master 虚拟机：\nk8s-master1 k8s-master2 k8s-master3 等系统起来后，在任意 master 上检查基础服务：\nsystemctl status containerd --no-pager systemctl status kubelet --no-pager systemctl status haproxy --no-pager systemctl status keepalived --no-pager 再检查 VIP：\nip addr | grep 192.168.3.217 如果当前这台没有 VIP，不代表异常。只要三台 master 里有一台持有 192.168.3.217 即可。\n检查 API Server：\nkubectl get nodes -o wide kubectl get pods -n kube-system -o wide 刚启动的前几分钟，节点可能短暂显示 NotReady，部分 Pod 可能处于 ContainerCreating。等 CNI、kube-proxy、CoreDNS 都恢复后再继续。\n7. 启动 worker master 正常后，再启动 worker 虚拟机：\nk8s-worker1 k8s-worker2 k8s-worker3 然后检查所有节点：\nkubectl get nodes -o wide 期望看到所有节点都是 Ready：\nk8s-master1 Ready k8s-master2 Ready k8s-master3 Ready k8s-worker1 Ready k8s-worker2 Ready k8s-worker3 Ready 8. 启动后检查业务 查看所有 Pod：\nkubectl get pods -A -o wide 如果有 Pod 一直不是 Running，先看事件：\nkubectl get events -A --sort-by=.lastTimestamp | tail -50 再看具体 Pod：\nkubectl describe pod -n 命名空间 Pod名 kubectl logs -n 命名空间 Pod名 --tail=100 如果安装了 Longhorn，重点确认卷和副本恢复：\nkubectl get pods -n longhorn-system -o wide kubectl get volumes.longhorn.io -n longhorn-system 如果安装了 ingress-nginx，确认入口 IP 还在：\nkubectl get svc -n ingress-nginx 如果业务域名访问不了，先检查 EXTERNAL-IP 是否仍然是原来的地址，比如：\n192.168.3.230 9. 常见问题 如果 kubectl get nodes 连接不上，先确认 VIP 和 HAProxy：\nip addr | grep 192.168.3.217 systemctl status haproxy --no-pager systemctl status keepalived --no-pager 如果节点是 NotReady，看 kubelet 和容器运行时：\nsystemctl status kubelet --no-pager systemctl status containerd --no-pager journalctl -u kubelet -n 100 --no-pager 如果 CoreDNS、应用 Pod 一直起不来，检查 CNI：\nkubectl get pods -n kube-flannel -o wide kubectl get pods -n kube-system -o wide 如果只有某一台 master 没起来，不要急着执行 kubeadm reset。先看服务状态和日志。三节点 etcd 可以容忍一台 master 暂时不可用；但如果三台里只有一台启动，etcd 多数派不足，集群可能暂时不可用。把三台 master 都启动后再判断。\n十五、MetalLB、Ingress 先说明三个组件的作用：\nMetalLB： 给裸机/家用 K8s 提供 LoadBalancer 能力。云厂商有云负载均衡，家里没有，所以用 MetalLB 从你的局域网里分配一个 IP。 ingress-nginx： K8s 里的 HTTP/HTTPS 入口控制器。它根据域名、路径，把请求转发到不同 Service。 Ingress： 一条路由规则。例如 nginx.k8s.local/* -\u003e nginx-demo:80。 0. 先确认集群状态\n在任意 master 上执行：\nkubectl get nodes -o wide kubectl get pods -A -o wide 如果你是三台 master 都跑业务 Pod，确认 taint 已移除：\nkubectl taint nodes --all node-role.kubernetes.io/control-plane- || true 如果有资源，不用急着删，下面的 kubectl apply 可以重复执行。\n1. 规划 MetalLB 地址池\n你家里 K8s 节点是：\n192.168.3.214 192.168.3.215 192.168.3.216 建议预留：\n192.168.3.230-192.168.3.240 注意：最好去主路由 DHCP 设置里确认这段不会分配给普通设备，避免 IP 冲突。\n2. 安装 MetalLB\n执行：\nkubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.16.1/config/manifests/metallb-native.yaml 等待组件启动：\nkubectl get pods -n metallb-system -o wide 正常会看到：\ncontroller speaker 其中：\ncontroller：负责分配 LoadBalancer IP speaker：负责在局域网里宣告这个 IP，让其他设备能访问 等待 controller 可用：\nkubectl wait --namespace metallb-system \\ --for=condition=available deployment/controller \\ --timeout=180s 3. 配置 MetalLB 地址池\n创建地址池和二层广播配置： 一定等上面的组件启动成功后才能执行\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: home-lan-pool namespace: metallb-system spec: addresses: - 192.168.3.230-192.168.3.240 --- apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: home-lan-l2 namespace: metallb-system spec: ipAddressPools: - home-lan-pool EOF 解释：\nIPAddressPool： 告诉 MetalLB 可以使用哪些 IP。 L2Advertisement： 让 MetalLB 用 ARP/NDP 在局域网里声明这些 IP 属于某个节点。 检查：\nkubectl get ipaddresspool -n metallb-system kubectl get l2advertisement -n metallb-system 4. 先单独测试 MetalLB\n创建一个测试 nginx LoadBalancer：\nkubectl create deployment lb-test-nginx --image=nginx:latest kubectl expose deployment lb-test-nginx --port=80 --type=LoadBalancer 查看外部 IP：\nkubectl get svc lb-test-nginx -w 集群内部访问 https://192.168.3.230 是通的 已运行 3 条命令 关键点找到了：三台节点都有这个 label：\nnode.kubernetes.io/exclude-from-external-load-balancers=\n你应该看到类似：\nEXTERNAL-IP: 192.168.3.230 如果一直是：\n\u003cpending\u003e 说明 MetalLB 地址池还没生效。\n测试访问： 目前虚拟机方式部署的k8s无法使用该地址访问\ncurl http://192.168.3.230 如果在 Windows 浏览器里访问也可以：\nhttp://192.168.3.230 成功后可以删除这个临时测试：\nkubectl delete deployment lb-test-nginx kubectl delete svc lb-test-nginx 5. 安装 Helm\n后面部署很多 K8s 应用都会用 Helm。可以理解为：\nHelm = K8s 的包管理器 安装：\ncurl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash 检查：\nhelm version 6. 安装 ingress-nginx\n添加仓库：\nhelm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm repo update 安装 ingress-nginx，并让它创建 LoadBalancer 类型 Service：\nhelm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \\ --namespace ingress-nginx \\ --create-namespace \\ --set controller.service.type=LoadBalancer \\ --set controller.ingressClassResource.default=true 解释：\ncontroller.service.type=LoadBalancer： 让 MetalLB 给 ingress-nginx 分配一个局域网 IP。 controller.ingressClassResource.default=true： 让默认 IngressClass 使用 nginx。 等待启动：\nkubectl wait --namespace ingress-nginx \\ --for=condition=ready pod \\ --selector=app.kubernetes.io/component=controller \\ --timeout=180s 查看入口 IP：\nkubectl get svc -n ingress-nginx 你应该看到：\ningress-nginx-controller LoadBalancer ... 192.168.3.230/231... 记住这个 EXTERNAL-IP，后面假设它是：\n192.168.3.230 你实际以命令输出为准。\n7. 创建 nginx Ingress 测试应用\n创建 namespace：\nkubectl create namespace demo 创建 nginx Deployment：\nkubectl create deployment nginx-demo \\ --image=nginx:latest \\ --port=80 \\ -n demo 创建 ClusterIP Service：\nkubectl expose deployment nginx-demo \\ --port=80 \\ --target-port=80 \\ -n demo 创建 Ingress：\ncat \u003c\u003c'EOF' | kubectl apply -f - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-demo namespace: demo spec: ingressClassName: nginx rules: - host: nginx.k8s.local http: paths: - path: / pathType: Prefix backend: service: name: nginx-demo port: number: 80 EOF 解释：\nnginx-demo Deployment： 真正运行 nginx 容器。 nginx-demo Service： 给 Pod 提供一个稳定的集群内访问入口。 nginx-demo Ingress： 告诉 ingress-nginx：访问 nginx.k8s.local 时转发到 nginx-demo Service。 8. 测试 Ingress\n先查 ingress-nginx 的外部 IP：\nkubectl get svc -n ingress-nginx ingress-nginx-controller 假设是：\n192.168.3.230 用 curl 测试，不需要改 DNS：\ncurl -H \"Host: nginx.k8s.local\" http://192.168.3.230 如果返回 nginx 欢迎页 HTML，说明成功。\n如果想浏览器访问，在 Windows 的 hosts 文件里加：\n192.168.3.230 nginx.k8s.local Windows hosts 路径：\nC:\\Windows\\System32\\drivers\\etc\\hosts 然后浏览器打开：\nhttp://nginx.k8s.local 9. 排查命令\n如果 EXTERNAL-IP 是 \u003cpending\u003e：\nkubectl get pods -n metallb-system kubectl describe svc -n ingress-nginx ingress-nginx-controller kubectl get ipaddresspool -n metallb-system kubectl get l2advertisement -n metallb-system 如果 ingress-nginx Pod 没起来：\nkubectl get pods -n ingress-nginx -o wide kubectl describe pod -n ingress-nginx -l app.kubernetes.io/component=controller kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller 如果 curl 返回 404：\nkubectl get ingress -n demo kubectl describe ingress nginx-demo -n demo 通常是 Host 不匹配，确认你用了：\ncurl -H \"Host: nginx.k8s.local\" http://入口IP 如果返回 502/503：\nkubectl get pods -n demo -o wide kubectl get svc -n demo kubectl get endpoints -n demo 通常是 Service 没选中 Pod。\n10. 成功后的状态应该是\nkubectl get svc -n ingress-nginx 看到：\ningress-nginx-controller LoadBalancer 192.168.3.xxx kubectl get ingress -n demo 看到：\nnginx-demo nginx nginx.k8s.local curl -H \"Host: nginx.k8s.local\" http://192.168.3.xxx 返回 nginx 页面。\n到这里，你就完成了：\nMetalLB + ingress-nginx + Ingress 路由 ","permalink":"/coding/k8s-getting-started/","summary":"三主节点高可用 3 台机器全部做 control-plane 3 个 etcd 组成 stacked etcd HAProxy + Keepalived 提供 API Server 虚拟 IP containerd 作为容器运行时 规划：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip API 入口用：\n192.168.3.217:8443 不用 6443 是因为 HAProxy 和 kube-apiserver 都在同一批机器上，避免端口冲突。\n快捷部署（脚本版） 如果你想先快速把三主节点 HA 集群跑起来，可以直接用我整理好的脚本。脚本放在仓库的 static/k8s 目录，站点发布后访问路径是 /k8s/脚本名。\n脚本默认使用这组地址：\n192.168.3.214 k8s-master1 192.168.3.215 k8s-master2 192.168.3.216 k8s-master3 192.168.3.217 k8s-vip Kubernetes: v1.36 API 入口: 192.168.3.217:8443 如果 IP、VIP、Kubernetes 版本不一样，先改 k8s_env.sh，或者执行脚本时通过环境变量覆盖：\nMASTER1_IP=192.168.3.214 \\ MASTER2_IP=192.168.3.215 \\ MASTER3_IP=192.168.3.216 \\ VIP_IP=192.168.3.217 \\ K8S_VERSION=v1.36 \\ bash k8s_prepare.sh 1. 下载脚本 如果你在这份文档仓库里，可以直接进入脚本目录：\n","title":"K8s入门教程"},{"content":"挣钱这件事，本质上是在交换价值。你能拿到多少报酬，取决于你帮别人解决了多大的问题，以及这个问题对对方有多重要。\n大体来看，普通人挣钱有三条路：\n靠关系：依赖人脉、信用、资源和信息差。 卖时间：用自己的时间和技能换钱，比如上班、兼职、外包。 卖东西：把产品、服务、信息、工具或解决方案卖给需要的人。 前两条路当然也能挣钱，但限制比较明显：关系不是人人都有，时间也不可能无限放大。真正值得长期琢磨的，是第三条路：卖东西。\n卖东西不是简单卖货 这里说的“卖东西”，不只是卖实物商品。它可以是一门课程、一份资料、一个工具、一次服务、一个账号、一套方案，甚至只是帮别人把麻烦事处理掉。\n关键不在于“我想卖什么”，而在于“别人正在为什么问题付钱”。只要你能找到问题，并且用更省心、更便宜或更高效的方式解决它，就有交易机会。\n1. 借鉴和复制已经跑通的模式 普通人刚开始做事，不要一上来就追求颠覆式创新。更稳的方式，是先观察别人已经验证过的商业模式，然后拆解、模仿、微调。\n创意更多是锦上添花，不是凭空造物。先复制能跑通的结构，再在选品、包装、渠道、服务和交付上做一点点差异化，成功率会高很多。\n可以重点观察这几个问题：\n别人在卖什么？ 卖给谁？ 为什么有人愿意付钱？ 他通过什么渠道成交？ 我能不能用更低成本、更细分的方式复现？ 2. 把问题拆成可执行动作 很多人不是没有想法，而是想法太大，最后不知道从哪里开始。\n做一件事之前，可以先把目标拆开：\n目标是什么：比如 30 天内验证一个副业项目。 用户是谁：谁最可能为这个问题付钱。 痛点是什么：对方到底麻烦在哪里。 解决方案是什么：我能提供产品、服务还是信息。 成交动作是什么：发帖、私信、建群、上架商品、做内容，还是线下拜访。 只要拆到“今天能做什么”，事情就会从想法变成行动。\n3. 借势起步，利用别人的资源 普通人一开始通常资金少、人脉少、流量少，所以不要急着从零搭建一整套系统。更现实的方式，是先借用已有的平台、渠道、工具和供应链。\n比如：\n借平台流量：在小红书、抖音、闲鱼、公众号、社群里测试需求。 借别人的产品：做分销、代运营、渠道合作或服务撮合。 借工具能力：用 AI、自动化工具、模板和现成系统提高交付效率。 借成熟场景：围绕热门行业或热门消费需求，提供配套服务。 这不是投机，而是降低试错成本。先寄生在成熟生态里活下来，再逐步沉淀自己的产品、客户和品牌。\n4. 跟上时代的风口 时代趋势会放大个人努力。真正的大机会，往往出现在需求快速增长、旧方案跟不上、新工具刚刚普及的时候。\n比如：\nAI：帮别人降本增效，做内容、客服、编程、设计、自动化。 享乐经济：围绕娱乐、陪伴、体验、情绪价值做产品和服务。 Web3：围绕资产、社区、工具、教育和基础设施寻找机会。 超级个体：一个人借助工具完成过去小团队才能完成的事。 风口不是喊口号，而是观察：哪里出现了新需求，哪里就会出现新问题；哪里有新问题，哪里就可能有新生意。\n一个简单例子 假设某个地方突然出现小龙虾热，餐饮店越来越多，这就是一个需求增长的信号。随之而来的问题可能是设备安装、门店装修、菜单设计、外卖运营、短视频推广、供应链对接、临时用工等等。\n如果你能帮店家解决其中一个具体问题，比如帮忙安装设备、优化外卖图片、做探店内容、搭建团购页面，那你卖的就不是“时间”，而是一个明确的解决方案。\n所以，挣钱的核心不是到处找风口，而是训练自己看到问题、拆解问题、解决问题的能力。\n你获得的报酬，本质上就是别人对你所解决问题的定价。\n","permalink":"/learning/money-money-home/","summary":"挣钱这件事，本质上是在交换价值。你能拿到多少报酬，取决于你帮别人解决了多大的问题，以及这个问题对对方有多重要。\n大体来看，普通人挣钱有三条路：\n靠关系：依赖人脉、信用、资源和信息差。 卖时间：用自己的时间和技能换钱，比如上班、兼职、外包。 卖东西：把产品、服务、信息、工具或解决方案卖给需要的人。 前两条路当然也能挣钱，但限制比较明显：关系不是人人都有，时间也不可能无限放大。真正值得长期琢磨的，是第三条路：卖东西。\n卖东西不是简单卖货 这里说的“卖东西”，不只是卖实物商品。它可以是一门课程、一份资料、一个工具、一次服务、一个账号、一套方案，甚至只是帮别人把麻烦事处理掉。\n关键不在于“我想卖什么”，而在于“别人正在为什么问题付钱”。只要你能找到问题，并且用更省心、更便宜或更高效的方式解决它，就有交易机会。\n1. 借鉴和复制已经跑通的模式 普通人刚开始做事，不要一上来就追求颠覆式创新。更稳的方式，是先观察别人已经验证过的商业模式，然后拆解、模仿、微调。\n创意更多是锦上添花，不是凭空造物。先复制能跑通的结构，再在选品、包装、渠道、服务和交付上做一点点差异化，成功率会高很多。\n可以重点观察这几个问题：\n别人在卖什么？ 卖给谁？ 为什么有人愿意付钱？ 他通过什么渠道成交？ 我能不能用更低成本、更细分的方式复现？ 2. 把问题拆成可执行动作 很多人不是没有想法，而是想法太大，最后不知道从哪里开始。\n做一件事之前，可以先把目标拆开：\n目标是什么：比如 30 天内验证一个副业项目。 用户是谁：谁最可能为这个问题付钱。 痛点是什么：对方到底麻烦在哪里。 解决方案是什么：我能提供产品、服务还是信息。 成交动作是什么：发帖、私信、建群、上架商品、做内容，还是线下拜访。 只要拆到“今天能做什么”，事情就会从想法变成行动。\n3. 借势起步，利用别人的资源 普通人一开始通常资金少、人脉少、流量少，所以不要急着从零搭建一整套系统。更现实的方式，是先借用已有的平台、渠道、工具和供应链。\n比如：\n借平台流量：在小红书、抖音、闲鱼、公众号、社群里测试需求。 借别人的产品：做分销、代运营、渠道合作或服务撮合。 借工具能力：用 AI、自动化工具、模板和现成系统提高交付效率。 借成熟场景：围绕热门行业或热门消费需求，提供配套服务。 这不是投机，而是降低试错成本。先寄生在成熟生态里活下来，再逐步沉淀自己的产品、客户和品牌。\n4. 跟上时代的风口 时代趋势会放大个人努力。真正的大机会，往往出现在需求快速增长、旧方案跟不上、新工具刚刚普及的时候。\n比如：\nAI：帮别人降本增效，做内容、客服、编程、设计、自动化。 享乐经济：围绕娱乐、陪伴、体验、情绪价值做产品和服务。 Web3：围绕资产、社区、工具、教育和基础设施寻找机会。 超级个体：一个人借助工具完成过去小团队才能完成的事。 风口不是喊口号，而是观察：哪里出现了新需求，哪里就会出现新问题；哪里有新问题，哪里就可能有新生意。\n一个简单例子 假设某个地方突然出现小龙虾热，餐饮店越来越多，这就是一个需求增长的信号。随之而来的问题可能是设备安装、门店装修、菜单设计、外卖运营、短视频推广、供应链对接、临时用工等等。\n如果你能帮店家解决其中一个具体问题，比如帮忙安装设备、优化外卖图片、做探店内容、搭建团购页面，那你卖的就不是“时间”，而是一个明确的解决方案。\n所以，挣钱的核心不是到处找风口，而是训练自己看到问题、拆解问题、解决问题的能力。\n你获得的报酬，本质上就是别人对你所解决问题的定价。\n","title":"普通人挣钱的三条路"},{"content":"本文记录使用 cloudflare_temp_email 搭建临时邮箱的过程。整体思路是：域名接入 Cloudflare，使用 D1 存邮件数据，Workers 作为后端接口与邮件处理入口，Email Routing 把收件流量转给 Worker，最后用 Cloudflare Pages 部署前端页面。\n参考资料：\n项目地址 官方 UI 部署文档 部署前准备 开始前需要准备：\n一个 Cloudflare 账号。 一个已接入 Cloudflare 的域名。 一个用于访问前端的域名，例如 mail.example.com。 一个用于 Worker 后端接口的域名，例如 email-api.example.com。 如果只是个人使用，Cloudflare 免费计划基本够用。国内访问时建议给 Worker 绑定自定义域名，因为 workers.dev 域名可能无法正常访问。\n注册并托管域名 这里使用 dnshe 注册免费域名。\n注册完成后，进入 Cloudflare 添加站点，并把域名托管到 Cloudflare。\n选择连接已有域名。\n输入刚注册好的域名。\n套餐选择免费计划即可。\n继续前往激活流程。\nCloudflare 会给出两条名称服务器地址，需要复制下来。\n回到域名注册商后台，把原来的 DNS 服务器替换成 Cloudflare 提供的名称服务器。\n替换完成后，回到 Cloudflare 点击确认。\n等待 Cloudflare 检测生效。看到域名处于可用状态后，再继续后面的部署。\n创建 D1 数据库 进入 Cloudflare 控制台，打开 Storage \u0026 Databases，选择 D1 SQL Database，然后创建数据库。\n输入数据库名称后创建。\n创建完成后进入数据库的 Console，按官方文档复制 db/schema.sql 中的 SQL 内容并执行，用来初始化表结构。\n执行成功后，D1 数据库就准备好了。\n部署 Workers 后端 进入 Compute (Workers)，打开 Workers \u0026 Pages，创建一个新的 Worker。\n选择 Worker 模板并创建。\n设置 Worker 名称。建议后端服务名与后续域名保持一致，例如 email-api。\n配置运行时 部署项目脚本前，先在 Worker 的 Settings -\u003e Runtime 中添加兼容标记 nodejs_compat。如果没有开启这个标记，部署或访问时可能出现缺少 Node 模块的错误。\n确认运行时配置已保存。\n上传 Worker 脚本 下载项目发布页中的 worker.js，进入 Worker 的代码编辑页面，替换默认脚本并部署。\n设置变量和机密 进入 Settings -\u003e Variables and Secrets，按项目文档添加变量。常用配置如下：\nDOMAINS：可用于临时邮箱的域名，JSON 数组格式，例如 [\"example.com\"]。 DEFAULT_DOMAINS：普通用户默认可选的域名，个人部署时通常和 DOMAINS 保持一致。 JWT_SECRET：用于登录和鉴权的随机密钥，建议作为 Secret 保存。 ADMIN_PASSWORDS：管理后台密码，JSON 数组格式，例如 [\"your-password\"]。 ENABLE_USER_CREATE_EMAIL：是否允许用户创建邮箱，按需设置为 true。 ENABLE_USER_DELETE_EMAIL：是否允许用户删除邮件，按需设置为 true。 PREFIX：新建邮箱地址的默认前缀，不需要可以不填。 DISABLE_ANONYMOUS_USER_CREATE_EMAIL：是否关闭匿名用户创建邮件，若不想未注册用户创建邮件，可以设置为 true。 注意：在 Cloudflare 控制台填写字符串变量时，最外层通常不需要额外加引号；JSON 数组和对象则要保持合法 JSON 格式。\n绑定 D1 数据库 进入 Settings -\u003e Bindings，添加 D1 数据库绑定。\n选择前面创建的 D1 数据库。绑定名称必须填写为 DB，并且是大写。\n绑定后端访问域名 进入 Settings -\u003e Triggers，为 Worker 添加自定义域名。\n这里建议使用单独的后端接口域名，例如 email-api.example.com。\n保存后访问 https://email-api.example.com/health_check。如果页面返回 OK，说明 Worker 后端部署成功。\n也可以访问下面两个地址做进一步验证：\nhttps://email-api.example.com/health_check：返回 OK 表示后端健康。 https://email-api.example.com/open_api/settings：返回 JSON 表示前端初始化依赖的公开配置正常。 创建并绑定 KV 缓存 如果需要启用用户注册、验证码或部分缓存能力，需要创建 KV 命名空间。不需要这些能力时可以先跳过。\n回到 Worker 的 Settings -\u003e Bindings 添加 KV 绑定。绑定名称必须填写为 KV，并且是大写。\n确认添加绑定。\n配置邮件转发 临时邮箱能否收到邮件，关键在 Cloudflare Email Routing。进入域名的 Email Routing，先启用邮件路由，并按提示添加 Cloudflare 要求的邮件 DNS 记录。\n然后添加路由规则。\n选择需要接收邮件的域名。\n在路由规则中配置 Catch-all 地址，把所有邮件转发到前面部署好的 Worker。\n最后启用规则。\n完成后，可以给当前域名随机发送一封邮件，观察前端是否能收到。如果收不到，优先检查 Email Routing 是否完成 DNS 记录配置，以及 Catch-all 是否指向正确的 Worker。\n配置 Pages 前端 进入项目文档提供的前端生成页面，填写 Worker 后端 API 根地址。这里填写的是后端接口域名，例如 https://email-api.example.com，不要填写 Pages 前端域名，也不要带 /admin、/api 或末尾 /。\n生成前端代码并下载压缩包。\n进入 Workers \u0026 Pages 创建 Pages 项目，选择直接上传，把生成的压缩包上传部署。部署时建议把未找到处理设置为 Single-page application (SPA)，避免刷新 /admin 等页面时报 404。\n部署完成后，为 Pages 添加自定义访问域名，例如 mail.example.com。\n验证部署 部署完成后按下面顺序检查：\n打开 https://email-api.example.com/health_check，确认返回 OK。 打开 https://email-api.example.com/open_api/settings，确认返回 JSON。 打开 https://mail.example.com，确认前端页面能正常加载。 创建一个临时邮箱地址，并向该地址发送测试邮件。 如果使用管理后台，进入 /admin 验证管理员密码和数据库连接。 如果前端报 Network Error、map 相关错误或接口返回 405，通常是前端生成时填错了后端 API 地址，或者 Worker 的 D1 绑定、变量配置不完整。优先检查 DB 绑定名、DOMAINS 变量、Email Routing 和 /open_api/settings 返回结果。\n","permalink":"/coding/temp-mail/","summary":"本文记录使用 cloudflare_temp_email 搭建临时邮箱的过程。整体思路是：域名接入 Cloudflare，使用 D1 存邮件数据，Workers 作为后端接口与邮件处理入口，Email Routing 把收件流量转给 Worker，最后用 Cloudflare Pages 部署前端页面。\n参考资料：\n项目地址 官方 UI 部署文档 部署前准备 开始前需要准备：\n一个 Cloudflare 账号。 一个已接入 Cloudflare 的域名。 一个用于访问前端的域名，例如 mail.example.com。 一个用于 Worker 后端接口的域名，例如 email-api.example.com。 如果只是个人使用，Cloudflare 免费计划基本够用。国内访问时建议给 Worker 绑定自定义域名，因为 workers.dev 域名可能无法正常访问。\n注册并托管域名 这里使用 dnshe 注册免费域名。\n注册完成后，进入 Cloudflare 添加站点，并把域名托管到 Cloudflare。\n选择连接已有域名。\n输入刚注册好的域名。\n套餐选择免费计划即可。\n继续前往激活流程。\nCloudflare 会给出两条名称服务器地址，需要复制下来。\n回到域名注册商后台，把原来的 DNS 服务器替换成 Cloudflare 提供的名称服务器。\n","title":"Cloudflare 临时邮箱搭建记录"},{"content":"hermes-webui 安装 hermes-webui仓库地址 下面是这次完整可用版流程，适合 Ubuntu 22.04 + root 用户 + 局域网访问 Hermes WebUI。\n1. 安装基础依赖\napt update apt install -y git curl python3 python3-pip python3-venv python3.10-venv 2. 下载 Hermes WebUI\nmkdir -p /work cd /work git clone https://github.com/nesquena/hermes-webui.git cd /work/hermes-webui 3. 配置 .env\nnano /work/hermes-webui/.env 写入：\nHERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PORT=8787 HERMES_WEBUI_PASSWORD=换成你的强密码 HERMES_HOME=/root/.hermes HERMES_CONFIG_PATH=/root/.hermes/config.yaml HERMES_WEBUI_DEFAULT_WORKSPACE=/root/workspace HERMES_WEBUI_AGENT_DIR=/usr/local/lib/hermes-agent HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python 保存：\nCtrl + O Enter Ctrl + X 4. 确认 Hermes Agent 存在\nls /usr/local/lib/hermes-agent ls /usr/local/lib/hermes-agent/venv/bin/python 如果 Hermes Agent 没装，需要先安装 Hermes。\n5. 安装 WebUI 依赖到 Hermes Agent 的 Python 环境\n这是这次排查里最关键的一步。否则 WebUI 自己的 .venv 可能无法同时加载 WebUI 依赖和 Hermes Agent。\ncd /work/hermes-webui /usr/local/lib/hermes-agent/venv/bin/python -m pip install -r requirements.txt 6. 如果之前启动失败过，删除坏掉的 .venv\nrm -rf /work/hermes-webui/.venv 7. 启动 WebUI\ncd /work/hermes-webui ./ctl.sh start 查看状态：\n./ctl.sh status 正常应该看到：\nrunning Bound: 0.0.0.0:8787 Health: ok 8. 检查端口监听\nss -lntp | grep 8787 正常应该看到：\n0.0.0.0:8787 如果只看到：\n127.0.0.1:8787 说明 .env 没生效，检查 HERMES_WEBUI_HOST=0.0.0.0。\n9. 局域网访问\n在其他电脑浏览器打开：\nhttp://Ubuntu机器IP:8787 例如：\nhttp://192.168.3.212:8787 健康检查地址：\nhttp://192.168.3.212:8787/health 10. 如果访问不了，检查防火墙\nufw status 如果开启了 ufw，放行局域网：\nufw allow from 192.168.3.0/24 to any port 8787 proto tcp 11. 常用管理命令\ncd /work/hermes-webui ./ctl.sh start ./ctl.sh stop ./ctl.sh restart ./ctl.sh status ./ctl.sh logs --lines 100 日志位置：\n/root/.hermes/webui.log 这次遇到的问题和解决方式\n旧进程占用端口：\nss -lntp | grep 8787 kill 进程PID 缺少 venv：\napt install -y python3-venv python3.10-venv 坏掉的 .venv：\nrm -rf /work/hermes-webui/.venv WebUI 无法同时加载 Hermes Agent：\n/usr/local/lib/hermes-agent/venv/bin/python -m pip install -r /work/hermes-webui/requirements.txt 并在 .env 指定：\nHERMES_WEBUI_AGENT_DIR=/usr/local/lib/hermes-agent HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python 最终确认方式：\ncurl http://127.0.0.1:8787/health curl http://192.168.3.212:8787/health 能返回：\n{ \"status\": \"ok\" } 就说明安装成功。\n","permalink":"/ai/hermes-getting-started/","summary":"hermes-webui 安装 hermes-webui仓库地址 下面是这次完整可用版流程，适合 Ubuntu 22.04 + root 用户 + 局域网访问 Hermes WebUI。\n1. 安装基础依赖\napt update apt install -y git curl python3 python3-pip python3-venv python3.10-venv 2. 下载 Hermes WebUI\nmkdir -p /work cd /work git clone https://github.com/nesquena/hermes-webui.git cd /work/hermes-webui 3. 配置 .env\nnano /work/hermes-webui/.env 写入：\nHERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PORT=8787 HERMES_WEBUI_PASSWORD=换成你的强密码 HERMES_HOME=/root/.hermes HERMES_CONFIG_PATH=/root/.hermes/config.yaml HERMES_WEBUI_DEFAULT_WORKSPACE=/root/workspace HERMES_WEBUI_AGENT_DIR=/usr/local/lib/hermes-agent HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python 保存：\nCtrl + O Enter Ctrl + X 4. 确认 Hermes Agent 存在\nls /usr/local/lib/hermes-agent ls /usr/local/lib/hermes-agent/venv/bin/python 如果 Hermes Agent 没装，需要先安装 Hermes。\n","title":"hermes安装、接入飞书"},{"content":"安装cc-switch windows环境 Windows 下载地址：CC-Switch v3.15.0 Windows 安装包\nlinux环境 # 确认架构 uname -m # 如果输出x86_64,则下载x86_64版本的 wget https://github.com/farion1231/cc-switch/releases/download/v3.15.0/CC-Switch-v3.15.0-Linux-x86_64.deb # 安装 sudo apt update sudo apt install ./CC-Switch-v3.15.0-Linux-x86_64.deb #终端启动方式 cc-switch 配置claudeCode-CLI 使用cc-switch快速配置 写入provider、claudecode 专用分组令牌、Base URL 与模型\n根据API格式差异会有不同配置方法。这里介绍Anthropic Messages和OpenAI Chat Completions (需开启路由)格式。如果是原生claudecode中转选前者，如果是用的阿里云或本地ollama选择后者。阿里云也有支持Anthropic Messages格式的模型可以参考：接入claudecode客户端\nAnthropic Messages格式 OpenAI Chat Completions (需开启路由)格式 如果是按量计费的模型，请将Base URL填写为：\nhttps://dashscope.aliyuncs.com/compatible-mode/v1 完成cc-switch配置之后 安装 Node.js：Node.js 中文下载页\n安装claudecode-cli\nnpm install -g @anthropic-ai/claude-code 检查claudecode 确认命令可用，并检查本机环境。\nclaude --version claude doctor 打开powershell，进入你的项目目录启动claudecode，并发送一次简单消息验证链路。\ncd your-project-folder claude 配置claudeCode桌面端 下载并安装 claudeCode 桌面版：Claude 下载页\n参考前面claudecode-cli配置步骤配置cc-switch 打开开发者模式Help → Troubleshooting → Enable Developer mode 进入 Developer → Configure third-party inference 进入第三方推理配置页后按照下图配置 简单测试 接入ollama模型 安装ollama\n如果要提供给外部访问，请按照下图开启 下载模型:qwen3.5 根据显卡配置按需选择 # 下载并启动模型 ollama run qwen3.5:0.8b 配置cc-switch ","permalink":"/ai/claudecode-getting-started/","summary":"安装cc-switch windows环境 Windows 下载地址：CC-Switch v3.15.0 Windows 安装包\nlinux环境 # 确认架构 uname -m # 如果输出x86_64,则下载x86_64版本的 wget https://github.com/farion1231/cc-switch/releases/download/v3.15.0/CC-Switch-v3.15.0-Linux-x86_64.deb # 安装 sudo apt update sudo apt install ./CC-Switch-v3.15.0-Linux-x86_64.deb #终端启动方式 cc-switch 配置claudeCode-CLI 使用cc-switch快速配置 写入provider、claudecode 专用分组令牌、Base URL 与模型\n根据API格式差异会有不同配置方法。这里介绍Anthropic Messages和OpenAI Chat Completions (需开启路由)格式。如果是原生claudecode中转选前者，如果是用的阿里云或本地ollama选择后者。阿里云也有支持Anthropic Messages格式的模型可以参考：接入claudecode客户端\nAnthropic Messages格式 OpenAI Chat Completions (需开启路由)格式 如果是按量计费的模型，请将Base URL填写为：\n","title":"claudeCode 入门：安装登录与 CLI 使用指南"},{"content":"在 Ubuntu 中安装 本文记录 Ubuntu 环境下通过 Docker 官方 apt 仓库安装 Docker Engine 的流程。步骤参考 Docker 官方 Ubuntu 安装文档。\n卸载残留安装文件 安装官方版本前，先移除系统仓库或旧环境里可能冲突的 Docker 相关包。\nsudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1) 配置 Docker apt 仓库 # Add Docker's official GPG key: sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # Add the repository to Apt sources: sudo tee /etc/apt/sources.list.d/docker.sources \u003c\u003cEOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release \u0026\u0026 echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt update 安装 Docker Engine sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 安装完成后验证 Docker 服务和命令是否正常。\nsudo systemctl status docker sudo docker run hello-world 如果需要安装指定版本，可以先查看当前 Ubuntu 版本可用的 Docker 版本。\napt list --all-versions docker-ce 然后选择对应版本安装。\nVERSION_STRING=\u003c从 apt list --all-versions docker-ce 输出中复制的版本\u003e sudo apt install -y docker-ce=$VERSION_STRING docker-ce-cli=$VERSION_STRING containerd.io docker-buildx-plugin docker-compose-plugin 配置镜像加速器 如果不配置加速器，拉取 Docker Hub 镜像时可能触发匿名访问限制。\nsudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json \u003e /dev/null \u003c\u003c-'EOF' { \"registry-mirrors\": [ \"https://docker.m.daocloud.io\" ] } EOF sudo systemctl daemon-reload sudo systemctl restart docker 可能遇到的拉取限制示例：\nUsing default tag: latest Error response from daemon: error from registry: You have reached your unauthenticated pull rate limit. https://www.docker.com/increase-rate-limit 一键部署脚本 脚本地址：Docker 一键部署脚本\n建议先下载并查看脚本内容，确认无误后再执行。\ncurl -fsSL https://doc.jihw.top/docker/install-docker-ubuntu.sh -o install-docker-ubuntu.sh # 检查脚本（可选），若没问题按q退出 less install-docker-ubuntu.sh # 执行脚本 bash install-docker-ubuntu.sh ","permalink":"/coding/docker-getting-started/","summary":"在 Ubuntu 中安装 本文记录 Ubuntu 环境下通过 Docker 官方 apt 仓库安装 Docker Engine 的流程。步骤参考 Docker 官方 Ubuntu 安装文档。\n卸载残留安装文件 安装官方版本前，先移除系统仓库或旧环境里可能冲突的 Docker 相关包。\nsudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1) 配置 Docker apt 仓库 # Add Docker's official GPG key: sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # Add the repository to Apt sources: sudo tee /etc/apt/sources.list.d/docker.sources \u003c\u003cEOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release \u0026\u0026 echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt update 安装 Docker Engine sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 安装完成后验证 Docker 服务和命令是否正常。\n","title":"Docker 入门：Ubuntu 安装与基础配置"},{"content":"codex官网地址：https://openai.com/zh-Hans-CN/codex/\n安装codex 测试修改 下载安装windows客户端 选择登录方式 安装ccswitch：https://github.com/farion1231/cc-switch\n安装成功效果 从中转站导入配置 配置成功 安装cli npm install -g @openai/codex@latest codex --version 使用ccswitch设置apikey完成后 启动codex\ncd your-project-folder codex ","permalink":"/ai/codex-getting-started/","summary":"codex官网地址：https://openai.com/zh-Hans-CN/codex/\n安装codex 测试修改 下载安装windows客户端 选择登录方式 安装ccswitch：https://github.com/farion1231/cc-switch\n安装成功效果 从中转站导入配置 配置成功 安装cli npm install -g @openai/codex@latest codex --version 使用ccswitch设置apikey完成后 启动codex\ncd your-project-folder codex ","title":"Codex 入门：安装登录与 CLI 使用指南"},{"content":"在win电脑操作\ncd C:\\Users\\Rx\\.ssh ssh-keygen -t rsa -b 4096 -C \"你的注释，比如：win11-to-ubuntu-vm\" # 查看公钥 cat C:\\Users\\Rx/.ssh/自定义名字.pub -t rsa：指定密钥类型为 RSA -b 4096：指定密钥长度为 4096 位（更安全） -C：添加注释，帮助识别这个密钥的用途 可以生成不同key，需要起不同名字。\n在ubuntu操作\n# 创建 .ssh 目录（如果不存在） mkdir -p ~/.ssh # 设置正确的权限 chmod 700 ~/.ssh # 将公钥添加到授权文件，必须是完整的字符串 echo \"xxx.pub公钥内容\" \u003e\u003e ~/.ssh/authorized_keys # 设置授权文件权限 chmod 600 ~/.ssh/authorized_keys ​ # 手动登录 ssh -i C:\\Users\\Rx\\.ssh\\new_api root@192.168.3.211 修改C:\\Users\\Rx.ssh\\config文件，指向实际私钥地址。 vscode默认生成的路径有误，不要使用。\n","permalink":"/coding/ssh-key-login/","summary":"在win电脑操作\ncd C:\\Users\\Rx\\.ssh ssh-keygen -t rsa -b 4096 -C \"你的注释，比如：win11-to-ubuntu-vm\" # 查看公钥 cat C:\\Users\\Rx/.ssh/自定义名字.pub -t rsa：指定密钥类型为 RSA -b 4096：指定密钥长度为 4096 位（更安全） -C：添加注释，帮助识别这个密钥的用途 可以生成不同key，需要起不同名字。\n在ubuntu操作\n# 创建 .ssh 目录（如果不存在） mkdir -p ~/.ssh # 设置正确的权限 chmod 700 ~/.ssh # 将公钥添加到授权文件，必须是完整的字符串 echo \"xxx.pub公钥内容\" \u003e\u003e ~/.ssh/authorized_keys # 设置授权文件权限 chmod 600 ~/.ssh/authorized_keys ​ # 手动登录 ssh -i C:\\Users\\Rx\\.ssh\\new_api root@192.168.3.211 修改C:\\Users\\Rx.ssh\\config文件，指向实际私钥地址。 vscode默认生成的路径有误，不要使用。\n","title":"SSH 免密登录：Windows 连接 Ubuntu 配置指南"},{"content":"# 手动安装go bash \u003c\u003c'EOF' sudo apt remove -y golang-go sudo rm -rf /usr/local/go wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz # 非标准路径，需要手动加入path echo 'export PATH=$PATH:/usr/local/go/bin' \u003e\u003e ~/.bashrc source ~/.bashrc go version EOF #刷新缓存 hash -r # 手动安装hugo bash \u003c\u003c'EOF' sudo apt remove -y hugo # /usr/local/bin 这是标准目录已经在path中了。 sudo rm -f /usr/local/bin/hugo wget https://github.com/gohugoio/hugo/releases/download/v0.161.0/hugo_extended_withdeploy_0.161.0_linux-amd64.tar.gz sudo tar -C /usr/local/bin -xzf hugo_extended_withdeploy_0.161.0_linux-amd64.tar.gz hugo version EOF #刷新缓存 hash -r # 创建新文章 hugo new posts/first.md hugo new coding/hugo-setup/index.md # 启动服务 hugo server -D ","permalink":"/coding/hugo-setup/","summary":"# 手动安装go bash \u003c\u003c'EOF' sudo apt remove -y golang-go sudo rm -rf /usr/local/go wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz # 非标准路径，需要手动加入path echo 'export PATH=$PATH:/usr/local/go/bin' \u003e\u003e ~/.bashrc source ~/.bashrc go version EOF #刷新缓存 hash -r # 手动安装hugo bash \u003c\u003c'EOF' sudo apt remove -y hugo # /usr/local/bin 这是标准目录已经在path中了。 sudo rm -f /usr/local/bin/hugo wget https://github.com/gohugoio/hugo/releases/download/v0.161.0/hugo_extended_withdeploy_0.161.0_linux-amd64.tar.gz sudo tar -C /usr/local/bin -xzf hugo_extended_withdeploy_0.161.0_linux-amd64.tar.gz hugo version EOF #刷新缓存 hash -r # 创建新文章 hugo new posts/first.md hugo new coding/hugo-setup/index.md # 启动服务 hugo server -D ","title":"Hugo 入门：Windows 环境安装与本地运行"}]