Spring Boot・Spring Security・JPA・Dockerで本番環境対応のREST APIを構築する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

このスタックを選んだ背景と理由

午前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.conflog_min_duration_statement = 1000を設定し、1秒以上かかるクエリをキャッチする
  • レスポンスタイムのパーセンタイル — MicrometerとPrometheusを追加してp99を追跡しよう。平均値はユーザーが実際に経験するテールレイテンシーを隠してしまう

過去の自分に伝えるとしたら:createupdateではなく、最初からddl-auto: validateを設定しなさい、ということだ。Hibernateのスキーマ管理にはロールバックがない。デプロイの途中で失敗したマイグレーションは、最悪のタイミングで手動復旧を強いる。一人開発を卒業したら、バージョン管理されたマイグレーションのためにFlywayに切り替えよう。セットアップに2時間かかるが、それ以降のクリーンな本番デプロイがすべての報酬だ。

Share: