Skip to content

Commit 246d4f9

Browse files
authored
Merge pull request #84 from Qualcomm-Capstone/develop
deploy: EDA choreography 패턴 전환 및 GCS ADC 마이그레이션
2 parents d780306 + 6688c4b commit 246d4f9

24 files changed

Lines changed: 1973 additions & 335 deletions

.github/workflows/docker-build.yml

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ name: Docker Build
22

33
on:
44
push:
5-
branches: [develop]
5+
branches: [develop, main]
66
pull_request:
7-
branches: [develop]
7+
branches: [develop, main]
88

99
concurrency:
1010
group: docker-${{ github.ref }}
1111
cancel-in-progress: true
1212

13+
env:
14+
AR_REGION: asia-northeast3
15+
GCP_PROJECT_ID: project-4f918446-ebe5-4774-a52
16+
AR_REPO: speedcam
17+
1318
jobs:
1419
build:
1520
name: ${{ matrix.service.name }}
1621
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
id-token: write
1725

1826
strategy:
1927
fail-fast: false
@@ -33,12 +41,39 @@ jobs:
3341
- name: Set up Docker Buildx
3442
uses: docker/setup-buildx-action@v3
3543

36-
- name: Build ${{ matrix.service.name }} image
44+
- name: Authenticate to Google Cloud
45+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
46+
uses: google-github-actions/auth@v2
47+
with:
48+
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
49+
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
50+
51+
- name: Configure Docker for Artifact Registry
52+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
53+
run: gcloud auth configure-docker ${{ env.AR_REGION }}-docker.pkg.dev
54+
55+
- name: Build and push ${{ matrix.service.name }} image
3756
uses: docker/build-push-action@v6
3857
with:
3958
context: .
4059
file: ${{ matrix.service.dockerfile }}
41-
push: false
60+
push: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
4261
cache-from: type=gha,scope=${{ matrix.service.name }}
4362
cache-to: type=gha,mode=max,scope=${{ matrix.service.name }}
44-
tags: speedcam/${{ matrix.service.name }}:ci-${{ github.sha }}
63+
tags: |
64+
${{ env.AR_REGION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.AR_REPO }}/speedcam-${{ matrix.service.name }}:latest
65+
${{ env.AR_REGION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.AR_REPO }}/speedcam-${{ matrix.service.name }}:${{ github.sha }}
66+
67+
trigger-deploy:
68+
name: Trigger Deploy
69+
needs: build
70+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
71+
runs-on: ubuntu-latest
72+
steps:
73+
- name: Trigger depoly CD workflow
74+
run: |
75+
curl -X POST \
76+
-H "Accept: application/vnd.github.v3+json" \
77+
-H "Authorization: token ${{ secrets.BACKEND_DEPLOY_TOKEN }}" \
78+
https://api.github.com/repos/${{ github.repository_owner }}/depoly/dispatches \
79+
-d '{"event_type":"deploy-backend","client_payload":{"image_tag":"${{ github.sha }}","triggered_by":"${{ github.actor }}"}}'

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,21 @@ http://localhost:5173 에 접속하여 결과물을 조회합니다.
381381

382382
<br />
383383
<br />
384+
385+
## 저장소 책임 범위
386+
387+
> "Django API와 Celery Worker의 소스 코드, Dockerfile, 로컬 개발 환경을 소유한다"
388+
389+
| 항목 | 이 저장소 | depoly 저장소 |
390+
|------|----------|--------------|
391+
| 애플리케이션 소스 코드 | O | X |
392+
| Dockerfile (3개) | O | X |
393+
| 로컬 개발 docker-compose | O | X |
394+
| 로컬 개발 모니터링 설정 | O | X |
395+
| 로컬 개발 env.example | O | X |
396+
| GitHub Actions CI (빌드/테스트) | O | X |
397+
| 프로덕션 compose 파일 | X | O |
398+
| 프로덕션 모니터링 설정 | X | O |
399+
| 프로덕션 env 템플릿 | X | O |
400+
| GitHub Actions CD (배포) | X | O |
401+
| 배포 스크립트/문서 | X | O |

apps/vehicles/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ class Meta:
2121
class VehicleCreateSerializer(serializers.ModelSerializer):
2222
class Meta:
2323
model = Vehicle
24-
fields = ["plate_number", "owner_name", "owner_phone", "fcm_token"]
24+
fields = ["id", "plate_number", "owner_name", "owner_phone", "fcm_token"]
25+
read_only_fields = ["id"]
2526

2627

2728
class FCMTokenUpdateSerializer(serializers.Serializer):

backend.env.example

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,17 @@ MQTT_USER=sa
3838
MQTT_PASS=1234
3939

4040
# ===========================================
41-
# GCS (Google Cloud Storage) 설정
41+
# GCS (Google Cloud Storage) 인증
4242
# ===========================================
43-
# OCR_MOCK=false일 때만 필요
44-
GOOGLE_APPLICATION_CREDENTIALS=/-path(secret)-/gcp-cloud-storage.json
45-
# GCS_BUCKET_NAME은 불필요 (GCS URI에서 자동 파싱)
43+
# GCS는 ADC (Application Default Credentials) 사용
44+
# GCE 인스턴스: 메타데이터 서버에서 자동 인증 (설정 불필요)
45+
# 로컬 개발: gcloud auth application-default login 실행 후 사용
46+
# OCR_MOCK=true이면 GCS 호출 없으므로 인증 불필요
4647

4748
# ===========================================
4849
# Firebase 설정 (FCM Push Notification)
4950
# ===========================================
50-
FIREBASE_CREDENTIALS=/-path(secret)-/firebase-service-account.json
51+
FIREBASE_CREDENTIALS=/app/credentials/firebase-service-account.json
5152

5253
# ===========================================
5354
# Celery Worker 설정
@@ -91,3 +92,5 @@ OTEL_RESOURCE_ATTRIBUTES=service.namespace=speedcam,deployment.environment=dev
9192
# Valid values: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio
9293
OTEL_TRACES_SAMPLER=parentbased_always_on
9394
OTEL_PYTHON_LOG_CORRELATION=true
95+
# GCP metadata 내부 요청을 트레이싱에서 제외 (404 ERROR span 방지)
96+
OTEL_PYTHON_REQUESTS_EXCLUDED_URLS=metadata.google.internal

config/celery.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515

1616
# Exchange 정의
1717
ocr_exchange = Exchange("ocr_exchange", type="direct", durable=True)
18-
fcm_exchange = Exchange("fcm_exchange", type="direct", durable=True)
1918
dlq_exchange = Exchange("dlq_exchange", type="fanout", durable=True)
2019

2120
# Queue 정의
21+
# Note: fcm_queue 제거 — Alert Service는 Celery Command가 아닌
22+
# AMQP domain_events exchange의 detections.completed 이벤트를 직접 구독 (Choreography)
2223
app.conf.task_queues = (
23-
# 새로운 Queue (PRD 구조)
2424
Queue(
2525
"ocr_queue",
2626
exchange=ocr_exchange,
@@ -31,15 +31,6 @@
3131
"x-max-priority": 10,
3232
},
3333
),
34-
Queue(
35-
"fcm_queue",
36-
exchange=fcm_exchange,
37-
routing_key="fcm",
38-
queue_arguments={
39-
"x-dead-letter-exchange": "dlq_exchange",
40-
"x-message-ttl": 3600000,
41-
},
42-
),
4334
Queue(
4435
"dlq_queue",
4536
exchange=dlq_exchange,
@@ -49,17 +40,11 @@
4940

5041
# Task 라우팅
5142
app.conf.task_routes = {
52-
# 새로운 Tasks (PRD 구조)
5343
"tasks.ocr_tasks.process_ocr": {
5444
"queue": "ocr_queue",
5545
"exchange": "ocr_exchange",
5646
"routing_key": "ocr",
5747
},
58-
"tasks.notification_tasks.send_notification": {
59-
"queue": "fcm_queue",
60-
"exchange": "fcm_exchange",
61-
"routing_key": "fcm",
62-
},
6348
"tasks.dlq_tasks.process_dlq_message": {
6449
"queue": "dlq_queue",
6550
"exchange": "dlq_exchange",

core/events/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Domain Events Module (AMQP)
2+
# 백엔드 서비스 간 도메인 이벤트 발행/구독 (Choreography)
3+
from .publisher import publish_event
4+
5+
__all__ = ["publish_event"]

core/events/consumer.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
AMQP Domain Event Consumer (Kombu)
3+
4+
Alert Service가 도메인 이벤트를 직접 구독하여 자율적으로 처리.
5+
Choreography: 각 서비스는 이벤트에 독립적으로 반응한다.
6+
7+
Main Service를 거치지 않고 Alert Service가 직접
8+
detections.completed 이벤트를 구독하여 알림 발송 여부를 결정한다.
9+
"""
10+
11+
import json
12+
import logging
13+
import os
14+
import time
15+
16+
from kombu import Connection, Exchange, Queue
17+
from kombu.mixins import ConsumerMixin
18+
19+
logger = logging.getLogger(__name__)
20+
21+
DOMAIN_EVENTS_EXCHANGE = Exchange("domain_events", type="topic", durable=True)
22+
23+
24+
class AlertEventConsumer(ConsumerMixin):
25+
"""
26+
Alert Service 도메인 이벤트 소비자
27+
28+
detections.completed 이벤트를 구독하고,
29+
Alert Service가 자율적으로 알림 발송 여부를 결정한다.
30+
OCR Service의 존재도, Main Service의 중개도 모른다.
31+
"""
32+
33+
def __init__(self, connection):
34+
self.connection = connection
35+
36+
def get_consumers(self, Consumer, channel):
37+
queue = Queue(
38+
"alert_domain_events",
39+
exchange=DOMAIN_EVENTS_EXCHANGE,
40+
routing_key="detections.completed",
41+
durable=True,
42+
queue_arguments={
43+
"x-dead-letter-exchange": "dlq_exchange",
44+
},
45+
)
46+
return [
47+
Consumer(
48+
queues=[queue],
49+
callbacks=[self.on_event],
50+
accept=["json"],
51+
)
52+
]
53+
54+
def on_event(self, body, message):
55+
"""도메인 이벤트 수신 및 처리"""
56+
try:
57+
payload = json.loads(body) if isinstance(body, str) else body
58+
routing_key = message.delivery_info.get("routing_key", "")
59+
60+
if routing_key == "detections.completed":
61+
self._on_detection_completed(payload)
62+
63+
message.ack()
64+
except Exception as e:
65+
logger.error(f"Failed to process domain event: {e}")
66+
message.reject(requeue=False)
67+
68+
def _on_detection_completed(self, payload):
69+
"""
70+
detections.completed 이벤트에 반응
71+
72+
Alert Service의 자율적 판단:
73+
"OCR이 완료됐으니 알림을 보내야겠다"
74+
"""
75+
detection_id = payload["detection_id"]
76+
logger.info(
77+
f"Detection {detection_id} completed event received — "
78+
f"processing notification"
79+
)
80+
81+
max_retries = 3
82+
for attempt in range(max_retries + 1):
83+
try:
84+
from tasks.notification_tasks import process_notification
85+
86+
process_notification(detection_id)
87+
return
88+
except Exception as e:
89+
if "DoesNotExist" in type(e).__name__ and attempt < max_retries:
90+
logger.warning(
91+
f"Detection {detection_id} not ready, "
92+
f"retry {attempt + 1}/{max_retries}"
93+
)
94+
time.sleep(3)
95+
else:
96+
raise
97+
98+
99+
def start_event_consumer():
100+
"""Alert Service 도메인 이벤트 소비자 시작 (blocking)"""
101+
broker_url = os.getenv("CELERY_BROKER_URL", "amqp://guest:guest@rabbitmq:5672//")
102+
logger.info(f"Starting Alert Event Consumer on {broker_url}")
103+
104+
with Connection(broker_url) as conn:
105+
consumer = AlertEventConsumer(conn)
106+
consumer.run()

core/events/publisher.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""
2+
AMQP Domain Event Publisher (Kombu)
3+
4+
Choreography 패턴에서 백엔드 서비스 간 도메인 이벤트 발행.
5+
프로토콜 분리 원칙: IoT 경계는 MQTT, 서비스 간 이벤트는 AMQP.
6+
"""
7+
8+
import json
9+
import logging
10+
import os
11+
12+
from kombu import Connection, Exchange
13+
14+
logger = logging.getLogger(__name__)
15+
16+
# 도메인 이벤트 교환기 (topic exchange: routing key 기반 선택적 구독)
17+
DOMAIN_EVENTS_EXCHANGE = Exchange("domain_events", type="topic", durable=True)
18+
19+
20+
def publish_event(routing_key: str, payload: dict):
21+
"""
22+
AMQP 도메인 이벤트 발행
23+
24+
Topic exchange를 사용하여 이벤트를 발행하면,
25+
관심 있는 서비스가 routing key 패턴으로 독립적으로 구독한다.
26+
27+
Args:
28+
routing_key: 이벤트 라우팅 키 (예: "detections.completed")
29+
payload: 이벤트 페이로드
30+
"""
31+
broker_url = os.getenv("CELERY_BROKER_URL", "amqp://guest:guest@rabbitmq:5672//")
32+
33+
try:
34+
with Connection(broker_url) as conn:
35+
producer = conn.Producer()
36+
producer.publish(
37+
json.dumps(payload),
38+
exchange=DOMAIN_EVENTS_EXCHANGE,
39+
routing_key=routing_key,
40+
content_type="application/json",
41+
declare=[DOMAIN_EVENTS_EXCHANGE],
42+
)
43+
logger.info(f"Domain event published: {routing_key} -> {payload}")
44+
except Exception as e:
45+
logger.error(f"Failed to publish domain event {routing_key}: {e}")
46+
raise

core/firebase/fcm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def initialize_firebase():
3131
firebase_admin.initialize_app(cred)
3232
logger.info(f"Firebase initialized with credentials: {cred_path}")
3333
else:
34-
# GOOGLE_APPLICATION_CREDENTIALS 사용
34+
# ADC (Application Default Credentials) 사용
3535
firebase_admin.initialize_app()
3636
logger.info("Firebase initialized with default credentials")
3737

core/mqtt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# MQTT Module
1+
# MQTT Module (IoT Edge 전용)
22
from .subscriber import MQTTSubscriber
33

44
__all__ = ["MQTTSubscriber"]

0 commit comments

Comments
 (0)