跳到内容

第 0 卷 / 第 8 章 / 第 1 课

8.1 从源码到可运行制品 ​

前置知识:终端、Git、Maven、Docker

预计时间:30 分钟

练习环境:Bash、curl 7.71+、JDK 17+、Maven 3.6.3+;镜像小节使用 Docker Engine 28+ 的默认 bridge/NAT 网络

完成标志:能区分源码、测试、制品、镜像、部署、发布和运行,并在本地验证一个 Java 17 制品

终端、Git、调试器、构建工具和 Docker 已经就位。现在把源码变成本机能够请求的服务:构建制品、启动进程、暴露端口,并留下可验证的发布证据。

一条链,九个不同动作 ​

动作输出或状态
编写源码与配置变更
编译字节码或机器码
测试对候选版本的验证结果
打包JAR、归档、软件包等制品
构建镜像含运行配置的 OCI/Docker 镜像
发布制品制品进入仓库,获得版本或 digest
部署目标环境开始使用候选版本
发布(release)用户流量或功能开关转向该版本
运行与观测健康、日志、指标、追踪和告警

部署不一定等于用户已经看到新版本。可以先部署到无流量实例,验证后再发布流量;也可以先上线代码,再通过 feature flag 开启功能。

从空目录做一个 Java 17 服务 ​

本章使用 JDK 自带的 HTTP server,避免把框架依赖混进第一条发布链。先建立项目:

bash
workdir=$(mktemp -d)
release_lab_dir="$workdir/release-lab"
mkdir -p "$release_lab_dir/src/main/java/dev/atlas"
cd "$release_lab_dir"

写入完整的 pom.xml:

bash
cat > pom.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.atlas</groupId>
  <artifactId>release-lab</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.4.1</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>dev.atlas.App</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
XML

再写入完整的服务入口:

bash
cat > src/main/java/dev/atlas/App.java <<'JAVA'
package dev.atlas;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

public final class App {
    private App() {}

    public static void main(String[] args) throws IOException {
        String host = System.getenv().getOrDefault("HOST", "127.0.0.1");
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0);
        server.createContext("/", exchange -> respond(exchange, 200, "Hello Atlas! 出发!\n"));
        server.createContext("/health", exchange -> respond(exchange, 200, "ok\n"));
        String version = System.getenv().getOrDefault("APP_VERSION", "v1");
        server.createContext("/version", exchange -> respond(exchange, 200, version + "\n"));
        server.setExecutor(null);
        Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(1)));
        server.start();
        System.out.printf("listening=%d%n", server.getAddress().getPort());
    }

    private static void respond(HttpExchange exchange, int status, String text)
            throws IOException {
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
        exchange.sendResponseHeaders(status, body.length);
        try (OutputStream output = exchange.getResponseBody()) {
            output.write(body);
        }
    }
}
JAVA

/health 只回答进程是否能处理这个请求,/version 返回环境中的版本标识,默认是 v1。它们没有检验数据库或外部服务。JDK HttpServer 按路径前缀匹配 context,本例也未实现精确路由、认证或生产流量控制;机制见 JDK HttpServer 文档。

服务默认监听 127.0.0.1,直接在主机练习时不会暴露到局域网。构建后让操作系统分配一个临时端口,并在同一终端完成验证和停止:

bash
(
set -e
mvn --batch-mode verify
sha256sum target/release-lab-1.0.0.jar

HOST=127.0.0.1 PORT=0 java -jar target/release-lab-1.0.0.jar >app.log 2>&1 &
app_pid=$!
cleanup_app() {
  kill "$app_pid" 2>/dev/null || true
  wait "$app_pid" 2>/dev/null || true
}
trap cleanup_app EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

for attempt in 1 2 3 4 5; do
  grep -q '^listening=' app.log && break
  sleep 1
done
app_port=$(sed -n 's/^listening=//p' app.log)
test -n "$app_port"
curl --fail --silent --show-error "http://127.0.0.1:$app_port/health"
curl --fail --silent --show-error "http://127.0.0.1:$app_port/"

)

圆括号创建独立的子 Shell,失败也会进入退出清理;SIGKILL 无法触发 trap。请求应依次返回 ok 和问候语。macOS 可用 shasum -a 256。Maven 首次解析插件可能联网;项目正式使用时还应提交 Maven Wrapper。这里的 verify 会经过编译、测试和打包,但它不会凭空产生测试;“No tests to run” 仍表示需要另补回归用例。

JAR 能否 java -jar 运行取决于 manifest 和依赖打包方式。这个示例只使用 JDK 模块,所以普通 JAR 足够;有第三方依赖时,普通 Maven JAR 不会自动把它们装进去。Spring Boot 可执行 JAR 则由相应插件重打包,两者不能混为一谈。保留 $workdir 和 $release_lab_dir,章末实验还会使用。

把已验证 JAR 放进镜像 ​

把下面内容保存为 $release_lab_dir/Dockerfile。APP_VERSION 只是公开的练习版本标识,构建时可覆盖;真实凭据不能通过 ARG 或 ENV 烤进镜像:

dockerfile
FROM eclipse-temurin:17-jre
ARG APP_VERSION=v1
ENV APP_VERSION=$APP_VERSION
WORKDIR /app
COPY target/release-lab-1.0.0.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
bash
(
set -e
image_tag="workshop-ch08-release:${RANDOM}-${RANDOM}"
container_id=
cleanup_image() {
  if [ -n "$container_id" ]; then
    docker stop "$container_id" >/dev/null 2>&1 || true
  fi
  docker image rm "$image_tag" >/dev/null 2>&1 || true
}
trap cleanup_image EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
docker build -t "$image_tag" .
container_id=$(docker run --detach --rm --env HOST=0.0.0.0 \
  --publish 127.0.0.1::8080 "$image_tag")
published=$(docker port "$container_id" 8080/tcp)
host_port=${published##*:}
curl --fail --silent --show-error --retry 10 --retry-all-errors \
  --retry-delay 1 --max-time 2 "http://127.0.0.1:$host_port/health"
curl --fail --silent --show-error --max-time 2 "http://127.0.0.1:$host_port/version"
)

容器启动时可能先出现连接拒绝或重置;这里对只读健康 GET 使用最多 10 次重试,每次请求最多 2 秒。--retry-all-errors 需要 curl 7.71+,参见 curl 重试选项。

基础镜像若未缓存,构建会访问 registry;离线环境应提前准备并验证镜像来源。生产镜像还应固定经过审核的版本或 digest,而不是长期依赖可移动标签。

一次构建,逐级晋级 ​

一次发布应构建一个候选制品并逐级使用它。生产项目记录 commit SHA、JAR 校验和、镜像 digest、构建日志和测试结果。开发、预发、生产逐级引用同一 digest,而不是每个环境重新构建。

这样能减少依赖漂移,却不自动保证安全。仍需可重复构建、来源证明、签名、SBOM、漏洞检查和受控配置。

验收 ​

  • 能解释九个动作的边界;
  • 能说明 mvn verify 与“确实有测试”的区别;
  • 能说明普通 JAR 与 Spring Boot 可执行 JAR 的区别;
  • 能用 /health 验证本地 JAR 和容器;
  • 能解释为何部署应引用不可变 digest。

完成健康请求、版本请求和清理后,手动链路才算走通。下一步把验证交给自动化,同时限制它的权限。

→ 下一步:CI/CD

Built with VitePress | Software Systems Atlas