Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
1060 lines (1038 loc) · 59.1 KB
/
Copy pathdocker-compose.yml
File metadata and controls
1060 lines (1038 loc) · 59.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Life API credentials managed in the project root .env.
x-life-public-settings: &life-public-settings
DATA_GO_KR_KEY: ${DATA_GO_KR_KEY:-}
KAKAO_REST_KEY: ${KAKAO_REST_KEY:-}
KMA_HUB_KEY: ${KMA_HUB_KEY:-}
# Walk settings come only from the project root .env (or explicit shell overrides).
x-walk-settings: &walk-settings
DAENGS_WALK_SGIS_KEY: ${DAENGS_WALK_SGIS_KEY:-}
DAENGS_WALK_SGIS_SECRET: ${DAENGS_WALK_SGIS_SECRET:-}
DAENGS_WALK_PUBLIC_DATA_KEY: ${DAENGS_WALK_PUBLIC_DATA_KEY:-}
DAENGS_WALK_PARK_CATALOG_PATH: ${DAENGS_WALK_PARK_CATALOG_PATH:-/data/walk-public/parks.json}
DAENGS_WALK_COMMERCE_CATALOG_PATH: ${DAENGS_WALK_COMMERCE_CATALOG_PATH:-/data/walk-public/commerce.json}
DAENGS_WALK_RIVER_CATALOG_PATH: ${DAENGS_WALK_RIVER_CATALOG_PATH:-/data/walk-public/river.json}
DAENGS_WALK_PUBLIC_CATALOG_ROOT: ${DAENGS_WALK_PUBLIC_CATALOG_ROOT:-/data/walk-public/regions}
DAENGS_WALK_CATALOG_REFRESH_ENABLED: ${DAENGS_WALK_CATALOG_REFRESH_ENABLED:-false}
DAENGS_WALK_CATALOG_DAILY_REQUESTS: ${DAENGS_WALK_CATALOG_DAILY_REQUESTS:-300}
DAENGS_WALK_ENTRY_CONTEXT_ENABLED: ${DAENGS_WALK_ENTRY_CONTEXT_ENABLED:-false}
DAENGS_WALK_PUBLIC_CONTEXT_ENABLED: ${DAENGS_WALK_PUBLIC_CONTEXT_ENABLED:-false}
DAENGS_WALK_AREA_CONTEXT_ENABLED: ${DAENGS_WALK_AREA_CONTEXT_ENABLED:-false}
DAENGS_WALK_DIARY_ENABLED: ${DAENGS_WALK_DIARY_ENABLED:-false}
DAENGS_WALK_DIARY_SPACE_ENABLED: ${DAENGS_WALK_DIARY_SPACE_ENABLED:-false}
DAENGS_WALK_DIARY_ROUTE_PATTERNS_ENABLED: ${DAENGS_WALK_DIARY_ROUTE_PATTERNS_ENABLED:-false}
DAENGS_WALK_DIARY_SPACE_RADIUS_M: ${DAENGS_WALK_DIARY_SPACE_RADIUS_M:-1000}
DAENGS_WALK_LAND_COVER_LAYER: ${DAENGS_WALK_LAND_COVER_LAYER:-EGIS:lv3_2025y}
DAENGS_WALK_ENTRY_V2_ENABLED: ${DAENGS_WALK_ENTRY_V2_ENABLED:-false}
DAENGS_WALK_ENTRY_V2_WRITE_ENABLED: ${DAENGS_WALK_ENTRY_V2_WRITE_ENABLED:-false}
DAENGS_WALK_PHOTO_METADATA_ENABLED: ${DAENGS_WALK_PHOTO_METADATA_ENABLED:-false}
DAENGS_WALK_DIARY_SLOTS_PREVIEW_ENABLED: ${DAENGS_WALK_DIARY_SLOTS_PREVIEW_ENABLED:-false}
services:
nginx:
image: nginx:1.31.1
container_name: daengs-nginx
restart: unless-stopped
networks:
- daengs-network
ports:
- "80:80" # daengs.~ 프론트
- "8000:8000" # daengback.~ API. backend 컨테이너는 직접 노출하지 않습니다
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# 컨테이너 안에서 호스트(이 PC)로 접속하기 위한 이름.
# Docker Desktop 은 기본 제공하지만, 리눅스 서버에서도 동작하도록 명시해 둡니다.
# (프론트는 아직 PM2 로 호스트에서 돕니다)
extra_hosts:
- "host.docker.internal:host-gateway"
backend:
# docker/uv/Dockerfile 로 굽습니다. 이미지가 없으면 compose 가 알아서 빌드합니다.
build:
context: ./docker/uv
image: uv:1
container_name: daengs-backend
restart: unless-stopped
networks:
- daengs-network
# ports 가 없습니다. 바깥에서는 nginx 의 8000 을 통해서만 닿습니다.
env_file: &walk-context-env
# 없으면 그냥 넘어갑니다. (파일 마운트로 두면 없을 때 폴더가 생겨 버립니다)
- path: ./backend/.env
required: false
environment:
<<: [*walk-settings, *life-public-settings]
# venv 를 마운트 경로 밖에 둡니다.
# /app 안에 두면 호스트의 Windows 용 .venv 와 섞여 깨집니다.
UV_PROJECT_ENVIRONMENT: /opt/venv
# 바인드 마운트에서는 inotify 이벤트가 전달되지 않아 리로드가 안 돕니다.
WATCHFILES_FORCE_POLLING: "true"
# 컨테이너 안에서는 루프백이 아니라 전체 인터페이스에 바인딩해야 합니다.
DAENGS_HOST: 0.0.0.0
# backend/.env 의 값을 덮어씁니다 (environment 가 env_file 보다 우선).
# backend/.env 는 개발 PC 에서 uv run dev 로 띄울 때 쓰는 호스트를 갖고,
# 컨테이너는 여기서 compose 네트워크의 서비스명으로 바꿔 받습니다.
# 접속 정보를 최상단 .env 하나로 모아 두려는 것이기도 합니다 (D-009).
#
# URL 을 통째로 넘기지 않고 조각으로 넘깁니다 (D-013).
# 이어 붙인 URL 은 비밀번호에 @ / # 가 들어가면 깨지는데,
# 조각으로 주면 config.py 의 URL.create 가 이스케이프해 줍니다.
DAENGS_DB_HOST: pgvector
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${POSTGRES_USER:-postgres}
DAENGS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DAENGS_DB_NAME: ${POSTGRES_DB:-vectordb}
# Training RAG 는 이 backend 프로세스 안에서 돕니다. 데이터는 공용 pgvector의
# vectordb.public.training_rag_documents / training_rag_chunks 에 있습니다.
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.1-flash-lite}
GEMINI_TIMEOUT_MS: ${GEMINI_TIMEOUT_MS:-30000}
# 도감 카드 틀 폴더 (#496). 위 volumes 의 ./cardimage:/cardimage:ro 와 짝입니다.
# ⚠ 키(DAENGS_CARDIMAGE_GEMINI_API_KEY)는 여기 두지 않습니다 — environment 가
# env_file 보다 우선이라, 적으면 backend/.env 의 값이 빈 값으로 덮입니다
# (GEMINI_API_KEY·HF_TOKEN 과 같은 함정). 키는 backend/.env 로만 받습니다.
DAENGS_CARDIMAGE_DIR: /cardimage
# backend 기본 DB와 같은 POSTGRES_* 접속 정보를 사용합니다.
RAG_PGVECTOR_DSN: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@pgvector:5432/${POSTGRES_DB:-vectordb}
# 실시간 산책(②)의 캐시·일 예산 카운터. **접두사 DAENGS_ 가 없습니다** —
# daengs_life 쪽 Settings 는 발급 기관/도구가 부르는 이름을 그대로 씁니다.
#
# DB 와 달리 조각이 아니라 URL 한 줄이라, 비밀번호에 @ / # 이 들어가면 깨집니다
# (D-013 과 같은 함정). .env 에서 영숫자로만 지으세요.
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
# `/ask` 의 검색이 읽는 접속 정보입니다 (D-021).
#
# **`DAENGS_DB_*` 와 별개입니다** — `daengs_life.rag` 는 자기 Settings 를 갖고
# `POSTGRES_*`(또는 DATABASE_URL)를 읽습니다. 위 `DAENGS_DB_*` 는 `daengs_backend` 것이라
# rag 는 그 이름을 모릅니다. 안 넣으면 컨테이너 안에서 `localhost:5432` 로 가서,
# `/ask` 만 502 가 됩니다 (다른 API 는 멀쩡해서 더 헷갈립니다).
#
# 조각으로 넘기는 것은 위와 같은 이유입니다 (D-013) — `Settings.dsn` 이 `quote_plus` 로
# 이스케이프합니다. **두 이름 체계를 통일하는 것은 이 카드가 아닙니다**: D-018 이 미뤄 둔
# 규약 정합 작업이고, 2단계에서 `/ask` 를 별도 프로세스로 떼면 그쪽이 자기 접속을
# 가져야 해서 지금 합치면 나중에 되돌립니다.
POSTGRES_IP: pgvector
POSTGRES_PORT: 5432
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-vectordb}
# 트레이싱 (D-054). **기본은 꺼져 있습니다** — 최상단 .env 에서 켭니다.
#
# **여기 있어야 합니다. `backend/.env` 에 적으면 안 켜집니다.** 그 파일은
# pydantic-settings 가 자기 Settings 로만 읽고 `os.environ` 에 넣지 않는데,
# langsmith SDK 는 `os.environ` 만 봅니다. 접두사 DAENGS_ 가 없는 것은
# GEMINI_*·REDIS_URL 과 같은 이유입니다 — 도구가 부르는 이름을 그대로 씁니다.
#
# `langsmith` 는 **계측 SDK 로만** 씁니다. MODE=otel 이면 트레이스는 위
# otel-collector 를 거쳐 **우리 GCP 프로젝트의 Cloud Trace** 로 가고,
# LangSmith 라는 회사에는 아무것도 안 갑니다 (API 키가 필요 없는 이유).
#
# 켜면 질문 원문·검색 청크·프롬프트·답변이 트레이스에 담깁니다. 그것이
# 목적이고(민원 진단), 신원과 정밀 좌표는 `core/tracing.py` 가 지웁니다.
LANGSMITH_TRACING: ${LANGSMITH_TRACING:-false}
# ⚠ **기본값 langsmith 는 제3자로 나갑니다.** 우리 운영값은 otel 입니다.
LANGSMITH_TRACING_MODE: ${LANGSMITH_TRACING_MODE:-otel}
LANGSMITH_PROJECT: ${LANGSMITH_PROJECT:-daengs}
# collector 는 compose 네트워크 안에서만 닿습니다. 호스트 포트가 없습니다.
#
# ⚠ **`/v1/traces` 까지 적어야 합니다.** langsmith 가 이 값을 직접 읽어
# `OTLPSpanExporter(endpoint=...)` 로 **명시 전달**하는데, exporter 는
# 명시 전달된 주소에는 경로를 안 붙입니다(env 폴백 경로에서만 붙입니다).
# 베이스 URL 만 주면 모든 export 가 `/` 로 POST 돼 404 가 나고, backend 는
# "트레이싱을 켰습니다" 를 찍고 collector 로그도 멀쩡한데 Cloud Trace 만
# 영원히 비어 있습니다.
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318/v1/traces}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-daengs-backend}
# MODE=langsmith 로 되돌릴 때만 씁니다 (그때는 제3자 제공입니다 — 처리방침 확인).
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-}
LANGSMITH_ENDPOINT: ${LANGSMITH_ENDPOINT:-https://api.smith.langchain.com}
# **공용 파일 저장소** (D-043, D-052). 보행 영상·점령지 사진, 앞으로 프로필 사진·
# 도감 카드까지 같은 볼륨에 접두사로 들어옵니다.
#
# 기본이 none 인 것은 **개발 PC 가 아무 설정 없이도 뜨게** 하려는 것입니다
# (`/app/gait/*` 가 503). **운영 서버는 .env 에서 GAIT_STORAGE=local 로 켭니다** —
# backend 가 bridge 엔드포인트로 받은 파일을 gait-bridge 볼륨에 쓰고, 두 워커가
# 같은 볼륨에서 읽습니다. base_url 은 앱이 올릴 주소라 공개 API 오리진입니다
# (집 daengback.~ / GCP daengapi.~ — **서버마다 다른 유일한 값**).
#
# ⚠️ 환경 변수는 프로세스가 뜰 때 한 번만 읽습니다. 바꾸면 backend 와 워커 둘을
# 모두 다시 띄우세요 (.env.example 에 명령 그대로 있습니다).
GAIT_STORAGE: ${GAIT_STORAGE:-none}
GAIT_LOCAL_STORAGE_DIR: ${GAIT_LOCAL_STORAGE_DIR:-}
GAIT_BRIDGE_BASE_URL: ${GAIT_BRIDGE_BASE_URL:-}
# 보행 분석 엔진 (#304 · D-063). **값은 v4 하나입니다** — 옛 legacy 추론 runtime 은
# 6단계에서 없앴습니다(옛 legacy **기록**은 그대로 읽고 비교합니다).
# backend 는 v4 의 **비교 함수만** 씁니다 — `daengs_gait.compare_v4`(numpy 만,
# 5B 부터 ./backend/src 안). 별도 마운트 없습니다.
GAIT_ENGINE: ${GAIT_ENGINE:-v4}
# bridge 업로드 상한(바이트). D-052 로 모든 바이트가 backend 를 지나므로
# 이게 메모리 방어선입니다. nginx 의 200m 보다 낮게 유지하세요.
#
# ⚠️ 다른 값들처럼 `:-` 를 **빈 값으로 두면 안 됩니다.** 이건 int 라서 빈 문자열이
# 오면 pydantic 이 기동에서 죽습니다 (`unable to parse string as an integer`).
# 그래서 여기 기본값은 코드 기본값과 같은 150MB 를 그대로 적습니다.
GAIT_MAX_UPLOAD_BYTES: ${GAIT_MAX_UPLOAD_BYTES:-157286400}
# ── GCS 로 되돌아갈 때만 쓰는 자리 (지금은 비웁니다) ────────────────
# **비어 있으면 아무 일도 하지 않습니다** — `GAIT_STORAGE` 가 none/local 인 동안
# `GcsStorage` 가 아예 만들어지지 않기 때문입니다 (core/storage.get_storage).
# 값을 채우는 것만으로는 안 바뀌고, `GAIT_STORAGE=gcs` 로 바꿔야 켜집니다.
#
# ⚠️ **backend 와 gait-worker 양쪽에 같은 값이 필요합니다.** backend 는 업로드용
# Signed URL(PUT)을 발급하고, 워커는 다운로드(GET)와 overlay 업로드를 합니다.
# 한쪽만 채우면 발급은 되는데 분석에서 실패하는, 알아채기 어려운 상태가 됩니다.
GAIT_GCS_BUCKET: ${GAIT_GCS_BUCKET:-}
GAIT_GCS_LOCATION: ${GAIT_GCS_LOCATION:-}
# 자격증명은 GCP 표준(ADC)입니다. 서비스 계정 키 파일을 쓰는 경우 이 변수가
# **컨테이너 안 경로**를 가리켜야 하고, 아래 volumes 의 주석 처리된 마운트를
# 같이 열어야 합니다. GKE·GCE 워크로드 아이덴티티로 가면 둘 다 필요 없습니다.
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
# ★ 피부 스크리닝 — **계열 한 줄**을 응답(`stage2.group`)에 실을지.
# `1` 이면 켜집니다. **기본이 켜짐**입니다 — 끄려면 `0`.
# (앱도 #214 부터 이 값을 그려서, 이제 보호자 화면이 같이 바뀝니다.)
#
# 무엇인가 — 6종 이름(`농포·여드름`)은 holdout 에서 열에 넷밖에 못
# 말합니다(41.1%). 네 묶음으로 굵게 말하면 **열에 일곱**(67.9%)이고
# 안전 관문 둘도 통과합니다. 지금은 **콘솔에서 눈으로 보는 단계**입니다.
# ⚠️ 켜도 "1등 병변" 은 안 나갑니다 (D-023). 계열은 이름이 아니라 묶음이고
# 확률은 묶음 안을 더한 값입니다.
# ⚠️ 확신이 낮으면 서버가 알아서 `null` 을 보냅니다 — 화면은 안 그립니다.
# 근거: 원본 docs/results/STEP28~34.
DOG_SKIN_SHOW_GROUP: ${DOG_SKIN_SHOW_GROUP:-1}
# ★ 가중치를 허깅페이스에서 받는 설정(`SCREENING_RELEASE_REPO` ·
# `SCREENING_RELEASE_REVISION` · `HF_TOKEN` · `SCREENING_EXPECT_STAGE2_ARMS`)은
# **여기 두지 않습니다.** `backend/.env` 에 넣으세요 (env_file 로 들어갑니다).
#
# ⚠️ 여기에 `HF_TOKEN: ${HF_TOKEN:-}` 를 추가하지 마세요. `environment` 가
# `env_file` 보다 **우선**이라, 그 줄이 있으면 `backend/.env` 의 토큰이
# 빈 값으로 덮입니다. `deploy.yml` 이 셸로 끌어올려 주는 건
# `GEMINI_API_KEY` **하나뿐**입니다 (48~56줄) — 그래서 자동 배포마다
# 빈 토큰이 박히고 `/screen/` 만 401 로 죽습니다. 다른 API 는 멀쩡해서
# 로그에 아무 문제도 안 보입니다 (`GEMINI_API_KEY` 와 똑같은 함정).
volumes:
# 폴더째 마운트하지 않습니다. backend/.venv 가 딸려 들어오면 깨집니다.
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
# pyproject 의 readme 항목 때문에 패키지 빌드에 필요합니다.
# 없으면 uv sync 가 실패합니다.
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src
# 도감 카드 틀 12장 + 글꼴 (#496). 읽기 전용. backend/.env 의 DAENGS_CARDIMAGE_DIR=/cardimage 와 짝.
- ./cardimage:/cardimage:ro
- backend-venv:/opt/venv
- walk-public-catalogs:/data/walk-public:ro
- uv-cache:/root/.cache/uv
# 임베딩 모델 가중치(약 1.2GB)를 컨테이너 밖에 둡니다 (D-021).
# 없으면 컨테이너를 다시 만들 때마다 통째로 다시 받습니다 — venv 를 볼륨에 둔 것과
# 같은 이유이고, 여기는 단위가 GB 입니다.
- hf-cache:/root/.cache/huggingface
# ⚠️ 피부 스크리닝 가중치 폴더 마운트는 **없앴습니다.** 이제 허깅페이스에서
# 받아 위 `hf-cache` 볼륨에 둡니다 (`backend/.env` 의
# `SCREENING_RELEASE_REPO` · `SCREENING_RELEASE_REVISION` · `HF_TOKEN`).
# 되살리려면 그 셋을 비우고 `SCREENING_RELEASE_DIR` 을 채우면 됩니다 —
# 다만 그때는 이 마운트도 같이 되살려야 합니다.
# ★ **공용 파일 저장소** (D-052). gait-worker · territory-vision-worker 와 셋이
# **공유**합니다. backend 는 bridge 엔드포인트로 여기에 쓰고, 워커들은 같은
# 경로에서 읽습니다 — 그래서 한 볼륨입니다.
# ⚠️ 임시가 아닙니다. 영상·사진 **원본이 여기 남습니다.** 지우지 마세요.
- gait-bridge:/data/gait-bridge
# ⬇ GCS 로 **되돌아갈** 때만 엽니다 (서비스 계정 키를 쓸 때).
# 호스트의 키 파일을 읽기 전용으로 물리고, 위 GOOGLE_APPLICATION_CREDENTIALS 를
# `/secrets/gcp-sa.json` 으로 맞춥니다. 배포 폴더 **바깥**에 두세요 — 재배포에
# 지워지지 않아야 합니다(가중치·.env 와 같은 취급).
# 주석으로 둔 이유: 파일이 없는 채로 마운트하면 도커가 그 자리에 **폴더를 만들고**,
# 클라이언트가 "키가 디렉터리"라며 엉뚱한 에러를 냅니다.
# - ${GAIT_GCP_CREDENTIALS_FILE:-./_secrets/gcp-sa.json}:/secrets/gcp-sa.json:ro
depends_on:
# Training RAG 테이블도 이 컨테이너의 vectordb 안에 있습니다.
pgvector:
condition: service_healthy
# Redis 가 없어도 앱은 뜹니다 — 캐시가 메모리로 떨어질 뿐입니다.
# 그런데도 healthy 를 기다리는 이유: 그 판단이 프로세스 생애에 **한 번뿐**입니다
# (lru_cache 로 감싼 get_cache 가 lifespan 에서 한 번 열립니다).
# backend 가 먼저 뜨면 Redis 가 1초 뒤에 올라와도 그 프로세스는 재시작 전까지
# 영영 메모리 폴백이고, 그러면 일 예산 카운터가 안 쌓입니다 (D-019).
redis:
condition: service_healthy
# uv sync 는 컨테이너가 뜰 때만 돕니다. 자동 배포는 checkout의 bind-mount 변경을
# 확실히 읽도록 매번 backend를 재시작합니다. 서버에서 수동으로 의존성을 바꿨다면
# docker compose restart backend 를 직접 실행하세요.
#
# **`--group ml` 이 붙었습니다** (D-021). `/ask` 의 임베딩 모델이 이 프로세스에 상주하므로
# torch 가 필요합니다. 리눅스에서는 CPU 판을 받습니다 — `pyproject.toml` 의 `pytorch-cpu`
# 인덱스가 그것이고, 안 그러면 GPU 를 안 쓰는데 NVIDIA 라이브러리를 수 GB 받습니다.
#
# ⚠ **`ml` 을 걷어내는 것은 `uv run` 이 아니라 인자 없는 `uv sync` 입니다** (uv 0.12.5 실측).
# · `uv run` → inexact 동기화. 남는 패키지를 **안 지웁니다** — `ml` 이 살아남습니다.
# · `uv sync` → exact 동기화. 기본 그룹에 없는 것을 **지웁니다** — `ml` 이 사라집니다.
#
# 그래서 이 줄은 안전하지만, **컨테이너 안에서 `uv sync` 를 인자 없이 돌리면 안 됩니다.**
# 그러면 torch 가 빠지고 `/ask` 만 503 이 되는데, 다른 API 는 멀쩡해서 로그에는
# 아무 문제도 안 보입니다. 고칠 때는 `uv sync --group ml` 로 부르세요.
#
# `--no-sync` 는 그래서 방어선이 아니라 **중복 제거**입니다 — 바로 앞에서 이미 맞췄으므로
# 한 번 더 볼 이유가 없습니다. 그룹 이름을 한 곳에만 적어 두는 효과도 같이 얻습니다.
command: sh -c "uv sync --frozen --group ml --group screening && exec uv run --no-sync dev"
pgvector:
image: pgvector/pgvector:pg18
container_name: pgvector
restart: unless-stopped
networks:
- daengs-network
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-vectordb}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
TZ: Asia/Seoul
PGTZ: Asia/Seoul
ports:
# 0.0.0.0 바인딩입니다. 같은 네트워크의 팀원이 붙을 수 있도록 의도한 것입니다.
# 그래서 POSTGRES_PASSWORD 는 .env 에서 반드시 기본값이 아닌 값으로 지정하세요.
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql:rw
- ./db/init:/docker-entrypoint-initdb.d:ro
shm_size: 1gb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-vectordb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# 트레이스를 우리 GCP 프로젝트의 Cloud Trace 로 넘깁니다 (D-054).
#
# **기본 기동에는 안 뜹니다** — `profiles: ["tracing"]` 이라 명시해야 올라옵니다.
# 트레이싱은 옵트인이고, 안 켤 사람의 서버에 컨테이너를 하나 더 얹지 않습니다:
#
# docker compose --profile tracing up -d
#
# 앱이 Cloud Trace 로 직접 안 쏘는 이유는 `otel/collector.yaml` 머리말에 있습니다
# (OAuth 토큰이 만료되는데 OTLP 헤더는 고정이라 조용히 멈춥니다).
otel-collector:
image: otel/opentelemetry-collector-contrib:0.160.0
container_name: daengs-otel-collector
restart: unless-stopped
profiles: ["tracing"]
networks:
- daengs-network
# ports 가 없습니다. 트레이스에는 질문 원문이 들어 있어서 LAN 에도 안 엽니다 —
# backend 만 compose 네트워크 안에서 4318 로 붙습니다.
command: ["--config=/etc/otelcol/collector.yaml"]
volumes:
- ./otel/collector.yaml:/etc/otelcol/collector.yaml:ro
# 서비스 계정 키 파일을 쓰면 이 줄과 아래 GOOGLE_APPLICATION_CREDENTIALS 를 켭니다.
# gait 세 서비스가 쓰는 것과 **같은 키 파일**입니다 (.env 의 GAIT_GCP_CREDENTIALS_FILE).
# GCP VM 위에서 돌면 메타데이터 서버가 대신하므로 둘 다 필요 없습니다.
# - ${GAIT_GCP_CREDENTIALS_FILE:-./_secrets/gcp-sa.json}:/secrets/gcp-sa.json:ro
environment:
# 어느 프로젝트로 보낼지.
#
# ⚠ **여기에 `:?` 를 쓰면 안 됩니다.** compose 는 프로파일로 거르기 **전에**
# 보간하므로, 이 서비스가 안 뜨는 사람의 `docker compose config` 까지 실패합니다.
# 배포 워크플로우가 `docker compose config --quiet` 로 검증하기 때문에
# (`.github/workflows/deploy.yml` "Compose 설정 검증"), 그러면 트레이싱을
# 안 켜는 사람의 **배포가 통째로 깨집니다.**
#
# 비면 googlecloud exporter 가 자격증명에서 프로젝트를 유추합니다. 그것도 없으면
# collector 가 기동에 실패하는데, 그건 이 프로파일을 켠 사람에게만 일어나고
# `docker compose logs otel-collector` 에 그대로 보입니다 — 실패를 알아야 할
# 사람에게만 알리는 자리가 거기입니다.
GOOGLE_CLOUD_PROJECT: ${GOOGLE_CLOUD_PROJECT:-}
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
redis:
image: redis:8.2-alpine
container_name: daengs-redis
restart: unless-stopped
networks:
- daengs-network
# 실시간 산책(②)이 씁니다 — 값 캐시 · **일 예산 카운터** · 활성 키 집합 (RT-001 ④).
# 뒤의 둘이 진짜 이유입니다: 예산이 프로세스 메모리에 있으면 재시작마다 리셋이라
# data.go.kr 의 1,000회/일 제약이 지켜지지 않습니다.
#
# ⚠ maxmemory 를 일부러 두지 않습니다. 캐시 항목은 전부 TTL 이 붙어 스스로
# 사라지는데, 여기에 allkeys-lru 를 걸면 나중에 Celery 를 띄웠을 때 **큐에 들어간
# 태스크가 evict** 됩니다. eviction 은 DB 번호가 아니라 인스턴스 단위라
# DB 를 나눠도 안 풀립니다 (D-019).
command:
- redis-server
# ports 로 LAN 에 열기 때문에 필수입니다. Redis 는 기본이 무인증이라
# 이게 없으면 같은 네트워크의 누구나 FLUSHALL 할 수 있습니다.
# `:?` 는 필수 환경 변수가 비어 있으면 compose 단계에서 중단하는 장치입니다. 여기서는 **인증 없는 Redis 가
# LAN 에 열리는 것**을 막습니다. 안 뜨는 편이 열린 채로 뜨는 것보다 낫습니다.
# 파일 전체가 해석되고 나서 컨테이너가 뜨므로, 이 한 곳이면 아래 REDISCLI_AUTH 와
# backend 의 REDIS_URL 까지 같이 지켜집니다.
- --requirepass
- ${REDIS_PASSWORD:?Set REDIS_PASSWORD in the deployment root .env (LAN 에 열리는 포트입니다)}
# RDB 스냅샷만 씁니다. 캐시 값은 사라져도 다시 받으면 그만이지만
# 예산 카운터는 잃으면 그날 한도를 넘겨 부르게 됩니다.
- --save
- "900 1"
- --appendonly
- "no"
environment:
# healthcheck 의 redis-cli 가 읽습니다. -a 로 넘기면 호출마다 경고가 로그에 쌓입니다.
REDISCLI_AUTH: ${REDIS_PASSWORD}
TZ: Asia/Seoul
ports:
# pgvector 와 같은 이유로 0.0.0.0 바인딩입니다 — 개발 PC 에서도 서버 Redis 에
# 붙어야 일 예산 카운터가 하나로 모입니다. 그래서 REDIS_PASSWORD 는 .env 에서
# 반드시 지정하세요.
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
gait-worker:
profiles: ["gait"]
build:
context: ./docker/uv
image: uv:1
container_name: daengs-gait-worker
restart: unless-stopped
networks:
- daengs-network
env_file:
# 암호화 키 3개(config 가 없으면 뜨지 않습니다)와 DAENGS_DB_* 기본값이 여기 있습니다.
# 아래 environment 가 DB 호스트 등을 compose 네트워크 값으로 덮어씁니다 (backend 와 같은 방식).
- path: ./backend/.env
required: false
environment:
UV_PROJECT_ENVIRONMENT: /opt/venv
# 브로커. backend·redis 와 **같은 인스턴스·같은 DB 번호**여야 발행한 태스크를 받습니다 (D-019).
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
# gait_records 를 쓰는 접속. backend 와 같은 조각 방식(D-013) — URL 로 잇지 않습니다.
# 이 워커는 daengs_backend 만 씁니다(POSTGRES_* 는 daengs_life 것이라 필요 없습니다).
DAENGS_DB_HOST: pgvector
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${POSTGRES_USER:-postgres}
DAENGS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DAENGS_DB_NAME: ${POSTGRES_DB:-vectordb}
# 모델 가중치 위치를 컨테이너 안 경로로 알려 줍니다.
GAIT_RELEASE_DIR: /models/release
# 저장소 (D-052). 워커는 backend 와 **같은 gait-bridge 볼륨·같은 경로**를 봐야
# 파일을 읽습니다.
#
# ⚠️ **BASE_URL 도 여기 있어야 합니다 — 워커가 안 쓰는데도 그렇습니다.**
# 워커는 티켓을 발급하지 않아 이 값이 필요 없어 보이지만, `get_storage()` 가
# gait_storage=local 이면 **만들 때** 이 값을 검사하고 비어 있으면 예외입니다.
# 빠뜨리면 업로드는 되는데 **분석만 FAILED** 로 떨어지고, 로그에는 저장소
# 미설정이라고만 남아 원인이 안 보입니다 (territory-vision-worker 에는 원래
# 있었고 여기만 빠져 있었습니다).
GAIT_STORAGE: ${GAIT_STORAGE:-none}
GAIT_LOCAL_STORAGE_DIR: ${GAIT_LOCAL_STORAGE_DIR:-}
GAIT_BRIDGE_BASE_URL: ${GAIT_BRIDGE_BASE_URL:-}
# ── GCS 전환용 자리 (#78) — **backend 와 같은 값이어야 합니다.** ────────
# 워커가 쓰는 쪽은 다운로드(GET Signed URL)와 overlay 업로드입니다
# (services/gait._analyze_from_storage). backend 만 채우고 여기를 빠뜨리면
# 업로드는 되는데 분석이 FAILED 로 떨어집니다.
#
# ⚠️ 워커는 LocalBridge 를 HTTP 가 아니라 **같은 볼륨의 파일로** 직접 읽습니다.
# 그래서 `GAIT_BRIDGE_BASE_URL` 을 *쓰지는* 않는데, **넣기는 넣어야 합니다** —
# 이유는 위 블록에 있습니다(`get_storage()` 가 만들 때 검사합니다).
# 2026-09-07(#290)까지 이 주석이 "워커에는 없습니다" 라고 적혀 있었는데,
# 바로 위 `GAIT_BRIDGE_BASE_URL:` 줄과 반대라 #289 배포에서 헷갈렸습니다.
GAIT_GCS_BUCKET: ${GAIT_GCS_BUCKET:-}
GAIT_GCS_LOCATION: ${GAIT_GCS_LOCATION:-}
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
TZ: Asia/Seoul
# ── 보행 분석 엔진 (#304 · D-063 5B·6) ────────────────────────────
# 워커가 **자기 인터프리터**(`/opt/venv/bin/python`)로 `python -m daengs_gait.inference`
# 를 서브프로세스로 부릅니다 (daengs_gait.engines.subprocess_bridge). torch·rtmlib·
# onnxruntime 은 그 자식에만 올라오고 이 프로세스에는 안 올라옵니다. 별도 venv 는 없습니다.
# **6단계부터 값은 v4 하나** — 없어진 이름을 주면 분석이 FAILED 사유와 함께 멈춥니다.
GAIT_ENGINE: ${GAIT_ENGINE:-v4}
# 가중치는 **위 GAIT_RELEASE_DIR 한 곳**입니다 — `ssdlite.pt` 와
# `rtmpose-m_ap10k/end2end.onnx` 를 거기 두세요. 옛 `GAIT_V4_WEIGHTS` 는 5B 에서 없앴습니다
# (코드가 `daengs_gait.config.RELEASE_DIR` 하나만 봅니다 — 지우기 전에 서버
# `/models/release` 의 두 파일을 워커 컨테이너 안에서 크기·sha256 까지 대조했습니다).
volumes:
# backend 와 같은 프로젝트 정의를 공유하되 venv 는 `gait-venv`(이 워커 전용)를 씁니다.
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
# 가중치 (best.pt·yolov8n.pt).
- ${GAIT_RELEASE_DIR:-./backend/_models/release}:/models/release:ro
# ★ backend 와 **공유**하는 공용 파일 저장소 (D-052). 원본이 여기 남습니다.
- gait-bridge:/data/gait-bridge
# ⬇ GCS 로 되돌아갈 때 backend 와 **같이** 엽니다 (위 backend 의 같은 줄 주석 참고).
# - ${GAIT_GCP_CREDENTIALS_FILE:-./_secrets/gcp-sa.json}:/secrets/gcp-sa.json:ro
# 이 워커 전용 venv (`--group gait`). 5B 부터 legacy·v4 둘 다 이 venv 하나입니다 —
# 옛 v4 전용 venv 볼륨과 v4 코드 폴더 마운트는 없어졌습니다(v4 코드는 ./backend/src 안).
- gait-venv:/opt/venv
- uv-cache:/root/.cache/uv
depends_on:
pgvector:
condition: service_healthy
redis:
condition: service_healthy
# backend 와 같은 모양. --frozen 이라 lock 이 pyproject 와 어긋나면 멈춥니다.
# `--group gait` 하나 — v4(torch·rtmlib·onnxruntime)가 이 그룹입니다
# (D-063 5B: 옛 `gait-v4` 그룹·별도 venv 동기화 분기가 없어졌습니다.
# 6단계: legacy 추론 runtime 과 함께 ultralytics 가 이 그룹에서 빠졌습니다).
# `--concurrency 1`: 분석이 무겁고 GPU 없는 CPU 추론이라 한 번에 하나만 돕니다.
# ⚠️ 이어지는 줄을 **첫 줄과 같은 칸**에 맞춥니다 — `>` 가 더 깊이 들여쓴 줄바꿈을 살려 두면
# sh 가 여러 줄 스크립트를 받아 exec 뒤 인자가 실행되지 않습니다 (#55 의 함정).
command: >
sh -c "uv sync --frozen --group gait &&
exec uv run --no-sync celery -A daengs_backend.tasks.gait worker
--loglevel info --concurrency 1 --queues gait"
# 점령지 사진 판정은 웹 요청과 분리된 전용 큐에서만 돕니다. 기본 compose에 포함하는
# 이유는 confirm이 VISION_PENDING을 발행한 뒤 이 소비자가 없으면 영원히 대기하기
# 때문입니다. 모델 가중치는 없고 base backend 의 google-genai만 사용합니다.
territory-vision-worker:
init: true
build:
context: ./docker/uv
image: uv:1
container_name: daengs-territory-vision-worker
restart: unless-stopped
stop_grace_period: 60s
networks:
- daengs-network
env_file: *walk-context-env
environment:
UV_PROJECT_ENVIRONMENT: /opt/venv
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
DAENGS_DB_HOST: pgvector
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${POSTGRES_USER:-postgres}
DAENGS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DAENGS_DB_NAME: ${POSTGRES_DB:-vectordb}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
TERRITORY_VISION_MODEL: ${TERRITORY_VISION_MODEL:-gemini-3.1-flash-lite}
TERRITORY_VISION_TIMEOUT_MS: ${TERRITORY_VISION_TIMEOUT_MS:-12000}
# 점령지 사진도 현재 gait 저장소 provider를 공유합니다. local은 개발용 bridge,
# 운영은 같은 GCS bucket/ADC를 backend와 워커 양쪽에 넣어야 합니다.
GAIT_STORAGE: ${GAIT_STORAGE:-none}
GAIT_LOCAL_STORAGE_DIR: ${GAIT_LOCAL_STORAGE_DIR:-}
GAIT_BRIDGE_BASE_URL: ${GAIT_BRIDGE_BASE_URL:-}
GAIT_GCS_BUCKET: ${GAIT_GCS_BUCKET:-}
GAIT_GCS_LOCATION: ${GAIT_GCS_LOCATION:-}
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
TZ: Asia/Seoul
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
# ★ backend 와 **공유**하는 공용 파일 저장소 (D-052) — 점령지 사진을 여기서 읽습니다.
- gait-bridge:/data/gait-bridge
# ⬇ GCS 로 되돌아갈 때만 엽니다 (backend 의 같은 줄 주석 참고).
# - ${GAIT_GCP_CREDENTIALS_FILE:-./_secrets/gcp-sa.json}:/secrets/gcp-sa.json:ro
# backend와 sync 그룹이 같아도 동시에 exact sync하지 않도록 별도 venv를 둡니다.
- territory-vision-venv:/opt/venv
- uv-cache:/root/.cache/uv
depends_on:
pgvector:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"uv run --no-sync celery -A daengs_backend.tasks.territory inspect ping -d celery@$$HOSTNAME --timeout 5 | grep -q pong",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
command: >
sh -c "uv sync --frozen &&
exec uv run --no-sync celery -A daengs_backend.tasks.territory worker
--loglevel info --concurrency 2 --queues territory-vision"
# Explicit preparation only: ordinary deployment never activates this profile.
# All game processes read backend/.env; no separate game flag override here.
activity-worker: &activity-runtime
init: true
build:
context: ./docker/uv
image: uv:1
container_name: daengs-activity-worker
profiles: ["activity-game"]
restart: unless-stopped
stop_grace_period: 60s
networks: [daengs-network]
env_file: *walk-context-env
environment:
UV_PROJECT_ENVIRONMENT: /opt/venv
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
DAENGS_DB_HOST: pgvector
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${POSTGRES_USER:-postgres}
DAENGS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DAENGS_DB_NAME: ${POSTGRES_DB:-vectordb}
TZ: Asia/Seoul
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- activity-worker-venv:/opt/venv
- uv-cache:/root/.cache/uv
depends_on:
pgvector:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "uv run --no-sync celery -A daengs_backend.tasks.activity:app inspect ping -d activity@$$HOSTNAME --timeout 5 | grep -q pong"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
command: >-
sh -c "uv sync --frozen &&
uv run --no-sync python -m daengs_backend.cli.activity_runtime &&
exec uv run --no-sync celery -A daengs_backend.tasks.activity:app worker
--hostname=activity@%h --concurrency=1 --queues=activity --loglevel=INFO"
activity-beat:
<<: *activity-runtime
container_name: daengs-activity-beat
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- activity-beat-venv:/opt/venv
- uv-cache:/root/.cache/uv
depends_on:
redis:
condition: service_healthy
activity-worker:
condition: service_healthy
healthcheck:
test: ["CMD", "uv", "run", "--no-sync", "python", "-m", "daengs_backend.cli.activity_runtime", "--beat-health"]
interval: 15s
timeout: 10s
retries: 3
start_period: 120s
command: >-
sh -c "uv sync --frozen &&
uv run --no-sync python -m daengs_backend.cli.activity_runtime &&
exec uv run --no-sync celery -A daengs_backend.tasks.activity:app beat --loglevel=INFO"
# Place 검색 (D-026, D-027). APP 의 기존 `/v2/places/search` 계약에 연결되어
# 기본 compose 기동에 포함됩니다. backend 나 반려견 프로필은 선행 조건이 아닙니다.
# 스키마는 place-search 기동 명령이 Alembic 으로 먼저 맞춥니다.
place-db:
image: postgis/postgis:18-3.6
container_name: daengs-place-db
restart: unless-stopped
networks:
- daengs-network
environment:
POSTGRES_USER: ${PLACE_POSTGRES_USER:-place}
# 기본값은 compose 내부 전용입니다. place-db 는 호스트 포트를 열지 않습니다.
POSTGRES_PASSWORD: ${PLACE_POSTGRES_PASSWORD:-place}
POSTGRES_DB: ${PLACE_POSTGRES_DB:-place}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
TZ: Asia/Seoul
PGTZ: Asia/Seoul
volumes:
# PG18 이미지의 VOLUME 은 /var/lib/postgresql 입니다. 상위 경로를 마운트해야
# 데이터가 보존됩니다 (pgvector 와 같음). db/init 은 물리지 않습니다 —
# place 스키마의 원본은 backend/infra/place/alembic 입니다 (D-026, dev DB 규칙의 예외).
- place-pgdata:/var/lib/postgresql:rw
shm_size: 1gb
healthcheck:
# initdb 의 임시 PostgreSQL 도 pg_isready 에는 성공합니다. postgis 확장이 실제로
# 활성화된 뒤에만 healthy 가 되게 합니다 (원본 geo compose 의 장치).
test:
- CMD-SHELL
- >-
psql -U $$POSTGRES_USER -d $$POSTGRES_DB -tAc
"SELECT 1 WHERE (SELECT count(*) FROM pg_extension
WHERE extname = 'postgis') = 1" | grep -qx 1
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
place-search:
# backend 와 같은 uv 베이스 이미지 (docker/uv/Dockerfile).
build:
context: ./docker/uv
image: uv:1
container_name: daengs-place-search
restart: unless-stopped
networks:
- daengs-network
# ports 가 없습니다. 외부 요청은 nginx 의 8000 포트만 통과합니다 (D-027).
environment:
UV_PROJECT_ENVIRONMENT: /opt/venv
# URL 한 줄로 조립하지 않습니다. 비밀번호의 @ / # 를 SQLAlchemy URL.create가
# 안전하게 다루도록 조각으로 넘깁니다 (D-028).
DAENGS_DB_HOST: place-db
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${PLACE_POSTGRES_USER:-place}
DAENGS_DB_PASSWORD: ${PLACE_POSTGRES_PASSWORD:-place}
DAENGS_DB_NAME: ${PLACE_POSTGRES_DB:-place}
# 공공데이터 키 — 배치(ingest) 전용. 서버 기동에는 필요 없습니다 (D-026).
DAENGS_DATA_GO_KR_SERVICE_KEY: ${DAENGS_DATA_GO_KR_SERVICE_KEY:-${PLACE_DATA_GO_KR_SERVICE_KEY:-}}
DAENGS_KTO_SERVICE_KEY: ${DAENGS_KTO_SERVICE_KEY:-${PLACE_KTO_SERVICE_KEY:-}}
# 내부 discovery만 소비합니다. 비어 있어도 기존 Place 검색과 health는 정상입니다.
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.1-flash-lite}
GEMINI_TIMEOUT_MS: ${GEMINI_TIMEOUT_MS:-30000}
FACILITY_CONVERSATION_MODEL: ${FACILITY_CONVERSATION_MODEL:-gemini-3.1-flash-lite}
TZ: Asia/Seoul
working_dir: /app
volumes:
# backend 의 단일 프로젝트 정의를 공유하되 venv 는 서비스별로 분리합니다.
# tests 와 다른 인프라 파일은 컨테이너에 필요 없습니다.
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./backend/infra/place:/app/infra/place:ro
- place-search-venv:/opt/venv
- uv-cache:/root/.cache/uv
depends_on:
place-db:
condition: service_healthy
# DB 가 처음 뜬 배포에서도 수동 조치가 남지 않게 마이그레이션 뒤 서버를 띄웁니다.
# 이어지는 줄은 첫 줄과 같은 칸이어야 한 셸 명령으로 접힙니다.
command: >
sh -c "uv sync --frozen --extra place &&
uv run --no-sync alembic -c infra/place/alembic.ini upgrade head &&
exec uv run --no-sync uvicorn daengs_place.main:app
--host 0.0.0.0 --port 8000"
# 장소 카드를 누른 뒤의 단발 이동 스냅샷. DAENGS_geo main@c5f0d5f에서 APP이
# 실제 사용하는 좌표 기반 `/journey` 경로만 옮겼습니다. Place DB나 Dog Profile을
# 조회하지 않으며, 외부 경로 제공사 호출은 원본 Usage Gate를 통과합니다.
journey-service:
build:
context: ./docker/uv
image: uv:1
container_name: daengs-journey
restart: unless-stopped
networks:
- daengs-network
environment:
UV_PROJECT_ENVIRONMENT: /opt/venv
DAENGS_WALK_ROUTE_PROVIDER: ${JOURNEY_WALK_ROUTE_PROVIDER:-fake}
DAENGS_CAR_ROUTE_PROVIDER: ${JOURNEY_CAR_ROUTE_PROVIDER:-fake}
DAENGS_TRANSIT_ROUTE_PROVIDER: ${JOURNEY_TRANSIT_ROUTE_PROVIDER:-fake}
DAENGS_USAGE_POLICY: ${JOURNEY_USAGE_POLICY:-deny-all}
DAENGS_TMAP_APP_KEY: ${JOURNEY_TMAP_APP_KEY:-}
TZ: Asia/Seoul
working_dir: /app
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- journey-service-venv:/opt/venv
- uv-cache:/root/.cache/uv
command: >
sh -c "uv sync --frozen &&
exec uv run --no-sync uvicorn daengs_journey.main:app --host 0.0.0.0 --port 8000"
# ------------------------------------------------------------------ 크롤러 (RAG-044 · RAG-050)
#
# `docker compose up -d` 에 **같이 뜹니다** (RAG-050 에서 profile 을 뗐습니다). 매일 KST 04:00 에
# Beat 가 `crawl-due-sources` 를 쏘고 워커가 due 소스만 받습니다. 수집에서 멈추고 적재는 사람이
# 합니다 (RAG-044 ⑤).
#
# **코퍼스는 서버 디스크의 `DAENGS_CORPUS_DIR` 입니다** — 러너 체크아웃 밖, 최상단 `.env` 로
# 지정합니다 (SCREENING_RELEASE_DIR 과 같은 취급). 체크아웃 밑 `./data` 에 두지 않는 이유는
# `data/` 가 git 미추적이라 `git clean` 한 번에 통째로 사라지기 때문입니다 — 워크트리에서 실제로
# 그렇게 한 번 잃었습니다 (CLAUDE.md). named volume 을 안 쓴 이유는 개발 PC 가 그 안의 원본을
# 꺼내 와야 해서입니다 (서버에 SSH·SMB 가 없어 파일로 옮깁니다. README "코퍼스" 절).
#
# ⚠ **워커는 `crawl_log.jsonl` 이 없으면 뜨지 않습니다** (아래 command 의 test). 로그가 없으면
# **전 소스가 due** 라 처음부터 다 받아 개발 PC 와 별개의 코퍼스를 만드는데, 원본은 불변이고
# 개정되는 문서라 나중에 합칠 수 없습니다 (RAG-008 · RAG-017). 빈 마운트(경로 오타 · 볼륨 새로
# 만듦 · 이관 전 기동)에서 그 사고가 조용히 나지 않게 재시작 루프로 시끄럽게 실패합니다.
# `docker compose ps` 에 Restarting 으로 보입니다. 정말로 빈 코퍼스에서 시작하려면 빈
# `manifests/crawl_log.jsonl` 을 만들어 두세요 — 그것이 "알고 한다"는 표시입니다.
#
# ⚠ **`backend-venv` 를 같이 쓰지 않습니다.** backend 는 `--group ml`(torch 1.2GB)로 동기화하고
# 크롤러는 그것이 필요 없는데, **같은 볼륨에 인자 없는 `uv sync` 를 돌리면 exact 동기화라
# torch 가 지워집니다** (uv 0.12.5 실측, CLAUDE.md). 그러면 `/ask` 만 503 이 되고 다른 API 는
# 멀쩡해서 로그에 아무 문제도 안 보입니다. 볼륨을 나누는 것이 그 사고를 **구조적으로** 막는
# 유일한 방법입니다 — 명령줄을 조심하는 것으로는 언젠가 뚫립니다.
crawler-worker:
build:
context: ./docker/uv
image: uv:1
container_name: daengs-crawler-worker
restart: unless-stopped
networks:
- daengs-network
env_file:
- path: ./backend/.env
required: false
environment:
DATA_GO_KR_KEY: ${DATA_GO_KR_KEY:-}
UV_PROJECT_ENVIRONMENT: /opt/venv
# 레포 밖이 아니라 컨테이너 안이라 자동 탐색이 안 됩니다. 아래 볼륨과 짝입니다.
DAENGS_DATA_DIR: /data
# 브로커. backend 와 **같은 인스턴스·같은 DB 번호**입니다 (D-019) — 큐가 갈리면
# Beat 가 보낸 것을 이 워커가 못 받습니다.
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
# 실행 이력 `crawl_runs` 를 쓰는 접속 (RAG-047). backend 와 같은 이유로 `POSTGRES_*` 조각입니다
# (`daengs_life.rag.core.config` 가 읽습니다). **안 넣으면 기록만 조용히 빠집니다** —
# 기록 실패는 크롤을 죽이지 않는 설계라(RAG-047 ②) 컨테이너 안에서 `localhost:5432` 로
# 가서 실패해도 크롤은 돌고, 관리자 화면에 실행이 안 보일 뿐입니다.
POSTGRES_IP: pgvector
POSTGRES_PORT: 5432
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-vectordb}
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src
# **원본이 쌓이는 곳.** 서버 디스크의 폴더를 바인드 마운트합니다 — 컨테이너를 지워도,
# 러너가 체크아웃을 갈아엎어도 남습니다. 코퍼스는 재수집으로 복구되지 않습니다
# (개정되는 원문이라 재수집은 곧 다른 코퍼스입니다). 비어 있으면 compose 가 멈춥니다
# (`:?`) — REDIS_PASSWORD 와 같은 장치입니다. 기본값을 두면 `./data` 로 조용히 떨어집니다.
- ${DAENGS_CORPUS_DIR:?Set DAENGS_CORPUS_DIR in the deployment root .env (server folder holding raw/ and manifests/crawl_log.jsonl)}:/data
# **시드는 코퍼스가 아닙니다** — git 추적 파일이라 정본이 체크아웃에 있고, 코퍼스 폴더에는
# 없습니다 (이관 zip 은 raw/ 와 crawl_log.jsonl 만 담습니다). 파일 하나만 코퍼스 마운트 위에
# 겹쳐 마운트합니다 — 배포가 체크아웃을 갱신하면 시드도 따라옵니다. 이것이 없으면
# `load_seeds()` 가 FileNotFoundError 로 죽어 `crawl_due` 가 아무것도 못 고릅니다.
- ./data/manifests/seed_sources.yaml:/data/manifests/seed_sources.yaml:ro
- crawler-venv:/opt/venv # ⚠ backend-venv 가 아닙니다 (위 경고)
- uv-cache:/root/.cache/uv
depends_on:
redis:
condition: service_healthy
# `--group ml` 이 **없습니다.** 크롤러는 torch 를 안 씁니다 — 붙이면 이미지 밖으로
# 1.2GB 를 더 받고 워커 상주 RAM 이 2.4GB 늘어납니다 (D-021).
#
# `--concurrency 1` 인 이유: 크롤 예절입니다. 워커를 늘리면 같은 호스트로 동시에 나가
# `request_delay_sec` 1.5초가 무의미해집니다 (docs/life/data-sources.md §12).
#
# 첫 줄의 `test -f` 가 위 ⚠ 의 가드입니다. 로그 파일의 **존재**만 봅니다(크기가 아니라) —
# 빈 파일은 "알고 비웠다"는 뜻이라 통과시킵니다.
command: >-
sh -c "test -f /data/manifests/crawl_log.jsonl ||
{ echo '코퍼스가 없습니다: /data/manifests/crawl_log.jsonl 이 없어 뜨지 않습니다.
로그 없이 뜨면 전 소스가 due 로 잡혀 개발 PC 와 다른 코퍼스를 만듭니다 (RAG-050).
DAENGS_CORPUS_DIR 에 raw/ 와 manifests/crawl_log.jsonl 을 옮긴 뒤 다시 띄우세요.' >&2;
exit 1; } &&
uv sync --frozen &&
exec uv run --no-sync celery -A daengs_life.tasks.celery_app worker
--loglevel info --concurrency 1 --queues crawl"
# Beat 는 **스케줄을 보내기만** 합니다. 태스크를 실행하지 않으므로 프로세스가 하나여야
# 합니다 — 둘이 뜨면 같은 시각에 두 번 보냅니다.
#
# ⚠ **이름은 crawler-beat 지만 발사하는 것은 앱 전체 스케줄입니다.** `beat_schedule` 에는
# 크롤(하루 1회)과 실시간 프리페치 `warm-active-grids`(60초, RT-002)가 같이 있습니다.
# 그래서 **프리페치 메시지도 같이 발사됩니다.** 크롤 워커는 `--queues crawl` 이라 그것을
# 먹지 않으므로 크롤을 방해하지는 않지만, 소비자가 없는 동안 기본 `celery` 큐에 쌓입니다
# (하루 1,440개, 1.5MB 남짓 — `docker compose exec redis redis-cli -a $REDIS_PASSWORD LLEN celery`
# 로 보입니다). Redis 에 `maxmemory` 를 안 건 것이 바로 이 종류를 조용히 지우지 않기
# 위해서입니다 (D-019). RT-002 의 워커는 **이 카드에서 띄우지 않았습니다** (RAG-050 ③) —
# 그 워커가 뜨는 날 쌓인 것은 `expires` 로 실행 없이 버려집니다 (celery_app.py).
crawler-beat:
build:
context: ./docker/uv
image: uv:1
container_name: daengs-crawler-beat
restart: unless-stopped
networks:
- daengs-network
env_file:
- path: ./backend/.env
required: false
environment:
DATA_GO_KR_KEY: ${DATA_GO_KR_KEY:-}
UV_PROJECT_ENVIRONMENT: /opt/venv
DAENGS_DATA_DIR: /data
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src
# Beat 자신은 data/ 를 안 읽습니다(판정은 워커가 합니다). 그래도 마운트하는 이유는
# `crawler.core.config` 가 import 시점에 경로를 잡기 때문입니다 — 없어도 뜨지만
# (require_data_dir 은 실제로 쓸 때 부릅니다) 두 컨테이너의 설정을 갈라 두면
# "왜 워커에서만 되지"를 나중에 한 번 더 겪습니다.
- ${DAENGS_CORPUS_DIR:?Set DAENGS_CORPUS_DIR in the deployment root .env (server folder holding raw/ and manifests/crawl_log.jsonl)}:/data
# 워커와 같은 이유의 시드 겹쳐 마운트 — 두 컨테이너의 설정을 갈라 두지 않습니다 (위 문단).
- ./data/manifests/seed_sources.yaml:/data/manifests/seed_sources.yaml:ro
# 워커와 **같은 볼륨**입니다. 둘의 `uv sync` 인자가 글자까지 같아 내용이 같고,
# 어긋나도 기동 로그에 바로 터집니다 — backend 와 나눈 것과는 성격이 다릅니다
# (그쪽은 내용이 다르고 조용히 틀립니다).
- crawler-venv:/opt/venv
- crawler-beat-state:/var/lib/celery
- uv-cache:/root/.cache/uv
depends_on:
redis:
condition: service_healthy
# 마지막 발사 시각을 볼륨에 남깁니다. 안 남기면 컨테이너를 새로 만들 때마다 스케줄이
# 초기화되어 재시작 직후 한 번 더 쏠 수 있습니다.
command: >-
sh -c "uv sync --frozen &&
exec uv run --no-sync celery -A daengs_life.tasks.celery_app beat
--loglevel info --schedule /var/lib/celery/beat-schedule"
# Opt-in: prepare catalogs and verify schema before starting either daemon.
# Separate venvs preserve backend's ML packages and avoid concurrent uv sync.
walk-context-worker: &walk-context-runtime
build:
context: ./docker/uv
image: uv:1
container_name: daengs-walk-context-worker
profiles: ["walk-diary"]
restart: unless-stopped
networks: [daengs-network]
env_file: *walk-context-env
environment: &walk-context-settings
<<: [*walk-settings, *life-public-settings]
UV_PROJECT_ENVIRONMENT: /opt/venv
DAENGS_DB_HOST: pgvector
DAENGS_DB_PORT: 5432
DAENGS_DB_USER: ${POSTGRES_USER:-postgres}
DAENGS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DAENGS_DB_NAME: ${POSTGRES_DB:-vectordb}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- walk-context-venv:/opt/venv
- walk-public-catalogs:/data/walk-public:ro
- uv-cache:/root/.cache/uv
depends_on:
pgvector:
condition: service_healthy
redis:
condition: service_healthy
command: >-
sh -c "uv sync --frozen &&
uv run --no-sync python -m daengs_backend.cli.walk_runtime_check --startup &&
exec uv run --no-sync celery -A daengs_backend.tasks.walk_entry_context:app worker
--hostname=walk-context@%h --pool=solo --concurrency=1 --queues=walk-entry-context --loglevel=WARNING"
walk-context-beat:
<<: *walk-context-runtime
container_name: daengs-walk-context-beat
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- walk-context-beat-venv:/opt/venv
- walk-public-catalogs:/data/walk-public:ro
- walk-context-beat-state:/var/lib/celery
- uv-cache:/root/.cache/uv
command: >-
sh -c "uv sync --frozen &&
uv run --no-sync python -m daengs_backend.cli.walk_runtime_check --startup &&
exec uv run --no-sync celery -A daengs_backend.tasks.walk_entry_context:app beat
--schedule=/var/lib/celery/beat-schedule --loglevel=WARNING"
# Slow public downloads have their own queue and write access, outside entry leases.
walk-catalog-worker:
<<: *walk-context-runtime
container_name: daengs-walk-catalog-worker
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- walk-catalog-venv:/opt/venv
- walk-public-catalogs:/data/walk-public
- uv-cache:/root/.cache/uv
command: >-
sh -c "uv sync --frozen &&
uv run --no-sync python -m daengs_backend.cli.walk_runtime_check --startup &&
exec uv run --no-sync celery -A daengs_backend.tasks.walk_entry_context:app worker
--hostname=walk-catalog@%h --pool=solo --concurrency=1 --queues=walk-public-catalog --loglevel=WARNING"
# One-shot operator CLI only; never starts on an ordinary profile up.
walk-context-tools:
<<: *walk-context-runtime
container_name: daengs-walk-context-tools
profiles: ["walk-diary-tools"]
restart: "no"
volumes:
- ./backend/pyproject.toml:/app/pyproject.toml:ro
- ./backend/uv.lock:/app/uv.lock:ro
- ./backend/README.md:/app/README.md:ro
- ./backend/src:/app/src:ro
- ./db/migrations:/schema-checks:ro
- walk-context-tools-venv:/opt/venv
- walk-public-catalogs:/data/walk-public
- uv-cache:/root/.cache/uv
entrypoint: ["sh", "-c", "uv sync --frozen && exec uv run --no-sync python -m \"$$@\"", "walk-tools"]
command: ["daengs_backend.cli.walk_runtime_check", "--allow-disabled", "--lat", "37.4878", "--lng", "127.052"]
# ── pgAdmin (GUI 가 필요할 때만) ─────────────────────────────────────
#
# **기본 기동에는 안 뜹니다** — `profiles: ["tools"]` 라 명시해야 올라옵니다:
# docker compose --profile tools up -d
#
# ⚠ **로그인 없는 모드입니다** (`PGADMIN_CONFIG_SERVER_MODE: "False"`). 띄워 둔 동안에는
# 같은 네트워크의 누구나 들어와 DB 를 만집니다. 쓸 때만 올리고 내리세요 (CLAUDE.md).
pgadmin:
image: dpage/pgadmin4:9.17
container_name: pgadmin
profiles: ["tools"]
restart: unless-stopped