数据中心迁移——在 IT 领域,特别是在大型企业中,“数据中心迁移”(Data Center Migration)一词已广为流传。在许多情况下,企业已经具备将迁移至云端的实践经验。这类项目早在"云数据中心"这一术语被创造出来之前就已经实现了。
成本节约对大多数决策者来说是主要动机。但还有一个关键因素——可持续性。数据中心消耗着巨量的电力。以法国为例,仅仅在2022 年,它们的全球能源消耗估计为 415 太瓦时——几乎与法国的整个电力消耗量(426 太瓦时)相当。而且这还是在 ChatGPT 等 AI 工具引发热潮之前。预计未来电力消耗还将继续增长。
因此,所有能够减少应用程序能耗的技术方法都备受关注。同时,这些措施也有助于降低应用程序的运营成本。
本文探讨了三项基本技术——Java、虚拟化和容器化——的发展历程。我们将从早期的 Java 和虚拟化技术开始,逐步过渡到现代方法,如 GraalVM 原生构建和 Kubernetes 上的无服务器应用。我们将展示如何将一个企业级的 Java 应用程序从简单的 Kubernetes 部署转换为原生可执行程序,作为无服务器容器通过 Knative 实现——优化冷启动时间至秒以下——以及这一转变如何促进可持续发展。
2000 年
2000年初期,Java 作为一种能够创建可移植应用程序的语言备受推崇。只需编写一次代码并编译成 Java 字节码表示。这种字节码(也称为.class 文件)可以打包成一个压缩文件格式(.jar),只要目标系统上安装了 Java 运行环境(JRE),就可以在任何地方执行。
这意味着应用程序可以在 Windows 或 macOS 上开发并构建——这些操作系统在最终用户中更为普及——而同一软件包仍然可以在 Linux 或 Unix 系统上运行。唯一的前提条件是目标系统具备兼容的 JRE。
与其他因素,如丰富的库资源、面向对象编程语言功能以及内存管理,使得 Java 得以迅速普及。开发者现在能够使用与现实世界相关的术语描述数据模型——这得益于面向对象特性。可以编写复杂的程序,而无需关注每个字节的内存管理。只要遵循成熟的内存管理实践,系统会自动处理内存分配和释放。
尽管 Java 提供了平台无关性,但当时企业应用程序通常按照“每个机器一个应用程序”的原则在物理服务器上运行。这种方法的优点在于不同服务器上应用程序之间的强隔离性,以及可以在不同的应用程序中使用不同的操作系统依赖性。明显的缺点是缺乏可扩展性和资源浪费。如果一个物理服务器上只运行一个应用程序,那么所有可用的资源只能被这个应用程序使用。在这种场景下,资源共享并不容易实现。
JRE 和虚拟机
但 Java 如何实现其平台无关性?简单来说:Java 应用程序不直接与操作系统交互。所有与操作系统的交互都由 Java 虚拟机(JVM)抽象化处理。对开发者而言,JVM 就像一台真正的机器。它通过 Java 库提供对文件系统、CPU 和内存等系统资源的访问。只要能使用 Java 系统库,就不需要掌握特定操作系统的知识。
Java 程序会被编译成一种平台无关的、低级的表示形式——字节码。这种字节码不能直接在任何系统上执行。相反,JRE 在运行时会启动一个 JVM,该 JVM 知道如何解释字节码指令。
此时,即时编译(JIT)技术登场。并非所有应用程序所需的库都包含在字节码中。一个库会在第一次调用时动态加载并绑定到内存中。
这意味着一个 Java 包是一个松散的字节码组件集合,这些组件在运行时由 JIT 编译器组合和优化。
在硬件采购需要数月时间的时代,Java 的这种可移植性很有帮助——应用程序可以开发,而无需绑定到特定硬件。只要数据中心有可用资源,应用程序就可以在那里部署。因此,Java 在 2000 年至 2010 年间经历了飞速的普及。
在这个时期广受欢迎的另一种技术是虚拟机。虚拟机——就像 Java 一样——基于虚拟化原理,但它们将其应用于操作系统和内核层面,而不是应用层面。通过虚拟化,一台物理服务器可以分成多个虚拟机,这些虚拟机共享 CPU 和内存资源——从而实现更高效的资源利用。
虚拟化通过软件组件——即虚拟机监控程序(Hypervisor)——模拟物理服务器的硬件接口来实现。该监控程序为多个虚拟机提供虚拟化的硬件环境。每台虚拟机都拥有自己的内核,用于与虚拟化操作系统交互。因此,不同虚拟机上的进程能够严格隔离,同时实现高效的资源利用——其安全性和隔离级别接近物理服务器。然而,虚拟化会带来一定的开销,因为每台虚拟机都运行自己的操作系统和内核,这会消耗资源。
在应用层面,虚拟化也会导致开销。在 JVM 内部运行的所有内容都会:
- 比原生编译的程序消耗更多资源,因为 JVM 本身需要占用内存。
- 启动时间更慢,因为 JVM 也需要先完成初始化。
- 在函数首次执行时通常比第二次执行性能差,这是由于动态 JIT 编译造成的。
为了解决这些由 JIT 引起的问题,引入了企业 Java Beans 或 Spring 等库。它们允许通过所谓的“Beans”预加载所需库。每个常用库在启动时被加载到内存中一次,然后随时可用。
这改善了启动后的执行时间,但也存在缺点。主要原因是启动时间变长,因为除了 JVM 之外,现在还需要一个“预热阶段”来加载 Beans。然而,运行时性能更优越,因为没有额外的 JIT 开销。所有运行时所需的类都已加载。
容器、无服务器和 GRAALVM 原生镜像
2013 年,Docker 推广了一种名为容器化的技术,此后彻底改变了应用程序的开发、交付和部署方式。与虚拟机不同,容器不模拟完整的硬件环境。相反,它们共享宿主操作系统的内核,从而实现了进程的轻量级高效隔离。每个容器都包含应用程序及其所有依赖项和库,确保它们在不同环境中——从开发到生产——始终如一地运行。
这种做法使得每个实例中无需完整的操作系统,与传统的虚拟机相比,这带来了更快的启动时间和更低的资源消耗。
容器还提供了更高的可扩展性和灵活性。通过使用 Kubernetes 等编排工具,企业可以在集群之间管理数千个容器,并根据需求动态扩展资源。因此,容器技术非常适合微服务架构或基于单元的架构,其中应用程序被拆分为更小、可独立部署的组件。此外,容器的可移植性简化了开发流程,使开发人员能够专注于编写代码,而无需担心不同环境中的兼容性问题。
软件技术中的另一项现代发展是无服务器计算,这是一种抽象化基础设施管理的范式,允许开发人员专注于编写和部署代码。尽管“无服务器”一词在不同语境下使用方式各异,但在此我们将其定义为满足以下标准的部署模型:
- 动态扩展:无服务器应用程序会根据需求自动扩展,从而无需人工干预即可确保最佳性能。
- 零扩展:在无需求时,无服务器应用程序会自动缩减至零,从而避免为未使用资源产生费用。
- 服务器管理抽象化:开发人员无需再关注底层基础设施——例如资源配置、补丁管理或水平/垂直扩展。
通过动态扩展和降级至零,可以显著提高资源利用率。流量少或无流量的应用程序会释放其分配的 CPU 和内存资源,这些资源随后可供集群中的其他工作负载使用。随着负载的增加,无服务器模型通过水平扩展确保高可用性——无需过度配置资源。
虽然无服务器范式最初由 AWS Lambda、Azure Functions 或 Google Cloud Functions 等公共云提供商主导,但目前它也进入了本地和混合环境——通过 Knative 或 OpenFaaS 等开源解决方案,这些方案使无服务器部署在 Kubernetes 集群上成为可能。这使企业能够利用其在容器编排方面的大量投资,在内部应用无服务器原则。
然而,对于在 JIT 范式下开发的应用程序,转向无服务器模型意味着 JIT 的优势将不再适用——甚至可能产生负作用。
首先,容器化(例如使用 Docker)允许开发者控制整个应用程序环境,包括操作系统,而无需关心底层硬件。因此,JVM 的可移植性优势已基本失去意义,因为可以专门在容器内选择特定的目标操作系统。
另一方面,Java 仍然在 JVM 内部运行——因此 JVM 的启动时间仍然是一个真正的瓶颈。如果再使用 Spring 等大型库,这些库在运行时加载大量 Bean,应用程序会变得过于沉重,无法按需启动和停止。启动时间可能因复杂度而异,从几秒到几分钟不等。
将这类单体应用程序转换为多个小型微服务虽然早已成为现实,并且被许多 Java 开发者实践——但启动时间在亚秒级仍然是一项挑战。
情况并非如此悲观——Java 社区正迎接这一挑战。其中一种解决方案早已经过历史验证:在 C 或 C++这类经典编译型语言中,代码会直接被翻译成针对目标平台的原生可执行二进制文件。
与 Java 字节码编译不同,这些语言直接生成平台相关的机器码二进制文件。这些文件不仅包含程序代码本身,还包括所有相关的库、系统函数和依赖项——所有内容都链接在一个单一的优化二进制文件中。这个过程被称为提前编译(AOT)编译。
在 AOT 编译中,所有执行所需的库在编译时就已经确定。因此,运行时无需进行任何动态类加载或字节码解释——这带来了更快的启动速度和更低的资源消耗。
因此,解决 Java 应用程序启动和预热时间长的问题,在于回归成熟的原理——结合现代的 Java 适宜实现:而正是在这里,GraalVM 发挥作用。
不深入探讨 AOT 编译的技术细节,可以这样说:GraalVM 是一个能够实现这一功能的技术平台。GraalVM 静态分析现有 Java 代码,识别所有使用的依赖项,并从中生成一个原生可执行文件或库。在此过程中,会包含 Classpath 和 JVM 运行时系统中实际需要的所有类,而其他部分则会被剔除,这使得最终生成的二进制文件更加精简高效。
如此生成的原生二进制文件在很多情况下启动速度明显快于在 JVM 内部运行的常规 Java 应用程序。因为不需要启动 JVM,无需动态加载类,也没有即时编译——所有连接在编译时就已经完成,因此应用程序在启动时可以直接开始运行。
当然,这种方法也有一定的局限性。由于 GraalVM 执行静态分析,对于使用动态功能(如反射、JNI(Java 本地接口)或序列化)的程序,可能无法自动识别所有依赖项。在这种情况下,开发者需要通过配置来辅助,以告知 GraalVM 在运行时需要使用哪些额外的类或资源。
GraalVM 的文档为此提供了详细的工具和辅助工具——但这些技术细节超出了本文的范围。
可以明确的是:借助 GraalVM 的即时编译功能,Java 应用程序能够显著更好地适应云环境——无论是运行在 Kubernetes 环境中、在 Knative 配置中,甚至是在 AWS Lambda 内部。
方面 | JVM(例如 HotSpot) | GraalVM 本机镜像 |
---|---|---|
启动时间 | 更慢(由于类加载、JIT 预热) | 非常快(提前编译,几乎立即启动) |
内存消耗 | 更高(JIT 开销,运行时元数据) | 更低(最小运行时间,激进优化) |
性能(峰值) | 更高(JIT 优化会根据运行时调整) | 良好,但通常低于 JVM 在长时间运行的进程中的表现 |
构建时间 | 快速(标准编译) | 缓慢(生成原生镜像很复杂且耗时) |
应用规模 | 小型(仅包含字节码和依赖项) | 更大(包含静态链接的原生代码) |
兼容性 | Breit(支持大多数 Java 库和功能) | 受限(动态特性和反射需要配置) |
预热行为 | 需要预热才能达到峰值性能 | 无需预热 |
适用于部署 | 非常适合长时间运行的 | 非常适合短期/无服务器或“扩展至零”应用程序 |