VM 배포를 위한 Docker 컨테이너 설정 추가

- content-service/build.gradle: bootJar 파일명 설정 추가
- deployment/container/Dockerfile-backend: 백엔드 서비스 Docker 이미지 파일
- deployment/container/docker-compose.yml: Docker Compose 설정 (환경변수 포함)
- deployment/container/build-and-run.sh: 자동화 빌드 및 배포 스크립트
- deployment/container/build-image.md: 상세 배포 가이드 문서

주요 환경변수:
- JWT_SECRET: 32자 이상 JWT 서명 키 (JWT 오류 해결)
- REDIS/KAFKA: 외부 서버 연결 정보
- REPLICATE_API_TOKEN: Stable Diffusion API 토큰
- AZURE_STORAGE_CONNECTION_STRING: Azure Blob Storage 연결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-27 17:00:20 +09:00
parent 2da2f124a2
commit 5f8bd7cf68
20 changed files with 1737 additions and 2 deletions

View File

@ -21,3 +21,8 @@ dependencies {
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
// JAR
bootJar {
archiveFileName = 'content-service.jar'
}

View File

@ -23,6 +23,22 @@ public class ContentCommand {
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
/**
* 업종 (: "고깃집", "카페", "베이커리")
*/
private String industry;
/**
* 지역 (: "강남", "홍대", "서울")
*/
private String location;
/**
* 트렌드 키워드 (최대 3개 권장, : ["할인", "신메뉴", "이벤트"])
*/
private List<String> trends;
private List<ImageStyle> styles;
private List<Platform> platforms;
}

View File

@ -0,0 +1,288 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter;
import com.kt.event.content.infra.gateway.client.HuggingFaceApiClient;
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Hugging Face Inference API 이미지 생성 서비스
*
* Hugging Face Inference API를 사용하여 Stable Diffusion으로 이미지 생성 (무료)
*/
@Slf4j
@Service
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class HuggingFaceImageGenerator implements GenerateImagesUseCase {
private final HuggingFaceApiClient huggingFaceClient;
private final CDNUploader cdnUploader;
private final JobWriter jobWriter;
private final ContentWriter contentWriter;
private final CircuitBreaker circuitBreaker;
public HuggingFaceImageGenerator(
HuggingFaceApiClient huggingFaceClient,
CDNUploader cdnUploader,
JobWriter jobWriter,
ContentWriter contentWriter,
@Qualifier("huggingfaceCircuitBreaker") CircuitBreaker circuitBreaker) {
this.huggingFaceClient = huggingFaceClient;
this.cdnUploader = cdnUploader;
this.jobWriter = jobWriter;
this.contentWriter = contentWriter;
this.circuitBreaker = circuitBreaker;
}
@Override
public JobInfo execute(ContentCommand.GenerateImages command) {
log.info("Hugging Face 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
// Job 생성
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장
RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성
processImageGeneration(jobId, command);
return JobInfo.from(job);
}
@Async
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
try {
log.info("Hugging Face 이미지 생성 시작: jobId={}", jobId);
// Content 생성 또는 조회
Content content = Content.builder()
.eventDraftId(command.getEventDraftId())
.eventTitle(command.getEventDraftId() + " 이벤트")
.eventDescription("AI 생성 이벤트 이미지")
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
Content savedContent = contentWriter.save(content);
log.info("Content 생성 완료: contentId={}", savedContent.getId());
// 스타일 x 플랫폼 조합으로 이미지 생성
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> images = new ArrayList<>();
int totalCount = styles.size() * platforms.size();
int currentCount = 0;
for (ImageStyle style : styles) {
for (Platform platform : platforms) {
currentCount++;
// 진행률 업데이트
int progress = (currentCount * 100) / totalCount;
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
// Hugging Face로 이미지 생성
String prompt = buildPrompt(command, style, platform);
String imageUrl = generateImage(prompt, platform);
// GeneratedImage 저장
GeneratedImage image = GeneratedImage.builder()
.eventDraftId(command.getEventDraftId())
.style(style)
.platform(platform)
.cdnUrl(imageUrl)
.prompt(prompt)
.selected(currentCount == 1) // 번째 이미지를 선택
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
if (currentCount == 1) {
image.select();
}
GeneratedImage savedImage = contentWriter.saveImage(image);
images.add(savedImage);
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
savedImage.getId(), style, platform, imageUrl);
}
}
// Job 완료
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
jobWriter.updateJobResult(jobId, resultMessage);
log.info("Hugging Face Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: jobId={}", jobId, e);
jobWriter.updateJobError(jobId, e.getMessage());
}
}
/**
* Hugging Face로 이미지 생성
*
* @param prompt 이미지 생성 프롬프트
* @param platform 플랫폼 (이미지 크기 결정)
* @return 생성된 이미지 URL
*/
private String generateImage(String prompt, Platform platform) {
try {
// 플랫폼별 이미지 크기 설정
int width = platform.getWidth();
int height = platform.getHeight();
// Hugging Face API 요청
HuggingFaceRequest request = HuggingFaceRequest.builder()
.inputs(prompt)
.parameters(HuggingFaceRequest.Parameters.builder()
.negative_prompt("blurry, bad quality, distorted, ugly, low resolution")
.width(width)
.height(height)
.guidance_scale(7.5)
.num_inference_steps(50)
.build())
.build();
log.info("Hugging Face API 호출: prompt={}, size={}x{}", prompt, width, height);
// 이미지 생성 (동기 방식)
byte[] imageData = generateImageWithCircuitBreaker(request);
log.info("Hugging Face 이미지 생성 완료: size={} bytes", imageData.length);
// Azure Blob Storage에 업로드
String fileName = String.format("event-%s-%s-%s.png",
platform.name().toLowerCase(),
UUID.randomUUID().toString().substring(0, 8),
System.currentTimeMillis());
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
return azureCdnUrl;
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: prompt={}", prompt, e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
/**
* 이미지 생성 프롬프트 구성
*/
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
StringBuilder prompt = new StringBuilder();
// 업종 정보 추가
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
prompt.append(command.getIndustry()).append(" ");
}
// 기본 프롬프트
prompt.append("event promotion image");
// 지역 정보 추가
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
prompt.append(" in ").append(command.getLocation());
}
// 트렌드 키워드 추가 (최대 3개)
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
prompt.append(", featuring ");
int count = Math.min(3, command.getTrends().size());
for (int i = 0; i < count; i++) {
if (i > 0) prompt.append(", ");
prompt.append(command.getTrends().get(i));
}
}
prompt.append(", ");
// 스타일별 프롬프트
switch (style) {
case FANCY:
prompt.append("elegant, luxurious, premium design, vibrant colors, ");
break;
case SIMPLE:
prompt.append("minimalist, clean design, simple layout, modern, ");
break;
case TRENDY:
prompt.append("trendy, contemporary, stylish, modern design, ");
break;
}
// 플랫폼별 특성 추가
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
prompt.append("high quality, detailed, 4k resolution");
return prompt.toString();
}
/**
* Circuit Breaker로 보호된 Hugging Face 이미지 생성
*
* @param request Hugging Face 요청
* @return 생성된 이미지 바이트 데이터
*/
private byte[] generateImageWithCircuitBreaker(HuggingFaceRequest request) {
try {
return circuitBreaker.executeSupplier(() -> huggingFaceClient.generateImage(request));
} catch (CallNotPermittedException e) {
log.error("Hugging Face Circuit Breaker가 OPEN 상태입니다. 이미지 생성 차단");
throw new RuntimeException("Hugging Face API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패", e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
}

View File

@ -23,13 +23,13 @@ import java.util.UUID;
/**
* Mock 이미지 생성 서비스 (테스트용)
* 실제 Kafka 연동 전까지 사용
* local test 환경에서만 사용
*
* 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다.
*/
@Slf4j
@Service
@Profile({"local", "test", "dev"})
@Profile({"local", "test"})
@RequiredArgsConstructor
public class MockGenerateImagesService implements GenerateImagesUseCase {

View File

@ -2,6 +2,7 @@ package com.kt.event.content.infra;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableAsync;
/**
@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
"com.kt.event.common"
})
@EnableAsync
@EnableFeignClients(basePackages = "com.kt.event.content.infra.gateway.client")
public class ContentApplication {
public static void main(String[] args) {

View File

@ -0,0 +1,128 @@
package com.kt.event.content.infra.config;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Resilience4j Circuit Breaker 설정
*
* Hugging Face API, Replicate API Azure Blob Storage에 대한 Circuit Breaker 패턴 적용
*/
@Slf4j
@Configuration
public class Resilience4jConfig {
/**
* Replicate API Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 5개 요청 평가
* - Open 60초 대기 (Half-Open 전환)
* - Half-Open 상태에서 3개 요청으로 평가
*/
@Bean
public CircuitBreaker replicateCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(120)) // 120초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(60)) // Open 60초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(5) // 최소 5개 요청 평가
.permittedNumberOfCallsInHalfOpenState(3) // Half-Open에서 3개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("replicate");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Replicate Circuit Breaker: Success"))
.onError(event -> log.warn("Replicate Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Replicate Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Replicate Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Replicate Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
/**
* Azure Blob Storage Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 3개 요청 평가
* - Open 30초 대기 (Half-Open 전환)
* - Half-Open 상태에서 2개 요청으로 평가
*/
@Bean
public CircuitBreaker azureCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(30)) // 30초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(30)) // Open 30초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(3) // 최소 3개 요청 평가
.permittedNumberOfCallsInHalfOpenState(2) // Half-Open에서 2개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("azure");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Azure Circuit Breaker: Success"))
.onError(event -> log.warn("Azure Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Azure Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Azure Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Azure Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
/**
* Hugging Face API Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 3개 요청 평가
* - Open 30초 대기 (Half-Open 전환)
* - Half-Open 상태에서 2개 요청으로 평가
*/
@Bean
public CircuitBreaker huggingfaceCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(60)) // 60초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(30)) // Open 30초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(3) // 최소 3개 요청 평가
.permittedNumberOfCallsInHalfOpenState(2) // Half-Open에서 2개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("huggingface");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Hugging Face Circuit Breaker: Success"))
.onError(event -> log.warn("Hugging Face Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Hugging Face Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
}

View File

@ -0,0 +1,149 @@
package com.kt.event.content.infra.gateway.client;
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* Azure Blob Storage 업로더
*
* Azure Blob Storage에 이미지를 업로드하고 CDN URL을 반환
*/
@Slf4j
@Component
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class AzureBlobStorageUploader implements CDNUploader {
@Value("${azure.storage.connection-string}")
private String connectionString;
@Value("${azure.storage.container-name}")
private String containerName;
private final CircuitBreaker circuitBreaker;
private BlobServiceClient blobServiceClient;
private BlobContainerClient containerClient;
public AzureBlobStorageUploader(@Qualifier("azureCircuitBreaker") CircuitBreaker circuitBreaker) {
this.circuitBreaker = circuitBreaker;
}
/**
* Azure Blob Storage 클라이언트 초기화
*/
@PostConstruct
public void init() {
// Connection string이 비어있으면 초기화 건너뛰기
if (connectionString == null || connectionString.trim().isEmpty()) {
log.warn("Azure Blob Storage connection string이 설정되지 않았습니다. Azure 업로드 기능을 사용할 수 없습니다.");
return;
}
try {
log.info("Azure Blob Storage 클라이언트 초기화 시작");
// BlobServiceClient 생성
blobServiceClient = new BlobServiceClientBuilder()
.connectionString(connectionString)
.buildClient();
// Container 클라이언트 생성 (없으면 생성)
containerClient = blobServiceClient.getBlobContainerClient(containerName);
if (!containerClient.exists()) {
containerClient.create();
log.info("Azure Blob Container 생성 완료: {}", containerName);
}
log.info("Azure Blob Storage 클라이언트 초기화 완료: container={}", containerName);
} catch (Exception e) {
log.error("Azure Blob Storage 클라이언트 초기화 실패", e);
throw new RuntimeException("Azure Blob Storage 초기화 실패: " + e.getMessage(), e);
}
}
/**
* 이미지 업로드
*
* @param imageData 이미지 바이트 데이터
* @param fileName 파일명 (확장자 포함)
* @return CDN URL
*/
@Override
public String upload(byte[] imageData, String fileName) {
try {
// Circuit Breaker로 업로드 메서드 실행
return circuitBreaker.executeSupplier(() -> doUpload(imageData, fileName));
} catch (CallNotPermittedException e) {
log.error("Azure Circuit Breaker가 OPEN 상태입니다. 업로드 차단: fileName={}", fileName);
throw new RuntimeException("Azure Blob Storage에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Azure Blob Storage 업로드 실패: fileName={}", fileName, e);
throw new RuntimeException("이미지 업로드 실패: " + e.getMessage(), e);
}
}
/**
* 실제 업로드 수행 (Circuit Breaker로 보호됨)
*/
private String doUpload(byte[] imageData, String fileName) {
// Container 초기화 확인
if (containerClient == null) {
throw new RuntimeException("Azure Blob Storage가 초기화되지 않았습니다. Connection string을 확인해주세요.");
}
// 고유한 Blob 이름 생성 (날짜 폴더 구조 + UUID)
String blobName = generateBlobName(fileName);
log.info("Azure Blob Storage 업로드 시작: blobName={}, size={} bytes", blobName, imageData.length);
// BlobClient 생성
BlobClient blobClient = containerClient.getBlobClient(blobName);
// 이미지 업로드 (덮어쓰기 허용)
blobClient.upload(new ByteArrayInputStream(imageData), imageData.length, true);
// CDN URL 생성
String cdnUrl = blobClient.getBlobUrl();
log.info("Azure Blob Storage 업로드 완료: blobName={}, url={}", blobName, cdnUrl);
return cdnUrl;
}
/**
* Blob 이름 생성
*
* 형식: {YYYY}/{MM}/{DD}/{UUID}-{fileName}
* 예시: 2025/01/27/a1b2c3d4-e5f6-7890-abcd-ef1234567890-event-image.png
*
* @param fileName 원본 파일명
* @return Blob 이름
*/
private String generateBlobName(String fileName) {
// 현재 날짜로 폴더 구조 생성
LocalDateTime now = LocalDateTime.now();
String dateFolder = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
// UUID 생성
String uuid = UUID.randomUUID().toString();
// Blob 이름 생성: {날짜폴더}/{UUID}-{파일명}
return String.format("%s/%s-%s", dateFolder, uuid, fileName);
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.content.infra.gateway.client;
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Hugging Face Inference API 클라이언트
*
* API 문서: https://huggingface.co/docs/api-inference/index
* Stable Diffusion 모델: stabilityai/stable-diffusion-2-1
*/
@Component
@Profile({"prod", "dev"})
public class HuggingFaceApiClient {
private final RestClient restClient;
@Value("${huggingface.api.url:https://api-inference.huggingface.co}")
private String apiUrl;
@Value("${huggingface.api.token}")
private String apiToken;
@Value("${huggingface.model:stabilityai/stable-diffusion-2-1}")
private String modelId;
public HuggingFaceApiClient(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.build();
}
/**
* 이미지 생성 요청 (동기 방식)
*
* @param request Hugging Face 요청
* @return 생성된 이미지 바이트 데이터
*/
public byte[] generateImage(HuggingFaceRequest request) {
String url = String.format("%s/models/%s", apiUrl, modelId);
return restClient.post()
.uri(url)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiToken)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(byte[].class);
}
}

View File

@ -0,0 +1,46 @@
package com.kt.event.content.infra.gateway.client;
import com.kt.event.content.infra.gateway.client.dto.ReplicateRequest;
import com.kt.event.content.infra.gateway.client.dto.ReplicateResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Replicate API FeignClient
*
* Stable Diffusion 이미지 생성을 위한 Replicate API 클라이언트
* - API Docs: https://replicate.com/docs/reference/http
* - 인증: Authorization: Token {api_token}
*/
@FeignClient(
name = "replicate-api",
url = "${replicate.api.url:https://api.replicate.com}",
configuration = ReplicateApiConfig.class
)
public interface ReplicateApiClient {
/**
* 예측 생성 (이미지 생성 요청)
*
* POST /v1/predictions
*
* @param request 이미지 생성 요청 (모델 버전, 프롬프트 )
* @return 예측 응답 (예측 ID, 상태)
*/
@PostMapping("/v1/predictions")
ReplicateResponse createPrediction(@RequestBody ReplicateRequest request);
/**
* 예측 상태 조회
*
* GET /v1/predictions/{prediction_id}
*
* @param predictionId 예측 ID
* @return 예측 응답 (상태, 결과 이미지 URL )
*/
@GetMapping("/v1/predictions/{prediction_id}")
ReplicateResponse getPrediction(@PathVariable("prediction_id") String predictionId);
}

View File

@ -0,0 +1,40 @@
package com.kt.event.content.infra.gateway.client;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Replicate API FeignClient 설정
*
* Authorization 헤더 추가 로깅 설정
*/
@Slf4j
@Configuration
public class ReplicateApiConfig {
@Value("${replicate.api.token}")
private String apiToken;
/**
* Authorization 헤더 추가
*
* Replicate API는 "Authorization: Token {api_token}" 형식 요구
*/
@Bean
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// Authorization 헤더 추가
template.header("Authorization", "Token " + apiToken);
template.header("Content-Type", "application/json");
log.debug("Replicate API Request: {} {}", template.method(), template.url());
}
};
}
}

View File

@ -0,0 +1,59 @@
package com.kt.event.content.infra.gateway.client.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Hugging Face Inference API 요청 DTO
*
* API 문서: https://huggingface.co/docs/api-inference/index
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HuggingFaceRequest {
/**
* 이미지 생성 프롬프트
*/
private String inputs;
/**
* 생성 파라미터
*/
private Parameters parameters;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Parameters {
/**
* Negative prompt (생성하지 않을 내용)
*/
private String negative_prompt;
/**
* 이미지 너비
*/
private Integer width;
/**
* 이미지 높이
*/
private Integer height;
/**
* Guidance scale (프롬프트 준수 정도, 기본: 7.5)
*/
private Double guidance_scale;
/**
* Inference steps (품질, 기본: 50)
*/
private Integer num_inference_steps;
}
}

View File

@ -0,0 +1,92 @@
package com.kt.event.content.infra.gateway.client.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Replicate API 요청 DTO
*
* Stable Diffusion 이미지 생성 요청
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReplicateRequest {
/**
* 사용할 모델 버전
*
* Stable Diffusion XL 1.0:
* "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b"
*/
@JsonProperty("version")
private String version;
/**
* 모델 입력 파라미터
*/
@JsonProperty("input")
private Input input;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Input {
/**
* 이미지 생성 프롬프트
*/
@JsonProperty("prompt")
private String prompt;
/**
* 네거티브 프롬프트 (제외할 요소)
*/
@JsonProperty("negative_prompt")
private String negativePrompt;
/**
* 이미지 너비 (default: 1024)
*/
@JsonProperty("width")
private Integer width;
/**
* 이미지 높이 (default: 1024)
*/
@JsonProperty("height")
private Integer height;
/**
* 생성할 이미지 (default: 1)
*/
@JsonProperty("num_outputs")
private Integer numOutputs;
/**
* Guidance scale (default: 7.5)
* 높을수록 프롬프트에 충실
*/
@JsonProperty("guidance_scale")
private Double guidanceScale;
/**
* 추론 스텝 (default: 50)
* 높을수록 품질 향상, 시간 증가
*/
@JsonProperty("num_inference_steps")
private Integer numInferenceSteps;
/**
* 랜덤 시드 (재현성을 위해 사용)
*/
@JsonProperty("seed")
private Long seed;
}
}

View File

@ -0,0 +1,101 @@
package com.kt.event.content.infra.gateway.client.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Replicate API 응답 DTO
*
* 이미지 생성 결과
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReplicateResponse {
/**
* 예측 ID
*/
@JsonProperty("id")
private String id;
/**
* 모델 버전
*/
@JsonProperty("version")
private String version;
/**
* 상태: starting, processing, succeeded, failed, canceled
*/
@JsonProperty("status")
private String status;
/**
* 입력 파라미터
*/
@JsonProperty("input")
private Map<String, Object> input;
/**
* 출력 결과 (이미지 URL 리스트)
*/
@JsonProperty("output")
private List<String> output;
/**
* 에러 메시지 (실패시)
*/
@JsonProperty("error")
private String error;
/**
* 로그 메시지
*/
@JsonProperty("logs")
private String logs;
/**
* 메트릭 정보
*/
@JsonProperty("metrics")
private Metrics metrics;
/**
* 생성 시간
*/
@JsonProperty("created_at")
private String createdAt;
/**
* 시작 시간
*/
@JsonProperty("started_at")
private String startedAt;
/**
* 완료 시간
*/
@JsonProperty("completed_at")
private String completedAt;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Metrics {
/**
* 예측 시간 ()
*/
@JsonProperty("predict_time")
private Double predictTime;
}
}

View File

@ -21,6 +21,17 @@ azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_CONTAINER_NAME:event-images}
replicate:
api:
url: ${REPLICATE_API_URL:https://api.replicate.com}
token: ${REPLICATE_API_TOKEN:r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa}
huggingface:
api:
url: ${HUGGINGFACE_API_URL:https://api-inference.huggingface.co}
token: ${HUGGINGFACE_API_TOKEN:}
model: ${HUGGINGFACE_MODEL:runwayml/stable-diffusion-v1-5}
logging:
level:
com.kt.event: ${LOG_LEVEL_APP:DEBUG}

View File

@ -21,6 +21,11 @@ azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_CONTAINER_NAME:event-images}
replicate:
api:
url: ${REPLICATE_API_URL:https://api.replicate.com}
token: ${REPLICATE_API_TOKEN:}
logging:
level:
com.kt.event: ${LOG_LEVEL_APP:DEBUG}

View File

@ -0,0 +1,25 @@
# Build stage
FROM openjdk:23-oraclelinux8 AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM openjdk:23-slim
ENV USERNAME=k8s
ENV ARTIFACTORY_HOME=/home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN adduser --system --group ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
COPY --from=builder app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
USER ${USERNAME}
ENTRYPOINT [ "sh", "-c" ]
CMD ["java ${JAVA_OPTS} -jar app.jar"]

View File

@ -0,0 +1,67 @@
#!/bin/bash
# 색상 정의
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Content Service 빌드 및 배포 스크립트${NC}"
echo -e "${GREEN}========================================${NC}"
# 1. Gradle 빌드
echo -e "\n${YELLOW}1단계: Gradle 빌드 시작...${NC}"
./gradlew clean content-service:bootJar
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Gradle 빌드 실패!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Gradle 빌드 완료${NC}"
# 2. Docker 이미지 빌드
echo -e "\n${YELLOW}2단계: Docker 이미지 빌드 시작...${NC}"
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f ${DOCKER_FILE} \
-t content-service:latest .
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Docker 이미지 빌드 실패!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Docker 이미지 빌드 완료${NC}"
# 3. 이미지 확인
echo -e "\n${YELLOW}3단계: 생성된 이미지 확인...${NC}"
docker images | grep content-service
# 4. 기존 컨테이너 중지 및 삭제
echo -e "\n${YELLOW}4단계: 기존 컨테이너 정리...${NC}"
docker-compose -f deployment/container/docker-compose.yml down
# 5. 컨테이너 실행
echo -e "\n${YELLOW}5단계: Content Service 컨테이너 실행...${NC}"
docker-compose -f deployment/container/docker-compose.yml up -d
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 컨테이너 실행 실패!${NC}"
exit 1
fi
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN}✅ 배포 완료!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "\n${YELLOW}컨테이너 로그 확인:${NC}"
echo -e " docker logs -f content-service"
echo -e "\n${YELLOW}컨테이너 상태 확인:${NC}"
echo -e " docker ps"
echo -e "\n${YELLOW}서비스 헬스체크:${NC}"
echo -e " curl http://localhost:8084/actuator/health"
echo -e "\n${YELLOW}Swagger UI:${NC}"
echo -e " http://localhost:8084/swagger-ui/index.html"

View File

@ -0,0 +1,287 @@
# Content Service 컨테이너 이미지 빌드 및 배포 가이드
## 1. 사전 준비사항
### 필수 소프트웨어
- **Docker Desktop**: Docker 컨테이너 실행 환경
- **JDK 23**: Java 애플리케이션 빌드
- **Gradle**: 프로젝트 빌드 도구
### 외부 서비스
- **Redis 서버**: 20.214.210.71:6379
- **Kafka 서버**: 4.230.50.63:9092
- **Replicate API**: Stable Diffusion 이미지 생성
- **Azure Blob Storage**: 이미지 CDN
## 2. 빌드 설정
### build.gradle 설정 (content-service/build.gradle)
```gradle
// 실행 JAR 파일명 설정
bootJar {
archiveFileName = 'content-service.jar'
}
```
## 3. 배포 파일 구조
```
deployment/
└── container/
├── Dockerfile-backend # 백엔드 서비스용 Dockerfile
├── docker-compose.yml # Docker Compose 설정
└── build-and-run.sh # 자동화 배포 스크립트
```
## 4. 수동 빌드 및 배포
### 4.1 Gradle 빌드
```bash
# 프로젝트 루트에서 실행
./gradlew clean content-service:bootJar
```
### 4.2 Docker 이미지 빌드
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f ${DOCKER_FILE} \
-t content-service:latest .
```
### 4.3 빌드된 이미지 확인
```bash
docker images | grep content-service
```
예상 출력:
```
content-service latest abc123def456 2 minutes ago 450MB
```
### 4.4 Docker Compose로 컨테이너 실행
```bash
docker-compose -f deployment/container/docker-compose.yml up -d
```
### 4.5 컨테이너 상태 확인
```bash
# 실행 중인 컨테이너 확인
docker ps
# 로그 확인
docker logs -f content-service
# 헬스체크
curl http://localhost:8084/actuator/health
```
## 5. 자동화 배포 스크립트 사용 (권장)
### 5.1 스크립트 실행
```bash
# 프로젝트 루트에서 실행
./deployment/container/build-and-run.sh
```
### 5.2 스크립트 수행 단계
1. Gradle 빌드
2. Docker 이미지 빌드
3. 이미지 확인
4. 기존 컨테이너 정리
5. 새 컨테이너 실행
## 6. 환경변수 설정
`docker-compose.yml`에 다음 환경변수가 설정되어 있습니다:
### 필수 환경변수
- `SPRING_PROFILES_ACTIVE`: Spring Profile (prod)
- `SERVER_PORT`: 서버 포트 (8084)
- `REDIS_HOST`: Redis 호스트
- `REDIS_PORT`: Redis 포트
- `REDIS_PASSWORD`: Redis 비밀번호
- `JWT_SECRET`: JWT 서명 키 (최소 32자)
- `REPLICATE_API_TOKEN`: Replicate API 토큰
- `AZURE_STORAGE_CONNECTION_STRING`: Azure Storage 연결 문자열
- `AZURE_CONTAINER_NAME`: Azure Storage 컨테이너 이름
### JWT_SECRET 요구사항
- **최소 길이**: 32자 이상 (256비트)
- **형식**: 영문자, 숫자 조합
- **예시**: `kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025`
## 7. VM 배포
### 7.1 VM에 파일 전송
```bash
# VM으로 파일 복사 (예시)
scp -r deployment/ user@vm-host:/path/to/project/
scp docker-compose.yml user@vm-host:/path/to/project/deployment/container/
scp content-service/build/libs/content-service.jar user@vm-host:/path/to/project/content-service/build/libs/
```
### 7.2 VM에서 이미지 빌드
```bash
# VM에 SSH 접속 후
cd /path/to/project
# 이미지 빌드
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f deployment/container/Dockerfile-backend \
-t content-service:latest .
```
### 7.3 VM에서 컨테이너 실행
```bash
# Docker Compose로 실행
docker-compose -f deployment/container/docker-compose.yml up -d
# 또는 직접 실행
docker run -d \
--name content-service \
-p 8084:8084 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SERVER_PORT=8084 \
-e REDIS_HOST=20.214.210.71 \
-e REDIS_PORT=6379 \
-e REDIS_PASSWORD=Hi5Jessica! \
-e JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025 \
-e REPLICATE_API_TOKEN=r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa \
-e AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net" \
-e AZURE_CONTAINER_NAME=content-images \
content-service:latest
```
## 8. 모니터링 및 로그
### 8.1 컨테이너 상태 확인
```bash
docker ps
```
### 8.2 로그 확인
```bash
# 실시간 로그
docker logs -f content-service
# 최근 100줄
docker logs --tail 100 content-service
```
### 8.3 헬스체크
```bash
curl http://localhost:8084/actuator/health
```
예상 응답:
```json
{
"status": "UP",
"components": {
"ping": {
"status": "UP"
},
"redis": {
"status": "UP"
}
}
}
```
## 9. Swagger UI 접근
배포 후 Swagger UI로 API 테스트 가능:
```
http://localhost:8084/swagger-ui/index.html
```
## 10. 이미지 생성 API 테스트
### 10.1 이미지 생성 요청
```bash
curl -X POST "http://localhost:8084/api/v1/content/images/generate" \
-H "Content-Type: application/json" \
-d '{
"eventDraftId": 1001,
"industry": "고깃집",
"location": "강남",
"trends": ["가을", "단풍", "BBQ"],
"styles": ["FANCY"],
"platforms": ["INSTAGRAM"]
}'
```
### 10.2 Job 상태 확인
```bash
curl http://localhost:8084/api/v1/content/jobs/{jobId}
```
## 11. 컨테이너 관리 명령어
### 11.1 컨테이너 중지
```bash
docker-compose -f deployment/container/docker-compose.yml down
```
### 11.2 컨테이너 재시작
```bash
docker-compose -f deployment/container/docker-compose.yml restart
```
### 11.3 컨테이너 삭제
```bash
# 컨테이너만 삭제
docker rm -f content-service
# 이미지도 삭제
docker rmi content-service:latest
```
## 12. 트러블슈팅
### 12.1 JWT 토큰 오류
**증상**: `Error creating bean with name 'jwtTokenProvider'`
**해결방법**:
- `JWT_SECRET` 환경변수가 32자 이상인지 확인
- docker-compose.yml에 올바르게 설정되어 있는지 확인
### 12.2 Redis 연결 오류
**증상**: `Unable to connect to Redis`
**해결방법**:
- Redis 서버(20.214.210.71:6379)가 실행 중인지 확인
- 방화벽 설정 확인
- 비밀번호 확인
### 12.3 Azure Storage 오류
**증상**: `Azure storage connection failed`
**해결방법**:
- `AZURE_STORAGE_CONNECTION_STRING`이 올바른지 확인
- Storage Account가 활성화되어 있는지 확인
- 컨테이너 이름(`content-images`)이 존재하는지 확인
## 13. 빌드 결과
### 빌드 정보
- **서비스명**: content-service
- **JAR 파일**: content-service.jar
- **Docker 이미지**: content-service:latest
- **노출 포트**: 8084
### 빌드 일시
- **빌드 날짜**: 2025-10-27
### 환경
- **Base Image**: openjdk:23-slim
- **Platform**: linux/amd64
- **User**: k8s (non-root)

View File

@ -0,0 +1,58 @@
version: '3.8'
services:
content-service:
image: content-service:latest
container_name: content-service
ports:
- "8084:8084"
environment:
# Spring Profile
SPRING_PROFILES_ACTIVE: prod
# Server Configuration
SERVER_PORT: 8084
# Redis Configuration (외부 Redis 서버)
REDIS_HOST: 20.214.210.71
REDIS_PORT: 6379
REDIS_PASSWORD: Hi5Jessica!
# Kafka Configuration (외부 Kafka 서버)
KAFKA_BOOTSTRAP_SERVERS: 4.230.50.63:9092
KAFKA_CONSUMER_GROUP_ID: content-service-consumers
# JWT Configuration
JWT_SECRET: kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
JWT_ACCESS_TOKEN_VALIDITY: 3600000
JWT_REFRESH_TOKEN_VALIDITY: 604800000
# Replicate API (Stable Diffusion)
REPLICATE_API_TOKEN: r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa
# Azure Blob Storage Configuration
AZURE_STORAGE_CONNECTION_STRING: DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net
AZURE_CONTAINER_NAME: content-images
# Logging Configuration
LOG_LEVEL_APP: INFO
LOG_LEVEL_ROOT: INFO
# JVM Options
JAVA_OPTS: "-Xmx512m -Xms256m"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8084/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- kt-event-network
networks:
kt-event-network:
driver: bridge

View File

@ -0,0 +1,303 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Tripgen Service Runner Script
Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly.
Usage:
python run-config.py <service-name>
Examples:
python run-config.py user-service
python run-config.py location-service
python run-config.py trip-service
python run-config.py ai-service
"""
import os
import sys
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
import argparse
def get_project_root():
"""Find project root directory"""
current_dir = Path(__file__).parent.absolute()
while current_dir.parent != current_dir:
if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists():
return current_dir
current_dir = current_dir.parent
# If gradlew not found, assume parent directory of develop as project root
return Path(__file__).parent.parent.absolute()
def parse_run_configurations(project_root, service_name=None):
"""Parse run configuration files from .run directories"""
configurations = {}
if service_name:
# Parse specific service configuration
run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml'
if run_config_path.exists():
config = parse_single_run_config(run_config_path, service_name)
if config:
configurations[service_name] = config
else:
print(f"[ERROR] Cannot find run configuration: {run_config_path}")
else:
# Find all service directories
service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service']
for service in service_dirs:
run_config_path = project_root / service / '.run' / f'{service}.run.xml'
if run_config_path.exists():
config = parse_single_run_config(run_config_path, service)
if config:
configurations[service] = config
return configurations
def parse_single_run_config(config_path, service_name):
"""Parse a single run configuration file"""
try:
tree = ET.parse(config_path)
root = tree.getroot()
# Find configuration element
config = root.find('.//configuration[@type="GradleRunConfiguration"]')
if config is None:
print(f"[WARNING] No Gradle configuration found in {config_path}")
return None
# Extract environment variables
env_vars = {}
env_option = config.find('.//option[@name="env"]')
if env_option is not None:
env_map = env_option.find('map')
if env_map is not None:
for entry in env_map.findall('entry'):
key = entry.get('key')
value = entry.get('value')
if key and value:
env_vars[key] = value
# Extract task names
task_names = []
task_names_option = config.find('.//option[@name="taskNames"]')
if task_names_option is not None:
task_list = task_names_option.find('list')
if task_list is not None:
for option in task_list.findall('option'):
value = option.get('value')
if value:
task_names.append(value)
if env_vars or task_names:
return {
'env_vars': env_vars,
'task_names': task_names,
'config_path': str(config_path)
}
return None
except ET.ParseError as e:
print(f"[ERROR] XML parsing error in {config_path}: {e}")
return None
except Exception as e:
print(f"[ERROR] Error reading {config_path}: {e}")
return None
def get_gradle_command(project_root):
"""Return appropriate Gradle command for OS"""
if os.name == 'nt': # Windows
gradle_bat = project_root / 'gradlew.bat'
if gradle_bat.exists():
return str(gradle_bat)
return 'gradle.bat'
else: # Unix-like (Linux, macOS)
gradle_sh = project_root / 'gradlew'
if gradle_sh.exists():
return str(gradle_sh)
return 'gradle'
def run_service(service_name, config, project_root):
"""Run service"""
print(f"[START] Starting {service_name} service...")
# Set environment variables
env = os.environ.copy()
for key, value in config['env_vars'].items():
env[key] = value
print(f" [ENV] {key}={value}")
# Prepare Gradle command
gradle_cmd = get_gradle_command(project_root)
# Execute tasks
for task_name in config['task_names']:
print(f"\n[RUN] Executing: {task_name}")
cmd = [gradle_cmd, task_name]
try:
# Execute from project root directory
process = subprocess.Popen(
cmd,
cwd=project_root,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
encoding='utf-8',
errors='replace'
)
print(f"[CMD] Command: {' '.join(cmd)}")
print(f"[DIR] Working directory: {project_root}")
print("=" * 50)
# Real-time output
for line in process.stdout:
print(line.rstrip())
# Wait for process completion
process.wait()
if process.returncode == 0:
print(f"\n[SUCCESS] {task_name} execution completed")
else:
print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})")
return False
except KeyboardInterrupt:
print(f"\n[STOP] Interrupted by user")
process.terminate()
return False
except Exception as e:
print(f"\n[ERROR] Execution error: {e}")
return False
return True
def list_available_services(configurations):
"""List available services"""
print("[LIST] Available services:")
print("=" * 40)
for service_name, config in configurations.items():
if config['task_names']:
print(f" [SERVICE] {service_name}")
if 'config_path' in config:
print(f" +-- Config: {config['config_path']}")
for task in config['task_names']:
print(f" +-- Task: {task}")
print(f" +-- {len(config['env_vars'])} environment variables")
print()
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Tripgen Service Runner Script',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python run-config.py user-service
python run-config.py location-service
python run-config.py trip-service
python run-config.py ai-service
python run-config.py --list
"""
)
parser.add_argument(
'service_name',
nargs='?',
help='Service name to run'
)
parser.add_argument(
'--list', '-l',
action='store_true',
help='List available services'
)
args = parser.parse_args()
# Find project root
project_root = get_project_root()
print(f"[INFO] Project root: {project_root}")
# Parse run configurations
print("[INFO] Reading run configuration files...")
configurations = parse_run_configurations(project_root)
if not configurations:
print("[ERROR] No execution configurations found")
return 1
print(f"[INFO] Found {len(configurations)} execution configurations")
# List services request
if args.list:
list_available_services(configurations)
return 0
# If service name not provided
if not args.service_name:
print("\n[ERROR] Please provide service name")
list_available_services(configurations)
print("Usage: python run-config.py <service-name>")
return 1
# Find service
service_name = args.service_name
# Try to parse specific service configuration if not found
if service_name not in configurations:
print(f"[INFO] Trying to find configuration for '{service_name}'...")
configurations = parse_run_configurations(project_root, service_name)
if service_name not in configurations:
print(f"[ERROR] Cannot find '{service_name}' service")
list_available_services(configurations)
return 1
config = configurations[service_name]
if not config['task_names']:
print(f"[ERROR] No executable tasks found for '{service_name}' service")
return 1
# Execute service
print(f"\n[TARGET] Starting '{service_name}' service execution")
print("=" * 50)
success = run_service(service_name, config, project_root)
if success:
print(f"\n[COMPLETE] '{service_name}' service started successfully!")
return 0
else:
print(f"\n[FAILED] Failed to start '{service_name}' service")
return 1
if __name__ == '__main__':
try:
exit_code = main()
sys.exit(exit_code)
except KeyboardInterrupt:
print("\n[STOP] Interrupted by user")
sys.exit(1)
except Exception as e:
print(f"\n[ERROR] Unexpected error occurred: {e}")
sys.exit(1)