古人学问无遗力，少壮工夫老始成。 纸上得来终觉浅，绝知此事要躬行。
线上灰度发布翻车后，我用Nginx金丝雀发布救了一命
客户差点因为一次全量发布把整个独立站系统搞崩了。那天晚上加班到凌晨两点，冷汗都出来了。找到我们做架构优化，我们团队花了一周时间研究了Nginx的金丝雀发布方案，现在分享给大家，希望你们别踩我踩过的坑。说实话，金丝雀发布这个名字听起来挺高大上的，其实原理很简单。就像以前矿工下井前会带只金丝雀，如果有毒气金丝雀先挂了，矿工就知道危险了。我们发布新版本也是这个道理，先让一小部分用户试用新版本，有问题马上回滚，不至于全军覆没。为什么要用金丝雀发布我之前在一家创业公司待过，那时候发布新版本就是简单粗暴，直接全量上线。有一次产品经理非要在周五下午发布一个"小功能"，结果周末两天我都在公司处理线上问题，女朋友差点跟我分手。传统的发布方式问题太多了。你想啊，一个新功能开发完，测试环境跑得好好的，但生产环境的数据量、并发量、网络环境完全不一样。等你发现问题的时候，可能已经有几万个用户在骂娘了。金丝雀发布就不一样了，它可以让你：先给5%的用户试用新版本，观察一段时间如果没问题，再逐步放量到20%、50%、100%一旦发现问题，立刻切回旧版本整个过程用户基本无感知我现在的公司用了金丝雀发布之后，线上事故率直接降了70%，这个数据不是吹的。Nginx实现金丝雀发布的几种姿势Nginx实现金丝雀发布其实有好几种方法，我都试过，每种都有自己的适用场景。基于权重的流量分配这是最简单的一种方式，适合你刚开始尝试金丝雀发布的时候用。原理就是通过upstream的权重参数，把流量按比例分配到不同版本的服务器上。upstream backend { server 192.168.1.10:8080 weight=95; # 旧版本 server 192.168.1.11:8080 weight=5; # 新版本 } server { listen 80; server_name api.example.com; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置的意思是，100个请求里面，95个会打到旧版本服务器，5个会打到新版本。你可以根据实际情况调整这个比例。不过这种方式有个问题，就是同一个用户的请求可能一会儿打到旧版本，一会儿打到新版本，体验不太好。我之前就遇到过用户投诉说页面样式一会儿变一会儿不变的情况，后来才发现是这个原因。基于Cookie的灰度发布为了解决上面那个问题，我们可以用Cookie来做用户粘性。简单说就是，一个用户第一次访问的时候，我们给他打个标记，后续的请求都根据这个标记来决定走哪个版本。upstream backend_v1 { server 192.168.1.10:8080; } upstream backend_v2 { server 192.168.1.11:8080; } server { listen 80; server_name api.example.com; location / { set $backend "backend_v1"; # 如果Cookie中有canary标记，走新版本 if ($http_cookie ~* "canary=true") { set $backend "backend_v2"; } # 随机给5%的新用户打上canary标记 set $random_canary ""; if ($http_cookie !~* "canary") { set $random_canary "${random_canary}A"; } # 生成1-100的随机数 set $rand_num $request_id; if ($random_canary = "A") { # 这里简化处理，实际可以用Lua脚本 add_header Set-Cookie "canary=false; Path=/; Max-Age=86400"; } proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置看起来有点复杂，但逻辑很清楚。用户第一次访问的时候，我们随机决定他是不是金丝雀用户，然后种个Cookie。后续请求都会带着这个Cookie，保证同一个用户始终访问同一个版本。但是这个方案还是有点粗糙，因为Nginx原生不支持生成随机数，我们需要借助一些技巧或者第三方模块。基于Header的灰度发布有时候我们想更精确地控制哪些用户走新版本，比如内部员工、测试账号、特定地区的用户等等。这时候可以用Header来做判断。upstream backend_v1 { server 192.168.1.10:8080; } upstream backend_v2 { server 192.168.1.11:8080; } server { listen 80; server_name api.example.com; location / { set $backend "backend_v1"; # 如果请求头中有特定标记，走新版本 if ($http_x_canary_version = "v2") { set $backend "backend_v2"; } # 内部员工走新版本 if ($http_x_employee_id != "") { set $backend "backend_v2"; } proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这种方式特别适合做内部测试。我们可以在客户端（比如App或者前端页面）加个开关，员工登录后自动在请求头里加上特定标记，这样就能体验新版本了。基于IP地址的灰度发布还有一种场景，比如你想先在某个地区试点新功能，或者只给公司内网用户开放新版本。这时候可以用IP地址来做判断。geo $canary_user { default 0; 10.0.0.0/8 1; # 公司内网 192.168.1.0/24 1; # 特定网段 123.45.67.89 1; # 特定IP } upstream backend_v1 { server 192.168.1.10:8080; } upstream backend_v2 { server 192.168.1.11:8080; } server { listen 80; server_name api.example.com; location / { set $backend "backend_v1"; if ($canary_user = 1) { set $backend "backend_v2"; } proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }geo模块是Nginx内置的，性能很好。我之前用这个方案做过一次大版本升级，先在北京地区试点了一周，没问题后再全国推广，效果不错。进阶玩法：结合Lua实现动态灰度上面那些方案都有个问题，就是每次调整灰度比例都要修改配置文件，然后reload Nginx。这在生产环境其实挺麻烦的，万一配置写错了，reload失败，那就尴尬了。我现在用的方案是结合OpenResty（Nginx + Lua），把灰度规则存在Redis里，这样就可以动态调整了。upstream backend_v1 { server 192.168.1.10:8080; } upstream backend_v2 { server 192.168.1.11:8080; } server { listen 80; server_name api.example.com; location / { set $backend "backend_v1"; access_by_lua_block { local redis = require "resty.redis" local red = redis:new() red:set_timeout(1000) local ok, err = red:connect("127.0.0.1", 6379) if not ok then ngx.log(ngx.ERR, "failed to connect redis: ", err) return end -- 从Redis获取灰度比例 local canary_percent, err = red:get("canary:percent") if not canary_percent or canary_percent == ngx.null then canary_percent = 0 end -- 生成随机数判断是否走新版本 math.randomseed(ngx.now()) local rand = math.random(100) if rand <= tonumber(canary_percent) then ngx.var.backend = "backend_v2" end red:close() } proxy_pass http://$backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这样的话，我们只需要在Redis里修改canary:percent的值，就可以实时调整灰度比例了，不需要reload Nginx。# 设置灰度比例为10% redis-cli set canary:percent 10 # 逐步放量 redis-cli set canary:percent 30 redis-cli set canary:percent 50 redis-cli set canary:percent 100 # 紧急回滚 redis-cli set canary:percent 0我还做了个简单的管理后台，可以可视化地调整灰度比例，监控新旧版本的错误率、响应时间等指标。一旦发现新版本有问题，点一下按钮就能回滚，特别方便。实战中的一些坑说了这么多理论，我再分享几个实战中遇到的坑，这些都是血泪教训。Session一致性问题有一次我们做灰度发布，结果用户反馈说登录状态老是丢失。排查了半天才发现，是因为Session存在服务器本地，用户的请求一会儿打到v1一会儿打到v2，Session当然就丢了。解决办法有两个：把Session存到Redis这种共享存储里用Cookie或者Header做用户粘性，保证同一个用户的请求打到同一个版本我们最后选了第一种方案，顺便把整个Session机制重构了一遍，现在扩展性好多了。数据库兼容性问题还有一次更坑，新版本改了数据库表结构，结果灰度发布的时候，新旧版本同时在跑，旧版本写入的数据新版本读不了，新版本写入的数据旧版本也读不了，整个系统乱套了。后来我们定了个规矩，涉及数据库表结构变更的发布，必须分两步走：先发布一个兼容版本，既能处理旧数据格式，也能处理新数据格式等兼容版本全量发布后，再发布纯新版本虽然麻烦点，但安全多了。监控和告警金丝雀发布不是配置完就完事了，监控和告警特别重要。我们现在的做法是，在Nginx日志里加上版本标记，然后用ELK收集日志，实时对比新旧版本的各项指标。log_format canary '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'backend=$backend rt=$request_time'; access_log /var/log/nginx/access.log canary;这样在Kibana里就能看到每个版本的请求量、错误率、响应时间等指标，一目了然。我们还配置了告警规则，如果新版本的错误率比旧版本高出10%，或者响应时间慢了50%，就会自动发送告警，甚至可以自动回滚。一些优化建议用了这么久金丝雀发布，我总结了一些优化建议。灰度策略要灵活不要一上来就5%、10%、50%、100%这样机械地放量。要根据实际情况灵活调整。比如一个小功能，可能5%观察半小时没问题，就直接100%了。但如果是核心功能的大改动，可能要5%观察一天，10%观察一天，慢慢来。我们现在的策略是，先在内部员工里测试，没问题后再给1%的真实用户，然后5%、10%、30%、50%、100%，每个阶段都要观察一段时间。做好回滚预案金丝雀发布虽然降低了风险，但不代表没有风险。一定要做好回滚预案。我们的做法是，每次发布前都要演练一遍回滚流程，确保出问题的时候能在5分钟内回滚。而且回滚不能只是把流量切回去，还要考虑数据一致性、缓存清理等问题。有一次我们发现新版本有问题，回滚后发现缓存里还是新版本的数据，导致页面显示错乱。后来我们在回滚脚本里加了清理缓存的步骤，就没再出过这种问题。用户体验要考虑虽然金丝雀发布对用户来说应该是无感知的，但有些细节还是要注意。比如不要在用户操作的过程中切换版本，这样可能导致数据丢失或者页面错乱。我们的做法是，用Cookie做用户粘性，保证同一个用户在一段时间内（比如24小时）始终访问同一个版本。还有就是，如果新旧版本的UI差异比较大，最好在客户端做个平滑过渡，不要让用户觉得突兀。工具推荐最后推荐几个我常用的工具。Nginx Plus如果预算充足，可以考虑Nginx Plus，它自带了很多企业级功能，包括动态配置、健康检查、高级负载均衡等。金丝雀发布用起来会方便很多。不过Nginx Plus是商业版，要花钱的。我们推荐用的是开源版本加OpenResty，也够用了。OpenResty前面提到过，OpenResty是Nginx加上Lua脚本引擎，可以实现很多复杂的逻辑。我现在基本上所有的Nginx配置都是基于OpenResty的。学习曲线可能有点陡，但学会了之后真的很香。你可以用Lua实现各种复杂的灰度策略，甚至可以对接公司的配置中心、监控系统等。Prometheus + Grafana监控方面，我强烈推荐Prometheus + Grafana这套组合。Nginx可以通过nginx-module-vts或者nginx-prometheus-exporter暴露指标，然后用Prometheus收集，Grafana做可视化。我们现在的监控大盘可以实时看到每个版本的QPS、错误率、P99延迟等指标，还能设置告警规则，非常好用。Consul / Etcd如果你的服务是动态扩缩容的，可以考虑用Consul或者Etcd做服务发现。Nginx可以通过consul-template或者confd动态更新upstream配置，不需要手动维护服务器列表。我们现在用的是Consul，配合Kubernetes使用，服务上下线都是自动的，省了很多运维工作。写在最后金丝雀发布说起来简单，但真正用好需要很多配套的东西，包括监控、告警、回滚机制、数据兼容性等等。不要指望一步到位，可以先从简单的方案开始，逐步优化。我从最开始的全量发布，到现在的金丝雀发布，花了差不多一年时间。期间踩了无数的坑，但现在回头看，这些都是值得的。线上事故少了，晚上睡觉也踏实了，周末也不用老是待命了。如果你们公司还在用传统的发布方式，真的建议试试金丝雀发布。刚开始可能会觉得麻烦，但用习惯了之后，你会发现这是保证系统稳定性的利器。对了，金丝雀发布也不是万能的，它只能降低风险，不能完全消除风险。该做的测试还是要做，该有的监控还是要有，该写的文档还是要写。技术只是手段，最终还是要靠人来保证质量。好了，今天就分享到这里。如果你在实施金丝雀发布的过程中遇到什么问题，欢迎留言讨论。我虽然不是什么大神，但踩过的坑还是挺多的，说不定能帮上忙。如果这篇文章对你有帮助，欢迎点赞、转发，让更多的人看到。也欢迎关注@运维躬行录，我会持续分享运维实战经验，咱们一起成长，一起避坑。运维这条路不好走，但有你们陪伴，就不孤单。我们下期见!公众号：运维躬行录个人博客：躬行笔记
项目交付报告：Amazon Linux 2023 环境下 PHP 5.6 并行部署实施方案
文档版本：1.0实施环境：Amazon Linux 2023 (AL2023)目标组件：PHP 5.6.40 (配合 Nginx)核心策略：源码编译 + 依赖沙盒化 + 运行时环境隔离1. 核心挑战与技术背景 (Challenges)在 AL2023 上部署 PHP 5.6 并非简单的软件安装，而是一次跨越 10 年技术代差的系统集成。主要面临以下四大核心技术冲突：OpenSSL 版本断层 (Critical)冲突点：AL2023 内置 OpenSSL 3.0，API 已完全重构；PHP 5.6 仅支持 OpenSSL 1.0.x。后果：直接编译会报 libssl 相关符号未定义，无法建立 HTTPS 连接。Glibc 头文件变更冲突点：AL2023 的 Glibc (2.34+) 彻底移除了 xlocale.h；PHP 依赖的旧版 ICU 库硬编码了该头文件。后果：编译 ICU 时中断，提示 fatal error: xlocale.h: No such file or directory。编译器标准不兼容冲突点：系统默认 GCC 11 使用 C++17 标准；旧版 ICU 代码不符合新语法规范。后果：编译过程中出现大量 C++ 语法警告和错误。链接库冲突冲突点：PHP 5.6 需要 GNU 版本的 libiconv，而现代 Linux 将其整合在 glibc 中，链接器无法自动识别。后果：make 阶段报错 undefined reference to libiconv_open。2. 详细执行过程 (Execution Steps)为了解决上述冲突，我们采用了 “沙盒化依赖构建” 策略：将所有旧版依赖库安装到 /usr/local 下的独立目录，不污染系统原有环境。第一阶段：基础环境准备安装编译工具，并建立目录软链接以适配 AL2023 的文件结构。# 1. 安装开发工具包 dnf groupinstall "Development Tools" -y dnf install -y libxml2-devel sqlite-devel libcurl-devel \ libpng-devel libjpeg-devel freetype-devel bzip2-devel \ libxslt-devel systemd-devel libzip-devel gettext-devel gcc-c++ # 2. 创建 www 用户 id -u www &>/dev/null || useradd -s /sbin/nologin -M www # 3. 修复 Freetype 路径 (AL2023 路径变更适配) mkdir -p /usr/local/include ln -sf /usr/include/freetype2 /usr/local/include/freetype第二阶段：编译“古董”依赖库 (沙盒化)策略：手动编译 OpenSSL 1.0.2、ICU 58、Libiconv，并锁定安装路径。1. OpenSSL 1.0.2u (解决 SSL 冲突)cd /usr/local/src wget https://www.openssl.org/source/old/1.0.2/openssl-1.0.2u.tar.gz tar -zxvf openssl-1.0.2u.tar.gz && cd openssl-1.0.2u ./config --prefix=/usr/local/ssl-1.0.2 --openssldir=/usr/local/ssl-1.0.2 shared zlib make -j$(nproc) && make install2. Libmcrypt (系统缺失补充)cd /usr/local/src wget https://sourceforge.net/projects/mcrypt/files/Libmcrypt/2.5.8/libmcrypt-2.5.8.tar.gz tar -zxvf libmcrypt-2.5.8.tar.gz && cd libmcrypt-2.5.8 ./configure --prefix=/usr/local make -j$(nproc) && make install3. ICU4C 58.3 (解决 Intl 扩展问题)关键操作：使用 sed 修复头文件引用，指定 C++11 标准。cd /usr/local/src wget https://github.com/unicode-org/icu/releases/download/release-58-3/icu4c-58_3-src.tgz tar -zxvf icu4c-58_3-src.tgz && cd icu/source # 修复 xlocale.h 错误 (适应新版 Glibc) sed -i 's/xlocale.h/locale.h/g' i18n/digitlst.cpp # 指定 C++11 标准 (适应 GCC 11) ./configure --prefix=/usr/local/icu-58 CXXFLAGS="-std=c++11" make -j$(nproc) && make install4. GNU Libiconv 1.15 (解决链接错误)cd /usr/local/src wget https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.15.tar.gz tar -zxvf libiconv-1.15.tar.gz && cd libiconv-1.15 ./configure --prefix=/usr/local/libiconv make -j$(nproc) && make install第三阶段：PHP 5.6 编译与修复策略：通过环境变量注入依赖路径，并手动修改 Makefile 解决链接器错误。1. 配置 (Configure)cd /root/php-5.6.9 # 假设源码在此 make clean # 清理旧配置 # 注入依赖库路径 export PKG_CONFIG_PATH=/usr/local/ssl-1.0.2/lib/pkgconfig:$PKG_CONFIG_PATH export LD_LIBRARY_PATH=/usr/local/ssl-1.0.2/lib:/usr/local/icu-58/lib:$LD_LIBRARY_PATH ./configure --prefix=/usr/local/php56 \ --with-config-file-path=/usr/local/php56/etc \ --enable-fpm --with-fpm-user=www --with-fpm-group=www \ --with-mysql=mysqlnd --with-mysqli=mysqlnd --with-pdo-mysql=mysqlnd \ --with-iconv-dir --with-freetype-dir=/usr/local --with-jpeg-dir --with-png-dir \ --with-zlib --with-libxml-dir=/usr --enable-xml --disable-rpath \ --enable-bcmath --enable-shmop --enable-sysvsem --enable-inline-optimization \ --with-curl=/usr --enable-mbregex --enable-mbstring \ --with-mcrypt=/usr/local --with-gd --enable-gd-native-ttf \ --with-openssl=/usr/local/ssl-1.0.2 --with-mhash \ --enable-pcntl --enable-sockets --enable-zip --enable-soap \ --enable-intl --with-icu-dir=/usr/local/icu-582. 修复 Makefile (关键 Hack)原因：Configure 脚本无法自动识别手动安装的 libiconv 和 C++ 库依赖。操作：编辑 Makefile，修改 EXTRA_LIBS 行。# 修改前示例：EXTRA_LIBS = -lcrypt ... # 修改后逻辑：在开头加 -L路径，在结尾加 -l库名 EXTRA_LIBS = -L/usr/local/libiconv/lib -L/usr/local/ssl-1.0.2/lib -L/usr/local/icu-58/lib ... (原有内容) ... -lstdc++ -liconv3. 编译与安装操作：使用空文件欺骗法绕过 phar 构建错误。make -j$(nproc) # 绕过 phar 错误 (因环境问题导致 CLI 无法在安装阶段运行) touch ext/phar/phar.phar # 必须带环境变量执行安装 export LD_LIBRARY_PATH=/usr/local/ssl-1.0.2/lib:/usr/local/libiconv/lib:/usr/local/icu-58/lib:$LD_LIBRARY_PATH make install第四阶段：配置与服务化策略：端口隔离，并通过 Systemd 注入运行时环境变量。1. 配置文件初始化cp /root/php-5.6.9/php.ini-production /usr/local/php56/etc/php.ini cp /usr/local/php56/etc/php-fpm.conf.default /usr/local/php56/etc/php-fpm.conf # 修改端口为 9056 (防止与系统 PHP 9000 端口冲突) sed -i 's/listen = 127.0.0.1:9000/listen = 127.0.0.1:9056/g' /usr/local/php56/etc/php-fpm.conf2. 创建 Systemd 服务文件：/etc/systemd/system/php56-fpm.service核心逻辑：通过 Environment 指令，让 PHP 进程启动时能加载到我们在 /usr/local 下编译的旧版库。[Service] # ... ExecStart=/usr/local/php56/sbin/php-fpm --nodaemonize --fpm-config /usr/local/php56/etc/php-fpm.conf # 关键：加载沙盒库 Environment="LD_LIBRARY_PATH=/usr/local/ssl-1.0.2/lib:/usr/local/libiconv/lib:/usr/local/icu-58/lib"3. 启动systemctl daemon-reload systemctl start php56-fpm systemctl enable php56-fpm3. 验证与交付 (Verification)1. 端口验证检查 9056 端口是否由 PHP-FPM 监听：netstat -tulpn | grep 9056 # 预期输出: tcp ... 127.0.0.1:9056 ... php-fpm2. 功能验证 (Nginx 路由)配置 Nginx 特定路由指向新端口：location = /textll.php { fastcgi_pass 127.0.0.1:9056; include fastcgi.conf; }访问测试页面，phpinfo() 应显示版本为 5.6.9。3. 系统影响评估安全性：系统自带 OpenSSL 3.0 未被替换，系统安全性不受影响。稳定性：PHP 5.6 运行在独立目录和端口，与系统原有 PHP 5.4 互不干扰。可维护性：所有自定义依赖均在 /usr/local 下有清晰目录，卸载只需删除对应文件夹。4. 结论 (Conclusion)通过上述方案，我们成功攻克了 OpenSSL 版本不兼容、基础库缺失、编译器标准冲突等一系列难题。该方案在保证 Amazon Linux 2023 系统纯净性的前提下，完美实现了 PHP 5.6 的并行运行，满足了旧业务系统的迁移需求。公众号：运维躬行录个人博客：躬行笔记
抛弃Nginx？还是干掉Kong？聊聊为什么Apache APISIX现在这么火，看完这篇你就懂了！
昨晚又是加班，不为别的，就为了给客户提供无微不至的服务。有个微服务上线的配置写错了，导致流量没切过去，大半夜的还得爬起来改 Nginx 配置，reload，提心吊胆盯着日志看有没有报错。那一刻我就在想，咱们做运维的，日子真不该过得这么苦逼。也就是这时候，我想到了今天想跟大伙聊的这个东西——Apache APISIX。最近群里总有人问我：“现在那个 APISIX 好像很火啊，到底是干啥的？我有 Nginx 了，还要它干嘛？”这问题问得好。咱们很多做运维的，手里拿着 Nginx 这把锤子，看什么都是钉子。虽然 Nginx 确实是神，但在云原生这一波浪潮下，这尊神有时候也显得有点“腿脚不便”。今天咱们不整那些虚头巴脑的概念，我就结合我自己的踩坑经历，给大家好好唠唠，这个 Apache APISIX 到底是何方神圣，为啥我现在强烈建议大家都在生产环境试一试。官网地址：https://apisix.apache.org/zh/docs/apisix/getting-started/README/从“改配置重启”的噩梦说起咱们先别急着说 APISIX，先回想一下，咱们平时是怎么用 Nginx 的。业务要加个新域名？改 nginx.conf。后端服务扩容了，IP 变了？改 upstream。要做个黑白名单防刷？改配置，加 Lua 脚本。改完之后呢？nginx -t 测试一下，然后 nginx -s reload。看起来很流畅是吧？但是兄弟们，当你的业务量上来之后，当你的 upstream 有几千个的时候，当你一天要变更几百次配置的时候，这个 reload 就是个定时炸弹。我之前遇到过一个坑，Nginx 在高并发下 reload，新的 worker 进程起来了，旧的 worker 还在处理长连接，这时候系统负载会瞬间飙升，甚至导致这一瞬间的请求处理延迟极高，客户端直接超时。那种感觉，就像是给一辆正在高速公路上飞奔的赛车换轮胎，太特么吓人了。而且，它是静态的。哪怕你只是想改一个限流的参数，从每秒 100 改成 200，你都得重载进程。这时候你可能会说：“那我用 Kong 啊，Kong 不是动态的吗？”没错，Kong 是个好东西，也是基于 OpenResty 搞的。但是当年我用 Kong 的时候，最让我头疼的是它那个数据库依赖。Kong 默认很多配置是扔在 PostgreSQL 或者 Cassandra 里的。我就想问问，做一个网关，为啥要我维护一个关系型数据库？一旦 DB 挂了，或者 DB 响应慢了，网关就跟着抖。虽然后来 Kong 也有了 DB-less 模式，但那套架构对我来说，还是重。这就轮到 APISIX 出场了。APISIX 到底是什么？用官方的话说，它是一个动态、实时、高性能的 API 网关。用我的话说，它就是一个“甚至不需要你 reload，就能随意控制流量、随意插拔插件的超级 Nginx”。它底子还是 Nginx（确切地说是 OpenResty），所以性能上你完全不用担心，Nginx 能扛多少，它基本就能扛多少。甚至在某些场景下，因为它的路由算法优化得好，性能比原生的 Nginx 配置还要猛。但它最牛逼的地方在于两个字：动态。它是怎么做到的？这就得提它的架构了。APISIX 放弃了传统的数据库，转而拥抱了 etcd。做 K8s 的兄弟对 etcd 肯定不陌生，它是 K8s 的大脑。APISIX 选 etcd 做配置中心简直是神来之笔。为什么？快：etcd 是基于 Raft 协议的，数据一致性强，而且对这种 KV 类型的配置读取速度极快。推送机制：这是重点！Nginx 读取配置是启动时读文件的，而 APISIX 是通过 etcd 的 watch 机制。一旦你在 etcd 里改了配置（或者通过 APISIX 的 Admin API 改了配置），etcd 会瞬间把变更推送到所有的 APISIX 节点。整个过程毫秒级，而且是热更新。不需要 nginx -s reload！不需要 nginx -s reload！不需要 nginx -s reload！重要的事情说三遍。你的长连接不会断，你的业务没有任何感知，配置就生效了。安装教程APISIX 可以借助 quickstart 脚本快速安装并启动：curl -sL https://run.api7.ai/apisix/quickstart | sh该命令启动 apisix-quickstart 和 etcd 两个容器，APISIX 使用 etcd 保存和同步配置。APISIX 和 etcd 容器使用 Docker 的 host 网络模式，因此可以从本地直接访问。如果一切顺利，将输出如下信息：✔ APISIX is ready!验证你可以通过 curl 来访问正在运行的 APISIX 实例。比如，你可以发送一个简单的 HTTP 请求来验证 APISIX 运行状态是否正常：curl http://127.0.0.1:9080 --head | grep Server如果一切顺利，将输出如下信息：Server: APISIX/Version这里的 Version 是指你已经安装的 APISIX 版本，比如 APISIX/3.3.0。现在，你已经成功安装并运行了 APISIX！APISIX 提供内置的 Dashboard UI，可访问 http://127.0.0.1:9180/ui 使用。更多指南请阅读 Apache APISIX Dashboard。咱们拆开来看看里面都有啥这玩意儿里面全是宝。1. 路由（Route）——比 Nginx 灵活太多了在 Nginx 里，我们写 location，正则匹配有时候写得头皮发麻。APISIX 的路由匹配算法用的是 Radix Tree（基数树）。这玩意儿不仅快，而且支持各种花式匹配。你可以根据 HTTP Header、Query 参数、甚至 Cookie 来进行路由分发。举个真实例子，咱们做灰度发布（金丝雀发布）。以前在 Nginx 里，你可能得写一大堆 if 或者用 split_clients 模块，配置看着就晕。在 APISIX 里，你只需要调一下 API，发个 JSON 过去：“嘿，把 Header 里带着 version: v2 的请求，或者 ID 尾号是 1 的用户，转到这个新服务去。”这就完事了。想停？再发个 API，立马切回来。2. 插件（Plugin）——这才是核心生产力APISIX 之所以叫“全生命周期管理”，靠的就是插件。它自带了几十上百个插件，咱们日常运维需要的，基本都有。限流限速：limit-req，limit-count。不怕被刷爆了。身份认证：Key-auth，JWT，Basic-auth。以前这些逻辑可能要写在业务代码里，现在全扔给网关，业务服务只管处理逻辑，多爽。可观测性：这个我太喜欢了。一键开启 Prometheus 插件，Metrics 直接暴露出来，Grafana 面板都不用自己画，官方给你现成的。还有 SkyWalking、Zipkin 这种链路追踪，配置一下 IP 端口就能连上。安全：IP 黑白名单、CORS、URI 阻断，甚至还有 WAF（Web应用防火墙）功能的插件。而且，它的插件架构设计得很“散装”。什么意思呢？就是你可以给每一个路由单独配插件。比如 A 接口重要，我给它配个鉴权 + 链路追踪；B 接口是公共查询，我给它配个限流 + 缓存。互不干扰。3. 多语言支持——别再逼我学 Lua 了虽然 OpenResty 是 Lua 也就是 LuaJIT 的天下，但说实话，Lua 这语言，写点小脚本还行，逻辑复杂了维护起来真得掉头发。APISIX 这点做得特别鸡贼（褒义）。它支持 Plugin Runner。你是写 Java 的？写 Go 的？写 Python 的？没事，你可以用你熟悉的语言写插件，然后通过 RPC 的方式跟 APISIX 通信。最近它还支持了 Wasm (WebAssembly)。这就更猛了，把插件编译成 Wasm 跑在网关里，既安全又快，还不用受语言限制。为什么说它适合云原生？现在大家都在搞 K8s，搞微服务。传统的 Nginx 在 K8s 里用也就是做个 Ingress Controller。APISIX 也有 APISIX Ingress Controller。但它比官方那个 Nginx Ingress Controller 强在哪？官方那个 Nginx Ingress，每次你改个 Ingress 资源，它其实是在后台偷偷改 nginx.conf 然后 reload。如果你集群大，Ingress 经常变，那 Nginx 就不停地 reload，性能抖动很明显。APISIX 的 Ingress Controller 是全动态的。你改了 K8s 的资源，它直接转化成 APISIX 的配置通过 etcd 下发，全程无 reload。这就是为什么大厂上了 K8s 之后，很多都把网关换成了 APISIX。我的一次实战经历光说不练假把式。聊聊我去年做的一个迁移项目。当时客户有个独立站老系统，流量特别大，平时 QPS 大概在 2万左右，大促能翻好几倍。原来前面顶着的是一套魔改的 Nginx，里面塞满了各种乱七八糟的 Lua 脚本，用来做鉴权和分流。那个 nginx.conf 打开，几千行，谁都不敢动。也就是传说中的“屎山”。后来业务要搞微服务拆分，这个网关成了瓶颈。每次发版都要找运维改配置，改错了就全站 502，开发骂，老板吼。我就拍板，换 APISIX。刚开始那几天也虚啊。毕竟是新东西，怕不住。我们先搭了一套 APISIX 集群，用 Nginx 做流量镜像（Mirror），把生产流量复制一份给 APISIX，看看它会不会挂，结果处理得稳如老狗。然后开始切流量。先切 1%，再切 5%，最后全量。切完之后的效果是立竿见影的：开发爽了：我们给开发开了 Dashboard（APISIX 自带的一个控制台界面），他们要改路由、加限流，自己去点点点，或者调 API，不用再求着运维改文件了。排错快了：以前 Nginx 日志得去服务器上 grep，现在直接看 SkyWalking 的链路图，哪个节点慢，一目了然。睡得香了：这是重点。热更新真的太香了。以前大促前封网，谁都不许动配置，现在随时可以动态调整限流策略，稳得一匹。还有点小缺点咱也不能光吹彩虹屁，客观得说说它的一些门槛。首先，etcd 的维护。虽然 APISIX 帮你解决了网关的问题，但你得去维护一个高可用的 etcd 集群。etcd 这玩意儿，数据量大了或者磁盘 I/O 慢了，也是会闹脾气的。你得有懂 etcd 的人。其次，文档和学习曲线。虽然现在文档中文很全了（毕竟是中国团队开源的，必须点赞），但里面的概念比如 Service、Upstream、Route、Consumer 之间的关系，初学者上来还是容易晕。不像 Nginx，一个 location 走天下。你得花点时间去理解它的设计逻辑。总结一下所以，Apache APISIX 到底是什么？它不是用来简单的替代 Nginx 静态文件服务的，它是为了微服务和动态流量而生的操作系统。如果你的系统很简单，就两台服务器，跑个博客，那你用 Nginx 挺好，别折腾。但如果你的系统是微服务架构，跑在 K8s 上，有频繁的发布需求，有复杂的流量控制需求，或者你深受 Nginx reload 之苦，那么 APISIX 绝对是你该去看的方案。它就像是一个装备精良的现代特种兵，比起手里只有一把步枪的老兵（Nginx），它能应对的战场更复杂，战术更灵活。技术在变，咱们运维的工具箱也得更新。别守着老黄历过日子，该尝试的时候就得尝试。而且这还是 Apache 的顶级项目，背后有一大帮大佬在维护，社区活跃度极高，遇到问题去 GitHub 上提个 Issue，回复速度那是相当快。今天就大概聊这么多。关于 APISIX 怎么安装、怎么配 Dashboard，甚至怎么写个自定义插件，后面有机会我专门写个实操系列的教程，手把手教大家搭建一套高可用的网关集群。觉得这篇对你有启发的，别忘了点个赞，转发给身边还在苦逼改 nginx.conf 的兄弟们看看。最后，记得关注 @运维躬行录，咱们不讲空话，只聊运维一线的真枪实弹。我是悠悠，咱们下期见！公众号：运维躬行录个人博客：躬行笔记
我就输了个ls，Linux底层居然背着我干了这么多事？
大家好，我是悠悠。昨晚熬大夜，盯着黑漆漆的屏幕发呆。手里捧着那杯续命的冰美式，眼神空洞地看着终端里那个闪烁的光标。不知道你们有没有过这种时刻，脑子里突然蹦出一个极其无聊但又细思极恐的问题：我特么就在键盘上敲了个 l，又敲了个 s，然后回车，这屏幕上怎么就出来文件列表了？这事儿看着简单得令人发指，对吧？连刚才路过的保洁阿姨可能都知道 ls 是干啥的。但是，兄弟们，咱们干技术的不能只看表面。这就好比你按了一下冲水马桶，水哗啦流走了，你觉得挺自然，但地底下那复杂的管道系统、化粪池处理逻辑、市政管网压力平衡，那可是一套庞大的工程。Linux 也是一样。这一个简简单单的 ls，背后涉及了从硬件中断、内核调度、进程复制、文件系统读取到显卡渲染等一系列骚操作。那一瞬间，CPU 跑过的指令数可能比你这辈子吃过的米粒还多。今天反正也没啥故障，咱们就闲下来，哪怕是钻牛角尖也好，把这层窗户纸捅破，看看当我们敲下 ls 的时候，Linux 到底在底下偷偷摸摸干了啥。一、 那个并不简单的“连接”咱先别急着敲命令，故事得从你连上服务器那一刻说起。你坐在工位上，用着 SecureCRT 或者 Xshell，或者像我一样装X用 iTerm2，连上了一台几千公里外的 CentOS 服务器。这时候，服务器上的 sshd 守护进程（daemon）正百无聊赖地监听着 22 端口。突然，你的连接请求像个愣头青一样撞了进来。sshd 一看有客到，立马精神了，它不会亲自接待你，毕竟它还得在门口站岗接客。于是，它通过 fork() 系统调用，生了个孩子（子进程）。这个子进程就是专门为你服务的。接下来的加密协商、密钥交换这堆烂事儿我就不展开了，那是密码学的范畴。重点来了，认证通过后，这个 sshd 子进程还得干个体力活：它得给你准备环境。它会去读 /etc/passwd，看看你这小子到底是用 bash 还是 zsh，还是哪个反人类的 sh。通常咱们都是 /bin/bash 对吧？于是，sshd 子进程再次变身，调用 execve()，把自己脑子里的代码替换成了 /bin/bash 的代码。这时候，你屏幕上那个黑框框的背后，其实跑的就是一个 Bash 进程了。这个 Bash 刚醒过来，起床气还挺大，它得穿衣服洗漱。它会疯狂地去读一堆配置文件：/etc/profile、~/.bash_profile、~/.bashrc、/etc/bashrc……咱们平时给服务器配环境变量，搞得乱七八糟的，就是这时候生效的。如果这时候哪个文件里写了个 sleep 10，你登录的时候就得卡在那儿怀疑人生。最后，Bash 准备好了，它调用 write() 系统调用，向你的屏幕输出了一个提示符：[root@localhost ~]#然后，它调用 read()，阻塞在那里，静静地等着你临幸。二、 指尖的舞蹈：从键盘到内核现在，你的手指悬在键盘上方，准备敲下 l。当你按下 'l' 键的那一微秒，键盘内部的电路闭合，产生了一个扫描码（Scan Code）。键盘控制器一看，哟，来活了，立马向 CPU 发送一个硬件中断。CPU 正忙着算别的进程的圆周率呢，被这中断一搞，不得不停下手里的活，去执行键盘中断处理程序。操作系统内核（Kernel）接管了现场，它从端口读取扫描码，把它转换成 ASCII 码的 'l'。但这还没完。这个 'l' 现在还在内核的输入缓冲区里。你的 SSH 客户端（比如 Xshell）通过网络把这个字符发给了服务器。服务器网卡收到包，又是一顿中断处理，内核把数据包拆开，扔给了伪终端主设备（PTY Master）。这时候，Bash 进程之前不是在 read() 那儿傻等吗？内核一看，有数据来了，立马把 Bash 叫醒：“别睡了，起来干活！”Bash 醒过来，读到了这个 'l'。注意，这时候屏幕上还没显示 'l' 呢！Bash 拿到 'l' 后，它得决定怎么办。通常情况下，它会把这个字符“回显”给你。也就是说，Bash 又调用 write() 把 'l' 发回给你的 SSH 客户端，你的屏幕上这才出现了一个字母 'l'。这就是为什么有时候网络卡顿，你敲了半天键盘，屏幕上没反应。因为那个字符得去服务器溜达一圈再回来，你才能看见。同理，你敲下 's'，也是这么个流程。三、 回车那一刻：解析与查找当你按下回车键（Enter）的时候，那才是高潮的开始。Bash 读到了换行符 \n，它知道，这哥们儿输入完了，该我干活了。首先，Bash 会把你输入的字符串 ls 拿来进行切割和分析。这叫词法分析。它得搞清楚，哪部分是命令，哪部分是参数，哪部分是选项。对于 ls 这种简单命令，没啥好切的。但 Bash 也是个心机boy，它得先查查户口：是不是绝对路径？ 你输的不是 /bin/ls，所以它得接着找。是不是别名（Alias）？ 这一点很关键！在大部分发行版里，你输的 ls 其实根本不是 ls。不信你现在去终端敲一下 alias ls，十有八九会告诉你：alias ls='ls --color=auto'。原来是替身攻击！Bash 默默地把 ls 替换成了 ls --color=auto。这就是为什么你列出来的文件是五颜六色的，而不是灰头土脸的白字。是不是内置命令（Builtin）？ 像 cd、echo、alias 这种是 Bash 自带的，不需要去磁盘找。但 ls 显然不是，它是个独立的可执行文件。哈希表（Hash）里有没有？ Bash 为了快，会把以前执行过的命令位置记在内存里。如果以前敲过，直接去老地方找。查 PATH 变量。 如果上面都没找到，Bash 就得去翻箱倒柜了。它会读取 $PATH 环境变量，通常是 /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin 这一长串。它会按照顺序，先去 /usr/local/bin 看看有没有个叫 ls 的文件？没有？再去 /usr/bin 看看。哎，找到了！一旦找到，Bash 就算心里有底了：目标锁定 /usr/bin/ls。四、 细胞分裂：Fork 的魔法找到了程序文件，怎么运行它？这里就是 Linux 最反直觉也最迷人的地方了。在 Windows 里，可能有个 API 叫 CreateProcess 之类的直接搞定。但在 Linux 里，创造新生命的方式是细胞分裂。Bash 会调用 fork() 系统调用。那一瞬间，操作系统会把当前的 Bash 进程完完整整地复制一份。包括内存里的变量、打开的文件描述符、环境变量……简直就是克隆人。父进程（原来的 Bash）：它的 ID（PID）不变。子进程（新的 Bash）：它获得了一个新的 PID。此时此刻，系统里有两个一模一样的 Bash 在跑。父进程 Bash 现在的任务完成了，它会调用 wait() 或者 waitpid()，进入睡眠状态，就像老父亲看着儿子出门闯荡，自己在门口抽烟等着儿子回来汇报工作。而那个子进程 Bash，虽然长得和它爹一样，但它肩负着特殊的使命。它紧接着会调用 execve() 系统调用。execve 是个狠人，它会把子进程脑子里原有的 Bash 代码全部清空，把刚才找到的 /usr/bin/ls 的代码加载进内存，替换掉原来的脑子。这一刻，子进程不再是 Bash，它正式变身为 ls 进程。五、 动态链接的狂欢变身成功了，ls 就能直接跑了吗？太天真了。现在的程序很少有静态编译的（Golang 写的那种除外）。ls 这种 C 语言写的老古董，严重依赖动态链接库。你可以用 ldd /bin/ls 看看，它拖家带口的依赖了一堆库：libselinux.so、libc.so、libpcre.so 等等。在 ls 的 main() 函数执行之前，Linux 的动态链接器（通常是 /lib64/ld-linux-x86-64.so.2）得先出场。它负责把 ls 需要的所有共享库文件加载到内存里，并且把代码里的那些“坑”填上。比如 ls 里面调用了 printf，在代码里那只是个符号，链接器得把它指向 libc.so 里真正的 printf 函数地址。这步工作如果做不好，你就会看到那个著名的报错：error while loading shared libraries。这种错我估计你们也没少见，通常都是刚装完某个软件环境变量没配好。等到所有库都加载完毕，链接器功成身退，控制权终于交到了 ls 程序的入口点（Entry Point）。六、 真正的干活：系统调用的暴力美学现在，ls 终于开始执行它的业务逻辑了。它的核心任务很简单：列出目录里的东西。root@debian:~# strace ls execve("/usr/bin/ls", ["ls"], 0x7ffca85f2e60 /* 19 vars */) = 0 brk(NULL) = 0x55d90544d000 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f9820b9f000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=58767, ...}) = 0 mmap(NULL, 58767, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f9820b90000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0644, st_size=202984, ...}) = 0 mmap(NULL, 210640, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9820b5c000 mmap(0x7f9820b63000, 131072, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x7000) = 0x7f9820b63000 mmap(0x7f9820b83000, 36864, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x27000) = 0x7f9820b83000 mmap(0x7f9820b8c000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x30000) = 0x7f9820b8c000 mmap(0x7f9820b8e000, 5840, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f9820b8e000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360y\0\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0644, st_size=47288, ...}) = 0 mmap(NULL, 45128, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9820b50000 mmap(0x7f9820b53000, 20480, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x3000) = 0x7f9820b53000 mmap(0x7f9820b58000, 8192, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x8000) = 0x7f9820b58000 mmap(0x7f9820b5a000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0xa000) = 0x7f9820b5a000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\236\2\0\0\0\0\0"..., 832) = 832 pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840 fstat(3, {st_mode=S_IFREG|0755, st_size=2003408, ...}) = 0 pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840 mmap(NULL, 2055800, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f982095a000 mmap(0x7f9820982000, 1462272, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7f9820982000 mmap(0x7f9820ae7000, 352256, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18d000) = 0x7f9820ae7000 mmap(0x7f9820b3d000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e2000) = 0x7f9820b3d000 mmap(0x7f9820b43000, 52856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f9820b43000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpcre2-8.so.0", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0644, st_size=711216, ...}) = 0 mmap(NULL, 713544, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f98208ab000 mmap(0x7f98208ae000, 503808, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x3000) = 0x7f98208ae000 mmap(0x7f9820929000, 192512, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x7e000) = 0x7f9820929000 mmap(0x7f9820958000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0xac000) = 0x7f9820958000 close(3) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f98208a9000 arch_prctl(ARCH_SET_FS, 0x7f98208aa240) = 0 set_tid_address(0x7f98208aa510) = 73938 set_robust_list(0x7f98208aa520, 24) = 0 rseq(0x7f98208aa0a0, 0x20, 0, 0x53053053) = 0 mprotect(0x7f9820b3d000, 16384, PROT_READ) = 0 mprotect(0x7f9820958000, 4096, PROT_READ) = 0 mprotect(0x7f9820b5a000, 4096, PROT_READ) = 0 mprotect(0x7f9820b8c000, 4096, PROT_READ) = 0 mprotect(0x55d8f8f52000, 8192, PROT_READ) = 0 mprotect(0x7f9820bdb000, 8192, PROT_READ) = 0 prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0 munmap(0x7f9820b90000, 58767) = 0 prctl(PR_CAPBSET_READ, CAP_MAC_OVERRIDE) = 1 prctl(PR_CAPBSET_READ, 0x30 /* CAP_??? */) = -1 EINVAL (Invalid argument) prctl(PR_CAPBSET_READ, CAP_CHECKPOINT_RESTORE) = 1 prctl(PR_CAPBSET_READ, 0x2c /* CAP_??? */) = -1 EINVAL (Invalid argument) prctl(PR_CAPBSET_READ, 0x2a /* CAP_??? */) = -1 EINVAL (Invalid argument) prctl(PR_CAPBSET_READ, 0x29 /* CAP_??? */) = -1 EINVAL (Invalid argument) statfs("/sys/fs/selinux", 0x7fffce9f78e0) = -1 ENOENT (No such file or directory) statfs("/selinux", 0x7fffce9f78e0) = -1 ENOENT (No such file or directory) getrandom("\xe6\xef\xc7\x83\xae\x77\x10\x65", 8, GRND_NONBLOCK) = 8 brk(NULL) = 0x55d90544d000 brk(0x55d90546e000) = 0x55d90546e000 openat(AT_FDCWD, "/proc/filesystems", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0444, st_size=0, ...}) = 0 read(3, "nodev\tsysfs\nnodev\ttmpfs\nnodev\tbd"..., 1024) = 387 read(3, "", 1024) = 0 close(3) = 0 access("/etc/selinux/config", F_OK) = -1 ENOENT (No such file or directory) ioctl(1, TCGETS, {c_iflag=ICRNL|IXON, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0 ioctl(1, TIOCGWINSZ, {ws_row=61, ws_col=143, ws_xpixel=0, ws_ypixel=0}) = 0 openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 fstat(3, {st_mode=S_IFDIR|0700, st_size=4096, ...}) = 0 getdents64(3, 0x55d905452c40 /* 28 entries */, 32768) = 960 getdents64(3, 0x55d905452c40 /* 0 entries */, 32768) = 0 close(3) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0 write(1, " Python-3.14.0a5 awx_env\t "..., 98 Python-3.14.0a5 awx_env kimchi-3.0.0-0.noarch.deb sudo wok-3.0.0-modified.deb ) = 98 write(1, " Python-3.14.0a5.tgz daemon.js"..., 94 Python-3.14.0a5.tgz daemon.json kimchi-venv 'udo ss -tulpn | grep 9090' wok_extract ) = 94 write(1, " awx\t\t kickstart.sh ngsh"..., 77 awx kickstart.sh ngshow wok-3.0.0-0.debian.noarch.deb xuexi ) = 77 close(1) = 0 close(2) = 0 exit_group(0) = ? +++ exited with 0 +++ root@debian:~# 但应用程序是不能直接读硬盘的，那是内核的禁脔。ls 必须通过系统调用（System Call）来求内核办事。咱们可以用 strace ls 来偷窥一下它到底干了啥。这命令特好用，建议没事多跑跑，比看源码来得直观。openat()ls 首先得打开当前目录。它会调用 openat()，传入 .（当前目录）。内核检查一下权限：这小子是 root 吗？或者对这目录有读权限吗？如果有，内核返回一个文件描述符（File Descriptor，简称 FD），比如是 3。getdents64()这是最核心的一步。ls 调用 getdents64(3, ...)。这个系统调用的意思是“Get Directory Entries”，获取目录项。内核接到这个请求，会去文件系统（Ext4, XFS等）读取目录的元数据。注意，它读的不是文件内容，是目录结构。它会拿到一堆文件名、inode 号、文件类型（是文件还是目录）。如果目录文件特别多，这个 getdents64 可能要读好几次。lstat() / stat()如果你只是敲 ls，可能到上面就差不多了。但别忘了，我们默认可能有 alias 或者你敲了 ls -l。如果要显示详细信息（权限、大小、时间），或者要根据文件类型显示颜色，ls 必须知道每个文件的详细信息。于是，ls 会对着刚才拿到的每一个文件名，疯狂调用 lstat()。重点来了！ 如果你这个目录下有几万个文件，或者有个挂载的 NFS 网络盘卡住了，ls 就会在这里卡死！因为每一个 lstat 都要去问文件系统：“哎，这个文件多大？谁的？啥时候改的？”这就是为啥有时候 ls 一下，终端直接僵死半天不动的原因。IO 炸了。ioctl()ls 还没忘了一件事，它得知道你的终端窗口有多宽。它调用 ioctl(1, TIOCGWINSZ, ...)。这玩意儿就是问内核：“现在的终端窗口是几行几列啊？”如果你的窗口很宽，它就把文件名横着排；如果很窄，它就竖着排。很贴心是吧？七、 格式化与输出：为了让你看懂拿到了所有文件的元数据，ls 开始在用户态内存里拼凑字符串了。它会根据配置（比如 /etc/DIR_COLORS），给不同类型的文件加上 ANSI 转义码。比如，目录是蓝色的，它就在目录名字前面加个 \033[01;34m，后面加个 \033[0m。这些乱七八糟的字符拼好之后，ls 再次调用 write()，把这一大坨数据写入到文件描述符 1（标准输出 stdout）。八、 尘埃落定数据写入 stdout 后，又是熟悉的流程：内核 TTY 驱动接收数据 -> SSHD 进程读取 -> 加密 -> 通过 TCP/IP 发送 -> 你的网卡接收 -> OS 解包 -> SSH 客户端解密 -> 终端模拟器解析颜色代码 -> 显卡渲染字体。最终，你的视网膜上接收到了这些光子，大脑皮层处理了一下：“哦，有个 config.yaml 文件。”任务完成后，ls 进程调用 exit_group(0)，宣布自杀。内核回收它的内存，关闭它打开的文件描述符，向它的父进程（那个等着抽烟的 Bash）发送一个 SIGCHLD 信号：“儿砸完事儿了，你可以收尸了。”Bash 收到信号，wait() 返回，清理掉僵尸进程（Zombie Process），重新打印出提示符：[root@localhost ~]#整个过程，可能也就几毫秒。九、 这一点也不“简单”写到这，我不禁有点感慨。咱们平时敲个命令，觉得是天经地义的事。要是稍微慢了一点，还要骂一句“这破服务器真垃圾”。但你看，为了这简简单单的两个字母，Linux 内核、文件系统、内存管理、进程调度、网络协议栈，像一支训练有素的军队，进行了一次精密至极的协同作战。这里面任何一个环节出了岔子，比如内存耗尽（OOM）、磁盘坏道、网络丢包、权限配置错误，都会导致你看到的不是文件列表，而是一行冰冷的报错，甚至是一个永远转不完的圈圈。这也是为什么我总是跟刚入行的小兄弟说，别光背命令，要懂原理。当你明白了 ls 背后发生了什么，下次遇到服务器卡顿、命令 hang 住、Permission denied 的时候，你脑海里浮现的就不是焦躁，而是清晰的数据流图。你会知道是用 strace 去看卡在哪个 syscall，还是用 dmesg 去看是不是硬件报错，或者是去查查那个该死的 alias 是不是被谁改了。这就是“运维”和“操作员”的区别。技术这东西，越往深了挖，越觉得有意思。它不只是冷冰冰的代码，它是逻辑的艺术，是人类智慧的结晶……哎呀我去，扯远了，有点矫情了。反正吧，下次当你再敲下回车的时候，记得在心里给这套伟大的系统点个赞。它真的很努力了。总结一下：一个 ls，从硬件中断到 Shell 解析，从 Fork/Exec 到动态链接，再到系统调用读取文件系统，最后渲染输出。每一层都是前人踩过无数坑总结出来的最佳实践。咱们做技术的，就是要这种刨根问底的劲儿。你看完这篇文章，要是觉得哪怕有一点点收获，或者觉得“卧槽，原来是这样”，那我就没白写。公众号：运维躬行录个人博客：躬行笔记
踩坑无数！Linux命令直接跑和crontab定时跑，差别竟然这么大？
上周三凌晨两点，我被电话吵醒了。运维群里炸了锅，说数据备份脚本又没执行成功。我迷迷糊糊爬起来一看，脚本明明手动跑得好好的啊，怎么放到crontab里就不行了呢？这种事情遇到的次数多了，我发现很多人都有这个困惑。明明在终端里敲命令执行得好好的，结果写到定时任务里就各种报错、各种不生效。今天就跟大家聊聊这个话题，把我这些年踩过的坑都给你们讲清楚。环境变量这个大坑说实话，90%的crontab问题都出在环境变量上。你在终端里直接执行命令的时候，用的是你登录shell的完整环境，PATH、HOME、USER这些变量都是配置好的。但crontab呢？它运行的时候环境变量少得可怜。我记得有次写了个Python脚本，里面用到了虚拟环境。手动执行的时候：python /home/ops/backup.py一点问题都没有，因为我的.bashrc里配置了Python的路径，还激活了虚拟环境。结果放到crontab里：0 2 * * * python /home/ops/backup.py死活不行！后来才发现，crontab执行的时候，它压根不知道python在哪儿，PATH里只有/usr/bin和/bin这几个基础路径。解决办法其实也简单，要么在crontab里写绝对路径：0 2 * * * /usr/local/bin/python3 /home/ops/backup.py要么在脚本开头就把环境变量加载进来：#!/bin/bash source /etc/profile source ~/.bashrc /usr/local/bin/python3 /home/ops/backup.py不过这里有个细节，crontab默认的PATH通常是：/usr/bin:/bin你可以在crontab文件开头自己定义：PATH=/usr/local/bin:/usr/bin:/bin SHELL=/bin/bash 0 2 * * * python /home/ops/backup.py工作目录的问题这个坑我也踩过。有个脚本里用了相对路径读取配置文件：with open('config.ini', 'r') as f: config = f.read()手动在脚本所在目录执行，当然没问题。但crontab执行的时候，默认工作目录是用户的HOME目录，不是脚本所在的目录！所以它找不到config.ini文件。当时排查这个问题花了我半个小时，因为日志里只显示"FileNotFoundError"，我还以为是权限问题呢。后来才反应过来是路径的事。有两种解决方案，一种是在crontab里先cd到脚本目录：0 2 * * * cd /home/ops && python backup.py另一种是在脚本里获取脚本所在目录，然后切换过去：import os script_dir = os.path.dirname(os.path.abspath(__file__)) os.chdir(script_dir)我个人更喜欢后者，因为这样脚本的可移植性更好。输出重定向和日志直接在终端执行命令，所有输出都会打印到屏幕上，你能立刻看到结果。但crontab执行的时候，如果你不做任何处理，输出会通过邮件发送给用户。问题是很多服务器根本没配置邮件服务，这些输出就直接丢失了。我见过太多次脚本报错了，但因为没有日志记录，根本不知道哪里出了问题。所以我现在写定时任务，一定会加上输出重定向：0 2 * * * /home/ops/backup.sh >> /var/log/backup.log 2>&1这里的2>&1是把标准错误也重定向到标准输出，这样错误信息也能记录到日志文件里。有时候你不想保留历史日志，只想看最新一次的执行结果：0 2 * * * /home/ops/backup.sh > /var/log/backup.log 2>&1用单个>就会覆盖之前的内容。还有个技巧，如果你完全不想要输出：0 2 * * * /home/ops/backup.sh > /dev/null 2>&1不过我不太建议这么做，万一出问题了连个日志都没有。Shell环境的差异这个问题比较隐蔽。你在终端里执行命令，用的可能是bash、zsh或者其他shell，而且是交互式shell。但crontab执行的时候，用的是非交互式的sh或者bash。交互式shell和非交互式shell加载的配置文件是不一样的。比如bash，交互式登录shell会读取/etc/profile、~/.bash_profile、~/.bash_login、~/.profile这些文件，但非交互式shell只会读取~/.bashrc（而且还得看情况）。我之前有个脚本用到了alias定义的命令别名，手动执行没问题，crontab里就报"command not found"。因为alias是在.bashrc里定义的，而crontab的非交互式shell根本不会加载这个文件。解决办法就是在crontab里明确指定shell：SHELL=/bin/bash 0 2 * * * source ~/.bashrc && /home/ops/backup.sh或者干脆不用alias，直接写完整命令。权限和用户身份这个也很关键。你在终端里执行命令，是以你当前登录的用户身份执行的。但crontab呢？要看你是在哪个用户的crontab里配置的。系统级的crontab（/etc/crontab）可以指定执行用户：0 2 * * * root /home/ops/backup.sh但用户级的crontab（crontab -e）只能以当前用户身份执行。我见过有人把需要root权限的命令写到普通用户的crontab里，然后纳闷为什么总是失败。还有人在脚本里用了sudo，但crontab执行的时候sudo需要密码，结果就卡住了。如果确实需要root权限，要么把任务写到root用户的crontab里，要么配置sudo免密：# 在/etc/sudoers里添加 ops ALL=(ALL) NOPASSWD: /home/ops/backup.sh然后crontab里这样写：0 2 * * * sudo /home/ops/backup.sh不过从安全角度考虑，能不用sudo就别用。时区和时间问题这个坑我也踩过一次。服务器的系统时区是UTC，但我以为是CST（中国标准时间），结果定时任务的执行时间总是差8个小时。你可以用date命令查看当前系统时间和时区：date timedatectl如果时区不对，可以修改：timedatectl set-timezone Asia/Shanghai或者在crontab里指定时区：TZ=Asia/Shanghai 0 2 * * * /home/ops/backup.sh还有个容易忽略的点，crontab的时间是基于系统时间的，如果系统时间不准，定时任务的执行时间也会不准。所以最好配置NTP时间同步：yum install -y ntpdate ntpdate ntp.aliyun.com或者用systemd-timesyncd：timedatectl set-ntp true并发执行的问题这个问题很多人没注意到。假设你有个定时任务每分钟执行一次，但这个任务本身需要运行2分钟。那么第二次执行开始的时候，第一次还没结束，就会出现并发执行的情况。如果脚本没有做并发控制，可能会导致资源竞争、数据不一致等问题。我之前就遇到过一个数据同步脚本，因为并发执行导致数据重复写入。解决办法是加锁。可以用flock命令：* * * * * flock -xn /tmp/backup.lock -c '/home/ops/backup.sh'-xn参数表示如果无法获取锁就立即退出，不等待。或者在脚本里自己实现锁机制：#!/bin/bash LOCK_FILE=/tmp/backup.lock if [ -f "$LOCK_FILE" ]; then echo "Script is already running" exit 1 fi touch $LOCK_FILE trap "rm -f $LOCK_FILE" EXIT # 你的脚本逻辑 sleep 120 rm -f $LOCK_FILE这里用trap确保脚本退出时一定会删除锁文件，即使脚本异常退出也不会留下死锁。环境变量的完整对比我做过一个实验，分别在终端和crontab里打印环境变量，对比一下差异有多大。终端里执行：env > /tmp/terminal_env.txt然后在crontab里：* * * * * env > /tmp/cron_env.txt等执行完后对比两个文件：diff /tmp/terminal_env.txt /tmp/cron_env.txt你会发现crontab的环境变量少了很多，比如：DISPLAY（图形界面相关）SSH_CLIENT、SSH_CONNECTION（SSH连接信息）LANG、LC_ALL（语言和字符集）各种自定义的环境变量这就是为什么很多脚本在终端里能跑，crontab里不行。字符集和编码问题这个问题在处理中文的时候特别明显。你在终端里执行脚本，终端的字符集通常是UTF-8，所以中文显示正常。但crontab执行的时候，如果没有设置LANG环境变量，可能会用ASCII编码，导致中文乱码或者脚本报错。解决办法是在crontab里设置字符集：LANG=zh_CN.UTF-8 LC_ALL=zh_CN.UTF-8 0 2 * * * /home/ops/backup.sh或者在脚本开头设置：#!/bin/bash export LANG=zh_CN.UTF-8 export LC_ALL=zh_CN.UTF-8我之前有个脚本要发送邮件通知，邮件内容包含中文。手动执行的时候邮件正常，crontab执行的时候邮件里全是乱码，就是因为字符集的问题。交互式命令的问题有些命令在执行的时候需要用户输入，比如确认操作、输入密码等。这种命令在终端里可以正常交互，但在crontab里就会卡住，因为crontab是非交互式执行的，没有标准输入。比如rm -i会询问是否删除，apt-get install会询问是否继续。这些命令在crontab里要么加上-y参数自动确认，要么用yes命令：0 2 * * * yes | apt-get install some-package或者用expect工具来模拟交互：#!/usr/bin/expect spawn some-command expect "Are you sure?" send "yes\r" expect eof不过我一般尽量避免在定时任务里用需要交互的命令，改用非交互式的替代方案。资源限制的差异这个比较少见，但也值得注意。用户登录shell的时候，资源限制（ulimit）是根据/etc/security/limits.conf配置的。但crontab执行的时候，资源限制可能不一样。你可以在crontab里查看当前的资源限制：* * * * * ulimit -a > /tmp/cron_ulimit.txt如果发现限制太小，可以在脚本里调整：#!/bin/bash ulimit -n 65535 # 增加打开文件数限制 ulimit -u 4096 # 增加进程数限制我之前有个脚本需要打开很多文件，手动执行没问题，crontab里就报"Too many open files"错误，就是因为crontab的文件描述符限制比较小。网络环境的差异这个问题比较隐蔽。有些脚本需要访问网络资源，比如调用API、下载文件等。在终端里执行的时候，可能会使用代理服务器（通过http_proxy环境变量），但crontab执行的时候没有这些环境变量，就无法访问外网。如果你的服务器需要通过代理访问外网，记得在crontab里设置代理：http_proxy=http://proxy.example.com:8080 https_proxy=http://proxy.example.com:8080 0 2 * * * /home/ops/backup.sh或者在脚本里设置：#!/bin/bash export http_proxy=http://proxy.example.com:8080 export https_proxy=http://proxy.example.com:8080调试技巧说了这么多问题，那怎么调试crontab任务呢？我总结了几个实用的技巧。第一个技巧是增加日志输出。在脚本里多加一些echo语句，把关键变量、执行步骤都打印出来：#!/bin/bash echo "Script started at $(date)" >> /var/log/backup.log echo "Current user: $(whoami)" >> /var/log/backup.log echo "Current directory: $(pwd)" >> /var/log/backup.log echo "PATH: $PATH" >> /var/log/backup.log # 你的脚本逻辑 echo "Script finished at $(date)" >> /var/log/backup.log第二个技巧是用set -x开启调试模式：#!/bin/bash set -x # 开启调试，会打印每条执行的命令这样日志里会显示每条命令的执行过程，方便排查问题。第三个技巧是先把定时任务改成每分钟执行一次，方便测试：* * * * * /home/ops/backup.sh >> /var/log/backup.log 2>&1测试通过后再改回正常的执行时间。第四个技巧是模拟crontab环境。你可以写一个测试脚本，清空环境变量后再执行：#!/bin/bash env -i HOME=$HOME SHELL=/bin/bash PATH=/usr/bin:/bin /home/ops/backup.sh这样就能在终端里模拟crontab的执行环境，更容易发现问题。一些最佳实践说了这么多坑，我总结一些最佳实践，帮你避免这些问题。写定时任务脚本的时候，尽量遵循这些原则：所有路径都用绝对路径，包括命令路径、文件路径在脚本开头明确设置环境变量，不依赖外部环境做好日志记录，包括正常输出和错误输出加上执行锁，避免并发问题做好异常处理，脚本出错时要有明确的错误信息定期检查日志，及时发现问题我现在写的定时任务脚本，基本都是这个模板：#!/bin/bash # 设置环境变量 export PATH=/usr/local/bin:/usr/bin:/bin export LANG=zh_CN.UTF-8 # 设置工作目录 SCRIPT_DIR=$(cd $(dirname $0) && pwd) cd $SCRIPT_DIR # 日志文件 LOG_FILE=/var/log/backup.log # 加锁 LOCK_FILE=/tmp/backup.lock if [ -f "$LOCK_FILE" ]; then echo "$(date) Script is already running" >> $LOG_FILE exit 1 fi touch $LOCK_FILE trap "rm -f $LOCK_FILE" EXIT # 记录开始时间 echo "$(date) Script started" >> $LOG_FILE # 你的脚本逻辑 # ... # 记录结束时间 echo "$(date) Script finished" >> $LOG_FILE这个模板基本覆盖了常见的问题，用起来比较省心。扩展：systemd timer作为替代方案说到定时任务，不得不提一下systemd timer。现在很多Linux发行版都用systemd作为init系统，systemd提供了timer功能，可以替代crontab。相比crontab，systemd timer有一些优势：更灵活的时间配置，支持相对时间、随机延迟等更好的日志管理，可以用journalctl查看日志可以设置依赖关系，比如某个服务启动后才执行可以设置资源限制，比如CPU、内存使用上限举个例子，创建一个systemd timer：# /etc/systemd/system/backup.service [Unit] Description=Backup Service [Service] Type=oneshot ExecStart=/home/ops/backup.sh User=ops# /etc/systemd/system/backup.timer [Unit] Description=Backup Timer [Timer] OnCalendar=daily OnCalendar=02:00:00 Persistent=true [Install] WantedBy=timers.target然后启用timer：systemctl daemon-reload systemctl enable backup.timer systemctl start backup.timer查看timer状态：systemctl list-timers systemctl status backup.timer查看执行日志：journalctl -u backup.servicesystemd timer的时间配置比crontab更直观，比如：OnCalendar=daily # 每天执行 OnCalendar=weekly # 每周执行 OnCalendar=Mon *-*-* 02:00:00 # 每周一凌晨2点 OnCalendar=*-*-01 02:00:00 # 每月1号凌晨2点还可以设置随机延迟，避免多个任务同时执行：RandomizedDelaySec=300 # 随机延迟0-300秒不过systemd timer也有缺点，就是配置比crontab复杂一些，需要创建两个文件（service和timer）。而且不是所有系统都支持systemd。扩展：at命令用于一次性任务如果你只需要执行一次性的定时任务，不需要周期性执行，可以用at命令。比如你想在明天凌晨2点执行一个脚本：echo "/home/ops/backup.sh" | at 02:00 tomorrow或者3小时后执行：echo "/home/ops/backup.sh" | at now + 3 hours查看待执行的at任务：atq删除at任务：atrm 任务编号at命令比crontab简单，适合临时性的任务。不过at也有环境变量的问题，最好在脚本里明确设置环境。扩展：anacron用于不常开机的系统crontab有个问题，如果到了执行时间但系统没开机，任务就会被跳过。对于服务器来说这不是问题，但对于个人电脑或者不常开机的系统，可能会错过很多任务。anacron就是为了解决这个问题。它会记录任务的上次执行时间，如果错过了执行时间，下次开机后会补执行。anacron的配置文件是/etc/anacrontab：# 格式：周期(天) 延迟(分钟) 任务ID 命令 1 5 daily-backup /home/ops/backup.sh 7 10 weekly-report /home/ops/report.sh这表示daily-backup任务每天执行一次，如果错过了，开机后延迟5分钟执行。weekly-report任务每7天执行一次，开机后延迟10分钟执行。anacron适合个人电脑或者不常开机的系统，服务器一般用不到。扩展：分布式定时任务如果你有多台服务器，需要在多台服务器上执行定时任务，可以考虑用分布式定时任务系统。比较流行的有：XXL-JOB：国产的分布式任务调度平台，功能强大，有Web管理界面Elastic-Job：当当开源的分布式任务调度框架Quartz：Java生态的任务调度框架Airflow：Python生态的工作流调度平台，适合数据处理任务这些系统提供了统一的任务管理、执行监控、失败重试、任务依赖等功能，比单机的crontab强大很多。不过对于小规模的应用，crontab已经够用了，没必要引入复杂的分布式系统。写在最后说了这么多，其实核心就是一点：crontab执行环境和你的终端环境是不一样的。理解了这个，很多问题就迎刃而解了。我这些年踩过的坑基本都在这里了，希望能帮你少走一些弯路。定时任务看起来简单，但细节真的很多，每个细节都可能导致任务失败。最后给个建议，写定时任务的时候，一定要做好日志记录和监控。不要等到任务失败了才发现，那时候可能已经造成损失了。可以配合监控系统（比如Zabbix、Prometheus）或者告警系统（比如钉钉、企业微信），任务失败时及时通知。好了，今天就聊到这里。如果你也遇到过类似的问题，或者有其他的经验，欢迎在评论区分享。如果这篇文章对你有帮助，别忘了点赞转发支持一下！想了解更多运维实战经验和技术干货，记得关注微信公众号@运维躬行录，领取学习大礼包！！！我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习，共同进步！公众号：运维躬行录个人博客：躬行笔记
Linux运维神器？抛弃黑窗口，这款高颜值Web管理工具让我真香了！
今天咱不聊那些高大上的云原生架构，也不扯什么微服务治理的深奥理论，咱聊点接地气的，聊点能让你早点下班、少敲两个命令的实实在在的东西。事情是这样的。前两天，我也忘了是周几，反正是个想摸鱼的下午。有个刚入行的小兄弟跑来问我：“哥，那个LVM扩容怎么搞啊？我不小心把vg给弄没了……” 我当时心里就一万只羊驼奔腾而过，但看着他那无辜又惊恐的眼神，我只能叹口气，打开终端帮他收拾烂摊子。那时候我就在想，Linux 这玩意儿，对新手确实不太友好。满屏幕的黑底白字，敲错一个字符可能就是删库跑路。虽说咱们做运维的，敲命令是基本功，要是连个 ls 都要想半天那确实说不过去。但是！咱们能不能稍微让自己过得舒服点？是不是所有的操作都必须在那一行行的代码里死磕？以前我也觉得，真正的 Linux 玩家就该住在 Shell 里，用 GUI（图形界面）那是“弱鸡”的表现。直到我遇到了 Cockpit。说真的，这玩意儿彻底改变了我对 Linux 管理工具的看法。以前那些 Webmin 啥的，界面丑得像上个世纪的产物，而且还重，装上去系统负载先高一截。但 Cockpit 不一样，它是红帽（Red Hat）亲生的，现在 CentOS 8、RHEL 8 以后基本都原生自带了，就冲这“皇族”血统，咱也得瞧瞧不是？这篇文有点长，但我保证都是干货，咱们一边唠一边看，这东西到底神在哪。咱们先搞清楚，Cockpit 到底是啥？官方文档：https://cockpit-project.org/running.html简单说，它就是一个通过浏览器管理 Linux 服务器的 Web 界面。别急着喷，“切，不就是个网页版 SSH 嘛”。哎，还真不完全是。Cockpit 最牛的地方在于它的轻量级和零依赖感。很多管理工具，你装上它，它就在后台起一堆进程，在那疯狂吃你的内存，还要接管你的配置文件，一旦它挂了，或者你不想用它了，卸载的时候能把你系统搞崩。Cockpit 不是这样的。它其实就是个皮。它直接调用系统的 API（主要是 Systemd 和 D-Bus）。这就意味着，你在 Cockpit 里点了一个“重启服务”，实际上它就是在后台帮你执行了 systemctl restart xxx。你在终端里手动改了网络配置，Cockpit 界面上立马就能同步更新，因为它俩看的是同一个底层数据。这就像是你既可以用遥控器换台（Cockpit），也可以走到电视机前按按钮换台（命令行），这两者不冲突。这就是我最喜欢它的地方——它不绑架我的系统。装它，比煮泡面还简单咱都不用怎么教，但为了文章完整性，我还是得啰嗦两句。如果你用的是 CentOS 7 之后的版本，或者 Ubuntu 20.04+，这货基本都在官方源里躺着呢。我这边用的是debain13：. /etc/os-release echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" > \ sudo tee /etc/apt/sources.list.d/backports.list sudo apt update apt install cockpit完了。就这两行。注意啊，这里启动的是 cockpit.socket。这又是一个细节！平时没人访问的时候，它基本不占资源，只有当你浏览器发起连接请求的时候，Systemd 才会把它唤醒。这设计，绝了，省资源省到家了。在 Ubuntu 上也差不多，apt install cockpit 完事。装好之后，你拿个浏览器，输入 https://IP:9090。这时候浏览器会弹个大红叉，说证书不安全。那是肯定的，自签名的证书嘛，点“高级” -> “继续访问”就行。然后你就看到了登录界面，用你系统的 root 账号或者普通账号登进去。第一眼：这颜值，我爱了登进去之后，那个仪表盘（Dashboard）是真的清爽。左边一列导航栏，右边是实时的 CPU、内存、网络、磁盘 I/O 的波形图。这图表不是那种卡顿的静态图，是实时流动的，看着那个 CPU 曲线在那跳动，说实话，有一种莫名的治愈感。特别是当服务器负载高的时候，你一眼就能看出是 CPU 爆了还是内存炸了，不用再去敲 top 然后眯着眼睛找那一行跳动的数字。而且它顶部有个红条或者是蓝条，系统如果有问题，比如有服务挂了，右上角立马给你亮个感叹号，点进去就能直接看到是哪个服务在作妖。核心功能大起底：它能干啥？光好看没用，咱得看疗效。1. 存储管理：新手的救星，老手的福音这是我最推崇的功能，没有之一。Linux 的存储管理对很多人来说是个噩梦。什么物理卷（PV）、卷组（VG）、逻辑卷（LV），还有文件系统格式化、挂载点……这一套流程下来，老手有时候手滑都能敲错，更别说新手了。在 Cockpit 的“存储”页面，这一切变得异常简单。它能直接识别你插上的新硬盘。你想做个 RAID？点两下鼠标，选 RAID 级别，选盘，搞定。你想扩容一个 LVM？在界面上拖动一下滑块，或者输入个数字，点“扩容”，后台自动给你跑 lvextend 和 xfs_growfs（或者 resize2fs）。我之前有个旧服务器，盘乱七八糟的，我就用 Cockpit 上去理了一遍。它还能读硬盘的 SMART 信息，直接告诉你这块盘是不是快挂了。这比我去装个 smartmontools 然后敲命令看那一堆十六进制代码要直观太多了。我甚至还试过用它做 iSCSI 挂载，也是傻瓜式操作。对于那些不常碰存储配置的开发人员或者兼职运维来说，这个功能简直就是救命稻草。2. 网络配置：告别 vi /etc/sysconfig/network-scripts/...以前配个网桥（Bridge）或者做个双网卡绑定（Bonding），得去目录下面建配置文件，还得小心翼翼地检查 DEVICE 名、UUID 啥的，写错了重启网络服务就失联，还得跑机房接显示器。Cockpit 里配网络，直接利用 NetworkManager 的能力。你想做 Bonding？点击“添加绑定”，选模式（比如 active-backup 或者 802.3ad），勾选那两块网卡，填个 IP，应用。只要你不是手抖把管理口的 IP 设错了，基本都能一次成。它还会给你画出网络流量图，哪个网卡在跑流量一目了然。3. 容器管理：Podman 的最佳拍档现在的 RHEL/CentOS 8 以后，默认容器引擎是 Podman，不是 Docker 了。虽然命令差不多，但很多人还是不习惯。Cockpit 有个 cockpit-podman 的插件（需要单独装一下，yum install cockpit-podman）。装好后，你就能在网页上管理镜像和容器了。你可以直接搜索镜像、拉取、运行。最酷的是，它能帮你把容器的端口映射、环境变量、挂载卷都通过图形化界面配好。有时候我想临时起个 Nginx 测个页面，懒得写 docker-compose 或者是敲那一长串命令，直接在界面上点两下，齐活。它还能监控容器的资源使用情况，CPU、内存吃了多少，日志输出了啥，都能看。虽然比不上 Portainer 那么专业，但作为系统自带的管理工具，完全够用了。4. 那个“万能”的终端你可能会说：“悠悠，我就喜欢敲命令，这界面再好，有些复杂操作还是不行啊。”没关系，Cockpit 下方直接集成了一个全功能的 Web 终端。你点开它，就是一个标准的 Shell 窗口。这有啥用？大有用处！想象一下，你周末在外面浪，突然公司打电话说服务器崩了。你手边没电脑，只有个 iPad 甚至只有个手机。你指望在手机上装个 SSH 客户端敲命令？那输入法能把你逼疯。这时候你打开浏览器，登进 Cockpit，切到终端界面。虽然屏幕小点，但至少能用。我就干过这事，在地铁上用手机浏览器重启了一个卡死的 Java 进程，那种感觉，真的像是黑客附体（虽然姿势有点狼狈）。还有些隐藏的小彩蛋除了上面那些大块头功能，还有些细节做得挺到位。日志查看（Logs）： 它是直接读 journald 的日志。你可以按时间范围、按严重等级（比如只看 Error）、按服务名称来过滤。这比 journalctl -xe 然后在那翻页要爽多了。软件更新： 它能列出系统所有可用的更新包，你可以选择性地更新，还能配置自动更新策略。对于那种几百台机器的批量运维它可能不行，但对于手里攥着几台关键服务器的场景，这点一下很方便。多机管理： 这功能有点鸡肋但也有用。你可以在一台 Cockpit 上把其他开了 Cockpit 的服务器加进来，做成一个简易的控制台。虽然不能批量操作，但切换着看状态还是挺方便的。SELinux 排错： 哎哟这个得提一嘴。SELinux 这玩意儿，懂得都懂，平时报错了都不知道哪拦的。Cockpit 会把 SELinux 的报警归类，有时候还会给出修复建议（虽然我一般建议直接 setenforce 0，哈哈，开玩笑的，生产环境别乱搞）。咱们来聊聊它的局限性吹了半天，也不能把人家捧杀。悠悠我也是个实诚人，这东西不是万能的。首先，它不适合大规模集群管理。你有 1000 台服务器，你不可能一台台登网页去点吧？那是 Ansible、SaltStack、K8s 的活儿。Cockpit 是“单机管理神器”，或者说是“小规模集群的瑞士军刀”。其次，它不能完全替代命令行。很多深层调优、复杂的配置文件修改，还得靠 vi。Cockpit 只能覆盖 80% 到 90% 的日常高频操作。还有就是安全问题。毕竟把管理端口（9090）暴露在公网是很危险的。虽然它走 SSH 认证，但我强烈建议，要么改端口，要么只允许内网访问，或者前面挡个 VPN/堡垒机。千万别直接裸奔在公网，那简直是给黑客送人头。为什么我推荐你去试试？我知道，很多老运维（包括我自己）有一种执念，觉得用 GUI 就是“不专业”。但我现在的逻辑是：工具是为人服务的。如果一个工具能让我少记两个参数，能让我直观地看到磁盘是不是快满了，能让我在手机上也能应急处理故障，那它就是好工具。特别是对于现在很多刚转行做运维，或者开发转运维的朋友，Linux 的门槛确实高。Cockpit 给了你一个缓冲带，让你在熟悉系统架构的同时，不至于被黑窗口劝退。而且，Cockpit 的设计哲学非常克制。它没有试图去创造一套新的配置语法，它只是把系统原有的能力可视化了。这就很高级。它尊重系统，也尊重使用者。我就遇到过好几次，半夜脑子不清楚的时候，对着黑屏幕不敢敲回车。但在图形界面上，看着那个“删除”按钮，你会下意识地多确认一眼，这就是 GUI 的容错价值。总结一下Cockpit 就像是你服务器的“智能仪表盘”。它不会抢你的方向盘，但它能告诉你现在的速度、油量，还能帮你自动泊车。在这个都在追求自动化的时代，保留这么一个精致、好用、随手可得的手动管理入口，其实是一种安全感。不管你是刚入门的小白，还是头发日渐稀疏的大佬，我都建议你在你的服务器上装一个 Cockpit。哪怕你一年只用它一次，那一次可能就是你没带电脑、或者系统挂得只剩 SSH 能连的时候，它能救你的命。别跟工具过不去，让自己舒服点，不丢人。好了，今天的分享就到这儿。如果你也折腾过 Cockpit，或者有其他好用的管理工具，欢迎在评论区跟我唠唠。比如那个 Portainer，或者宝塔面板，咱们可以对比着聊聊。我是悠悠，一个喜欢瞎折腾、说真话的IT老兵（划掉，是永远年轻的运维人）。写文章不易，全靠一口仙气吊着。如果您觉得这篇文对您哪怕有一丁点帮助，或者逗您乐了一下：请关注我的公众号 @运维躬行录动动您发财的小手，点个【在看】和【转发】。您的每一次互动，都是我继续更新、继续掉头发的动力！咱们下期见！
问题背景需要通过Network Load Balancer (NLB) 支持WebSocket连接，但在配置过程中遇到连接失败问题。环境信息协议: WebSocket (OCPP 1.6)后端服务端口: 9521NLB监听端口: 80NLB 创建步骤（AWS 控制台）1. 创建目标组进入 EC2 控制台 → 左侧菜单 目标组 (Target Groups)点击 创建目标组 (Create target group)配置基本信息：目标类型: 实例 (Instances)目标组名称: xxx-nlb-tg协议: TCP端口: 9521 ⚠️ 注意：必须是后端服务实际监听的端口VPC: 选择 vpc协议版本: 默认配置健康检查：健康检查协议: TCP ⚠️ 重要：WebSocket服务器使用TCP检查健康检查间隔: 30秒健康阈值: 3不健康阈值: 3点击 下一步 (Next)注册目标：选择实例端口: 9521点击 包含为待处理项 (Include as pending below)点击 创建目标组 (Create target group)2. 创建 Network Load Balancer进入 EC2 控制台 → 左侧菜单 负载均衡器 (Load Balancers)点击 创建负载均衡器 (Create load balancer)选择 Network Load Balancer → 点击 创建 (Create)基本配置：负载均衡器名称: test-nlb方案: 面向互联网 (Internet-facing)IP地址类型: IPv4网络映射：VPC: 选择 vpc-xxxxxx可用区:✅ us-east-1d → 子网 subnet-xxxx✅ us-east-1e → 子网 subnet-0xxxxxf18监听器和路由：协议: TCP端口: 80默认操作: 转发至目标组 xxxx-tg`点击 创建负载均衡器 (Create load balancer)记录生成的 DNS名称: xxx-4xxxxxeast-1.amazonaws.com3. 启用跨可用区负载均衡 ⚠️ 重要选择刚创建的 NLB xxxx-nlb点击 属性 (Attributes) 标签点击 编辑 (Edit)找到 跨可用区负载均衡 (Cross-zone load balancing)选择 开启 (On)点击 保存更改 (Save changes)说明: 如果后端实例只部署在一个可用区，必须启用此选项，否则会导致50%的连接失败4. 配置 Route53 DNS 解析进入 Route 53 控制台 → 托管区域 (Hosted zones)选择域名 xxxxxx点击 创建记录 (Create record)配置记录：记录名称: xxxx记录类型: CNAME值: xxxxxxx.amazonaws.com`TTL: 300秒路由策略: 简单路由点击 创建记录 (Create records)5. 验证配置进入目标组 xxxnlb-tg` → 目标 (Targets) 标签确认目标状态为 healthy (可能需要等待1-2分钟)如果状态为 initial 或 unhealthy，检查：端口是否正确健康检查协议是否为 TCP实例安全组是否允许端口问题排查过程问题 1: 端口配置错误现象: 目标健康检查失败，WebSocket连接超时原因:目标组配置端口为 9520实际后端服务监听端口为 9521解决方案（控制台操作）:进入 EC2 控制台 → 目标组 → 选择 xxx-tg点击 目标 (Targets) 标签选中错误端口的目标 → 点击 注销 (Deregister)点击 注册目标 (Register targets)选择实例 i-xxxx端口覆盖: 输入 9521点击 包含为待处理项 → 注册待处理目标问题 2: 健康检查失败现象: 目标状态一直为 initial，无法变为 healthy原因:9521端口是纯WebSocket服务器HTTP健康检查路径 /asdsadasg 返回 404响应: 404 WebSocket Upgrade Failure解决方案（控制台操作）: 修改为TCP健康检查进入 EC2 控制台 → 目标组 → 选择 xxxxnlb-tg点击 健康检查 (Health checks) 标签点击 编辑 (Edit)修改配置：健康检查协议: 选择 TCP删除健康检查路径（TCP不需要）点击 保存更改 (Save changes)等待30-60秒，目标状态应变为 healthy验证:在目标组的 目标 (Targets) 标签查看状态状态应显示为 healthy 且带绿色图标问题 3: 跨可用区连接不稳定现象:NLB有两个IP地址 (x.x.x.x, a.a.a.a)其中一个IP连接超时连接成功率约50%原因:NLB部署在2个可用区 (us-east-1d, us-east-1e)后端实例只在 us-east-1d跨可用区负载均衡未启用流量路由到 us-east-1e 时无本地目标解决方案（控制台操作）: 启用跨可用区负载均衡进入 EC2 控制台 → 负载均衡器 → 选择 xxxx-nlb`点击 属性 (Attributes) 标签点击 编辑 (Edit) 按钮找到 跨可用区负载均衡 (Cross-zone load balancing) 部分选择 开启 (On)点击 保存更改 (Save changes)等待1-2分钟配置生效验证:在负载均衡器的 属性 标签确认 "跨可用区负载均衡" 显示为 已启用多次测试连接，成功率应达到100%注意: 跨可用区流量会产生数据传输费用 (~$0.01/GB)问题 4: 域名连接失败 (根本原因)现象:直接使用NLB DNS可以连接: ws://xxxxxx.amazonaws.com/dqwdwqd ✅使用自定义域名连接失败: ws://xxxxxxx/dqwdwqd ❌错误: Connection reset by peer排查过程:# 1. 验证DNS解析正确 nslookup xxxxxxx # 输出: 正确解析到 NLB 的两个IP # 2. 测试直接IP连接（带Host头） curl -H "Host: xxxxxxx" \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: test" \ -H "Sec-WebSocket-Protocol: ocpp1.6" \ http://x.x.x.x/dasdwq # 结果: Connection reset by peer # 3. 测试直接IP连接（不带自定义Host头） curl -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: test" \ -H "Sec-WebSocket-Protocol: ocpp1.6" \ http://x.x.x.x/dasdwq # 结果: HTTP/1.1 101 Web Socket Protocol Handshake ✅根本原因: 后端WebSocket服务器对Host头进行了验证，拒绝了 xxxx 域名的请求解决方案: 修改后端WebSocket服务器配置，将 xxxxx 添加到允许的域名白名单中最终配置总结NLB 配置名称: iot-nlb类型: Network Load Balancer监听器: TCP 80目标组: iot-nlb-tg目标端口: 9521健康检查: TCP (端口9521)跨可用区: 已启用DNS 配置记录: xxxxx类型: CNAME值: xxxxx.elb.us-east-1.amazonaws.comTTL: 300秒WebSocket 连接要求协议: ws:// (端口80)必需请求头: Sec-WebSocket-Protocol: ocpp1.6Host头: 需要后端服务器允许自定义域名测试验证命令1. 检查目标健康状态aws elbv2 describe-target-health \ --target-group-arn xxxxxx \ --region us-east-1 \ --query 'TargetHealthDescriptions[*].[Target.Id,Target.Port,TargetHealth.State]' \ --output table2. 测试WebSocket握手curl -i --max-time 3 \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: test" \ -H "Sec-WebSocket-Protocol: ocpp1.6" \ http://xxxxxx.elb.us-east-1.amazonaws.com/asdasds期望输出: HTTP/1.1 101 Web Socket Protocol Handshake3. 验证DNS解析dig +short xxxxxxx nslookup xxxxxxx4. 检查NLB属性aws elbv2 describe-load-balancer-attributes \ --load-balancer-arn xxxxxxxx \ --region us-east-1 \ --query 'Attributes[?Key==`load_balancing.cross_zone.enabled`]'常见问题与解决方案Q1: WebSocket连接超时检查清单:目标健康状态是否为 healthy安全组是否允许目标端口入站流量后端服务是否正常运行端口配置是否正确Q2: 连接成功率不稳定可能原因:跨可用区负载均衡未启用目标实例分布不均解决: 启用跨可用区负载均衡或在所有可用区部署实例Q3: 域名无法连接但直接DNS可以排查步骤:验证DNS解析是否正确清除本地DNS缓存检查后端服务Host头验证逻辑测试带/不带Host头的连接差异Q4: 健康检查失败WebSocket服务器特殊处理:纯WebSocket服务器可能不支持HTTP健康检查使用TCP健康检查替代或在后端实现专门的健康检查端点总结本次排查解决了以下关键问题:✅ 端口配置错误 (9520 → 9521)✅ 健康检查协议不匹配 (HTTP → TCP)✅ 跨可用区负载均衡未启用✅ 后端服务Host头验证限制最终状态: NLB配置正常，WebSocket连接稳定，需要后端服务添加域名白名单支持自定义域名访问。
故障概述故障时间: 2025年11月28日 15:12 - 15:27故障服务器: x.x.x.x故障现象: 无法通过 SSH 密钥认证登录 ec2-user 账户错误信息: Permission denied (publickey,gssapi-keyex,gssapi-with-mic)故障排查过程第一阶段：私钥文件权限问题 (15:12-15:14)初始错误:WARNING: UNPROTECTED PRIVATE KEY FILE! Permissions 0644 for 'xxxxx.pem' are too open.问题分析:私钥文件权限为 0644（所有用户可读）SSH 安全机制要求私钥文件仅所有者可访问解决措施:chmod 400 /home/test/xxxx.pem结果: 权限警告消失，但认证仍然失败第二阶段：密钥匹配性验证 (15:15-15:18)排查步骤:确认正确的私钥文件路径验证私钥与服务器公钥是否匹配验证命令:# 本地生成公钥指纹 ssh-keygen -y -f /home/test/xxxx.pem # 服务器端查看授权公钥 cat /home/ec2-user/.ssh/authorized_keys验证结果: 公钥完全匹配，确认私钥正确第三阶段：服务器端配置问题定位 (15:18-15:25)关键发现:通过查看服务器 SSH 日志 (/var/log/secure)，发现两个关键错误：目录权限错误:Authentication refused: bad ownership or modes for directory /home/ec2-user密钥算法不支持:userauth_pubkey: key type ssh-rsa not in PubkeyAcceptedAlgorithms [preauth]根本原因分析原因1：用户主目录权限配置错误问题详情:/home/ec2-user 目录的所有权或权限不符合 SSH 安全要求SSH 要求用户主目录及 .ssh 目录必须有严格的权限控制SSH 权限要求:用户主目录: 755 权限，所有者为用户本人.ssh 目录: 700 权限，所有者为用户本人authorized_keys 文件: 600 权限，所有者为用户本人原因2：SSH 服务器禁用了 RSA 算法问题详情:服务器 SSH 配置中未包含 ssh-rsa 算法客户端使用的是 RSA 类型的密钥新版本 OpenSSH (8.8+) 默认禁用了 SHA-1 相关的 RSA 算法解决方案完整修复命令在服务器端以 root 用户执行：# 1. 修复 ec2-user 主目录权限 chown ec2-user:ec2-user /home/ec2-user chmod 755 /home/ec2-user # 2. 修复 .ssh 目录权限 chmod 700 /home/ec2-user/.ssh chmod 600 /home/ec2-user/.ssh/authorized_keys chown -R ec2-user:ec2-user /home/ec2-user/.ssh # 3. 启用 ssh-rsa 算法支持 echo "PubkeyAcceptedAlgorithms +ssh-rsa" >> /etc/ssh/sshd_config # 4. 重启 SSH 服务 systemctl restart sshd验证结果修复后成功连接：ssh -o IdentitiesOnly=yes -i xxxx.pem ec2-user@x.x.x.x连接成功，问题完全解决。经验总结关键排查方法客户端调试: 使用 ssh -v 参数查看详细连接过程服务器日志: 检查 /var/log/secure 或 journalctl -u sshd 获取服务器端错误权限验证: 系统性检查文件和目录权限密钥验证: 使用 ssh-keygen -y 验证密钥对匹配性常见 SSH 认证失败原因问题类型症状解决方法私钥权限过开放UNPROTECTED PRIVATE KEY FILEchmod 400 私钥文件主目录权限错误bad ownership or modeschmod 755 ~ + chown user:user ~.ssh 目录权限错误Authentication refusedchmod 700 ~/.sshauthorized_keys 权限错误认证失败无明确提示chmod 600 ~/.ssh/authorized_keys密钥算法不支持key type not in PubkeyAcceptedAlgorithms修改 sshd_config 启用算法密钥不匹配认证失败验证并更换正确的密钥对预防措施定期备份密钥: 避免密钥丢失导致无法访问使用现代密钥算法: 推荐使用 ED25519 而非 RSA保持权限正确: 使用自动化脚本定期检查关键文件权限启用多种认证方式: 配置密码认证作为备用方案监控 SSH 日志: 及时发现异常登录尝试安全建议修复完成后，建议生成新的 ED25519 密钥对：ssh-keygen -t ed25519 -C "your_email@example.com"考虑移除 PubkeyAcceptedAlgorithms +ssh-rsa 配置，改用更安全的密钥类型定期审计 authorized_keys 文件，移除不再使用的公钥附录：相关配置文件SSH 客户端配置 (~/.ssh/config)Host aliyun-server HostName x.x.x.x Port xx User ec2-user IdentityFile xxxxxx.pem IdentitiesOnly yesSSH 服务器关键配置 (/etc/ssh/sshd_config)PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys PubkeyAcceptedAlgorithms +ssh-rsa StrictModes yes报告生成时间: 2025年11月28日 15:27 故障解决时长: 15分钟 故障级别: P2 (服务不可用) 处理状态: 已解决
故障概述故障时间： 2025-11-29 08:55 - 09:09 故障系统： Zabbix 7.4 监控系统 故障现象： Zabbix 图表无法显示坐标轴和图例，仅显示绿色折线图 影响范围： 所有监控图表的可读性受到严重影响 故障等级： 中等（功能性故障，不影响数据采集）故障表现视觉症状图表中只显示绿色折线和网格背景X轴（时间轴）完全缺失Y轴（数值轴）完全缺失图例信息不显示图表标题和说明文字缺失用户影响无法识别监控指标的具体数值无法确定时间范围无法区分多条监控曲线图表失去实际使用价值故障诊断过程第一阶段：初步排查（08:55-09:00）1. 系统日志检查# Zabbix Server 日志 tail -f /var/log/zabbix/zabbix_server.log结果： 仅显示正常的 housekeeper 清理任务，无异常错误2. PHP-FPM 日志检查tail -f /var/log/php-fpm/error.log结果： 显示正常的进程重启，无明显错误3. 字体文件检查ls -la /usr/share/zabbix/assets/fonts/发现：simhei.ttf (9.7 MB) - 存在simfang.ttf (10.5 MB) - 存在NotoSansCJK-Regular.ttc (19.9 MB) - 存在文件权限正常：-rw-r--r--初步结论： 字体文件存在且权限正常，问题不在文件本身第二阶段：深度分析（09:00-09:06）4. Web服务器错误日志分析tail -30 /var/log/nginx/error.log关键发现： 大量 PHP 错误信息PHP Warning: imagettfbbox(): Could not find/open font in /usr/share/zabbix/ui/include/graphs.inc.php on line 533 PHP Warning: imagettftext(): Could not find/open font in /usr/share/zabbix/ui/include/graphs.inc.php on line 486错误频率： 每次加载图表产生数百条相同错误 错误位置： graphs.inc.php 的字体渲染函数5. Zabbix 配置文件检查cat /usr/share/zabbix/ui/include/defines.inc.php | grep -i font配置内容：define('ZBX_FONTPATH', realpath('assets/fonts')); define('ZBX_GRAPH_FONT_NAME', 'simhei'); define('ZBX_FONT_NAME', 'simhei');问题识别： ZBX_FONTPATH 使用了相对路径 realpath('assets/fonts')6. 字体加载机制分析sed -n '480,490p' /usr/share/zabbix/ui/include/graphs.inc.php代码逻辑：$ttf = ZBX_FONTPATH.'/'.ZBX_FONT_NAME.'.ttf'; imagettftext($image, $fontsize, $angle, $x, $y, $color, $ttf, $string);拼接结果： realpath('assets/fonts') + '/' + 'simhei' + '.ttf'第三阶段：根因定位（09:06）核心问题分析问题1：相对路径解析失败realpath('assets/fonts') 在 PHP-FPM 环境下无法正确解析PHP-FPM 的工作目录与 Nginx 的文档根目录不一致导致相对路径返回 false 或空字符串问题2：字体路径拼接错误当 ZBX_FONTPATH 解析失败时最终路径变成：/simhei.ttf 或 simhei.ttfPHP GD 库无法找到字体文件问题3：错误处理缺失Zabbix 代码未对字体加载失败进行有效处理仅产生 PHP Warning，不中断图表生成导致图表显示不完整但不报致命错误解决方案修复步骤步骤1：修改字体路径为绝对路径sudo sed -i "s|define('ZBX_FONTPATH',.*|define('ZBX_FONTPATH', '/usr/share/zabbix/assets/fonts'); // where to search for font|" /usr/share/zabbix/ui/include/defines.inc.php修改前：define('ZBX_FONTPATH', realpath('assets/fonts'));修改后：define('ZBX_FONTPATH', '/usr/share/zabbix/assets/fonts');步骤2：验证字体名称配置sudo grep 'ZBX_FONT' /usr/share/zabbix/ui/include/defines.inc.php | head -3确认配置：define('ZBX_FONTPATH', '/usr/share/zabbix/assets/fonts'); define('ZBX_GRAPH_FONT_NAME', 'simhei'); define('ZBX_FONT_NAME', 'simhei');步骤3：清除 PHP 缓存sudo rm -rf /var/lib/php/opcache/* sudo rm -rf /var/lib/php/session/*原因： PHP OPcache 会缓存已编译的配置文件，必须清除步骤4：重启服务sudo systemctl restart php-fpm sudo systemctl restart nginx步骤5：验证修复刷新浏览器（Ctrl+F5 强制刷新）检查图表是否正常显示坐标轴和图例查看 Nginx 错误日志确认无新错误技术细节字体加载流程1. Zabbix 图表生成请求 ↓ 2. graphs.inc.php 调用 imageText() 函数 ↓ 3. 拼接字体路径：ZBX_FONTPATH + '/' + ZBX_FONT_NAME + '.ttf' ↓ 4. 调用 PHP GD 库函数：imagettftext() ↓ 5. GD 库尝试打开字体文件 ↓ 6. 成功：渲染文字 | 失败：产生 Warning，跳过文字渲染相对路径失败原因PHP-FPM 环境特性：PHP-FPM 以独立进程运行，工作目录为 /Nginx 将请求转发给 PHP-FPM 时，不传递文档根目录上下文realpath() 函数基于当前工作目录解析相对路径在 / 目录下查找 assets/fonts 必然失败对比 Apache + mod_php：Apache mod_php 的工作目录是网站根目录相对路径可以正常解析这解释了为什么某些环境下配置正常工作为什么图表仍能显示折线GD 图像库的容错机制：imagettftext() 失败时仅产生 Warning不影响其他绘图操作（线条、背景、网格）导致图表"部分正常"的假象预防措施1. 配置规范化建议： 所有路径配置使用绝对路径// 推荐 define('ZBX_FONTPATH', '/usr/share/zabbix/assets/fonts'); // 不推荐 define('ZBX_FONTPATH', realpath('assets/fonts'));2. 监控告警配置建议： 配置 PHP 错误日志监控# 添加到 Zabbix 监控项 log[/var/log/nginx/error.log,"imagettfbox|imagettftext",,,skip,\O]3. 部署检查清单[ ] 验证字体文件存在且可读[ ] 验证字体路径配置为绝对路径[ ] 测试图表显示完整性[ ] 检查 PHP 错误日志无字体相关警告[ ] 清除 PHP OPcache 缓存4. 文档更新建议： 更新部署文档，明确说明：PHP-FPM 环境必须使用绝对路径字体配置修改后必须重启 PHP-FPM必须清除 OPcache 缓存经验总结成功因素系统化排查： 从日志到配置到代码逐层深入代码分析： 查看实际字体加载代码，理解拼接逻辑环境理解： 认识到 PHP-FPM 与 Apache mod_php 的差异完整修复： 不仅修改配置，还清除缓存并重启服务关键教训相对路径风险： 在不同运行环境下行为不一致错误级别误导： Warning 级别错误也可能导致严重功能缺失缓存影响： 配置修改后必须清除相关缓存测试重要性： 部署后应立即验证所有功能模块可复用方案此问题的解决方案适用于：所有使用 PHP-FPM 的 Zabbix 部署其他使用 PHP GD 库的应用字体问题任何涉及相对路径配置的 PHP 应用附录A. 相关文件路径配置文件：/usr/share/zabbix/ui/include/defines.inc.php 代码文件：/usr/share/zabbix/ui/include/graphs.inc.php 字体目录：/usr/share/zabbix/assets/fonts/ 错误日志：/var/log/nginx/error.log PHP缓存：/var/lib/php/opcache/B. 验证命令# 检查字体文件 ls -la /usr/share/zabbix/assets/fonts/simhei.ttf # 检查配置 grep ZBX_FONT /usr/share/zabbix/ui/include/defines.inc.php # 检查错误日志 tail -f /var/log/nginx/error.log | grep font # 测试字体路径 php -r "echo '/usr/share/zabbix/assets/fonts' . '/' . 'simhei' . '.ttf';"C. 服务器信息操作系统：Linux Web服务器：Nginx PHP处理：PHP-FPM Zabbix版本：7.4报告结论故障原因： Zabbix 字体路径配置使用相对路径，在 PHP-FPM 环境下无法正确解析修复方法： 将 ZBX_FONTPATH 改为绝对路径 /usr/share/zabbix/assets/fonts修复结果： 图表完全恢复正常，坐标轴和图例正确显示修复时长： 30分钟（从故障发现到完全解决）后续建议：将此修复纳入标准部署流程对其他 Zabbix 服务器进行预防性检查建立 PHP 错误日志监控告警报告编制： YOUYOU 报告时间： 2025-11-29 09:09 报告版本： v1.0
听说你觉得VPN简单？手撸一遍IPsec，咱俩再聊聊人生
前两天有个刚做网络的小兄弟问我：“现在不都流行SSL VPN或者WireGuard了吗？IPsec这种‘老古董’是不是没必要学了？”我当时差点一口老血喷屏幕上。兄弟，你这就好比在说，现在开自动挡的车多了，手动挡的原理就不重要了？IPsec这玩意儿，就像是网络世界的“重型装甲车”，它是笨重，配置是繁琐，但在Site-to-Site（站点对站点）的连接上，它依然是无可撼动的霸主。你看看现在的公有云，AWS也好，阿里云也罢，跟IDC做专线互联备份的时候，哪个推荐的不是IPsec？别看WireGuard现在火得一塌糊涂，真到了复杂的企业级环境，还得是IPsec坐镇。今天咱们就来扒一扒IPsec的底裤，顺便教大家用开源神器StrongSwan搭建一套企业级的VPN。为什么IPsec让人头大？咱们先不谈配置，先聊聊为什么这玩意儿难懂。绝大多数人晕菜，是因为被IKE、ISAKMP、ESP、AH、Phase 1、Phase 2这些缩写给绕晕了。其实，你把IPsec想象成“押镖”。你要把一车黄金（数据包）从北京运到上海。路上有土匪（黑客），你不能直接运。第一种方式：AH（Authentication Header）这就像是在车厢上贴了个封条，上面盖了皇帝的印（签名）。到了上海，一看封条没坏，就知道东西没被换过，也是朝廷发出来的。但是！ 封条只能保证东西没被换，它不加密。黄金还是黄金，土匪在路边能看个精光。而且，最坑爹的是，AH会对整个IP头做签名。大家都知道现在网络到处都是NAT（地址转换），路由器一改IP地址，签名就这就对不上了，封条就“碎”了。所以，在现在的网络环境里，AH基本就是个废柴，很少用。第二种方式：ESP（Encapsulating Security Payload）这个厉害了。它不仅贴封条，还把黄金装进了保险箱（加密）。土匪截了车，只能看到一个铁箱子，里面是啥完全不知道。ESP最骚的操作是“隧道模式”。它不光把数据加密，它还把原来的车牌（原IP头）也给拆下来扔进保险箱，然后在外面挂个新车牌（新IP头）。这就是为什么我们在隧道里抓包，只能看到两个网关之间的公网IP在通信，完全看不到内网IP。那个让人抓狂的协商过程：IKE真正让人掉头发的，是IKE（Internet Key Exchange）。你想啊，两个网关（路由器或服务器）素不相识，怎么建立信任？怎么商量用什么密码？直接在公网上喊：“喂！密码是123456！”那不找死吗？所以得谈判。这个谈判过程就是IKE。第一阶段（Phase 1）：建立管理连接这就像两个外交官见面，先得在一个绝对安全的屋子里坐下来。他们要确认对方身份（Pre-Shared Key预共享密钥，或者证书），然后商量：“咱俩待会怎么聊天？用什么加密算法？”这一步要是通了，就建立了一个叫ISAKMP SA的东西。你可以理解为，外交官的保密电话接通了。这里有个大坑。Phase 1有两种模式：主模式（Main Mode）和野蛮模式（Aggressive Mode）。名字听着挺唬人。主模式慢，一来一回6个包，但是安全，它会隐藏身份信息。野蛮模式快，3个包搞定，但是它会把身份ID明文广播出去。以前为了快，很多老设备喜欢用野蛮模式，现在为了安全，大家基本都切回主模式了。第二阶段（Phase 2）：建立数据连接外交官电话通了（Phase 1成功），现在开始聊正事：怎么运黄金。这时候商量的才是真正用来加密用户数据的密钥（IPsec SA）。这就是所谓的“快速模式”（Quick Mode）。这也是为什么你看日志的时候，经常看到Phase 1过了，Phase 2报错。通常是因为两边的“感兴趣流”（Subnet，也就是你要通的内网网段）没对上。左边说我要通192.168.1.0/24，右边配置写的是192.168.2.0/24，聊不到一块去，崩了。NAT-T：穿越NAT的救命稻草刚才说了，AH过不了NAT。其实ESP过NAT也费劲。因为NAT路由器会修改端口号，而IPsec设计之初是基于IP层的，没端口号这概念（ESP协议号是50）。为了解决这个问题，聪明的工程师搞了个NAT-T（NAT Traversal）。简单说，就是把ESP数据包再包一层UDP头部，通常是用UDP 4500端口。这样NAT路由器一看：“哦，是UDP啊，改端口我会！”于是，IPsec就能在NAT环境里活下来了。敲黑板： 如果你的VPN服务器在防火墙后面，记得防火墙除了开UDP 500（IKE协商用），还得开UDP 4500（NAT穿透用）。好多人配半天不通，就是因为4500端口没开。实战：用StrongSwan搭建从阿里云到本地IDC的隧道理论扯多了容易困，咱们来点实操。Linux下做IPsec，以前有Openswan、Libreswan，但现在StrongSwan是绝对的一哥。它对IKEv2的支持最好，配置也相对“阳间”一点（虽然也没好到哪去）。假设场景：阿里云（左边 Left）：公网IP：1.1.1.1内网段：172.16.0.0/16本地IDC（右边 Right）：公网IP：2.2.2.2内网段：192.168.10.0/24系统咱们就用CentOS 7或者Ubuntu 20.04，都差不多。1. 安装StrongSwan这就不用我教了吧？yum install strongswan 或者 apt install strongswan。2. 开启内核转发（这点最容易忘！）很多人配好了VPN，发现ping不通内网，查了一圈发现是Linux没开启路由转发。编辑 /etc/sysctl.conf：net.ipv4.ip_forward = 1 # 如果有IPv6也顺手开了 net.ipv6.conf.all.forwarding = 1执行 sysctl -p 生效。3. 配置 ipsec.conf这是核心配置文件。虽然StrongSwan出了新的 swanctl.conf 格式，但为了照顾老玩家的习惯，咱们还是用经典的 ipsec.conf。阿里云那台机器（1.1.1.1）的配置 /etc/strongswan/ipsec.conf：config setup # 开启调试日志，排错的时候很有用，平时可以关了 charondebug="ike 2, knl 2, cfg 2" uniqueids=no conn ali-to-idc authby=secret # 使用预共享密钥认证 auto=start # 服务启动时自动连接 type=tunnel # 隧道模式 keyexchange=ikev2 # 强烈建议用IKEv2，比v1快且稳 # 左边（本机/阿里云）配置 left=1.1.1.1 # 本机公网IP leftid=1.1.1.1 # 标识ID，通常和IP一样 leftsubnet=172.16.0.0/16 # 本机要暴露给对端的内网段 # 右边（远端/IDC）配置 right=2.2.2.2 rightid=2.2.2.2 rightsubnet=192.168.10.0/24 # 加密算法（这个得两边商量好，必须一模一样！） ike=aes256-sha256-modp1024! esp=aes256-sha256! # 存活检测 dpddelay=30s dpdtimeout=120s dpdaction=restart本地IDC那台机器的配置，基本就是反过来。把 left 换成 2.2.2.2，right 换成 1.1.1.1，subnet也对调一下。这里有个细节：left 不一定非得写公网IP。如果你的服务器是在NAT后面（比如AWS EC2，或者家里路由器后面的虚拟机），left 其实应该写内网IP，然后 leftid 写公网IP。这块逻辑如果没理顺，StrongSwan根本起不来。4. 配置密码 ipsec.secrets这个文件简单，就是存密码的。两边得一样。# 格式：IP1 IP2 : PSK "你的超强密码" 1.1.1.1 2.2.2.2 : PSK "YoyouIsHandsome2025"记得这个文件权限要设成600，不然StrongSwan会报错说不安全。5. 启动与排错启动：systemctl start strongswan查看状态：strongswan statusall如果一切顺利，你会看到类似 Security Associations (1 up, 0 connecting) 的字样，那就是通了！要是通不了怎么办？（运维的日常崩溃）这时候就显出真本事了。要是状态里显示 CONNECTING 然后一直卡着，或者直接 0 up，千万别慌。第一招：看日志。tail -f /var/log/syslog 或者 /var/log/messages。如果你看到 NO_PROPOSAL_CHOSEN，这绝对是两边加密算法没对齐。比如一边配了AES128，另一边是AES256；或者一边是SHA1，另一边是SHA256。仔细检查 ike= 和 esp= 这两行。第二招：检查防火墙。再强调一遍，UDP 500 和 UDP 4500。还有，云厂商的安全组！阿里云的安全组入方向开了吗？第三招：MSS（最大报文段长度）问题。这是个隐形杀手。VPN加上了ESP头，包变大了。如果原来是1500字节，加上头就超了，会被分片或者丢弃。现象就是：ping是通的，但SSH连不上，或者网页打不开。这时候需要在防火墙（iptables）里做个手脚，把MSS改小点：iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu或者暴力点，直接设成1300：iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1300这招下去，90%的“怪病”都能好。总结说实话，配置IPsec VPN是一件挺枯燥的事，配置文件里哪怕多了一个空格，或者少了一个感叹号，都能让你查半天。但这就是运维的价值所在啊。当那条隧道打通，两边内网的数据像流水一样无缝传输的时候，那种成就感，真的是WireGuard这种“傻瓜式”工具给不了的（虽然我也很爱WireGuard，哈哈）。而且，搞懂了IPsec，你再去看SD-WAN，看云专线，其实底层逻辑都是通的。我知道这篇内容有点硬，可能大家得消化一会儿。建议收藏起来，下次被VPN折磨的时候，拿出来对一对配置，说不定就救了你的命。技术这东西，没有什么捷径，就是一遍遍试错试出来的。如果你在配置过程中遇到了奇葩的报错，或者对加密算法的选择有疑问，欢迎在评论区留言，咱一起探讨探讨。最后，码字不易，如果这篇文章帮你在深夜少掉了一根头发，记得点个在看和转发，支持一下！如果这篇文章对你有帮助，别忘了点赞转发支持一下！想了解更多运维实战经验和技术干货，记得关注微信公众号@运维躬行录，领取学习大礼包！！！我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习，共同进步！公众号：运维躬行录个人博客：躬行笔记