多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

多服务器部署日志排查:在Logback中自动打印服务器IP

多服务器部署日志排查:在Logback中自动打印服务器IP 多服务器部署的时候排查日志是最让人头疼的事。尤其是微服务架构铺开之后同一个接口在几百台机器上跑出问题了你从几十个日志文件里翻都翻不过来。更气人的是如果代码里没打印服务器信息你还得先去查这台请求到底落在哪台机器上再单独上去看日志来回折腾半小时起步。我在项目里踩了无数次这个坑之后强制要求所有服务上线前必须把服务器IP打进日志里在海量日志里一眼就能锁定是哪台机器。把实现方式整理一下核心就是一个工具类加一个日志配置花十分钟就能全部搞定但省下来的排查时间是真的值。1. 需求拆解与方案选型思路1.1 多服务器日志排查的痛点先说一个我实际经历的场景。有一套跑在十几台服务器上的订单系统某天业务反馈某些用户下单偶尔超时但看了一段日志发现异常请求分散在各个时间段里完全找不到规律。当时日志里没有任何机器标识我只能先通过网关日志和时间戳反推每个异常请求落在哪台机器上再登录上去抓那一个时间点的日志一整天就交代在这上面了。后来我把所有日志行前都加上了IP同样的场景再出现直接grep ERROR一眼就能看到是某几台特定IP的机器问题再上去排查就快多了。如果你维护的服务数量超过三台或者用了负载均衡和集群日志里没有机器信息排查效率至少打个五折。另外一个容易被忽略的问题是日志归集。现在很多团队用ELK或者Loki做集中式日志日志从几十台机器汇聚到一个平台里如果原始日志里没有IP字段你在搜索框里都不知道该怎么过滤。有IP字段之后无论是按机器筛选还是按时间范围聚合都顺畅很多。1.2 可行方案对比分析在Java项目里给日志加上服务器标识通常有几种实现方式按推荐程度排一下方案实现难度适用场景不足日志Pattern中添加自定义Converter低所有Logback项目需要写少量代码MDC方式注入IP低已有MDC体系的项目需要在入口处初始化启动参数-D指定极低部署脚本可控运维依赖性强容易漏配应用名IP拼接低需要区分应用维度多实例效果有限对比下来我个人最推荐的是自定义Converter方案因为它对业务代码零侵入日志框架在初始化时自动注入IP信息部署之后不用额外配置什么也不会因为运维漏传参数而丢失关键信息是最稳的做法。启动参数-D虽然最简单但依赖部署脚本对每台机器传入正确的参数一旦某台机器的脚本没同步日志里就无声无息丢了机器信息反而更容易出问题。真正上线前本地验证版和有容器化环境的项目我后面会给出更完善的方案。2. 服务器IP获取的完整实现2.1 Java获取本机IP的几种方式获取本机IP在Java里看起来简单但实际坑很多。最常见的InetAddress.getLocalHost()在大部分场景下能返回一个IP但存在两个隐患一是机器配置了主机名解析到127.0.0.1时获取到的是回环地址不是对外IP二是云服务器和容器环境下getLocalHost()经常返回不可用的地址。更稳的方式是遍历本机所有网卡接口过滤掉回环地址、虚拟网卡地址和链路本地地址找到真正能对外提供服务的IP。实现思路不复杂代码也不长。2.2 完整工具类代码直接贴这个工具类我在生产环境用了一年多做过多次优化。import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.List; public class ServerIpUtil { private static volatile String cachedIp; private ServerIpUtil() {} /** * 获取服务器IP优先返回非回环地址的IPv4地址 */ public static String getServerIp() { if (cachedIp ! null) { return cachedIp; } synchronized (ServerIpUtil.class) { if (cachedIp null) { cachedIp resolveIp(); } } return cachedIp; } private static String resolveIp() { ListString candidateIps new ArrayList(); try { EnumerationNetworkInterface interfaces NetworkInterface.getNetworkInterfaces(); if (interfaces null) { return getLocalHostIpFallback(); } while (interfaces.hasMoreElements()) { NetworkInterface networkInterface interfaces.nextElement(); // 过滤掉down状态的网卡启动阶段有些网卡会处于未就绪状态 if (!networkInterface.isUp()) { continue; } EnumerationInetAddress addresses networkInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress address addresses.nextElement(); // 只取IPv4过滤回环地址和链路本地地址 if (address instanceof Inet4Address !address.isLoopbackAddress() !address.isLinkLocalAddress()) { candidateIps.add(address.getHostAddress()); } } } } catch (SocketException e) { return getLocalHostIpFallback(); } if (candidateIps.isEmpty()) { return getLocalHostIpFallback(); } // 如果有多张网卡优先选择常见的私网网段。 for (String ip : candidateIps) { if (ip.startsWith(192.168.) || ip.startsWith(10.) || ip.startsWith(172.) || ip.startsWith(11.)) { return ip; } } // 返回第一个候选地址 return candidateIps.get(0); } private static String getLocalHostIpFallback() { try { InetAddress localHost InetAddress.getLocalHost(); return localHost.getHostAddress(); } catch (Exception e) { return unknown-ip; } } }关于这段代码有几处细节需要解释清楚很多人第一次写容易在这些地方踩坑。我在遍历网卡时加了isUp()过滤这个是经验之谈。容器环境或者机器刚启动的时候部分网卡可能会处于未就绪状态这时候去获取IP地址要么拿到空的要么拿到一个还没配置好的地址加这个过滤能避免日志里打出奇怪IP的情况。过滤isLinkLocalAddress()也是实际中遇到过的问题。某些云服务器会自动生成169.254.x.x这样的链路本地地址拿这个地址发起请求不通打出来的日志也没法定位问题。过滤掉之后才能拿到真实可用的IP。2.3 容器与云原生环境的特殊处理如果你的服务跑在Docker容器里上面的方式需要做一点调整。默认桥接模式下容器内看到的大多是172.17.0.x这类地址这个IP在容器外部根本无法用来访问所以打到日志里意义不大。这种情况建议通过环境变量注入宿主机IP部署时在容器启动命令里加上-e HOST_IP宿主机实际IP然后在Java代码里优先读取环境变量。public static String getServerIp() { // 优先读取环境变量HOST_IP String hostIp System.getenv(HOST_IP); if (hostIp ! null !hostIp.isEmpty()) { return hostIp; } // 其次尝试读取系统属性 String propIp System.getProperty(server.ip); if (propIp ! null !propIp.isEmpty()) { return propIp; } return resolveIp(); }Kubernetes环境思路类似Pod是通过节点IP访问的建议把节点IP通过环境变量注入进去或者直接用status.podIP配合downwardAPI注入容器。早期我图省事直接用容器内网卡获取的IP结果排查时发现日志里全是172.16.x.x用户实际访问的却是另一个网段白折腾一趟。3. 日志框架集成Logback实战3.1 自定义Converter完整实现获取IP的工具类写完之后接下来要接入日志框架。以Logback为例核心思路是写一个继承ClassicConverter的类然后注册到Pattern里。import ch.qos.logback.classic.pattern.ClassicConverter; import ch.qos.logback.classic.spi.ILoggingEvent; public class ServerIpConverter extends ClassicConverter { private static volatile String cachedIp; Override public String convert(ILoggingEvent event) { if (cachedIp null) { synchronized (ServerIpConverter.class) { if (cachedIp null) { cachedIp ServerIpUtil.getServerIp(); } } } return cachedIp; } }Converter设计成首次调用时初始化IP并缓存之后每次打印日志直接返回缓存值性能开销几乎为零。这里用双重检查锁保证并发安全也避免重复获取IP的IO开销。注册方式在logback.xml里配置?xml version1.0 encodingUTF-8? configuration !-- 注册自定义Converter -- conversionRule conversionWordserverIp converterClasscom.example.log.ServerIpConverter / !-- 控制台输出 -- appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%serverIp] %-5level %logger{36} - %msg%n /pattern /encoder /appender !-- 文件输出 -- appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/logs/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/logs/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%serverIp] %-5level %logger{36} - %msg%n /pattern /encoder /appender root levelINFO appender-ref refCONSOLE / appender-ref refFILE / /root /configuration核心就是在pattern里加[%serverIp]这一段conversionWord指定了我们写的Converter类。这样每一行日志都会带上IP中间用方括号包一下既醒目又方便后续用文本工具提取。日志样例效果2025-01-15 10:23:45.678 [http-nio-8080-exec-3] [192.168.10.12] INFO com.example.OrderService - receive order: ORDER-1001带上IP之后再看日志一眼就能分辨这行日志来自哪台机器不用再猜了。3.2 MDC方式的使用场景自定义Converter适合全局统一加IP。还有些场景希望更灵活比如只能在某个请求链路里展示IP或者希望IP自动跟随请求上下文变化这时候用MDC更合适。MDC的用法也不复杂在请求入口处初始化然后日志成功后移除。Filter的实现大概是这个结构import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; import org.slf4j.MDC; WebFilter(urlPatterns /*) public class IpMdcFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { try { MDC.put(serverIp, ServerIpUtil.getServerIp()); chain.doFilter(request, response); } finally { MDC.remove(serverIp); } } }配置里用%X{serverIp}引用MDC中的值pattern %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{serverIp}] %-5level %logger{36} - %msg%n /pattern两种方案的使用场景不同。如果所有服务都是统一入口Filter方式足够用但如果你有定时任务、MQ消费等不在Web请求链路里的日志就会漏掉IP这时候全局Converter方案更保险。所以我建议核心项目直接用ConverterMDC方式留给有特殊需求的场景。3.3 Log4j2适配方案用Log4j2的项目稍微有点不同需要继承LogEventPatternConverter。核心代码是这样的import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.config.plugins.Plugin; import org.apache.logging.log4j.core.pattern.ConverterKeys; import org.apache.logging.log4j.core.pattern.LogEventPatternConverter; Plugin(name ServerIp, category Converter) ConverterKeys({serverIp}) public class ServerIpLog4j2Converter extends LogEventPatternConverter { private static final ServerIpLog4j2Converter INSTANCE new ServerIpLog4j2Converter(); private ServerIpLog4j2Converter() { super(ServerIp, serverIp); } public static ServerIpLog4j2Converter newInstance(String[] options) { return INSTANCE; } Override public void format(LogEvent event, StringBuilder toAppendTo) { toAppendTo.append(ServerIpUtil.getServerIp()); } }在log4j2.xml里配置PatternLayout pattern%d{HH:mm:ss.SSS} [%t] [%serverIp] %-5level %logger{36} - %msg%n需要注意Log4j2的插件注册要求类必须加上Plugin注解否则运行时不识别。新版本的Log4j2还需要配置文件里加上plugin包的扫描路径。4. 生产环境实操经验与性能考量4.1 Spring Boot项目完整接入步骤如果你的项目用Spring Boot整体接入流程非常简单按下面几步操作即可。第一步把ServerIpUtil和ServerIpConverter这两个类放进项目的公共模块或者util包里。第二步在src/main/resources/logback-spring.xml里加上conversionRule注册和pattern配置注意Spring Boot默认是加载logback-spring.xml不是logback.xml。第三步重启应用确认日志里出现了IP字段。有个容易踩的坑需要提醒Spring Boot 2.x以上版本如果同时存在logback.xml和logback-spring.xml框架会优先加载logback.xml导致自定义配置不生效。删掉多余的旧文件或者干脆直接用logback-spring.xml可以避免这种问题。顺带推荐一个配套习惯在应用启动的时候把IP和端口打一条日志出来。Slf4j Component public class StartupLogger implements ApplicationRunner { Value(${server.port:8080}) private String port; Override public void run(ApplicationArguments args) { log.info(Application started, serverIp{}, port{}, env{}, ServerIpUtil.getServerIp(), port, System.getenv(SPRING_PROFILES_ACTIVE)); } }这样每次部署完直接在日志平台搜索启动日志就能确认服务注册上去的IP和预期是否一致。4.2 性能损耗与缓存策略关于性能先给结论加了IP打印后对日志性能的影响可以忽略不计。原因很简单IP在程序生命周期内基本不变所以我们在Converter里做了缓存第一次打印日志时获取一次后续直接拿缓存值。真正有性能开销的地方在于每次日志拼接字符串本身但这个开销不管加不加IP都会存在增量部分就是一个字符串引用微乎其微。需要特别注意的是不要在循环体内动态获取IP。有些新手为了省事直接在循环里调用InetAddress.getLocalHost()每个循环都去解析一次一个热点循环几千次下来解析延迟直接拖垮接口性能。如果将ServerIpUtil.getServerIp()放在一个高并发的大循环里建议用static final常量在类加载时初始化或者用我们上面工具类里的双重检查锁做缓存效果一样。4.3 多网卡环境下的IP策略服务器上多张网卡是很常见的情况比如既有内网网卡又有外网网卡或者配了Docker之后多出虚拟网卡。如果不做过滤获取到的IP可能不是你想要的。我们在解析逻辑里做了几个决策过滤掉回环地址和链路本地地址保证不取到127.0.0.1和169.254.x.x如果还有多个候选IP优先取10.、172.、192.168.开头的内网地址因为大部分业务日志里我们更关注内网环境里的机器标识实在无法判断时取第一个。如果你必须记录某个指定网卡的IP最直接的方式是把网卡名传进去按名称精确匹配。private static String getIpByNetworkInterfaceName(String interfaceName) { try { NetworkInterface networkInterface NetworkInterface.getByName(interfaceName); if (networkInterface null) { return null; } EnumerationInetAddress addresses networkInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress address addresses.nextElement(); if (address instanceof Inet4Address) { return address.getHostAddress(); } } } catch (SocketException ignored) { // ignore } return null; }这个方式适合对网络环境有严格要求的场景比如日志审计、对账系统需要确保记录到的IP一定是业务网段的。5. 常见问题与排查技巧实录5.1 获取到127.0.0.1的处理方法接入了日志IP功能后第一件事就是检查是否打出来的是127.0.0.1。如果打出来的是它说明获取链路有问题多半是网卡遍历方法没生效走了getLocalHost()的兜底逻辑。我排查过的一次真实情况是应用服务器启用了多网卡但默认路由指向的是回环口导致getLocalHost()返回127.0.0.1。这个时候检查一下解析方法的过滤条件确认服务器有非回环网卡且处于up状态不行就手动指定网卡名或者直接通过环境变量注入IP。另外一个常见场景是开发机Windows环境如果电脑装了虚拟机软件比如VMware或VirtualBox会多出几个虚拟网卡遍历时可能取到192.168.x.x的虚拟网卡IP。虽然不会打出127.0.0.1但这个IP在别的机器上根本无法访问。这种建议开发环境直接用环境变量覆盖生产环境的机器一般网卡相对干净。5.2 Docker容器里打印出172.17.x.x容器环境最大的坑就是拿到了容器内部IP。Docker默认桥接网络下容器内看到的网卡是eth0IP通常是172.17.0.x这个IP从宿主机外部访问不了打日志完全没有辨识意义。我踩过这个坑之后现在对所有跑在Docker里的服务统一采取注入宿主机IP的方案。在docker-compose里配置services: app: environment: - HOST_IP192.168.1.100K8s环境下则使用fieldRef做downwardAPI注入env: - name: HOST_IP valueFrom: fieldRef: fieldPath: status.hostIPJava侧读取逻辑前面已经给了优先环境变量后回退网卡解析覆盖所有部署场景。5.3 日志平台搜索过滤实操接入IP字段之后在日志平台上的操作效率能提升好几倍。如果用的是Kibana直接在搜索框里输入serverIp: 192.168.10.12 AND level: ERROR就能过滤出指定机器上的所有错误日志。如果是直接登录服务器终端配合Linux命令筛选也很顺# 从今天日志里筛指定IP的错误日志 grep 2025-01-15 /logs/app.log | grep 192.168.10.12 | grep ERROR | head -50 # 统计每个IP每天的错误日志量快速定位异常机器 grep ERROR /logs/app.log | awk {print $4} | sort | uniq -c | sort -rn这里$4对应日志格式里IP字段的位置如果你的pattern里IP位置不一样调整awk列号就行。除了解析日志字段, 日志平台统一的JSON结构是另一个很大话题简单提两句下节展开。5.4 JSON日志格式下的IP字段适配现在很多团队的日志链路是应用打印JSON格式日志Filebeat采集后输出到ES再做可视化分析。如果你也用JSON格式IP字段的搭配更加直观。Logback里输出JSON格式通常用logstash-logback-encoder配置方式是这样的appender nameJSON_FILE classch.qos.logback.core.rolling.RollingFileAppender file/logs/app.json.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/logs/app.json.%d{yyyy-MM-dd}.log/fileNamePattern /rollingPolicy encoder classnet.logstash.logback.encoder.LogstashEncoder !-- 增加自定义字段serverIp -- customFields{serverIp:${APP_IP}}/customFields /encoder /appender这种做法的好处在于字段独立成结构体后续在ES里做聚合、统计、排序都能直接按serverIp字段处理。想要让customFields自动填入IP可以在部署脚本里先执行一次获取IP的命令并导出环境变量或者在应用启动类里注入一个PropertySource把APP_IP设为系统属性。更简洁的做法是使用LogstashEncoder配合JsonInclude等高级配置实际生产环境按你团队的日志格式来就行。6. 踩坑总结与后续扩展思路用了一年多这个方案下来整体感受是投入产出比特别高。配置过程十分钟以内能搞定但后续每一次日志排查、每一次链路追踪都能省下不少时间。几个经验想单独拎出来说一下。第一IP获取逻辑一定要做缓存。我见过有人在Converter里每次打日志都遍历一次网卡热点日志系统里这个开销会被放大到肉眼可见的程度。定义成static变量或者用双重检查锁效果差不多建议直接抄代码。第二生产环境要验证日志真的带了IP。考虑这样一个情况一个服务有多个实例如果你只在场启动日志确认了IP字段存在但没确认不同实例IP是否一致等真正排查时才可能发现所有日志都是同一个IP那才是真的崩溃。所以上线后第一件事搜索一下日志确认至少有两个不同的IP出现。第三不要只加IP建议把应用名也放进去。服务多的时候IP只能区分机器不能区分是哪个应用。团队内部有一套规范公共工具日志打印格式统一为[应用名][IP]这样跨团队交流日志信息时对方一眼就能知道是哪个系统、哪台机器沟通成本低很多。如果想更省事每次部署时用脚本来更新主题可以基于这篇的工具类写个小工具在项目构建阶段直接生成一个带当前IP的文件加进Classpath不过日常用Converter方式已经完全够用了。第四后续可以考虑把主机名、容器ID一起加进去。IP能定位机器但如果机器做了迁移或IP变了日志里的IP可能误导排查方向。我后来在部分核心项目里加了hostname字段配合IP一起展示排查时信息更完整尤其适合K8s集群里跨节点调度频繁的场景。这个功能没有太多高深的技术含量但从业多年的经验告诉我基础设施上多做一层功夫日常排查的体验就能好一大截。值得推广到整个团队。
返回列表