Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Gradle / Kotlin / IDE 캐시 — 빌드 컨텍스트 비대화 + 호스트 잔여물 차단
**/build
**/.gradle
**/.kotlin
.idea
*.iml

# VCS / 인프라 산출물 — 컨테이너 안에서 필요 없음
.git
.github
.gitignore
.gitattributes

# 인프라/문서 — Dockerfile build stage에서 미사용
docs
docker
debezium
mock-fx
mock-payout
mock-webhook
mysql-init
docker-compose.yml
README.md
AGENTS.md
50 changes: 50 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1.7
#
# 단일 Dockerfile로 4개 Spring Boot 모듈(remittance-api, payout-worker,
# webhook-dispatcher, reconciler)을 ARG MODULE 로 빌드.
#
# Layered JAR 패턴: dependencies / spring-boot-loader / snapshot-dependencies
# / application 4개 layer를 분리 COPY 하여 코드 변경 시 application layer만
# 갱신되도록 Docker layer 캐시 효율을 확보.

# === build stage: gradle → bootJar ===
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace

COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
COPY common ./common
COPY payment-module ./payment-module
COPY remittance-api ./remittance-api
COPY payout-worker ./payout-worker
COPY webhook-dispatcher ./webhook-dispatcher
COPY reconciler ./reconciler

ARG MODULE
RUN chmod +x gradlew && ./gradlew :${MODULE}:bootJar --no-daemon -x test

# === extract stage: layered JAR을 layer별 디렉터리로 분해 ===
# tools jarmode의 extract --destination은 디렉터리가 비어있길 요구하므로
# JAR은 /tmp에 두고 빈 /extract를 destination으로 사용.
FROM eclipse-temurin:21-jdk AS extract
ARG MODULE
COPY --from=build /workspace/${MODULE}/build/libs/*.jar /tmp/app.jar
WORKDIR /extract
RUN java -Djarmode=tools -jar /tmp/app.jar extract \
--layers dependencies,spring-boot-loader,snapshot-dependencies,application \
--launcher --destination .

# === runtime stage: layer 순서대로 별도 COPY → 별도 Docker layer 형성 ===
FROM eclipse-temurin:21-jre
# curl: HEALTHCHECK가 actuator/health를 polling 하기 위해 필요
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=extract /extract/dependencies/ ./
COPY --from=extract /extract/spring-boot-loader/ ./
COPY --from=extract /extract/snapshot-dependencies/ ./
COPY --from=extract /extract/application/ ./
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=12 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java","org.springframework.boot.loader.launch.JarLauncher"]
97 changes: 73 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,30 @@

## 실행

두 가지 모드를 지원합니다. **개발 모드**는 인프라만 컨테이너로 띄우고 4개 앱은 IDE/호스트 JVM에서 `bootRun`, **풀스택 모드**는 4개 앱까지 컨테이너로 한 번에 기동합니다.

### 개발 모드 (인프라만)

```bash
docker compose up -d # 인프라만 (MySQL/Redis/Kafka/Debezium/WireMock×3/Prometheus/Grafana)
./gradlew :remittance-api:bootRun # 다른 터미널에서 모듈별 bootRun
```

### 풀스택 모드 — 1-command 실행 (`--profile app`)

```bash
docker compose up -d
./gradlew :remittance-api:bootRun
docker compose --profile app up -d --build
```

헬스체크: `http://localhost:8080/actuator/health`
4개 앱(remittance-api / payout-worker / webhook-dispatcher / reconciler)까지 컨테이너로 띄웁니다. 첫 실행은 Gradle 의존성 다운로드 때문에 수 분 걸리지만, 이후 코드 변경 시에는 layered JAR의 `application` layer만 갱신되어 빌드/푸시가 빠릅니다(상세는 ["컨테이너화 — Layered JAR + ARG MODULE"](#컨테이너화--layered-jar--arg-module) 섹션).

헬스체크 (두 모드 공통):
```
http://localhost:8080/actuator/health # remittance-api
http://localhost:8081/actuator/health # payout-worker
http://localhost:8082/actuator/health # webhook-dispatcher
http://localhost:8084/actuator/health # reconciler
```

## 모듈

Expand Down Expand Up @@ -192,54 +210,85 @@ GitHub Actions CI에서도 동일한 Testcontainers 기반으로 매 커밋 검

## docker-compose 풀스택 검증 (Debezium 포함 E2E)

```bash
docker compose up -d
```

기동 컨테이너:
기동 컨테이너 (default 프로필 — 인프라만):
- `openremit-mysql` (3306, binlog ROW + GTID 활성)
- `openremit-redis` (6379)
- `openremit-kafka` (9092 host / 29092 internal, KRaft single-node)
- `openremit-mock-fx` (9999), `openremit-mock-payout` (9998), `openremit-mock-webhook` (9997) — WireMock
- `openremit-debezium` (8083) — Connect standalone
- `openremit-debezium-register` — outbox 커넥터 자동 등록 (1회성, retry 6회)
- `openremit-prometheus` (9090) — `host.docker.internal`로 호스트 JVM의 4개 모듈 scrape
- `openremit-prometheus` (9090) — 풀스택 모드의 컨테이너 앱(`remittance-api:8080` 등)을 직접 scrape. 호스트 JVM 모드에서는 Grafana 대시보드 비어 보임(트레이드오프)
- `openremit-grafana` (3000) — anonymous Viewer 허용, datasource(`prometheus`) + 대시보드(`openremit`) provisioning

검증:
`--profile app` 추가 시 4개 앱 컨테이너도 기동:
- `openremit-remittance-api` (8080:8080), `openremit-payout-worker` (8081:8080), `openremit-webhook-dispatcher` (8082:8080), `openremit-reconciler` (8084:8080)

E2E 검증:
```bash
docker compose --profile app up -d --build

# Debezium 커넥터 상태
curl -sS http://localhost:8083/connectors/openremit-outbox/status | jq

# remittance-api 기동
./gradlew :remittance-api:bootRun
# 다른 터미널에서 payout-worker 기동
./gradlew :payout-worker:bootRun
# 4개 앱 헬스
for p in 8080 8081 8082 8084; do curl -fsS http://localhost:$p/actuator/health; echo; done

# 송금 요청 (POST /api/v1/remittances) 후 토픽에 흐르는지 확인
# 송금 요청(POST /api/v1/remittances) 후 토픽에 흐르는지 확인
docker exec -it openremit-kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic remittance.paid --from-beginning
```

볼륨 초기화가 필요하면 `docker compose down -v` 후 재기동 (mysql-init 스크립트가 다시 실행되어 Debezium 사용자가 생성됨).
볼륨 초기화가 필요하면 `docker compose --profile app down -v` 후 재기동 (mysql-init 스크립트가 다시 실행되어 Debezium 사용자가 생성됨).

## 컨테이너화 — Layered JAR + ARG MODULE

루트의 단일 [`Dockerfile`](Dockerfile) 한 벌로 4개 Spring Boot 모듈을 모두 빌드합니다. 두 가지 결정이 들어가 있습니다.

**1. `ARG MODULE` — Dockerfile 한 벌로 4개 이미지**

```dockerfile
ARG MODULE
RUN ./gradlew :${MODULE}:bootJar --no-daemon -x test
COPY --from=build /workspace/${MODULE}/build/libs/*.jar app.jar
```

`docker-compose.yml`에서 모듈마다 `build.args.MODULE`만 다르게 넘겨 4번 호출. Dockerfile 4개 복붙을 피하고, 빌드 패턴 변경 시 한 곳만 수정하면 됩니다.

**2. Layered JAR — 변경 빈도가 다른 4개 layer를 분리**

Spring Boot의 `tools` jarmode로 fat JAR을 layer별로 분해해 별도 `COPY`로 가져옵니다.

```dockerfile
RUN java -Djarmode=tools -jar app.jar extract --layers --destination .
COPY --from=extract /extract/dependencies/ ./ # 거의 안 바뀜
COPY --from=extract /extract/spring-boot-loader/ ./ # 거의 안 바뀜
COPY --from=extract /extract/snapshot-dependencies/ ./ # 가끔
COPY --from=extract /extract/application/ ./ # 매 커밋
```

코드만 바꾼 재빌드에서 dependencies layer는 Docker 캐시 hit로 재전송 0, application layer(수백 KB)만 갱신됩니다. 단순 `COPY *.jar` 방식은 코드 한 줄 변경에도 fat JAR 전체(수십 MB)가 새 layer로 묶여 레지스트리 푸시·노드 풀 비용을 그대로 받습니다.

**3. 컨테이너 내부 포트 표준화**

application.yaml의 `server.port`(8080/8081/8082/8084)는 호스트 JVM 모드를 위한 값이고, 컨테이너에서는 `SERVER_PORT=8080`을 ENV로 주입해 모두 8080을 듣게 합니다. HEALTHCHECK·Prometheus scrape 경로를 컨테이너 단에서 단일화하고, 호스트 포트는 매핑 단계(`8081:8080` 등)에서만 분리합니다.

## 관측성 — 메트릭 / 로그 (Day 12)

### 메트릭

각 Spring Boot 모듈이 `/actuator/prometheus`를 노출하고, `docker-compose`의 Prometheus가 `host.docker.internal:808x`로 scrape 합니다.
각 Spring Boot 모듈이 `/actuator/prometheus`를 노출하고, `docker-compose`의 Prometheus가 풀스택 모드의 컨테이너 앱(`remittance-api:8080` 등)을 직접 scrape 합니다. 호스트 JVM 모드(인프라만 띄우고 IDE에서 `bootRun`)에서는 Grafana 대시보드가 비어 보이며, raw 메트릭은 `curl localhost:808x/actuator/prometheus`로 확인합니다 — 호스트 JVM 잡과 컨테이너 잡을 동시에 두면 `host-published` 포트를 통해 같은 인스턴스가 중복 scrape 되어 Grafana 집계가 2배로 보이는 문제가 있어 풀스택 모드 정확도를 우선했습니다.

```bash
curl -s http://localhost:8080/actuator/prometheus | head # remittance-api
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[].health'
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job, health}'
```

| 모듈 | actuator 포트 | prometheus 라벨 |
|---|---|---|
| `remittance-api` | 8080 | `service="remittance-api"` |
| `payout-worker` | 8081 | `service="payout-worker"` |
| `webhook-dispatcher` | 8082 | `service="webhook-dispatcher"` |
| `reconciler` | 8084 | `service="reconciler"` |
| 모듈 | host 포트 | 컨테이너 포트 | prometheus 라벨 |
|---|---|---|---|
| `remittance-api` | 8080 | 8080 | `service="remittance-api"` |
| `payout-worker` | 8081 | 8080 | `service="payout-worker"` |
| `webhook-dispatcher` | 8082 | 8080 | `service="webhook-dispatcher"` |
| `reconciler` | 8084 | 8080 | `service="reconciler"` |

Grafana 접속: `http://localhost:3000` (anonymous Viewer). 좌측 메뉴 → Dashboards → **OpenRemit / OpenRemit — Service Overview** 자동 로드. 대시보드 UID는 `openremit`이며 [`docker/grafana/dashboards/openremit.json`](docker/grafana/dashboards/openremit.json)에서 관리합니다.

Expand Down
117 changes: 114 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ services:
- ./mock-fx/mappings:/home/wiremock/mappings:ro
command: ["--port", "8080", "--global-response-templating"]
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/__admin/health || exit 1"]
# WireMock /__admin/health는 GET만 응답(HEAD는 404). wget --spider가 HEAD를
# 보내서 false-negative가 나므로 GET 후 응답 폐기 형태로 변경.
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/__admin/health || exit 1"]
interval: 5s
timeout: 3s
retries: 10
Expand All @@ -66,7 +68,9 @@ services:
- ./mock-payout/mappings:/home/wiremock/mappings:ro
command: ["--port", "8080", "--global-response-templating"]
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/__admin/health || exit 1"]
# WireMock /__admin/health는 GET만 응답(HEAD는 404). wget --spider가 HEAD를
# 보내서 false-negative가 나므로 GET 후 응답 폐기 형태로 변경.
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/__admin/health || exit 1"]
interval: 5s
timeout: 3s
retries: 10
Expand All @@ -80,7 +84,9 @@ services:
- ./mock-webhook/mappings:/home/wiremock/mappings:ro
command: ["--port", "8080"]
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/__admin/health || exit 1"]
# WireMock /__admin/health는 GET만 응답(HEAD는 404). wget --spider가 HEAD를
# 보내서 false-negative가 나므로 GET 후 응답 폐기 형태로 변경.
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/__admin/health || exit 1"]
interval: 5s
timeout: 3s
retries: 10
Expand Down Expand Up @@ -208,6 +214,111 @@ services:
timeout: 3s
retries: 10

# ---------- 풀스택 모드 (--profile app) ----------
# 평소 개발은 인프라만 띄우고 호스트 JVM에서 :module:bootRun, 검증/시연 시
# `docker compose --profile app up -d --build` 로 4개 앱까지 한 번에 기동.
# 컨테이너 내부 포트는 모두 8080으로 표준화(SERVER_PORT 주입), 호스트 포트
# 매핑만 모듈별로 분리해 호스트 JVM 모드와 포트 충돌 없도록 정렬.

remittance-api:
profiles: ["app"]
build:
context: .
args:
MODULE: remittance-api
container_name: openremit-remittance-api
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
kafka:
condition: service_healthy
debezium-register:
condition: service_completed_successfully
mock-fx-api:
condition: service_healthy
environment:
SERVER_PORT: 8080
DB_URL: jdbc:mysql://mysql:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
DB_USER: openremit
DB_PASSWORD: openremit
KAFKA_BOOTSTRAP: kafka:29092
REDIS_ADDRESS: redis://redis:6379
FX_BASE_URL: http://mock-fx-api:8080
ports:
- "8080:8080"

payout-worker:
profiles: ["app"]
build:
context: .
args:
MODULE: payout-worker
container_name: openremit-payout-worker
depends_on:
mysql:
condition: service_healthy
kafka:
condition: service_healthy
debezium-register:
condition: service_completed_successfully
mock-payout-api:
condition: service_healthy
environment:
SERVER_PORT: 8080
DB_URL: jdbc:mysql://mysql:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
DB_USER: openremit
DB_PASSWORD: openremit
KAFKA_BOOTSTRAP: kafka:29092
PAYOUT_BASE_URL: http://mock-payout-api:8080
ports:
- "8081:8080"

webhook-dispatcher:
profiles: ["app"]
build:
context: .
args:
MODULE: webhook-dispatcher
container_name: openremit-webhook-dispatcher
depends_on:
mysql:
condition: service_healthy
kafka:
condition: service_healthy
debezium-register:
condition: service_completed_successfully
mock-webhook:
condition: service_healthy
environment:
SERVER_PORT: 8080
DB_URL: jdbc:mysql://mysql:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
DB_USER: openremit
DB_PASSWORD: openremit
KAFKA_BOOTSTRAP: kafka:29092
WEBHOOK_TARGET_URL: http://mock-webhook:8080/webhook
ports:
- "8082:8080"

reconciler:
profiles: ["app"]
build:
context: .
args:
MODULE: reconciler
container_name: openremit-reconciler
depends_on:
mysql:
condition: service_healthy
environment:
SERVER_PORT: 8080
DB_URL: jdbc:mysql://mysql:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
DB_USER: openremit
DB_PASSWORD: openremit
ports:
- "8084:8080"

volumes:
mysql-data:
redis-data:
Expand Down
13 changes: 9 additions & 4 deletions docker/prometheus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ global:
scrape_interval: 15s
evaluation_interval: 15s

# 풀스택 모드(--profile app)에서 컨테이너 앱을 직접 scrape. host.docker.internal
# 잡을 같이 두면 host-published 포트를 통해 같은 인스턴스를 중복 수집해 Grafana
# 대시보드(`sum by (service)`)가 2배로 보이므로 container 잡 단독 사용.
# 호스트 JVM 모드에서는 Grafana 대시보드가 비고 raw 메트릭은 actuator/prometheus
# 직접 curl 로 확인.
scrape_configs:
- job_name: openremit
metrics_path: /actuator/prometheus
static_configs:
- targets: ["host.docker.internal:8080"]
- targets: ["remittance-api:8080"]
labels: { service: remittance-api }
- targets: ["host.docker.internal:8081"]
- targets: ["payout-worker:8080"]
labels: { service: payout-worker }
- targets: ["host.docker.internal:8082"]
- targets: ["webhook-dispatcher:8080"]
labels: { service: webhook-dispatcher }
- targets: ["host.docker.internal:8084"]
- targets: ["reconciler:8080"]
labels: { service: reconciler }
6 changes: 3 additions & 3 deletions payout-worker/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ spring:
jackson:
property-naming-strategy: SNAKE_CASE
datasource:
url: jdbc:mysql://localhost:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
username: openremit
password: openremit
url: ${DB_URL:jdbc:mysql://localhost:3306/openremit?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC}
username: ${DB_USER:openremit}
password: ${DB_PASSWORD:openremit}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
Expand Down
Loading
Loading