利用Jib为基于MariaDB的Scala应用构建轻量级可复现的容器化CI/CD流程


团队维护的一个中等规模的Scala服务最近在CI/CD流程上遇到了瓶颈。旧有的构建方式是典型的两步走:首先通过sbt assembly打包一个包含所有依赖的“fat JAR”,然后执行一个冗长的Dockerfile,将这个巨大的JAR文件复制到一个JVM基础镜像中。这个流程不仅缓慢,还带来了几个棘手的生产问题:

  1. 构建速度缓慢: 每次代码的微小改动,CI都需要重新执行sbt assembly,并重新构建整个Docker镜像,无法有效利用Docker的层缓存。一个完整的CI流程耗时超过15分钟。
  2. 镜像体积庞大: Fat JAR通常在100MB以上,加上一个标准的OpenJDK镜像,最终的应用镜像轻松突破500MB。这不仅浪费存储,还拖慢了在Kubernetes集群中的分发和启动速度。
  3. 不可复现的构建: Dockerfile中的操作,例如apt-get install,或者基础镜像的更新,都可能导致两次构建产生不完全相同的镜像,这违背了不可变基础设施的核心原则。
  4. 环境依赖: 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的结构进行一些重构。


  目录