JVM核心技术-32应对容器时代面临的挑战:长风破浪会有时、直挂云帆济沧海

32 应对容器时代面临的挑战:长风破浪会有时、直挂云帆济沧海

当今的时代,容器的使用越来越普及,Cgroups、Docker、Kubernetes 等项目和技术越来越成熟,成为很多大规模集群的基石。

容器是一种沙盒技术,可以对资源进行调度分配和限制配额、对不同应用进行环境隔离。

容器时代不仅给我们带来的机遇,也带来了很多挑战。跨得过去就是机会,跳不过去就是坑。

在容器环境下,要直接进行调试并不容易,我们更多地是进行应用性能指标的采集和监控,并构建预警机制。而这需要架构师、开发、测试、运维人员的协作。

但监控领域的工具又多又杂,而且在持续发展和不断迭代。最早期的监控,只在系统发布时检查服务器相关的参数,并将这些参数用作系统运行状况的指标。监控服务器的健康状况,与用户体验之间紧密相关,悲剧在于监控的不完善,导致发生的问题比实际检测到的要多很多。

随着时间推移,日志管理、预警、遥测以及系统报告领域持续发力。其中有很多有效的措施,诸如安全事件、有效警报、记录资源使用量等等。但前提是我们需要有一个清晰的策略和对应工具,进行用户访问链路跟踪,比如 Zabbix、Nagios 以及 Prometheus 等工具在生产环境中被广泛使用。

性能问题的关键是人,也就是我们的用户。但已有的这些工具并没有实现真正的用户体验监控。仅仅使用这些软件也不能缓解性能问题,我们还需要采取各种措施,在勇敢和专注下不懈地努力。

一方面,Web 系统的问题诊断和性能调优,是一件意义重大的事情。需要严格把控,也需要付出很多精力。

当然,成功实施这些工作对企业的回报也是巨大的!

另一方面,拿 Java 领域事实上的标准 Spring 来说,SpringBoot 提供了一款应用指标收集器——Micrometer,官方文档连接:https://micrometer.io/docs。 支持直接将数据上报给 Elasticsearch、Datadog、InfluxData 等各种流行的监控系统。 自动采集最大延迟、平均延迟、95% 线、吞吐量、内存使用量等指标。

此外,在小规模集群中,我们还可以使用 Pinpoint、Skywalking 等开源 APM 工具。 容器环境的资源隔离性

容器毕竟是一种轻量级的实现方式,所以其封闭性不如虚拟机技术。

举个例子:

物理机/宿主机有 96 个 CPU 内核、256GB 物理内存,容器限制的资源是 4 核 8G,那么容器内部的 JVM 进程看到的内核数和内存数是多少呢?

目前来说,JVM 看到的内核数是 96,内存值是 256G。

这会造成一些问题,基于 CPU 内核数 availableProcessors 的各种算法都会受到影响,比如默认 GC 线程数:假如啥都不配置,JVM 看见 96 个内核,设置 GC 并行线程数为 96*5/8~=60,但容器限制了只能使用 4 个内核资源,于是 60 个并行 GC 线程来争抢 4 个机器内核,造成严重的 GC 性能问题。

同样的道理,很多线程池的实现,根据内核数量来设置并发线程数,也会造成剧烈的资源争抢。如果容器不限制资源的使用也会造成一些困扰,比如下面介绍的坏邻居效应。基于物理内存 totalPhysicalMemorySize 和空闲内存 freePhysicalMemorySize 等配置信息的算法也会产生一些奇怪的 Bug。

最新版的 JDK 加入了一些修正手段。 JDK 对容器的支持和限制

新版 JDK 支持 Docker 容器的 CPU 和内存限制:

https://blogs.oracle.com/java-platform-group/java-se-support-for-docker-cpu-and-memory-limits

可以增加 JVM 启动参数来读取 Cgroups 对 CPU 的限制:

https://www.oracle.com/technetwork/java/javase/8u191-relnotes-5032181.htmlJDK-8146115

Hotspot 是一个规范的开源项目,关于 JDK 的新特性,可以阅读官方的邮件订阅,例如:

https://mail.openjdk.java.net/pipermail/jdk8u-dev/

其他版本的 JDK 特性,也可以按照类似的命名规范,从官网的 Mailing Lists 中找到:

https://mail.openjdk.java.net/mailman/listinfo

关于这个问题的排查和分析,请参考前面的章节[《JVM 问题排查分析调优经验》]。 坏邻居效应

有共享资源的地方,就会有资源争用。在计算机领域,共享的资源主要包括: 网络 磁盘 CPU 内存

在多租户的公有云环境中,会存在一种严重的问题,称为"坏邻居效应”(noisy neighbor phenomenon)。当一个或多个客户过度使用了某种公共资源时,就会明显损害到其他客户的系统性能。(就像是小区宽带一样)

吵闹的坏邻居(noisy neighbor),用于描述云计算领域中,用来描述抢占共有带宽,磁盘 I/O、CPU 以及其他资源的行为。

坏邻居效应,对同一环境下的其他虚拟机/应用的性能会造成影响或抖动。一般来说,会对其他用户的性能和体验造成恶劣的影响。

云,是一种多租户环境,同一台物理机,会共享给多个客户来运行程序/存储数据。

坏邻居效应产生的原因,是某个虚拟机/应用霸占了大部分资源,进而影响到其他客户的性能。

带宽不足是造成网络性能问题的主要原因。在网络中传输数据严重依赖带宽的大小,如果某个应用或实例占用太多的网络资源,很可能对其他用户造成延迟/缓慢。坏邻居会影响虚拟机、数据库、网络、存储以及其他云服务。

有一种避免坏邻居效应的方法,是使用裸机云(bare-metal cloud)。裸机云在硬件上直接运行一个应用,相当于创建了一个单租户环境,所以能消除坏邻居。虽然单租户环境避免了坏邻居效应,但并没有解决根本问题。超卖(over-commitment)或者共享给太多的租户,都会限制整个云环境的性能。

另一种避免坏邻居效应的方法,是通过在物理机之间进行动态迁移,以保障每个客户获得必要的资源。此外,还可以通过 存储服务质量保障(QoS,quality of service)控制每个虚拟机的 IOPS,来限制坏邻居效应。通过 IOPS 来限制每个虚拟机使用的资源量,就不会造成某个客户的虚机/应用/实例去挤占其他客户的资源/性能。

有兴趣的同学可以查看:

[谈谈公有云的坏邻居效应]

GC 日志监听

从 JDK 7 开始,每一款垃圾收集器都提供了通知机制,在程序中监听 GarbageCollectorMXBean,即可在垃圾收集完成后收到 GC 事件的详细信息。目前的监听机制只能得到 GC 完成之后的 Pause 数据,其它环节的 GC 情况无法观察到。

一个简单的监听程序实现如下:

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sun.management.GarbageCollectionNotificationInfo;
import com.sun.management.GcInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.management.ListenerNotFoundException;
import javax.management.Notification;
import javax.management.NotificationEmitter;
import javax.management.NotificationListener;
import javax.management.openmbean.CompositeData;
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.OnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
 GC 日志监听并输出到 Log
 JVM 启动参数示例:
 -Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
 -Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
/
@Configuration
public class BindGCNotifyConfig {
    public BindGCNotifyConfig() {
    }
    //
    private Logger logger = LoggerFactory.getLogger(this.getClass());
    private final AtomicBoolean inited = new AtomicBoolean(Boolean.FALSE);
    private final List<Runnable> notifyCleanTasks = new OnWriteArrayList<Runnable>();
    private final AtomicLong maxPauseMillis = new AtomicLong(0L);
    private final AtomicLong maxOldSize = new AtomicLong(getOldGen().getUsage().getMax());
    private final AtomicLong youngGenSizeAfter = new AtomicLong(0L);
    @PostConstruct
    public void init() {
        try {
            doInit();
        } catch (Throwable e) {
            logger.warn("[GC 日志监听-初始化]失败! ", e);
        }
    }
    @PreDestroy
    public void close() {
        for (Runnable task : notifyCleanTasks) {
            task.run();
        }
        notifyCleanTasks.clear();
    }
    private void doInit() {
        //
        if (!inited.compareAndSet(Boolean.FALSE, Boolean.TRUE)) {
            return;
        }
        logger.info("[GC 日志监听-初始化]maxOldSize=" + mb(maxOldSize.longValue()));
        // 每个 mbean 都注册监听
        for (GarbageCollectorMXBean mbean : ManagementFactory.getGarbageCollectorMXBeans()) {
            if (!(mbean instanceof NotificationEmitter)) {
                continue;
            }
            final NotificationEmitter notificationEmitter = (NotificationEmitter) mbean;
            // 添加监听
            final NotificationListener notificationListener = getNewListener(mbean);
            notificationEmitter.addNotificationListener(notificationListener, null, null);
            logger.info("[GC 日志监听-初始化]MemoryPoolNames=" + JSON.toJSONString(mbean.getMemoryPoolNames()));
            // 加入清理队列
            notifyCleanTasks.add(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 清理掉绑定的 listener
                        notificationEmitter.removeNotificationListener(notificationListener);
                    } catch (ListenerNotFoundException e) {
                        logger.error("[GC 日志监听-清理]清理绑定的 listener 失败", e);
                    }
                }
            });
        }
    }
    private NotificationListener getNewListener(final GarbageCollectorMXBean mbean) {
        //
        final NotificationListener listener = new NotificationListener() {
            @Override
            public void handleNotification(Notification notification, Object ref) {
                // 只处理 GC 事件
                if (!notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) {
                    return;
                }
                CompositeData cd = (CompositeData) notification.getUserData();
                GarbageCollectionNotificationInfo notificationInfo = GarbageCollectionNotificationInfo.from(cd);
                //
                JSONObject gcDetail = new JSONObject();
                String gcName = notificationInfo.getGcName();
                String gcAction = notificationInfo.getGcAction();
                String gcCause = notificationInfo.getGcCause();
                GcInfo gcInfo = notificationInfo.getGcInfo();
                // duration 是指 Pause 阶段的总停顿时间,并发阶段没有 pause 不会通知。
                long duration = gcInfo.getDuration();
                if (maxPauseMillis.longValue() < duration) {
                    maxPauseMillis.set(duration);
                }
                long gcId = gcInfo.getId();
                //
                String type = "jvm.gc.pause";
                //
                if (isConcurrentPhase(gcCause)) {
                    type = "jvm.gc.concurrent.phase.time";
                }
                //
                gcDetail.put("gcName", gcName);
                gcDetail.put("gcAction", gcAction);
                gcDetail.put("gcCause", gcCause);
                gcDetail.put("gcId", gcId);
                gcDetail.put("duration", duration);
                gcDetail.put("maxPauseMillis", maxPauseMillis);
                gcDetail.put("type", type);
                gcDetail.put("collectionCount", mbean.getCollectionCount());
                gcDetail.put("collectionTime", mbean.getCollectionTime());
                // 存活数据量
                AtomicLong liveDataSize = new AtomicLong(0L);
                // 提升数据量
                AtomicLong promotedBytes = new AtomicLong(0L);
                // Update promotion and allocation counters
                final Map<String, MemoryUsage> before = gcInfo.getMemoryUsageBeforeGc();
                final Map<String, MemoryUsage> after = gcInfo.getMemoryUsageAfterGc();
                //
                Set<String> keySet = new HashSet<String>();
                keySet.addAll(before.keySet());
                keySet.addAll(after.keySet());
                //
                final Map<String, String> afterUsage = new HashMap<String, String>();
                //
                for (String key : keySet) {
                    final long usedBefore = before.get(key).getUsed();
                    final long usedAfter = after.get(key).getUsed();
                    long delta = usedAfter - usedBefore;
                    // 判断是 yong 还是 old,算法不同
                    if (isYoungGenPool(key)) {
                        delta = usedBefore - youngGenSizeAfter.get();
                        youngGenSizeAfter.set(usedAfter);
                    } else if (isOldGenPool(key)) {
                        if (delta > 0L) {
                            // 提升到老年代的量
                            promotedBytes.addAndGet(delta);
                            gcDetail.put("promotedBytes", mb(promotedBytes));
                        }
                        if (delta < 0L || GcGenerationAge.OLD.contains(gcName)) {
                            liveDataSize.set(usedAfter);
                            gcDetail.put("liveDataSize", mb(liveDataSize));
                            final long oldMaxAfter = after.get(key).getMax();
                            if (maxOldSize.longValue() != oldMaxAfter) {
                                maxOldSize.set(oldMaxAfter);
                                // 扩容;老年代的 max 有变更
                                gcDetail.put("maxOldSize", mb(maxOldSize));
                            }
                        }
                    } else if (delta > 0L) {
                        //
                    } else if (delta < 0L) {
                        // 判断 G1
                    }
                    afterUsage.put(key, mb(usedAfter));
                }
                //
                gcDetail.put("afterUsage", afterUsage);
                //
                logger.info("[GC 日志监听-GC 事件]gcId={}; duration:{}; gcDetail: {}", gcId, duration, gcDetail.toJSONString());
            }
        };
        return listener;
    }
    private static String mb(Number num) {
        long mbValue = num.longValue() / (1024 * 1024);
        if (mbValue < 1) {
            return "" + mbValue;
        }
        return mbValue + "MB";
    }
    private static MemoryPoolMXBean getOldGen() {
        List<MemoryPoolMXBean> list = ManagementFactory
                .getPlatformMXBeans(MemoryPoolMXBean.class);
        //
        for (MemoryPoolMXBean memoryPoolMXBean : list) {
            // 非堆的部分-不是老年代
            if (!isHeap(memoryPoolMXBean)) {
                continue;
            }
            if (!isOldGenPool(memoryPoolMXBean.getName())) {
                continue;
            }
            return (memoryPoolMXBean);
        }
        return null;
    }
    private static boolean isConcurrentPhase(String cause) {
        return "No GC".equals(cause);
    }
    private static boolean isYoungGenPool(String name) {
        return name.endsWith("Eden Space");
    }
    private static boolean isOldGenPool(String name) {
        return name.endsWith("Old Gen") || name.endsWith("Tenured Gen");
    }
    private static boolean isHeap(MemoryPoolMXBean memoryPoolBean) {
        return MemoryType.HEAP.equals(memoryPoolBean.getType());
    }
    private enum GcGenerationAge {
        OLD,
        YOUNG,
        UNKNOWN;
        private static Map<String, GcGenerationAge> knownCollectors = new HashMap<String, BindGCNotifyConfig.GcGenerationAge>() {{
            put("ConcurrentMarkSweep", OLD);
            put("", YOUNG);
            put("G1 Old Generation", OLD);
            put("G1 Young Generation", YOUNG);
            put("MarkSweepCompact", OLD);
            put("PS MarkSweep", OLD);
            put("PS Scavenge", YOUNG);
            put("ParNew", YOUNG);
        }};
        static GcGenerationAge fromName(String name) {
            return knownCollectors.getOrDefault(name, UNKNOWN);
        }
        public boolean contains(String name) {
            return this == fromName(name);
        }
    }
}

不只是 GC 事件,内存相关的信息都可以通过 JMX 来实现监听。很多 APM 也是通过类似的手段来实现数据上报。 APM 工具与监控系统

在线可视化监控是如今生产环境必备的一个功能。业务出错和性能问题随时都可能会发生,而且现在很多系统不再有固定的业务窗口期,所以必须做到 7x24 小时的实时监控。

目前业界有很多监控工具,各有优缺点,需要根据需要进行抉择。

一般来说,系统监控可以分为三个部分: 系统性能监控,包括 CPU、内存、磁盘 IO、网络等硬件资源和系统负载的监控信息。 业务日志监控,场景的是 ELK 技术栈、并使用 Logback+Kafka 等技术来采集日志。 APM 性能指标监控,比如 QPS、TPS、响应时间等等,例如 MicroMeter、Pinpoint 等。

系统监控的模块也是两大块: 指标采集部分 数据可视化系统

如今监控工具是生产环境的重要组成部分。测量结果的可视化、错误追踪、性能监控和应用分析是对应用的运行状况进行深入观测的基本手段。

认识到这一需求非常容易,但要选择哪一款监控工具或者哪一组监控工具却异常困难。

下面介绍几款监测工具,这些工具包括混合开源和 SaaS 模式,每个都有其优缺点,可以说没有完美的工具,只有合适的工具。 指标采集客户端 Micrometer:作为指标采集的基础类库,基于客户端机器来进行,用户无需关注具体的 JVM 版本和厂商。以相同的方式来配置,可以对接到不同的可视化监控系统服务。主要用于监控、告警,以及对当前的系统环境变化做出响应。Micrometer 还会注册 JMX 相关的 MBeans,非常简单和方便地在本地通过 JMX 来查看相关指标。如果是生产环境中使用,则一般是将监控指标导出到其他监控系统中保存起来。 云服务监控系统:云服务监控系统厂商一般都会提供配套的指标采集客户端,并对外开放各种 API 接口和数据标准,允许客户使用自己的指标采集系统。 开源监控系统:各种开源监控系统也会提供对应的指标采集客户端。 云服务监控系统** SaaS 服务的监控系统一般提供存储、查询、可视化等功能的一体化云服务。大多包含免费试用和收费服务两种模式。如果企业和机构的条件允许,付费使用云服务一般是最好的选择,毕竟"免费的才是最贵的”。

下面我们一起来看看有哪些云服务:

  • [AppOptics]
  • [Datadog]
  • [Dynatrace]
  • [Humio]
  • [Instana]
  • [New Relic]
  • [SignalFx]
  • [Stackdriver]
  • [Wavefront]
  • [听云]
  • [OneAPM]
  • [Plumbr]
  • [Takipi]

其中做得比较好的有国外的 Datadog,国内的听云。 开源监控系统*

  • [Pinpoint]
  • [Atlas]
  • [ELK 技术栈]
  • [Influx]
  • [Ganglia]
  • [Graphite]
  • [KairosDB]
  • [Prometheus]
  • [StatsD]

其中 Pinpoint 和 Prometheus 比较受欢迎。 参考链接

  • [利用 JMX 的 Notifications 监听 GC]
  • [推荐 7 个超棒的监控工具]

文章列表

更多推荐

更多
  • AWS自动化机器学习-十一、MLSDLC 的持续集成、部署和训练 技术要求,编纂持续集成阶段,管理持续部署阶段,管理持续训练,延伸,构建集成工件,构建测试工件,构建生产工件,自动化持续集成流程,回顾构建阶段,回顾测试阶段,审查部署和维护阶段,回顾应用用户体验,创建新的鲍鱼调查数据,回顾持续训练流程,清
    Apache CN

  • AWS自动化机器学习-六、使用 AWS 步骤函数自动化机器学习过程 技术要求,介绍 AWS 步骤功能,使用 Step 函数 Data Science SDK for CI/CD,建立 CI/CD 渠道资源,创建状态机,解决状态机的复杂性,更新开发环境,创建管道工件库,构建管道应用构件,部署 CI/CD
    Apache CN

  • AWS自动化机器学习-第三部分:优化以源代码为中心的自动化机器学习方法 本节将向您介绍整体 CI/CD 流程的局限性,以及如何将 ML 从业者的角色进一步整合到管道构建流程中。本节还将介绍这种角色集成如何简化自动化过程,并通过向您介绍 AWS Step 函数向您展示一种优化的方法。本节包括以下章节:
    Apache CN

  • AWS自动化机器学习-一、AWS 上的自动化机器学习入门 技术要求,洗钱流程概述,洗钱过程的复杂性,端到端 ML 流程示例,AWS 如何使 ML 开发和部署过程更容易自动化,介绍 ACME 渔业物流,ML 的情况,从数据中获得洞察力,建立正确的模型,训练模型,评估训练好的模型,探索可能的后续步
    Apache CN

  • AWS自动化机器学习-二、使用 SageMaker 自动驾驶器自动化机器学习模型开发 技术要求,介绍 AWS AI 和 ML 前景,SageMaker 自动驾驶器概述,利用 SageMaker 自动驾驶器克服自动化挑战,使用 SageMaker SDK 自动化 ML 实验,SageMaker Studio 入门,准备实验
    Apache CN

  • AWS自动化机器学习-四、机器学习的持续集成和持续交(CI/CD) 四、机器学习的持续集成和持续交CI/CD技术要求,介绍 CI/CD 方法,通过 CI/CD 实现 ML 自动化,在 AWS 上创建 CI/CD 管道,介绍 CI/CD 的 CI 部分,介绍 CI/CD 的 CD 部分,结束循环,采取以部
    Apache CN

  • AWS自动化机器学习-九、使用 Amazon Managed Workflows 为 Apache AirFlow 构建 ML 工作流 技术要求,开发以数据为中心的工作流程,创建合成鲍鱼调查数据,执行以数据为中心的工作流程,构建和单元测试数据 ETL 工件,构建气流 DAG,清理, 在前面的年龄计算器示例中,我们了解了如何通过 ML 从业者和开发人员团队之间的跨职能
    Apache CN

  • AWS自动化机器学习-七、使用 AWS 步骤函数构建 ML 工作流 技术要求,构建状态机工作流,执行集成测试,监控管道进度,设置服务权限,创建 ML 工作流程, 在本章中,我们将从第六章中的 [处继续,使用 AWS 步骤函数自动化机器学习过程。您将从那一章中回忆起,我们正在努力实现的主要目标是简化
    Apache CN

  • AWS自动化机器学习-八、使用 Apache Airflow 实现机器学习过程的自动化 技术要求,介绍阿帕奇气流,介绍亚马逊 MWAA,利用气流处理鲍鱼数据集,配置 MWAA 系统的先决条件,配置 MWAA 环境, 当建立一个 ML 模型时,有一个所有 ML 从业者都知道的基本原则;也就是说,最大似然模型只有在数据被训练时
    Apache CN

  • AWS自动化机器学习-五、自动化 ML 模型的持续部署 技术要求,部署 CI/CD 管道,构建 ML 模型工件,执行自动化 ML 模型部署,整理管道结构,创建 CDK 应用,部署管道应用,查看建模文件,审查申请文件,查看模型服务文件,查看容器构建文件,提交 ML 工件,清理, 在 [第 4
    Apache CN

  • 近期文章

    更多
    文章目录

      推荐作者

      更多