嵌入式 WebServer:端口、Context 与应用生命周期如何接合
嵌入式服务器由应用创建,和业务代码运行在同一个 JVM。Spring Boot 选择 WebServerFactory,把地址、端口、Servlet 注册和容器参数交给它,再把服务器启动与 ApplicationContext 生命周期连接起来。
一个 JAR 内部有哪些 Web 对象
Context、工厂和 Tomcat 组件
SpringApplication
└─ ServletWebServerApplicationContext
├─ ServletWebServerFactory
│ └─ TomcatServletWebServerFactory → TomcatWebServer
│ └─ Tomcat Server
│ └─ Service
│ ├─ Connector → 监听地址、端口、HTTP 处理
│ └─ Engine → Host → Context → Servlet
└─ ServletContextInitializer
├─ ServletRegistrationBean
├─ FilterRegistrationBean
└─ Listener 注册Spring ApplicationContext 保存 Bean,Tomcat Context 表示一个 Web 应用,ServletContext 提供 Servlet 应用级属性和注册接口。这三个 Context 位于不同层。Connector 接收网络连接,把请求交给容器内的映射和处理链;它不负责决定某个业务 Service 应如何注入。Tomcat 配置对象
ServletWebServerApplicationContext 查找一个 ServletWebServerFactory。缺少工厂时无法创建服务器,多个工厂也会造成选择错误。Boot 的默认自动配置提供合适工厂,用户替换工厂必须保留必要的定制和生命周期行为。Web Context API
应用类型为 NONE 时没有这条服务器创建过程;REACTIVE 使用另一种 Context 与服务器工厂。MVC 和 WebFlux 同时存在时默认选择 MVC。需要切换技术栈时同时检查实际依赖、应用类型和服务器实现,不只修改一个名称相近的配置。
版本与独立实验
下载嵌入式服务器实验,在 Linux Bash 解压为 embedded-webserver 并进入目录。实验为 Boot 4.1.1、Java 25、Maven 3.9.12;Boot 4.1.1 的 Servlet 基线是 6.1,支持 Tomcat 11.0.x 与 Jetty 12.1.x。系统要求
宿主需要 Docker、curl 7.76+ 和 Docker 操作权限。构建容器使用宿主 UID/GID,缓存及项目输出目录可写;应用容器另用 10001,Docker daemon 身份独立。
PROJECT_DIR="$PWD"
CACHE_DIR="$HOME/.cache/boot-08-maven"
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p "$CACHE_DIR"
docker run --rm --user "$(id -u):$(id -g)" -e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$CACHE_DIR:/cache" -w /work \
"$BUILD_IMAGE" mvn -B -Dmaven.repo.local=/cache clean verify构建应报告两个测试成功:一个启动实际 Tomcat、访问随机端口并验证关闭后可重绑;另一个由 ServerSocket 占用端口,再启动 Boot,要求失败。超大请求头也通过真实 HTTP 验证为 400。测试中输出端口冲突异常是预期路径,最终报告须为零失败。
镜像首次使用要拉取;内网可通过受信企业镜像和 Maven 私服提供依赖,挂载 settings.xml 并以 -s 选择。离线需要同时准备 Maven 插件、依赖和镜像,使用 mvn -o 检查缓存,不把第三方镜像加速地址写成必要条件。
绑定地址、映射路径与 Servlet 注册
让 Docker 发布一个实际端口
容器内监听 8080,宿主发布到回环随机端口,避免和已有服务竞争固定映射:
CONTAINER=boot-server-lab
docker run -d --name "$CONTAINER" --user 10001:10001 \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--cap-drop ALL --security-opt no-new-privileges \
-p 127.0.0.1::8080 \
-v "$PROJECT_DIR/target/embedded-webserver-lab-1.0.0.jar:/app.jar:ro" \
"$RUN_IMAGE" java -jar /app.jar
docker port "$CONTAINER" 8080/tcp
PORT=$(docker port "$CONTAINER" 8080/tcp | awk -F: '{print $NF}')
docker logs "$CONTAINER"
curl -q --noproxy '*' --fail-with-body -i "http://127.0.0.1:$PORT/hello"响应体为 server-hello,包含 X-Lab-Filter: request。实际 PORT 每次可能不同,以本次 docker port 为准。容器里只有 JAR 的只读挂载和可写 /tmp,没有对公网发布。
server.port=0 则改变容器内服务器端口,由操作系统选择。此时不能继续把 Docker 固定映射到 8080;测试通常不经过 Docker 端口发布,而是从 WebServerInitializedEvent 或 Context 获取实际端口。先查一个空闲端口再启动存在竞争,直接绑定 0 可把“选择与占用”交给操作系统。
server.address 控制应用监听地址。容器内的 127.0.0.1 属于容器自己的网络空间,宿主发布端口转发到容器网络地址时可能访问不到它。实验不强制设置容器内回环地址,而是在宿主映射处限制访问。业务路径还受 server.servlet.context-path 与 Servlet 映射影响;设置 /app 后请求地址成为 /app/hello。嵌入式服务器配置
注册范围与分派类型
实验只给 /hello 的 REQUEST 分派增加响应头:
@Bean
FilterRegistrationBean<Filter> marker() {
Filter filter = (request, response, chain) -> {
((HttpServletResponse) response).setHeader("X-Lab-Filter", "request");
chain.doFilter(request, response);
};
var registration = new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/hello");
registration.setDispatcherTypes(DispatcherType.REQUEST);
registration.setOrder(10);
return registration;
}响应头在 chain.doFilter 前写入,因为后续 Servlet 可能已提交响应。ERROR、ASYNC、FORWARD 是否经过 Filter,由 dispatcher types 和映射共同决定;需要异步支持时还要设置 asyncSupported,并确保链中相关组件也允许异步。仅给一个 Filter 添加注解,不能证明整个异步链都可用。
Boot 可自动注册 Servlet/Filter Bean;有明确映射、顺序或是否启用的要求时使用 RegistrationBean。一个 Filter 既被容器注册、又加入 SecurityFilterChain,可能执行两次;应明确哪一层拥有注册职责。FilterRegistrationBean API
服务器定制如何落到底层资源
优先组合 Customizer
WebServerFactoryCustomizer<T> 在工厂生成服务器之前修改工厂。通过它设置少量参数通常比重新声明整个 factory 更容易保留 Boot 的其他设置;多个 customizer 有顺序,后面的赋值可能覆盖前面的结果。容器专有代码应绑定专有类型,不能让 Jetty 应用无条件加载 Tomcat API。Tomcat 工厂 API
常用参数要先对应资源,不能只比较数字:
| 设置 | 控制对象 | 常见误用 |
|---|---|---|
server.tomcat.threads.max | 传统 Tomcat 请求处理线程上限 | 把线程数当下游可承受并发 |
server.tomcat.max-connections | 可接受和处理的连接数量 | 把 keep-alive 连接都当正在执行业务 |
server.tomcat.accept-count | 到达连接上限后由系统排队的连接 | 把它当应用请求队列 |
server.max-http-request-header-size | 请求行与头部大小限制 | 以为同时限制所有 JSON 请求体 |
spring.servlet.multipart.max-file-size | 单个 multipart 上传文件 | 以为限制任意媒体类型的 body |
spring.servlet.multipart.max-request-size | multipart 请求总大小 | 忽略代理还存在自己的限制 |
Tomcat 的 connectionTimeout、keepAliveTimeout 与业务超时分别针对不同等待阶段。修改连接超时不会自动终止阻塞的数据库查询。线程数、连接数和队列应结合到达率、处理时间、下游池容量和超时推导;启用虚拟线程后也要重新核对哪些线程属性仍参与实际执行。Tomcat HTTP Connector
观察请求头限制
实验配置 server.max-http-request-header-size=8KB,发送一个 12000 字符的演示请求头:
HEADER=$(printf '%12000s' '' | tr ' ' x)
STATUS=$(curl -q --noproxy '*' -sS -o /tmp/boot-header-error.txt \
-w '%{http_code}' -H "X-Large: $HEADER" "http://127.0.0.1:$PORT/hello")
TRANSPORT=$?
test "$TRANSPORT" -eq 0
test "$STATUS" = 400
head -c 240 /tmp/boot-header-error.txt传输完成且 HTTP 为 400,说明服务器在解析请求时拒绝了过大的头;业务 Filter 可能还没有执行。若结果是 431、连接关闭或由代理生成的错误,应确认请求是否经过另一个入口,以及目标容器与版本。不能将不同服务器的错误码视为固定通用行为。
访问日志可记录请求方法、路径、状态和耗时,但 URL 查询参数可能带秘密。开启 Tomcat access log 后为目录提供明确可写挂载,并配置轮转、大小和保留时间;只读容器不能假设临时目录永久保存日志。临时上传文件也需要容量预算,避免大文件填满 /tmp。
HTTPS 可由受信入口终止,也可在应用中配置 SSL bundle;两种方式改变证书和信任的维护位置。启用 HTTP/2 还需确认 TLS/ALPN 与目标容器支持,不能仅凭 server.http2.enabled=true 宣称客户端已协商为 h2。SSL 配置
创建与正式启动不是一个瞬间
Tomcat 工厂创建服务器时配置组件、ServletContext 和 Connector;Boot 的 Tomcat 集成在初始化期间管理连接器,让最终启动与 Context 生命周期协调。WebServerInitializedEvent 发生在服务器启动之后,ApplicationStartedEvent、Runner、ApplicationReadyEvent 继续在后面。TomcatWebServer 实现
Context refresh
├─ 创建/初始化 WebServer,注册 ServletContext 组件
├─ 完成 Bean 与生命周期相关工作
└─ 服务器生命周期启动 → WebServerInitializedEvent
SpringApplication
└─ Started → Runner → Ready → ACCEPTING_TRAFFICContext 刷新异常时,已创建的服务器需要停止并释放端口。随机端口客户端不能在一个早期构造器里读取尚不存在的 local port;应由服务器事件或测试框架在正确阶段提供。独立 management Context 可能再启动一个端口,监听器应比较事件所属 Context,避免把管理端口当业务端口。
端口异常、容器切换与关闭
判断失败发生在宿主还是 JVM
Docker 报“port is already allocated”时,冲突在宿主发布层;JVM 报 BindException/PortInUseException 时,冲突在应用监听层。先保留失败日志,不要随机改一组端口:
docker ps -a --filter "name=$CONTAINER"
docker inspect "$CONTAINER" --format '{{.State.Status}} {{.State.ExitCode}}'
docker port "$CONTAINER"
docker logs --tail 100 "$CONTAINER"
ss -lntss 查看的是当前网络命名空间,宿主命令不会自动列出容器内部全部监听。普通用户看不到其他进程详情时,先判断端口状态;确需识别进程再按运维授权提高权限。
端口冲突实验由测试中的 new ServerSocket(0) 保持真实占用,再把该端口传给 SpringApplication。修正占用关系后重跑测试和实际 HTTP;MockMvc 不启动真实服务器,无法覆盖这个故障。
切换 Jetty 时,在 webmvc starter 中排除 Tomcat starter,加入 Jetty starter,并重跑 Servlet 注册、大小限制、TLS、并发与停机测试。外置 WAR 则由外部服务器创建应用,Servlet 容器依赖使用 provided,应用需正确的 SpringBootServletInitializer;不要让两个生命周期同时拥有同一服务器。传统 WAR 部署
在有界时间内停止
Boot 4.1.1 默认启用支持服务器的优雅停机。Context 关闭时先改变可用性,服务器停止接受新请求,并在 spring.lifecycle.timeout-per-shutdown-phase 预算内等待活动请求;不同服务器拒绝新连接或新请求的具体方式有差异。优雅停机
Docker 的停止预算必须大于应用各阶段所需时间。长连接、后台消费和独立线程池不都属于 HTTP 活动请求,分别需要关闭和恢复策略。SIGKILL、节点断电不会执行 destroy,因此已经接受的业务操作仍应依靠事务、幂等或持久任务恢复。
docker stop -t 30 "$CONTAINER"
docker inspect "$CONTAINER" --format '{{.State.Status}} {{.State.ExitCode}}'
docker logs --tail 30 "$CONTAINER"
docker rm "$CONTAINER"确认日志包含服务器排空和停止,再删除本次实验容器。端口释放验证已由真实测试执行;生产仍需对自己的慢请求、WebSocket、代理摘流量和下游关闭顺序分别复验。
权威资料与规范地址
服务器配置、Servlet 集成和关闭行为见以下规范与 API。
