一、企业日志系统的两难选择

在微服务和容器化时代,日志系统已成为企业基础设施的核心组件。然而,技术选型时我们常常面临一个经典困境:

选 ELK?功能全面,但资源消耗大。选 Loki?轻量高效,但功能有所取舍。

经过近期的实践,我同时在项目中引入了 ELK(Elasticsearch + Logstash + Kibana) 和 Loki 两套日志系统,让它们各司其职、优势互补。本文将分享这次实战的完整过程、配置细节,以及两者在不同场景下的优劣对比。


二、ELK vs Loki:企业级日志方案对比

2.1 架构设计理念

对比维度 ELK Stack Loki + Grafana
核心思想 全文索引 + 分布式搜索 标签索引 + 日志压缩存储
索引方式 对日志内容建立倒排索引 仅对标签(Labels)建立索引
存储方式 结构化存储,每个字段独立索引 日志内容压缩存储为 Chunks
查询语言 Lucene Query / KQL LogQL

2.2 资源消耗对比(实测数据)

资源项 ELK(7.14.0) Loki(2.9.5)
镜像大小 ~5.2GB(ES + Kibana + Logstash) ~500MB(Loki + Grafana)
内存占用 1.5 - 2GB 200 - 500MB
磁盘占用(相同日志量) 基准线(1x) 约 1/10(压缩存储 + 无全文索引)
启动时间 较慢(ES 需要加载索引) 快速(轻量进程)

2.3 功能特性对比

功能 ELK Loki
全文搜索 ✅ 强大的 Lucene 全文检索 ⚠️ 有限支持(需通过 LogQL 表达式)
字段级聚合分析 ✅ 任意字段都可聚合统计 ❌ 仅支持标签聚合
日志内容解析 ✅ 多种方式(Grok、JSON 等) ⚠️ 需要在推送前格式化
告警能力 ✅ Elasticsearch Watcher / Kibana Alerting ✅ Grafana Alerting
可视化 ✅ Kibana(功能专业) ✅ Grafana(生态丰富)
多租户支持 ✅ 索引级别隔离 ✅ 通过标签和租户 ID

2.4 适用场景总结

场景 推荐方案 理由
复杂业务日志分析 ELK 需要全文搜索、字段聚合、复杂查询
大规模日志(TB 级) Loki 存储成本低,水平扩展容易
Kubernetes 环境 Loki 原生支持容器标签,与 Prometheus 生态无缝集成
安全审计 / SIEM ELK 需要保留原始日志、长期存储、复杂关联查询
开发测试环境 Loki 轻量、快速、资源友好
已有 Elasticsearch 生态 ELK APM、SIEM、机器学习等全家桶能力

三、企业级部署实践

3.1 环境概览

角色 配置
应用服务器 Spring Boot 应用
日志服务器 Ubuntu 虚拟机
ELK 版本 7.14.0
Loki 版本 2.9.5
Grafana 版本 10.4.10

3.2 统一 Docker Compose 编排

这里我统一使用的是ELK官方镜像,我把官方镜像同步至阿里云容器镜像服务(ACR),直接规避 Docker Hub 的网络限制和不稳定性。

docker pull crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/es:7.14.0
docker pull crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/logstash:7.14.0
docker pull crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/kibana:7.14.0
docker pull crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/loki:2.9.5
docker pull crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/grafana:10.4.10

version: '3.8'

services:
  elasticsearch:
    image: crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/es:7.14.0
    container_name: elk-es
    environment:
      - discovery.type=single-node
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
      - xpack.security.enabled=false
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data

  logstash:
    image: crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/logstash:7.14.0
    container_name: elk-logstash
    depends_on:
      - elasticsearch
    ports:
      - "5044:5044"
    volumes:
      - ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro

  kibana:
    image: crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/kibana:7.14.0
    container_name: elk-kibana
    depends_on:
      - elasticsearch
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"

  loki:
    image: crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/loki:2.9.5
    container_name: loki
    ports:
      - "3100:3100"
    volumes:
      - ./loki-config.yaml:/etc/loki/loki-config.yaml
      - loki_data:/loki
    command: -config.file=/etc/loki/loki-config.yaml
    restart: unless-stopped

  grafana:
    image: crpi-j6f1kvw2ikdkmgn4.cn-shanghai.personal.cr.aliyuncs.com/ym_prod/grafana:10.4.10
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana_data:/var/lib/grafana
    restart: unless-stopped

volumes:
  es_data:
  loki_data:
  grafana_data:

3.3 Logstash 配置:按天滚动索引

input {
  tcp {
    port => 5044
    codec => json_lines
  }
}

output {
  # 调试用:打印到logstash控制台,确认收到日志
  stdout { codec => rubydebug }

  elasticsearch {
    hosts => ["http://elasticsearch:9200"]
    index => "springboot-logs-%{+YYYY.MM.dd}"
  }
}

企业级考虑:按天分割索引便于数据生命周期管理,如 7 天自动归档、30 天自动删除。

3.4 Loki 配置:轻量存储引擎

auth_enabled: false

server:
  http_listen_port: 3100

common:
  ring:
    instance_addr: 127.0.0.1
    kvstore:
      store: inmemory
  replication_factor: 1
  path_prefix: /loki

schema_config:
  configs:
    - from: 2020-05-15
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

storage_config:
  filesystem:
    directory: /loki/chunks


四、Spring Boot 应用集成

4.1 多通道日志发送器

<!-- pom.xml 依赖 -->
<dependencies>
    <!-- ELK 通道 -->
    <dependency>
        <groupId>net.logstash.logback</groupId>
        <artifactId>logstash-logback-encoder</artifactId>
        <version>7.0</version>
    </dependency>
    
    <!-- Loki 通道 -->
    <dependency>
        <groupId>com.github.loki4j</groupId>
        <artifactId>loki-logback-appender</artifactId>
        <version>1.6.0</version>
    </dependency>
</dependencies>

4.2 自动获取节点 IP(企业级基础设施感知)

package com.example.logdemo.config;

import ch.qos.logback.core.PropertyDefinerBase;
import com.yhyl.utils.LocalIpUtil;

public class IpPropertyDefiner extends PropertyDefinerBase {
    @Override
    public String getPropertyValue() {
        return LocalIpUtil.getLocalIPv4Address();
    }
}
package com.example.logdemo.util;

import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.*;
import java.util.*;

public final class LocalIpUtil {

    private static final Logger log = LoggerFactory.getLogger(LocalIpUtil.class);

    /** K8s / Docker / CI 常用 IP 环境变量,按优先级排列 */
    private static final List<String> IP_ENV_KEYS = Collections.unmodifiableList(Arrays.asList(
            "POD_IP", "MY_POD_IP", "KUBERNETES_POD_IP", "CONTAINER_IP", "HOST_IP"
    ));

    /** 业务网卡前缀白名单(按优先级降序) */
    private static final List<String> PREFERRED_IFACE_PREFIXES = Collections.unmodifiableList(Arrays.asList(
            "eth", "bond", "enp", "ens", "em", "wlan"
    ));

    /** 明确排除的虚拟网卡前缀 */
    private static final List<String> IGNORED_IFACE_PREFIXES = Collections.unmodifiableList(Arrays.asList(
            "docker", "veth", "br-", "flannel", "cni", "calico", "kube", "virbr", "vmnet", "lo"
    ));

    private static volatile String cachedIp;

    @Getter
    private static volatile String ipSource;

    private LocalIpUtil() {}

    /**
     * 获取本机 IPv4 地址(线程安全,仅解析一次)
     */
    public static String getLocalIPv4Address() {
        if (cachedIp != null) {
            return cachedIp;
        }
        synchronized (LocalIpUtil.class) {
            if (cachedIp != null) {
                return cachedIp;
            }
            resolveAndCache();
            return cachedIp;
        }
    }

    private static void resolveAndCache() {
        // ========== P0: 环境变量(K8s / Docker / CI 首选)==========
        for (String key : IP_ENV_KEYS) {
            String envIp = System.getenv(key);
            if (isValidSiteLocalIPv4(envIp)) {
                cacheResult(envIp, "ENV:" + key);
                return;
            }
        }

        // ========== P1: JVM 系统属性(运维应急覆盖)==========
        String sysPropIp = System.getProperty("app.ip");
        if (isValidSiteLocalIPv4(sysPropIp)) {
            cacheResult(sysPropIp, "SYS_PROP:app.ip");
            return;
        }

        // ========== P2: 带优先级的网卡遍历(裸机 / 未配置环境兜底)==========
        NicResult nicResult = resolveFromNetworkInterfaces();
        if (nicResult != null) {
            cacheResult(nicResult.ip, "NIC:" + nicResult.ifaceName);
            return;
        }

        // ========== P3: InetAddress.getLocalHost() 最终兜底 ==========
        String fallbackIp = safeGetLocalHost();
        if (fallbackIp != null) {
            cacheResult(fallbackIp, "FALLBACK:getLocalHost");
            return;
        }

        // ========== 全部失败 ==========
        cacheResult("127.0.0.1", "NONE");
        log.error("[LocalIpUtil] 未能获取到有效本机IP,降级为127.0.0.1。" +
                "请通过环境变量({})或系统属性(-Dapp.ip=)显式指定", IP_ENV_KEYS);
    }

    // ==================== 网卡遍历(带排序与过滤)====================

    private static NicResult resolveFromNetworkInterfaces() {
        try {
            List<NetworkInterface> candidates = new ArrayList<>();
            Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
            while (interfaces.hasMoreElements()) {
                NetworkInterface ni = interfaces.nextElement();
                if (shouldIgnore(ni)) continue;
                candidates.add(ni);
            }

            // 按网卡名称优先级排序
            candidates.sort((a, b) -> Integer.compare(
                    getIfacePriority(b.getName()), getIfacePriority(a.getName())));

            for (NetworkInterface ni : candidates) {
                Enumeration<InetAddress> addrs = ni.getInetAddresses();
                while (addrs.hasMoreElements()) {
                    InetAddress addr = addrs.nextElement();
                    if (addr instanceof Inet4Address
                            && !addr.isLoopbackAddress()
                            && addr.isSiteLocalAddress()) {
                        return new NicResult(addr.getHostAddress(), ni.getName());
                    }
                }
            }
        } catch (SocketException e) {
            log.warn("[LocalIpUtil] 遍历网卡失败: {}", e.getMessage());
        }
        return null;
    }

    private static boolean shouldIgnore(NetworkInterface ni) throws SocketException {
        if (ni.isLoopback() || ni.isVirtual() || !ni.isUp()) return true;
        String name = ni.getName().toLowerCase();
        for (String prefix : IGNORED_IFACE_PREFIXES) {
            if (name.startsWith(prefix)) return true;
        }
        return false;
    }

    private static int getIfacePriority(String name) {
        String lower = name.toLowerCase();
        for (int i = 0; i < PREFERRED_IFACE_PREFIXES.size(); i++) {
            if (lower.startsWith(PREFERRED_IFACE_PREFIXES.get(i))) {
                return PREFERRED_IFACE_PREFIXES.size() - i;
            }
        }
        return -1; // 未匹配的网卡排到最后
    }

    // ==================== 辅助方法 ====================

    private static String safeGetLocalHost() {
        try {
            InetAddress localHost = InetAddress.getLocalHost();
            String ip = localHost.getHostAddress();
            if (isValidSiteLocalIPv4(ip)) return ip;
        } catch (UnknownHostException e) {
            log.debug("[LocalIpUtil] getLocalHost() 失败: {}", e.getMessage());
        }
        return null;
    }

    private static boolean isValidSiteLocalIPv4(String ip) {
        if (ip == null || ip.isEmpty()) return false;
        try {
            InetAddress addr = InetAddress.getByName(ip);
            return addr instanceof Inet4Address
                    && !addr.isLoopbackAddress()
                    && addr.isSiteLocalAddress();
        } catch (UnknownHostException e) {
            return false;
        }
    }

    private static void cacheResult(String ip, String source) {
        cachedIp = ip;
        ipSource = source;

        if (source.startsWith("FALLBACK")) {
            log.warn("[LocalIpUtil] 使用兜底方式获取本机IP: {} (来源: {})", ip, source);
        } else if (!"NONE".equals(source)) {
            // NONE 时不打印任何日志(等价于原来的空分支)
            log.info("[LocalIpUtil] 解析本机IP成功: {} (来源: {})", ip, source);
        }
    }

    /** 内部结果载体 */
    private static class NicResult {
        final String ip;
        final String ifaceName;
        NicResult(String ip, String ifaceName) {
            this.ip = ip;
            this.ifaceName = ifaceName;
        }
    }
}

4.3 企业级 logback-spring.xml 配置

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 动态获取节点 IP -->
    <define name="HOST" class="com.example.logdemo.config.IpPropertyDefiner"/>

    <!-- 控制台输出(开发调试) -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- ===== ELK 通道:支持复杂查询与全文检索 ===== -->
    <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
        <destination>192.168.x.x:5044</destination>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"service_name":"log-demo","host":"${HOST}"}</customFields>
        </encoder>
        <keepAliveDuration>5 minutes</keepAliveDuration>
    </appender>

    <!-- ===== Loki 通道:轻量标签索引,适合大规模日志 ===== -->
    <appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
        <http>
            <url>http://192.168.x.x:3100/loki/api/v1/push</url>
        </http>
        <format>
            <label>
                <pattern>service=log-demo,node=${HOST},level=%level</pattern>
            </label>
            <message>
                <pattern>
                    {
                        "time": "%d{yyyy-MM-dd HH:mm:ss.SSS}",
                        "level": "%level",
                        "logger": "%logger{36}",
                        "thread": "%thread",
                        "msg": "%message"
                    }
                </pattern>
            </message>
        </format>
        <batch>
            <maxTimeoutMs>5000</maxTimeoutMs>
        </batch>
    </appender>

    <!-- 优雅关闭:保证缓冲区日志不丢失 -->
    <shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook">
        <delay>3000</delay>
    </shutdownHook>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="LOGSTASH"/>   <!-- ELK 企业级通道 -->
        <appender-ref ref="LOKI"/>       <!-- Loki 轻量通道 -->
    </root>

</configuration>

五、运行效果展示

5.1 Kibana 控制台(ELK 优势:全文检索 + 字段聚合)

5.2 Grafana Explore(Loki 优势:标签快速检索)


六、企业级场景选型建议

场景 推荐方案 理由
核心业务系统排错 ELK 需要精准的全文检索和字段分析
K8s 集群日志归档 Loki 存储成本仅为 ELK 的 1/10
安全合规审计 ELK 支持长期数据保留和复杂关联查询
DevOps 日常巡检 Loki + Grafana 轻量快速,与监控无缝集成
混合部署 ELK + Loki 双通道 既保证分析能力,又兼顾大规模存储

七、总结

ELK 和 Loki 不是非此即彼的选择,而是可以共存的、各取所长的企业级日志方案。

Logo

纵情码海钱塘涌,杭州开发者创新动! 属于杭州的开发者社区!致力于为杭州地区的开发者提供学习、合作和成长的机会;同时也为企业交流招聘提供舞台!

更多推荐