diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a7c89c1..48a8d0a2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -287,15 +287,18 @@ jobs: target: "/tmp/team3-provision" strip_components: 2 - - name: Upload Alloy config + # 관측 수집기(Alloy)의 config·기동 스크립트는 TeamPiKi/infra 공용 블록이 SSOT 다(#743) — + # 위 'Checkout infra blocks' 가 받은 piki-infra/blocks/alloy/ 를 그대로 올린다 (healthcheck 블록과 + # 같은 패턴). strip_components: 2 로 piki-infra/blocks/ 를 떼어 /tmp/piki-blocks/alloy/ 로 안착. + - name: Upload alloy block id: upload_alloy uses: appleboy/scp-action@v1 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} - source: "infra/alloy/config.alloy" - target: "/tmp/team3-alloy" + source: "piki-infra/blocks/alloy/config.alloy,piki-infra/blocks/alloy/provision-alloy.sh" + target: "/tmp/piki-blocks" strip_components: 2 # infra 공용 블록을 박스로 올린다. 아래 Health check 스텝이 인라인 폴링 대신 이 블록을 호출한다. @@ -332,16 +335,13 @@ jobs: GRAFANA_TRACES_URL: ${{ secrets.GRAFANA_TRACES_URL }} GRAFANA_TRACES_USER: ${{ secrets.GRAFANA_TRACES_USER }} GRAFANA_CLOUD_TOKEN: ${{ secrets.GRAFANA_CLOUD_TOKEN }} - # piki-extractor(파싱 분리 이관) 메트릭 scrape 대상 — Alloy(--network host)가 이 주소를 긁는다. - # dev=동거 컨테이너(localhost:8090), prod=전용 박스(secrets.EXTRACTOR_PROD_ADDRESS, 앱 SG→8090 허용). staging 은 - # 빈 값 → config.alloy 가 discard 포트로 fallback(prod extractor 는 prod Alloy 가 이미 수집, 환경 라벨 오염 방지). - # prod 박스 private IP 는 secret 으로 관리한다(public repo 라 코드에 내부 주소를 박지 않는다). - EXTRACTOR_METRICS_TARGET: ${{ needs.resolve.outputs.environment == 'dev' && 'localhost:8090' || (needs.resolve.outputs.environment == 'prod' && secrets.EXTRACTOR_PROD_ADDRESS || '') }} + # EXTRACTOR_METRICS_TARGET(cross-box scrape)은 폐기(#743) — 수집 대상은 컨테이너 label opt-in 이고, + # extractor prod 박스는 자체 Alloy 가 수집한다. dev 동거 extractor 는 컨테이너 라벨로 잡힌다. with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} - envs: ENVIRONMENT,GRAFANA_METRICS_URL,GRAFANA_METRICS_USER,GRAFANA_LOGS_URL,GRAFANA_LOGS_USER,GRAFANA_TRACES_URL,GRAFANA_TRACES_USER,GRAFANA_CLOUD_TOKEN,EXTRACTOR_METRICS_TARGET + envs: ENVIRONMENT,GRAFANA_METRICS_URL,GRAFANA_METRICS_USER,GRAFANA_LOGS_URL,GRAFANA_LOGS_USER,GRAFANA_TRACES_URL,GRAFANA_TRACES_USER,GRAFANA_CLOUD_TOKEN script: | chmod +x /tmp/team3-provision/provision-runtime.sh /tmp/team3-provision/provision-runtime.sh @@ -499,12 +499,16 @@ jobs: echo " DEPLOY : $INACTIVE (port $INACTIVE_PORT) [new version]" echo "================================================" - # 로그 백업 후 비활성 슬롯 정리 + # 로그 백업 후 비활성 슬롯 정리 (디렉토리명은 컨테이너명 개편(#743)과 무관한 박스 로컬 경로라 유지) mkdir -p /var/log/team3 - docker logs "team3-$INACTIVE" > /var/log/team3/team3-$INACTIVE-$(date +%Y%m%d-%H%M%S).log 2>&1 || true - find /var/log/team3 -name "team3-*.log" -mtime +7 -delete || true + docker logs "piki-core-$INACTIVE" > /var/log/team3/piki-core-$INACTIVE-$(date +%Y%m%d-%H%M%S).log 2>&1 || true + find /var/log/team3 -name "*.log" -mtime +7 -delete || true # 직전 배포의 구버전 teardown 이 timeout 으로 끊겨 이 슬롯 컨테이너가 살아있을 수 있다. # 아래 docker run 의 name/port 충돌을 막으려 force 로 지우고, 데몬 지연에 대비해 timeout 으로 상한을 건다. + timeout 40 docker stop -t 30 "piki-core-$INACTIVE" 2>/dev/null || true + timeout 30 docker rm -f "piki-core-$INACTIVE" 2>/dev/null || true + # 전환기(#743): 컨테이너명 개편 이전 이름(team3-*)이 이 슬롯 포트를 물고 있으면 run 이 포트 충돌로 + # 깨진다. 없으면 no-op 라 유지 비용이 없고, 전 환경 개편 배포가 한 바퀴 돈 뒤 제거 가능. timeout 40 docker stop -t 30 "team3-$INACTIVE" 2>/dev/null || true timeout 30 docker rm -f "team3-$INACTIVE" 2>/dev/null || true @@ -533,8 +537,15 @@ jobs: # 식은 페이지는 swap 에 남는다. memory-swap 은 부팅 스파이크가 RAM 을 잠깐 넘어도 cgroup-OOM 대신 swap 으로 # 흘려 배포가 안 깨지게 하는 쿠션이다. 값은 시작점 — 검증 배포에서 부팅 피크·overlap RAM 을 실측해 확정. # ExitOnOutOfMemoryError: 힙 OOM 시 즉시 종료→재시작. + # --label piki.*: 관측 opt-in 라벨(TeamPiKi/infra contracts/observability.md) — 박스 Alloy 가 + # 이 라벨로 수집 대상을 인식한다(로그=observe+service, 메트릭=+port). piki.metrics.port 는 + # 호스트 127.0.0.1 에서 닿는 published 포트라 슬롯 포트($INACTIVE_PORT)를 준다. docker run -d \ - --name "team3-$INACTIVE" \ + --name "piki-core-$INACTIVE" \ + --label piki.observe=true \ + --label piki.service=piki-core \ + --label "piki.metrics.port=$INACTIVE_PORT" \ + --label piki.metrics.path=/actuator/prometheus \ --restart unless-stopped \ -p "127.0.0.1:$INACTIVE_PORT:8080" \ --add-host=host.docker.internal:host-gateway \ @@ -583,20 +594,21 @@ jobs: echo "server localhost:$INACTIVE_PORT;" | sudo tee /etc/nginx/team3-upstream.conf sudo nginx -s reload echo " SWITCHED : Nginx now → $INACTIVE (port $INACTIVE_PORT)" - # 구버전 종료 (team3-server 마이그레이션 포함) — switch 는 이미 끝났으므로 best-effort. + # 구버전 종료 (레거시 이름 team3-$ACTIVE·team3-server 포함 — 컨테이너명 개편(#743) 전환기와 + # 그 이전 마이그레이션 잔재. 없으면 no-op 라 유지 비용 없음) — switch 는 이미 끝났으므로 best-effort. # blue+green 동시 기동 직후라 메모리 압박으로 Docker 데몬이 느려지면 docker stop 이 멈출 수 있다. # 상한이 없으면 이미 성공한 switch 가 ssh-action 기본 command_timeout(10m)에 걸려 배포가 통째로 실패한다. # timeout 으로 클라이언트 호출에 상한을 걸어, 데몬이 느려도 switch 성공을 막지 못하게 한다. - timeout 60 docker stop -t 30 "team3-$ACTIVE" team3-server 2>/dev/null || true - timeout 30 docker rm -f "team3-$ACTIVE" team3-server 2>/dev/null || true + timeout 60 docker stop -t 30 "piki-core-$ACTIVE" "team3-$ACTIVE" team3-server 2>/dev/null || true + timeout 30 docker rm -f "piki-core-$ACTIVE" "team3-$ACTIVE" team3-server 2>/dev/null || true echo " STOPPED : $ACTIVE (port $ACTIVE_PORT)" echo "================================================" exit 0 else echo "Health check failed — rolling back" # 실패 경로도 같은 데몬 지연에 멈출 수 있어 상한을 건다 (롤백이 10분 hang 뒤에야 보고되는 것 방지). - timeout 40 docker stop -t 30 "team3-$INACTIVE" 2>/dev/null || true - timeout 30 docker rm -f "team3-$INACTIVE" 2>/dev/null || true + timeout 40 docker stop -t 30 "piki-core-$INACTIVE" 2>/dev/null || true + timeout 30 docker rm -f "piki-core-$INACTIVE" 2>/dev/null || true exit 1 fi diff --git a/infra/alloy/config.alloy b/infra/alloy/config.alloy deleted file mode 100644 index 13404cf3..00000000 --- a/infra/alloy/config.alloy +++ /dev/null @@ -1,239 +0,0 @@ -// Grafana Alloy — PIKI 운영 박스(EC2) 단일 수집기. -// 앱 prometheus 메트릭 scrape + team3-blue/green 컨테이너 로그 + 호스트 메트릭(node_exporter) + -// 앱 OTLP 트레이스(otelcol)를 Grafana Cloud 로 보낸다. -// 자격증명은 환경변수로 주입한다(provision-runtime.sh 의 docker run -e). secret 미등록 시 -// provision-runtime.sh 가 Alloy 기동 자체를 skip 하므로, 빈 endpoint 로 부팅해 crash loop 하는 일은 없다. - -// ── 메트릭: 앱 /actuator/prometheus → Grafana Cloud (Prometheus) ── -// blue-green 이라 한 시점에 8080·8081 중 한 포트만 떠 있다. 죽은 포트의 scrape 실패는 -// 해당 타겟만 down 으로 잡힐 뿐 무해하다. 앱 포트가 127.0.0.1 바인딩(PR #290)이므로 -// 이 Alloy 는 --network host 로 띄워 localhost 를 scrape 한다. -prometheus.scrape "app" { - targets = [ - {"__address__" = "localhost:8080", "instance" = "team3-blue"}, - {"__address__" = "localhost:8081", "instance" = "team3-green"}, - ] - metrics_path = "/actuator/prometheus" - scrape_interval = "30s" - forward_to = [prometheus.remote_write.grafana_cloud.receiver] -} - -// ── 메트릭: piki-extractor(파싱 분리 이관) /actuator/prometheus → 같은 Grafana Cloud ── -// 대상 주소는 환경별로 다르다(EXTRACTOR_METRICS_TARGET, deploy.yml 이 주입): -// dev = localhost:8090 (dev 박스 동거 컨테이너 — 이 Alloy 가 --network host 라 localhost 로 닿는다) -// prod = extractor 전용 박스의 private host:port (secret EXTRACTOR_PROD_ADDRESS, 앱 SG→8090 허용이라 크로스 scrape) -// staging/미설정 = 빈 값 → coalesce 로 127.0.0.1:9(discard 포트)로 fallback. staging 은 prod extractor 를 -// 바라보지만 그 메트릭은 prod Alloy 가 이미 수집하므로 여기선 스크레이프하지 않는다(환경 라벨 오염 방지). -// fallback 타깃은 아무도 안 듣는 포트라 scrape 가 down 으로만 잡히고 무해하다(죽은 blue/green 포트와 동일). -// environment 라벨은 remote_write external_labels(sys.env ENVIRONMENT)가 붙여 dev/prod 시계열이 갈린다. -prometheus.scrape "extractor" { - targets = [ - {"__address__" = coalesce(sys.env("EXTRACTOR_METRICS_TARGET"), "127.0.0.1:9"), "instance" = "piki-extractor"}, - ] - metrics_path = "/actuator/prometheus" - scrape_interval = "30s" - forward_to = [prometheus.remote_write.grafana_cloud.receiver] -} - -prometheus.remote_write "grafana_cloud" { - // dev/prod EC2 가 같은 Grafana Cloud 로 push 하므로 environment 라벨로 시계열을 가른다. - // 없으면 instance=team3-blue/green 이 양쪽 동일해 dev·prod 메트릭이 한 시계열로 충돌한다. - // ENVIRONMENT 는 provision-runtime.sh 가 alloy 컨테이너에 -e 로 주입(배포 워크플로가 derive). - external_labels = { - environment = sys.env("ENVIRONMENT"), - } - endpoint { - url = sys.env("GRAFANA_METRICS_URL") - basic_auth { - username = sys.env("GRAFANA_METRICS_USER") - password = sys.env("GRAFANA_CLOUD_TOKEN") - } - } -} - -// ── 로그: team3-blue/green 앱 컨테이너 stdout → Grafana Cloud (Loki) ── -discovery.docker "containers" { - host = "unix:///var/run/docker.sock" - // blue-green 배포로 컨테이너가 자주 교체된다. 짧은 refresh 로 사라진 컨테이너를 타겟에서 - // 빨리 제거해 loki.source.docker 의 "No such container" 추적 에러를 줄인다. - refresh_interval = "15s" -} - -discovery.relabel "team3" { - targets = discovery.docker.containers.targets - - // 앱 컨테이너(team3-blue / team3-green)만 수집한다. team3-redis·team3-alloy(자기) 등은 제외 — - // redis 의 과거 로그가 GC 의 too-old 거부(400)를 유발했고, 우리가 원하는 건 앱 로그뿐이기 때문. - rule { - source_labels = ["__meta_docker_container_name"] - regex = "/team3-(blue|green)" - action = "keep" - } - - // docker 가 붙이는 선행 슬래시를 떼어 container 라벨로 (/team3-blue → team3-blue). - rule { - source_labels = ["__meta_docker_container_name"] - regex = "/(.*)" - target_label = "container" - } -} - -loki.source.docker "containers" { - host = "unix:///var/run/docker.sock" - targets = discovery.relabel.team3.output - forward_to = [loki.process.app_logs.receiver] -} - -// ── ECS JSON 파싱: trace 를 structured metadata 로 승격, 본문은 message 만 (#364) ── -// 앱이 LOG_STRUCTURED=ecs 로 내보내는 ECS JSON 한 줄(예: -// {"@timestamp":...,"log":{"level":"INFO","logger":"...EntryPoint"},"message":"GET / ...", -// "traceId":"6a1f...","spanId":"2ca4...","ecs":{"version":"8.11"}}) -// 을 받아 다음을 한다: -// - traceId·spanId·logger 를 structured metadata 층으로 올린다. label 이 아니라 metadata 라 -// traceId 의 고카디널리티가 스트림을 쪼개지 않으면서 Grafana 에서 필드로 필터·표시된다. -// - 라인 본문을 message 로 교체해 Loki 에서 줄에 메시지만 깔끔히 남게 한다(가독성, 이 이슈의 목적). -// 키는 로컬 ecs 부팅 실측(2026-06-03)으로 확정: Spring Boot ECS formatter 는 micrometer MDC 키를 -// 그대로 top-level 로 둔다 → traceId·spanId(camelCase). log.level·log.logger 는 nested 라 점 경로. -loki.process "app_logs" { - // 비-JSON 라인(구버전 컨테이너의 평문 셧다운 로그 등)은 stage.json 이 조용히 무시하고 라인을 보존한다. - stage.json { - expressions = { - msg = "message", - trace_id = "traceId", - span_id = "spanId", - logger = "log.logger", - level = "log.level", - } - } - - // 고카디널리티(trace_id)·중카디널리티(logger)는 인덱스 없는 structured metadata 로. - // 빈 문자열 = 위 stage.json 이 추출한 동명 변수를 그대로 쓴다. - stage.structured_metadata { - values = { - trace_id = "", - span_id = "", - logger = "", - } - } - - // level 은 저카디널리티(INFO/WARN/ERROR 등)라 label 로 — Grafana Explore 의 레벨 필터·색상이 동작한다. - // 라인을 message 로 줄이면 본문에 레벨 문자열이 없어 Loki 자동 detect 가 놓치므로 명시적으로 올린다. - stage.labels { - values = { - level = "", - } - } - - // dev/prod 로그를 가르는 environment 라벨(메트릭 external_labels 와 같은 결). container 라벨이 - // 양쪽 EC2 에서 team3-blue/green 으로 동일해 로그가 뒤섞이는 걸 막는다. sys.env 는 config 로드 시 평가. - stage.static_labels { - values = { - environment = sys.env("ENVIRONMENT"), - } - } - - // 저장 라인 본문을 message 로 교체 — 줄에 메시지만 남는다. - stage.output { - source = "msg" - } - - forward_to = [loki.write.grafana_cloud.receiver] -} - -loki.write "grafana_cloud" { - endpoint { - url = sys.env("GRAFANA_LOGS_URL") - basic_auth { - username = sys.env("GRAFANA_LOGS_USER") - password = sys.env("GRAFANA_CLOUD_TOKEN") - } - } -} - -// ── 호스트 메트릭: node_exporter (메모리·swap·디스크·CPU) ── -// 906Mi 박스 + heap 256m + 컨테이너 메모리 무제한이라, OOM·swap 폭증은 JVM heap 메트릭만으론 안 보인다. -// 1순위 리스크(메모리)를 제대로 잡으려면 OS 레벨 메모리/swap/디스크를 봐야 한다. -// 컨테이너에서 호스트를 보려면 provision-runtime.sh 가 /proc·/sys·/ 를 /host/* 로 마운트하고, -// 아래 *_path 가 그 마운트를 가리킨다. node_exporter 메트릭엔 application 라벨이 없다(앱 메트릭과 구분). -prometheus.exporter.unix "host" { - procfs_path = "/host/proc" - sysfs_path = "/host/sys" - rootfs_path = "/host/root" - - // 작은 박스라 불필요한 collector 는 끈다. - disable_collectors = ["ipvs", "btrfs", "infiniband", "xfs", "zfs"] - - filesystem { - mount_points_exclude = "^/(dev|proc|sys|run/credentials/.+|var/lib/docker/.+|host/.+)($|/)" - fs_types_exclude = "^(autofs|binfmt_misc|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|mqueue|nsfs|overlay|proc|procfs|pstore|securityfs|sysfs|tracefs)$" - mount_timeout = "5s" - } -} - -prometheus.scrape "host" { - targets = prometheus.exporter.unix.host.targets - forward_to = [prometheus.remote_write.grafana_cloud.receiver] - scrape_interval = "30s" -} - -// ── 트레이스: 앱 OTLP push → Grafana Cloud (Tempo) (#380) ── -// 앱(micrometer-tracing + opentelemetry-exporter-otlp)이 span 을 이 receiver 로 push 한다. 메트릭(Alloy→앱 scrape)· -// 로그(컨테이너 stdout tail)와 달리 trace 는 앱이 능동적으로 push 하는 유일한 신호다. 앱 컨테이너는 bridge -// 네트워크(-p 127.0.0.1:PORT:8080)라 컨테이너 안 localhost 는 자기 자신을 가리켜 --network host 인 이 Alloy 에 -// 안 닿는다. 그래서 deploy.yml 이 앱에 --add-host=host.docker.internal:host-gateway 를 주고 endpoint 를 -// host.docker.internal:4318 로 보낸다 → 아래 receiver 를 0.0.0.0 으로 bind 해 호스트 게이트웨이로 들어온 걸 받는다. -otelcol.receiver.otlp "app" { - http { endpoint = "0.0.0.0:4318" } - grpc { endpoint = "0.0.0.0:4317" } - output { - traces = [otelcol.processor.transform.add_env.input] - } -} - -// dev/prod trace 를 가르는 deployment.environment resource attribute 주입(#380 후속). -// 메트릭(remote_write external_labels)·로그(loki static_labels)의 environment 와 같은 결 — -// dev/prod EC2 가 같은 Grafana Cloud(Tempo)로 push 하므로 이 라벨이 없으면 양쪽 trace 가 한데 섞인다. -// OTel 표준 키 deployment.environment 를 써, Tempo TraceQL 에서 resource.deployment.environment 로 필터한다 -// (기존 식별자 resource.service.name 과 같은 resource attribute 계층). ENVIRONMENT 는 provision-runtime.sh 가 -// alloy 컨테이너에 -e 로 주입(배포 워크플로가 derive). 미주입 시 빈 문자열 attr 가 set 되는데, 이는 빈 라벨을 -// 떨구는 메트릭(external_labels)·로그(static_labels)와 동작이 갈린다(빈 환경 trace 는 어느 env 필터에도 안 잡힘). -// 단 배포 워크플로가 항상 dev/prod 를 주입하므로 이 경로엔 닿지 않는다. -otelcol.processor.transform "add_env" { - error_mode = "ignore" - trace_statements { - context = "resource" - statements = [ - "set(attributes[\"deployment.environment\"], \"" + sys.env("ENVIRONMENT") + "\")", - ] - } - output { - traces = [otelcol.processor.batch.traces.input] - } -} - -// 작은 박스라 span 을 모아 한 번에 보내 네트워크·CPU 를 아낀다. -otelcol.processor.batch "traces" { - output { - traces = [otelcol.exporter.otlphttp.grafana_cloud.input] - } -} - -// GC 로 OTLP export. GC 는 통합 OTLP gateway(HTTP/protobuf, https://otlp-gateway-...grafana.net/otlp)를 -// 제공하므로 gRPC(otelcol.exporter.otlp)가 아니라 otlphttp 로 보낸다. otlphttp 는 endpoint 뒤에 /v1/traces 를 -// 붙여 전송한다. endpoint·instance ID(basic auth username)는 secret, token 은 메트릭·로그와 공유 -// (GRAFANA_CLOUD_TOKEN, traces:write scope 포함). -// GRAFANA_TRACES_URL 미설정(traces secret 미등록) 시 더미로 fallback 해 Alloy 부팅을 살린다(메트릭·로그는 계속 -// 수집되고 trace export 만 연결 실패 로그를 남긴다. skip 가드는 GRAFANA_METRICS_URL 기준이라 traces 만 비면 여기 닿는다). -// dev/prod 구분은 위 otelcol.processor.transform.add_env 가 deployment.environment 로 붙인다(메트릭 external_labels· -// 로그 static_labels 의 environment 와 같은 결). Tempo 에선 resource.deployment.environment 로 환경을 필터한다. -otelcol.exporter.otlphttp "grafana_cloud" { - client { - endpoint = coalesce(sys.env("GRAFANA_TRACES_URL"), "https://otlp-unset.invalid/otlp") - auth = otelcol.auth.basic.grafana_cloud.handler - } -} - -otelcol.auth.basic "grafana_cloud" { - username = sys.env("GRAFANA_TRACES_USER") - password = sys.env("GRAFANA_CLOUD_TOKEN") -} diff --git a/infra/grafana/README.md b/infra/grafana/README.md index 36b03999..aa9335e0 100644 --- a/infra/grafana/README.md +++ b/infra/grafana/README.md @@ -24,19 +24,19 @@ Grafana Cloud 가 호스팅하는 Grafana 이므로 셀프호스팅 provisioning 4. **호스트(node_exporter)** — 메모리 used/total · swap · 디스크 여유. OOM-kill·swap 폭증을 OS 레벨로 잡는다. 5. **의존성** — 502율(Gemini 등 외부 실패) · executor active/queued · HikariCP active/idle/pending. 6. **리소스** — 앱 process/system CPU · JVM 스레드 · 호스트 CPU. -7. **로그** — 에러/경고 필터 로그 + 전체 로그(`team3-blue`/`team3-green`). +7. **로그** — 에러/경고 필터 로그 + 전체 로그(`piki-core-blue`/`piki-core-green`, 전환기엔 옛 `team3-*` 도 양쪽 매칭). 8. **파싱·추출 관측** — **파싱 실패율**(`item.parsing` 메트릭 rate 기반 레드 스위치, 임계 색상, 환경 따라감) + **파싱 결과(result별)·추출 방법(via별)**(`item.parse.result`·`extract via=` 로그를 `count_over_time | logfmt` 로 셈) + **파싱 이벤트 로그**(어느 URL 이 왜 실패했는지 맥락). 단건 파이프라인 드릴다운은 상단 트레이스의 **`아이템` 탭**(`name = "item.parse"`)으로 본다(#506). - **누적 카운트 메트릭이 아니라 로그/트레이스 기반인 이유**: 인메모리 카운터는 앱 재배포마다 0으로 리셋된다. dev 는 하루 수십 번 배포해 누계가 무의미하고, `increase` 창집계도 잦은 재시작+blue/green instance churn 에 시리즈를 떨군다. 로그 이벤트는 Loki 에 durable 하게 쌓여 배포에 영향받지 않으므로 결과·방법 집계의 정확한 소스다. 메트릭은 "정확한 누계"가 아니라 "실패율 급등 감지(레드 스위치)" 한 가지에만 쓴다 — 그 역할엔 짧은 창 rate 라 리셋이 무관하다. -앱 메트릭은 `application="PIKI"` 라벨로 필터한다(Alloy 의 prometheus.scrape 가 붙임). +앱 메트릭은 `application="piki-core"` 라벨로 필터한다(#727 rename, 전환기 쿼리는 `PIKI|piki-core` 양쪽 매칭 — 구 시계열 stale 후 단독 정리). blue-green 이라 한 시점에 한 슬롯만 활성이며 `instance` 라벨로 blue/green 을 구분한다. 호스트 메트릭(`node_*`)은 application 라벨이 없다 — `prometheus.exporter.unix` 가 별도로 내보낸다. ### 환경 구분 (dev / prod) dev·prod EC2 가 **같은 Grafana Cloud** 로 push 하므로, 상단 **환경** 변수(`$environment`)로 시계열을 가른다. -이 라벨이 없으면 `instance`(team3-blue/green)가 양쪽 EC2 에서 같아 dev·prod 가 한 시계열로 섞인다. +이 라벨이 없으면 `instance`(piki-core-blue/green)가 양쪽 EC2 에서 같아 dev·prod 가 한 시계열로 섞인다. - **메트릭** — Alloy remote_write 의 `external_labels` 가 `environment` 라벨을 붙인다(앱·호스트 메트릭 모두). 모든 패널 쿼리가 `environment="$environment"` 로 필터한다. - **로그** — Loki `static_labels` 가 `environment` 를 붙인다. 로그 패널 stream selector 가 같은 라벨로 필터한다. diff --git a/infra/grafana/dashboard.json b/infra/grafana/dashboard.json index f3aea72d..69989b65 100644 --- a/infra/grafana/dashboard.json +++ b/infra/grafana/dashboard.json @@ -1398,7 +1398,7 @@ { "id": 106, "type": "row", - "title": "로그 (team3-blue / team3-green)", + "title": "로그 (piki-core-blue / piki-core-green)", "gridPos": { "h": 1, "w": 24, @@ -1435,7 +1435,7 @@ "type": "loki", "uid": "${DS_LOKI}" }, - "expr": "{container=~\"team3-(blue|green)\", environment=\"$environment\"} |~ \"(?i)(error|exception|warn|fail)\"" + "expr": "{container=~\"(team3|piki-core)-(blue|green)\", environment=\"$environment\"} |~ \"(?i)(error|exception|warn|fail)\"" } ] }, @@ -1468,7 +1468,7 @@ "type": "loki", "uid": "${DS_LOKI}" }, - "expr": "{container=~\"team3-(blue|green)\", environment=\"$environment\"}" + "expr": "{container=~\"(team3|piki-core)-(blue|green)\", environment=\"$environment\"}" } ] }, @@ -1590,7 +1590,7 @@ "type": "loki", "uid": "${DS_LOKI}" }, - "expr": "sum by (result) (count_over_time({container=~\"team3-(blue|green)\", environment=\"$environment\"} |= \"item.parse.result\" | logfmt | __error__=\"\" [$__interval]))", + "expr": "sum by (result) (count_over_time({container=~\"(team3|piki-core)-(blue|green)\", environment=\"$environment\"} |= \"item.parse.result\" | logfmt | __error__=\"\" [$__interval]))", "legendFormat": "{{result}}" } ] @@ -1640,7 +1640,7 @@ "type": "loki", "uid": "${DS_LOKI}" }, - "expr": "sum by (via) (count_over_time({container=~\"team3-(blue|green)\", environment=\"$environment\"} |= \"extract via=\" | logfmt | __error__=\"\" [$__interval]))", + "expr": "sum by (via) (count_over_time({container=~\"(team3|piki-core)-(blue|green)\", environment=\"$environment\"} |= \"extract via=\" | logfmt | __error__=\"\" [$__interval]))", "legendFormat": "{{via}}" } ] @@ -1675,7 +1675,7 @@ "type": "loki", "uid": "${DS_LOKI}" }, - "expr": "{container=~\"team3-(blue|green)\", environment=\"$environment\"} |~ \"item.parse.result|item.parse.error|extract via=|파싱 실패\"" + "expr": "{container=~\"(team3|piki-core)-(blue|green)\", environment=\"$environment\"} |~ \"item.parse.result|item.parse.error|extract via=|파싱 실패\"" } ] } diff --git a/infra/scripts/provision-runtime.sh b/infra/scripts/provision-runtime.sh index fa5120d6..12f0b6d9 100755 --- a/infra/scripts/provision-runtime.sh +++ b/infra/scripts/provision-runtime.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # EC2 런타임 프로비저닝 — 멱등(idempotent). 배포 때 실행되어 박스 안 런타임 설정 # (swap / redis / nginx default / grafana alloy)이 레포 정의 상태가 되도록 보장한다. -# swap·redis·nginx 는 이미 있으면 skip 하고, alloy 는 config 가 레포에서 오므로 매 배포 갱신·재기동한다. (#217) +# swap·redis·nginx 는 이미 있으면 skip 하고, alloy 는 공용 블록(TeamPiKi/infra)이 매 배포 갱신·재기동한다. (#217, #743) # # docker 명령은 sudo 없이(ubuntu 가 docker 그룹), 시스템·nginx 는 sudo 로 — deploy.yml 기존 패턴과 동일. set -euo pipefail @@ -125,42 +125,21 @@ else echo "[nginx] default 없음 — skip" fi -# 4) grafana-alloy — 앱 메트릭 scrape + team3-* 컨테이너 로그를 Grafana Cloud 로 보내는 단일 수집기. -# Alloy 는 stateless 이고 config 가 레포(infra/alloy/config.alloy)에서 오므로, redis 의 "있으면 skip" 과 달리 -# 매 배포마다 config 를 갱신하고 재기동한다 (restart 비용은 작고 scrape 공백도 수초 수준). -# 자격증명(GRAFANA_*)이 없으면(secret 미등록) 기동을 skip 한다 — 빈 endpoint 로 부팅하면 config 검증 실패로 -# crash loop 가 나기 때문. secret 등록 후 다음 배포에 자동 기동된다. -# --network host: 앱 포트가 127.0.0.1 바인딩(#290)이라 localhost:8080/8081 을 scrape 하려면 필요. -# --server.http.listen-addr=127.0.0.1: host 네트워크라 debug UI(12345)를 루프백에만 묶어 외부 노출을 막는다. -# /proc·/sys·/ 마운트: node_exporter(config 의 prometheus.exporter.unix)가 컨테이너 안에서 호스트 -# 메모리·swap·디스크를 읽으려면 필요하다. config 의 *_path 가 /host/* 를 가리킨다. ro,rslave 로 읽기전용. -if [ -z "${GRAFANA_METRICS_URL:-}" ]; then - echo "[alloy] GRAFANA_* 미설정 — skip (secret 등록 후 다음 배포에 기동)" -else - echo "[alloy] config 갱신 후 (재)기동" - sudo mkdir -p /etc/alloy-team3 - sudo cp /tmp/team3-alloy/config.alloy /etc/alloy-team3/config.alloy - docker rm -f team3-alloy 2>/dev/null || true - docker run -d \ - --name team3-alloy \ - --restart unless-stopped \ - --network host \ - -v /etc/alloy-team3/config.alloy:/etc/alloy/config.alloy:ro \ - -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -v /proc:/host/proc:ro,rslave \ - -v /sys:/host/sys:ro,rslave \ - -v /:/host/root:ro,rslave \ - -e ENVIRONMENT="${ENVIRONMENT:-}" \ - -e GRAFANA_METRICS_URL="${GRAFANA_METRICS_URL:-}" \ - -e GRAFANA_METRICS_USER="${GRAFANA_METRICS_USER:-}" \ - -e GRAFANA_LOGS_URL="${GRAFANA_LOGS_URL:-}" \ - -e GRAFANA_LOGS_USER="${GRAFANA_LOGS_USER:-}" \ - -e GRAFANA_TRACES_URL="${GRAFANA_TRACES_URL:-}" \ - -e GRAFANA_TRACES_USER="${GRAFANA_TRACES_USER:-}" \ - -e GRAFANA_CLOUD_TOKEN="${GRAFANA_CLOUD_TOKEN:-}" \ - -e EXTRACTOR_METRICS_TARGET="${EXTRACTOR_METRICS_TARGET:-}" \ - grafana/alloy:v1.16.1 \ - run --server.http.listen-addr=127.0.0.1:12345 /etc/alloy/config.alloy -fi +# 4) grafana-alloy — 관측 수집기. config·기동 블록의 SSOT 는 TeamPiKi/infra 공용 블록(blocks/alloy)이고(#743), +# deploy.yml 의 'Upload alloy block' 이 /tmp/piki-blocks/alloy/ 로 올려둔다. 여기는 core 박스 값 +# (--environment/--box)으로 호출만 한다. skip 가드(GRAFANA_METRICS_URL 미주입 시 exit 0)·기동 전 +# validate 게이트·--network host·호스트 마운트·Running 확인은 전부 블록이 책임진다. +# 자격증명(GRAFANA_*)은 deploy.yml 이 env 로 이미 export 해 뒀다(블록이 env 로 소비 — ps 노출 방지). +# 수집 대상은 컨테이너 label opt-in(piki.observe 등, contracts/observability.md) — 서비스 열거 regex 와 +# cross-box scrape(EXTRACTOR_METRICS_TARGET)는 폐기됐다(extractor prod 박스는 자체 Alloy 가 수집). +# 전환기 잔재 정리: 구 수집기(team3-alloy)·구 config 경로가 남으면 새 수집기(piki-alloy, 블록이 기동)와 +# 이중 수집된다. 없으면 no-op 라 유지 비용이 없고, 전 환경 개편 배포가 한 바퀴 돈 뒤 제거 가능. +docker rm -f team3-alloy 2>/dev/null || true +sudo rm -rf /etc/alloy-team3 +bash /tmp/piki-blocks/alloy/provision-alloy.sh \ + --config /tmp/piki-blocks/alloy/config.alloy \ + --name piki-alloy \ + --environment "${ENVIRONMENT:?ENVIRONMENT 미주입 — deploy.yml envs 확인}" \ + --box piki-core echo "런타임 프로비저닝 완료" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d622681f..4f16a842 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -208,7 +208,7 @@ logging: # - 시각을 Asia/Seoul 로 고정해 Grafana 표시(KST)와 맞춘다 (JVM 타임존은 안 건드린다 — now() 등 도메인 시각에 # 영향 주지 않도록 로그 출력에서만 KST 로 포맷. DB 는 serverTimezone=UTC 유지). # - PID·--- ·application name([piki-core])을 뺀다. 단일 컨테이너라 PID 는 항상 1 이고, 서비스 구분은 Loki 의 - # container 라벨(team3-blue/green)이 이미 한다 — 줄마다 반복할 가치가 없다. + # container 라벨(piki-core-blue/green)이 이미 한다 — 줄마다 반복할 가치가 없다. # - traceId/spanId 는 한 요청의 전체 로그를 묶는 추적 키라 유지한다 (sampling 1.0 이라 항상 채워진다). # - userId(MDC, JwtAuthenticationFilter 가 인증 요청에 주입)는 한 유저의 여러 요청을 가로질러 묶는 키다 — # grep userId= 로 로그인→플레이→… 여정 전체를 잇는다. 미인증 요청은 '-' 로 비워진다.