このスタックを選んだ背景と理由
午前2時47分、アラートが鳴り響いた。Node.jsのマイクロサービスが高負荷時に503エラーを連発し、PostgreSQLへのコネクションプールが完全に枯渇していた。午前4時までに結論を出した:重要な課金サービスをSpring Bootに移行する。Javaが流行っているからではなく、予測可能なスレッド管理、同時接続スパイク時にも崩れないコネクション処理、そして自分たちで構築せずに済むセキュリティレイヤーが必要だったからだ。
あれから18ヶ月が経った。課金サービスはそれ以来、一度もコネクション枯渇を起こしていない。数百万件のトランザクション処理、障害ゼロ。
Spring BootとJPA、Spring Securityの組み合わせが、深夜2時に本当に重要な3つのものを提供してくれる:
- Spring Security — セキュリティロジックをゼロから書かずに、JWT認証、CSRF保護、ロールベースのアクセス制御を実現
- Spring Data JPA — コネクションプーリング組み込みのデータベース抽象化レイヤー。高負荷時にコネクションを枯渇させるN+1クエリ問題を防止
- Docker — 再現可能なデプロイ環境を提供し、「自分の環境では動く」が深夜4時の言い訳にならないようにする
インストール
前提条件
Java 17以上、Maven 3.8以上、Docker、PostgreSQLが必要だ。JavaのバージョンはSdkmanで管理しよう。これなしでプロジェクト途中にJDKを切り替えるのは苦痛だ:
# SDKMANをインストール
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Java 17をインストール
sdk install java 17.0.9-tem
java -version
プロジェクトの初期化
CLIからSpring Initializrを使おう。深夜にセットアップするとき、Web UIよりも速い:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.2.0 \
-d baseDir=my-api \
-d groupId=com.example \
-d artifactId=my-api \
-d dependencies=web,data-jpa,security,postgresql,lombok,validation \
-o my-api.zip && unzip my-api.zip
これで、重要な処理を担う4つの依存関係を含むpom.xmlが生成される:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
設定
データベースとJPAの設定
application.ymlを設定する。.propertiesよりYAMLを選ぶ理由は、深夜2時にピリオドの欠落よりもインデントのずれの方が見つけやすいからだ:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # 本番環境では create-drop を絶対に使わないこと
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
HikariCPのプール設定は恣意的なものではない。PostgreSQLのデフォルト最大接続数は100だ。アプリインスタンスを2台起動して各20接続使えば40接続を消費する。残りの60接続はDBAの作業、監視クエリ、そして避けられない緊急の管理セッション用だ。上限に達すると、すべての新規接続がブロックされる。
エンティティ、リポジトリ、コントローラー
// Product.java
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
@NotBlank(message = "商品名は必須です")
private String name;
@Column(nullable = false)
@DecimalMin(value = "0.0", message = "価格は正の値でなければなりません")
private BigDecimal price;
@CreationTimestamp
private LocalDateTime createdAt;
}
// ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
List<Product> findByPriceRange(@Param("min") BigDecimal min,
@Param("max") BigDecimal max);
}
// ProductController.java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Validated
public class ProductController {
private final ProductRepository productRepository;
@GetMapping
public ResponseEntity<List<Product>> getAll() {
return ResponseEntity.ok(productRepository.findAll());
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody Product product) {
Product saved = productRepository.save(product);
URI location = URI.create("/api/v1/products/" + saved.getId());
return ResponseEntity.created(location).body(saved);
}
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
return productRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!productRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
productRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Spring Securityの設定
Spring Security 6(Boot 3.x)ではWebSecurityConfigurerAdapterが廃止された。2023年以前のStack Overflowの回答をコピーしてもコンパイルは通るが、設定が静かに誤った状態になる。実際に機能するコンポーネントベースのアプローチは次のとおりだ:
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // ステートレスREST — CSRFは不要
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("secret"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
}
アプリケーションのDockerコンテナ化
マルチステージビルドにより、イメージサイズを約500MBから約150MBに削減できる。深夜に劣化したネットワークでイメージをプルするとき、この差は大きい。dependency:go-offlineステップは余分なキャッシュレイヤーとして価値がある。アプリケーションコードのみを変更した場合、次のビルドでDockerがMavenのダウンロードを完全にスキップし、イテレーションごとに2〜3分の節約になる:
# Dockerfile
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn clean package -DskipTests -q
# ランタイムステージ — より小さいベースイメージ、JDKオーバーヘッドなし
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8080:8080"
environment:
DB_USERNAME: postgres
DB_PASSWORD: secret
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
検証とモニタリング
APIの起動とテスト
# すべてを起動
docker-compose up -d
# 起動ログを確認
docker-compose logs -f api
# パブリックエンドポイントをテスト
curl http://localhost:8080/api/v1/products
# 商品を作成(Basic認証が必要)
curl -X POST http://localhost:8080/api/v1/products \
-u admin:secret \
-H "Content-Type: application/json" \
-d '{"name": "PostgreSQL Guide", "price": 29.99}'
# 戻り値:201 Created(Location: /api/v1/products/1)
ヘルスモニタリングのためのSpring Actuator
Actuatorの依存関係を追加してエンドポイントを公開しよう。深夜のインシデント時にSSHアクセスなしでコネクションプールの状態を確認する必要があったとき、これが私たちを救った:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.ymlに追加
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
# アプリケーションのヘルスチェック
curl http://localhost:8080/actuator/health
# レスポンス:
# {
# "status": "UP",
# "components": {
# "db": {"status": "UP"},
# "diskSpace": {"status": "UP"}
# }
# }
# HikariCPプールのメトリクスを確認
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
本番環境で監視すべき指標
3つのサービスにわたってこのパターンを運用した経験から、実際にアラートが発動するメトリクスはこれらだ:
- HikariCPのアクティブコネクション数 —
maximum-pool-sizeの80%を継続的に超えた場合にアラートを発動 - JVMヒープ使用量 — Spring Bootアプリは通常200〜400MBで安定する。スパイクは大抵、大量の行を返すクエリが原因
- PostgreSQLのスロークエリ —
postgresql.confでlog_min_duration_statement = 1000を設定し、1秒以上かかるクエリをキャッチする - レスポンスタイムのパーセンタイル — MicrometerとPrometheusを追加してp99を追跡しよう。平均値はユーザーが実際に経験するテールレイテンシーを隠してしまう
過去の自分に伝えるとしたら:createやupdateではなく、最初からddl-auto: validateを設定しなさい、ということだ。Hibernateのスキーマ管理にはロールバックがない。デプロイの途中で失敗したマイグレーションは、最悪のタイミングで手動復旧を強いる。一人開発を卒業したら、バージョン管理されたマイグレーションのためにFlywayに切り替えよう。セットアップに2時間かかるが、それ以降のクリーンな本番デプロイがすべての報酬だ。
