团队维护的一个中等规模的Scala服务最近在CI/CD流程上遇到了瓶颈。旧有的构建方式是典型的两步走:首先通过sbt assembly
打包一个包含所有依赖的“fat JAR”,然后执行一个冗长的Dockerfile
,将这个巨大的JAR文件复制到一个JVM基础镜像中。这个流程不仅缓慢,还带来了几个棘手的生产问题:
- 构建速度缓慢: 每次代码的微小改动,CI都需要重新执行
sbt assembly
,并重新构建整个Docker镜像,无法有效利用Docker的层缓存。一个完整的CI流程耗时超过15分钟。 - 镜像体积庞大: Fat JAR通常在100MB以上,加上一个标准的OpenJDK镜像,最终的应用镜像轻松突破500MB。这不仅浪费存储,还拖慢了在Kubernetes集群中的分发和启动速度。
- 不可复现的构建:
Dockerfile
中的操作,例如apt-get install
,或者基础镜像的更新,都可能导致两次构建产生不完全相同的镜像,这违背了不可变基础设施的核心原则。 - 环境依赖: CI runner和开发者本地都必须安装和配置Docker守护进程,这增加了环境配置的复杂性和潜在的“在我机器上可以”问题。
为了解决这些痛点,我们决定彻底改造构建流程。目标是:移除Dockerfile
和fat JAR,实现无Docker守护进程的、可复现的、分层优化的容器镜像构建,并将其无缝集成到现有的sbt工程和CI/CD流水线中。Jib,作为一个直接集成到构建工具(Maven/Gradle/Sbt)的Java/Scala容器化工具,成为了我们的首选方案。
我们的技术栈核心是Scala(基于Akka HTTP)、MariaDB作为持久化存储,以及Flyway进行数据库schema的版本管理。以下是整个改造过程的复盘。
技术选型决策:为何是Jib与Flyway的组合
在启动改造前,我们评估了sbt-native-packager
的Docker插件。它虽然也能生成Dockerfile
并构建镜像,但本质上仍是Dockerfile
模式的封装,无法根治我们面临的核心痛点。
Jib的优势则非常突出:
- Daemonless: Jib直接与镜像仓库API交互,完全不需要本地或CI环境中的Docker守护进程。这简化了环境配置,并规避了Docker-in-Docker的复杂性。
- 智能分层: Jib能自动将应用拆分为多个逻辑层:依赖项、资源文件、编译后的类文件。依赖项被进一步细分为稳定(SNAPSHOT)和不稳定(非SNAPSHOT)的部分。这意味着,只要项目依赖没有变化,无论你修改多少次业务代码,Jib在推送镜像时都只会重新上传那个最小的、包含你代码的“类文件”层。这是CI速度提升的关键。
- 可复现性: Jib生成的镜像元数据(如文件创建时间戳)是固定的,确保了只要输入(代码和依赖)相同,输出的镜像digest就完全一致。
而对于数据库,仅仅将应用容器化是不够的。一个有状态服务的部署流程必须原子化地处理应用版本和数据库Schema版本的匹配。Flyway通过将SQL迁移脚本作为版本化资源与代码一同管理,确保了应用在启动时能自动检查并应用所需的数据库变更,实现了数据库状态与代码状态的同步演进。
项目基础结构与依赖配置
我们的第一步是在build.sbt
中引入sbt-jib
插件并配置好所有必要的依赖。
// project/plugins.sbt
addSbtPlugin("com.google.cloud.tools" % "sbt-jib" % "0.8.0")
// build.sbt
ThisBuild / version := "0.1.0-SNAPSHOT"
ThisBuild / scalaVersion := "2.13.10"
lazy val root = (project in file("."))
.enablePlugins(JibPlugin) // 启用Jib插件
.settings(
name := "scala-jib-mariadb-service",
libraryDependencies ++= Seq(
// Akka HTTP for web layer
"com.typesafe.akka" %% "akka-http" % "10.5.0",
"com.typesafe.akka" %% "akka-stream" % "2.8.0",
"com.typesafe.akka" %% "akka-actor-typed" % "2.8.0",
// Circe for JSON serialization
"io.circe" %% "circe-core" % "0.14.5",
"io.circe" %% "circe-generic" % "0.14.5",
"de.heikoseeberger" %% "akka-http-circe" % "1.39.2",
// Database layer: Quill for type-safe queries and MariaDB driver
"io.getquill" %% "quill-jdbc" % "4.6.0",
"org.mariadb.jdbc" % "mariadb-java-client" % "3.1.4",
// Flyway for database migrations
"org.flywaydb" % "flyway-core" % "9.16.0",
"org.flywaydb" % "flyway-mysql" % "9.16.0", // MariaDB uses the MySQL driver for Flyway
// Configuration
"com.typesafe" % "config" % "1.4.2",
// Logging
"ch.qos.logback" % "logback-classic" % "1.4.7",
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.5",
// Testing
"org.scalatest" %% "scalatest" % "3.2.15" % Test
)
)
这个build.sbt
文件定义了一个典型的Scala服务所需的核心库。关键在于.enablePlugins(JibPlugin)
,它为我们的sbt项目注入了所有Jib相关的命令。
配置先行:HOCON与环境变量
一个生产级的应用必须将配置与代码分离。我们使用Typesafe Config(HOCON)格式,并通过环境变量覆盖敏感信息或环境特定配置。
src/main/resources/application.conf
:
app {
http {
interface = "0.0.0.0"
port = 8080
}
}
db {
// 这些值应该通过环境变量在生产环境中覆盖
url = "jdbc:mariadb://localhost:3306/appdb"
url = ${?DB_URL} // 使用环境变量 DB_URL 覆盖
user = "appuser"
user = ${?DB_USER}
password = "changeme"
password = ${?DB_PASSWORD}
driver = "org.mariadb.jdbc.Driver"
}
这种 ${?VAR}
语法允许环境变量平滑地覆盖默认值,是云原生应用配置管理的标准实践。
数据库迁移与版本控制
Flyway的核心是管理一系列版本化的SQL脚本。我们在src/main/resources/db/migration
目录下创建第一个迁移脚本。Flyway会自动发现并按顺序执行它们。
src/main/resources/db/migration/V1__Create_user_table.sql
:
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_users_email ON users(email);
脚本的命名遵循V<VERSION>__<DESCRIPTION>.sql
格式,这是Flyway识别版本的关键。这个脚本将在数据库中创建一个users
表。
核心业务逻辑:数据访问与HTTP服务
为了让示例更具体,我们实现一个简单的用户管理服务。
1. 数据模型与Quill数据访问层
我们使用Quill来提供类型安全的数据库查询,避免手写SQL带来的潜在错误。
src/main/scala/com/example/model/User.scala
:
package com.example.model
import java.time.Instant
import java.util.UUID
case class User(id: UUID, email: String, createdAt: Instant)
src/main/scala/com/example/repository/UserRepository.scala
:
package com.example.repository
import com.example.model.User
import io.getquill.{CamelCase, MariaDBJdbcContext}
import scala.concurrent.{ExecutionContext, Future}
import java.util.UUID
import javax.sql.DataSource
class UserRepository(dataSource: DataSource)(implicit ec: ExecutionContext) {
// Quill的上下文,它将Scala代码翻译成SQL
private val ctx = new MariaDBJdbcContext(CamelCase, dataSource)
import ctx._
// 定义users表和列的映射
private val usersSchema = quote {
querySchema[User]("users")
}
def create(user: User): Future[Unit] = Future {
ctx.run(usersSchema.insertValue(lift(user)))
()
}
def findById(id: UUID): Future[Option[User]] = Future {
ctx.run(usersSchema.filter(_.id == lift(id))).headOption
}
def findByEmail(email: String): Future[Option[User]] = Future {
ctx.run(usersSchema.filter(_.email == lift(email))).headOption
}
}
UserRepository
封装了所有数据库交互。注意它依赖于一个DataSource
,我们将在应用启动时创建并注入它。
2. 应用程序启动与服务路由
主应用负责整合所有组件:配置加载、数据库连接池创建、执行数据库迁移,最后启动HTTP服务器。
src/main/scala/com/example/Main.scala
:
package com.example
import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.Behaviors
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import com.example.repository.UserRepository
import com.typesafe.config.ConfigFactory
import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
import org.flywaydb.core.Flyway
import scala.concurrent.ExecutionContextExecutor
import scala.util.{Failure, Success}
import com.typesafe.scalalogging.LazyLogging
object Main extends App with LazyLogging {
implicit val system: ActorSystem[Nothing] = ActorSystem(Behaviors.empty, "scala-jib-service")
implicit val executionContext: ExecutionContextExecutor = system.executionContext
// 1. 加载配置
val config = ConfigFactory.load()
val dbConfig = config.getConfig("db")
// 2. 配置HikariCP连接池
val hikariConfig = new HikariConfig()
hikariConfig.setJdbcUrl(dbConfig.getString("url"))
hikariConfig.setUsername(dbConfig.getString("user"))
hikariConfig.setPassword(dbConfig.getString("password"))
hikariConfig.setDriverClassName(dbConfig.getString("driver"))
val dataSource = new HikariDataSource(hikariConfig)
// 3. 执行数据库迁移
// 这是一个关键步骤,确保应用启动前数据库schema是最新的
logger.info("Running database migrations...")
try {
val flyway = Flyway.configure().dataSource(dataSource).load()
flyway.migrate()
logger.info("Database migrations completed successfully.")
} catch {
case e: Exception =>
logger.error("Database migration failed!", e)
// 在生产环境中,迁移失败应该导致应用启动失败
system.terminate()
sys.exit(1)
}
// 4. 初始化Repository和路由
val userRepository = new UserRepository(dataSource)
// 此处可以定义你的HTTP路由
val routes =
path("health") {
get {
complete("OK")
}
}
// ... 其他业务路由
// 5. 启动HTTP服务器
val httpConfig = config.getConfig("app.http")
val interface = httpConfig.getString("interface")
val port = httpConfig.getInt("port")
Http().newServerAt(interface, port).bind(routes).onComplete {
case Success(binding) =>
logger.info(s"Server online at http://${binding.localAddress.getHostString}:${binding.localAddress.getPort}/")
case Failure(ex) =>
logger.error("Failed to bind HTTP endpoint, terminating system", ex)
system.terminate()
}
}
在Main.scala
中,我们在创建DataSource
之后、初始化任何依赖于数据库的组件(如UserRepository
)之前,立即执行了flyway.migrate()
。这是一个关键的健壮性设计,它保证了应用代码所期望的数据库表结构一定存在。
Jib深度配置与优化
现在,回到build.sbt
,我们来添加并解释Jib的详细配置。
// build.sbt (添加Jib相关配置)
// ... (之前的依赖配置) ...
// Jib 配置
jibBaseImage := "gcr.io/distroless/java11-debian11"
jibImageFormat := "OCI" // 使用开放容器格式(OCI)标准
jibMainClass := Some("com.example.Main")
// JVM参数优化,对于容器环境至关重要
// XshowSettings:vm -打印最终的VM设置,用于调试
// XX:+UseContainerSupport -让JVM能识别容器的内存和CPU限制
// XX:MaxRAMPercentage=80.0 -使用80%的容器分配内存作为堆大小上限
jibJvmFlags := Seq(
"-XshowSettings:vm",
"-XX:+UseContainerSupport",
"-XX:MaxRAMPercentage=80.0"
)
// 定义镜像标签
jibImage := "my-registry/scala-jib-mariadb-service"
jibTags := Set("latest", version.value)
// 针对多模块项目,可以配置jibProjectName
// jibProjectName := name.value
这里的每一个配置都基于生产实践考量:
-
jibBaseImage
: 我们选择distroless
作为基础镜像。它仅包含应用运行所需的最低限度的库和Java运行时,不含shell、包管理器等,极大地减小了镜像体积和潜在的攻击面。 -
jibJvmFlags
: 这是容器化JVM应用性能调优的关键。UseContainerSupport
让JVM能够正确地从cgroups读取内存和CPU限制,而不是读取整个宿主机的资源。MaxRAMPercentage
则使其能动态地根据容器分配的内存来调整堆大小,避免了硬编码-Xmx
值带来的不灵活性。 -
jibTags
: 同时给镜像打上latest
和具体的版本号标签,是常见的镜像版本管理策略。
构建与集成CI/CD
有了以上配置,整个构建流程变得异常简单。在本地,开发者只需执行:
# 构建并推送到远程仓库 (需要认证,例如 docker login)
sbt jibBuild
# 或者构建到本地Docker守护进程,用于本地测试
sbt jibDockerBuild
注意jibBuild
命令完全不需要本地安装Docker。
接下来,我们将这个流程集成到GitLab CI。
.gitlab-ci.yml
:
stages:
- build
variables:
SBT_VERSION: "1.8.2" # 指定sbt版本
build_job:
stage: build
image: hseeberger/scala-sbt:${SBT_VERSION} # 使用一个包含sbt和jdk的镜像
script:
- echo "Logging in to GitLab Container Registry..."
# CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY 是GitLab提供的预定义变量
- echo "$CI_REGISTRY_PASSWORD" | sbt --error "jibLogin --username $CI_REGISTRY_USER --password-stdin $CI_REGISTRY"
- echo "Building and pushing container image..."
# 将Jib的镜像名设置为GitLab仓库的镜像地址
- sbt "set jibImage := \"$CI_REGISTRY_IMAGE\"; jibBuild"
rules:
- if: $CI_COMMIT_BRANCH == "main" # 只在主分支上执行
这个CI配置非常简洁。它使用的hseeberger/scala-sbt
镜像本身不包含Docker。jibLogin
命令处理了认证,而jibBuild
则完成了构建和推送。整个过程没有docker build
,没有docker push
,也没有fat JAR。
为了直观展示流程的优化,我们可以用Mermaid图对比前后差异:
graph TD subgraph "旧流程 (Fat JAR + Dockerfile)" A[Git Push] --> B{CI Runner}; B --> C[sbt clean assembly]; C --> D[生成 Fat JAR]; D --> E[docker build -t myapp .]; E --> F[执行Dockerfile: COPY fat.jar]; F --> G[docker push my-registry/myapp]; end subgraph "新流程 (Jib)" H[Git Push] --> I{CI Runner}; I --> J[sbt jibBuild]; J --> K[Jib分析代码和依赖]; K --> L[直接与镜像仓库API通信]; L --> M[增量推送Layers]; end style C fill:#f9f,stroke:#333,stroke-width:2px; style E fill:#f9f,stroke:#333,stroke-width:2px; style J fill:#9cf,stroke:#333,stroke-width:2px;
局限性与未来迭代路径
尽管这套方案极大地提升了我们的构建效率和镜像质量,但它并非银弹。当前的实现依然存在一些值得探讨和优化的点。
首先,将数据库迁移(flyway.migrate()
)放在应用启动时执行的策略,在简单场景下可行,但在大规模、高并发的Kubernetes部署中存在风险。当新版本应用滚动更新时,多个Pod实例可能会同时启动并尝试执行迁移,引发竞态条件。Flyway虽然有自己的锁机制来防止并发迁移,但更稳健的生产实践是将数据库迁移作为一个独立的、一次性的任务(例如Kubernetes Job)在应用部署之前执行。这能确保在任何新应用实例启动前,数据库已处于正确的状态。
其次,我们的健康检查端点(/health
)目前过于简单,只返回200 OK
。一个更完善的健康检查应该包含对关键依赖(如数据库连接)的检查。可以设计readiness
探针来检查数据库连接池是否正常,只有在准备就绪后,流量才应被路由到该实例。
最后,对于大型的多模块(multi-module)sbt项目,Jib的配置需要更精细。为了最大化层缓存的效率,需要仔细规划模块间的依赖关系,确保频繁变动的业务代码模块与稳定不变的基础库模块分离,Jib才能为它们创建不同的层。这可能需要对build.sbt
的结构进行一些重构。