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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ jobs:
- name: Validate observability IaC
run: bash deploy/observability/scripts/validate-iac.sh

- name: Verify local log pipeline and trace correlation
timeout-minutes: 10
run: python3 deploy/observability/scripts/verify-log-pipeline.py

- name: Upload log pipeline verification
uses: actions/upload-artifact@v4
with:
name: observability-log-pipeline
path: build/reports/observability/log-pipeline.json

backend-build-test:
name: Backend Build & Test (Unit + Integration)
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions deploy/docker-compose.ec2.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ services:
image: ${IMAGE_REPOSITORY}:${IMAGE_TAG}
container_name: beach-complex-backend
restart: unless-stopped
labels:
beach.logs: backend
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
compress: "false"
labels: "beach.logs"
env_file:
- ${APP_RUNTIME_ENV_FILE:-.env}
ports:
Expand Down
182 changes: 182 additions & 0 deletions deploy/observability/README.md
Comment thread
geonusp marked this conversation as resolved.
Comment thread
geonusp marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# 관측 로그 파이프라인

앱 서버의 Docker `json-file` 로그를 Alloy가 읽어 관측 서버 Loki로 보내고, Grafana에서 로그와 Tempo Trace를 연결한다.
Trace는 기존 경로대로 애플리케이션에서 Tempo로 직접 전송한다.

```text
앱 EC2: Backend stdout → Docker json-file → Alloy ── TCP 3100 ──→ 관측 EC2: Loki
└────────────────── OTLP 4317/4318 ──────────────────→ Tempo
↑
Grafana 조회
```

구현 범위는 [이슈 #233](https://github.com/Beach-complex/Beach_complex/issues/233)의 PR4다. 서버 적용과 실제 앱 검증은 아직 수행하지 않았다.

- 검증 명령만 필요하면 [verify-local.txt](verify-local.txt)를 사용한다.
- 서버에 적용할 때는 아래의 서버 적용 절차와 운영 제약을 함께 확인한다.

## 로컬 검증

필요 도구: Docker Engine + Compose v2 이상, Python 3, Terraform 1.15.8, cloud-init.
Terraform 테스트는 mock AWS provider를 사용하므로 AWS 자격증명이나 실제 리소스가 필요하지 않다.

기존 `beach.logs=backend` 컨테이너가 있으면 중지된 컨테이너도 포함하여 통합 테스트가 실행을 거부한다.
이 경우 앱 컨테이너를 건드리지 않고 별도의 테스트용 Docker 데몬에서 실행한다.

저장소 루트에서 다음 명령을 실행한다.

```bash
bash deploy/observability/scripts/validate-iac.sh
python3 deploy/observability/scripts/verify-log-pipeline.py
```

### 설정 검사

`validate-iac.sh`는 Terraform fmt/init/validate와 mock plan 3건(dev/staging/prod), cloud-init schema 및 EC2 user data 16KB 제한,
Compose 병합, Loki/Alloy 실제 이미지의 설정 검증을 수행한다.

### 통합 테스트

`verify-log-pipeline.py`는 임의의 localhost 포트, 전용 Compose 프로젝트와 볼륨을 만든다. 합성 JSON 로그와 OTLP span으로 다음을 확인하고 자체 리소스를 정리한다.

- 실제 Docker json-file → Alloy → Loki 수집, 원본 JSON 보존 및 traceId 검색
- 다른 컨테이너 제외, `/series` 기준 인덱스 라벨 4개만 존재
- Grafana 데이터 소스의 변수·정규식·연결 상태, Grafana 프록시 API를 통한 로그 ↔ Trace 조회
- Alloy 정상 재시작 후 저장된 파일 지문·읽기 위치 사용 및 중지 중 쌓인 로그 수집
- 두 컨테이너의 stdout/stderr에서 조각이 교차하는 40KB JSON의 원문 보존과 traceId 검색
- Alloy 중지 중 회전된 로그 110건과 파일 경계에 걸친 90KB JSON 복구, 재시작 후 중복 전송 검사
- backend 컨테이너 교체 시 새 로그 경로 발견, 짧은 Loki 중단 후 전송 재시도
- Loki 컨테이너 재생성 후 기존 데이터 조회 및 실제 적용된 보존 기간 설정

보고서는 `build/reports/observability/log-pipeline.json`에 생성하며 CI에서도 같은 검증을 실행하도록 구성했다.

### 서버에서 별도로 확인할 항목

로컬 검증은 합성 데이터와 Docker 볼륨을 사용한다. 실제 EC2/EBS 부팅·재부팅, 브라우저 클릭,
운영 앱의 로그와 span 연결, 보존 기간 경과에 따른 실제 삭제는 서버 적용 후 확인할 항목이다.
24시간 운영 검증, 대시보드·알림·SLI/SLO 및 Terraform 원격 상태 저장소는 #233에서 분리한 후속 범위다.

## 구성과 저장 정책

| 구성 | 파일 / 동작 |
|---|---|
| Loki | `compose/loki/local-config.yaml`, 단일 프로세스, TSDB v13 + 파일 시스템 저장소 |
| 저장소 | 관측 EBS의 `/opt/beach-observability/data/loki` → `/loki`; chunk/index/WAL/compactor 상태 포함 |
| 보존 기간 | Terraform `env`: dev 3일, staging 7일, prod 14일; compactor 활성화, 삭제 지연 2시간 |
| 접근 | 관측 SG TCP 3100은 앱 SG만 허용. 인증 없는 Loki를 인터넷에 직접 공개하지 않는다. |
| 수집 | `app-agent/alloy/config.alloy`, Docker 로그 디렉터리 읽기 전용; Docker socket 사용 없음 |
| 수집 대상 | Docker logging attribute `beach.logs=backend`인 컨테이너만 통과 |
| 재배포 | 기본 200ms 주기로 새 로그 파일을 탐색. 컨테이너 ID를 설정에 고정하지 않는다. |
| 읽기 위치 | 파일 지문과 읽기 위치를 앱 서버 `alloy-data` Docker 볼륨에 저장. `down -v`로 삭제하지 않는다. |
| 회전 | backend Docker 로그 파일당 50MB, 최대 5개, 압축하지 않음 |
| 인덱스 라벨 | `app=beach-complex`, `env`, `service=backend`, `host`만 유지 |
| 식별자 | traceId/spanId/requestId/userId/notificationId/outboxEventId는 JSON 본문에 유지; 검색 인덱스용 라벨로 만들지 않는다. |

## 서버 적용 절차

### 1. 관측 서버의 변경 사항 확인

관측 서버 Terraform은 검증된 EBS 마운트 아래에 Loki 데이터 디렉터리(UID/GID 10001)를 만들고,
설정 및 환경별 `LOKI_RETENTION_PERIOD`를 배치한다. 기존 마운트 가드를 유지한다.
현재 모듈의 `user_data_replace_on_change = true` 때문에 이 변경의 apply는 관측 EC2 교체를 유발할 수 있다.
적용 전 실제 state 기준 plan의 교체 대상과 EBS 보존 여부를 확인해야 한다.

### 2. 앱 서버의 환경 변수 설정

`app-agent/.env.example`을 참고해 `app-agent/.env`를 작성한다.

```dotenv
LOKI_URL=http://<관측-서버-private-ip>:3100/loki/api/v1/push
APP_ENVIRONMENT=dev
LOG_HOST=beach-app-ec2
DOCKER_LOG_ROOT=/var/lib/docker/containers
```

`LOG_HOST`에는 재배포 후에도 유지되는 서버 식별자를 사용한다.
`DOCKER_LOG_ROOT`는 Docker 데이터 경로 아래의 `containers` 디렉터리다.
앱 서버에서 `docker info --format '{{.DockerRootDir}}'`로 데이터 경로를 확인한 뒤 `/containers`를 붙인다.

### 3. 앱 로그 설정 적용 및 Alloy 기동

실제 앱 Compose의 서비스가 `app`인지 확인한 뒤 `backend-logging.override.yml`을 함께 사용한다.
서비스명이 다르면 override의 서비스 키를 실제 이름으로 맞춘다.
로그 속성은 컨테이너 생성 때 적용되므로 기존 컨테이너에는 재생성이 필요하다.

```bash
# 앱 서버, 실제 기존 Compose 파일·환경 파일을 그대로 지정해서 병합 결과를 먼저 확인한다.
docker compose -f <기존-compose.yml> -f <경로>/app-agent/backend-logging.override.yml config
# 이후 배포 단계에서만 app을 재생성하고 Alloy를 기동한다.
docker compose -f <기존-compose.yml> -f <경로>/app-agent/backend-logging.override.yml up -d --no-deps app
docker compose --env-file <경로>/app-agent/.env -f <경로>/app-agent/docker-compose.yml up -d
```

지속 배포에 사용하는 Compose에도 logging 설정이 남아 있어야 한다. 저장소의 `deploy/docker-compose.ec2.yml.example`에도 같은 설정을 추가했다.
`APP_ENVIRONMENT`는 Loki 라벨이며 Spring profile을 변경하지 않는다. 현재 Logback의 prod profile이 NDJSON을 출력한다.
JSON 검색을 확인할 때는 실제 앱 출력 형식을 먼저 확인하고, 수집 때문에 애플리케이션 profile을 임의로 바꾸지 않는다.

### 4. Grafana에서 로그와 Trace 조회

Grafana Explore → Loki에서 다음을 조회한다. `env`는 적용한 환경에 맞춘다.

```logql
{app="beach-complex", env="dev", service="backend"} | json
{app="beach-complex", env="dev", service="backend"} | json | traceId="<32자리 trace ID>"
```

로그의 `View trace` 링크는 Tempo를 가리킨다. JSON `traceId`와 현재 개발용 텍스트의 `traceId=...`를 지원한다.
Tempo의 Trace to logs는 span 시간 전후 5분에서 동일 traceId 문자열을 검색한다.
Trace가 샘플링되지 않았거나 Tempo 보존 기간이 지났으면 해당 로그의 링크 대상은 없을 수 있다.

## 운영 제약

- **전송 보장:** 읽기 위치 저장과 Loki 전송 완료는 하나의 작업으로 처리되지 않는다. 짧은 장애에는 재시도하지만, 장시간 장애·강제 종료·회전 한도 초과 시 무손실이나 정확히 한 번의 전송을 보장하지 않는다.

- **긴 로그 재조립:** 조각은 메모리에만 저장한다. 첫 조각을 읽은 뒤 5초 안에 한 줄이 완성되지 않거나 재조립 도중 Alloy가 종료되면 원문 보존을 보장하지 않는다.

- **로그 크기와 회전:** 한 줄은 256KiB 이내로 유지한다. 수집 대상은 최대 5개 파일이므로 Docker의 보존 개수와 압축하지 않는 설정도 유지한다.

- **읽기 위치 보존:** `alloy-data` 볼륨을 삭제하면 저장된 읽기 위치도 사라진다. `docker compose down -v`를 사용하지 않는다.

- **이전 초안에서 전환:** 기존 `loki.source.file`의 positions 파일은 자동 이관되지 않는다. 새 수집기를 적용하면 남아 있는 로그를 다시 읽을 수 있다.

- **Loki 보존 기간:** 보존 기간은 디스크 용량 상한이 아니다. 오래된 데이터는 compactor 실행 주기와 삭제 지연 이후에 삭제된다.

## 구현 상세

<details>
<summary>Alloy 실행 권한, 파일 식별 방식과 정렬 우회 설정</summary>

### 실행 권한

Alloy는 root 소유 Docker 파일을 읽기 위해 UID 0으로 실행하지만, capability를 모두 제거하고 `no-new-privileges`를 사용한다.
빈 상태 볼륨에 이미지 내부 UID 소유권이 복사되지 않도록 `nocopy: true`를 지정한다.
이는 rootful Docker의 기본 `json-file` 배치를 기준으로 한다. rootless Docker나 user namespace remapping 환경은 별도 권한 검증이 필요하다.

### 수집기 버전과 로그 처리

`otelcol.receiver.filelog`와 `otelcol.storage.file`은 고정한 Alloy v1.19.2에서 공개 미리보기 기능이다.
Compose와 설정 검사에 `--stability.level=public-preview`를 명시했다. 업그레이드할 때 같은 회귀 테스트를 실행한다.

파일 지문은 파일 내용 일부를 비교해 같은 파일인지 식별하는 값이다. Docker 레코드 끝의 시각까지 비교하도록 크기를 128KiB로 설정했다. 긴 로그 조각의 앞부분이 같아도 다른 파일로 구분한다.
파일 지문으로 이름이 바뀐 파일을 추적하고 `.log.4`부터 `.log`까지 차례로 읽는다.
Docker가 나눈 한 줄은 컨테이너 ID와 stdout/stderr를 기준으로 묶어, 줄바꿈이 나올 때까지 구분자 없이 합친다.

### 정렬 옵션의 우회 설정

이 버전의 Alloy 정렬 설정은 `lexicographic`을 받지만 내부 Stanza는 `alphabetical`을 요구한다.
이를 피하려고 `operators`의 `file_input`에 정렬을 직접 설정했다. 바깥쪽 입력은 사용하지 않는 경로를 가리킨다.
두 입력이 같은 파일을 읽지 않으며, 실제 입력은 같은 file storage에 읽기 위치를 저장한다.
이 우회 설정은 해당 버전의 매핑이 수정되면 제거할 수 있다.

</details>

## 참고 자료

- [ADR-014: Loki 및 보존·라벨 정책](https://github.com/Beach-complex/beach_complex_docs/blob/main/adr/ADR-014-logging-storage-backend.md)
- [ADR-015: Alloy 및 file tail](https://github.com/Beach-complex/beach_complex_docs/blob/main/adr/ADR-015-log-collector.md)
- [Alloy filelog receiver](https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.receiver.filelog/)
- [Alloy file storage](https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.storage.file/)
- [Stanza 재조립 설정](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.158.0/pkg/stanza/docs/operators/recombine.md)
- [Alloy Loki write 및 재시도](https://grafana.com/docs/alloy/latest/reference/components/loki/loki.write/)
- [Loki retention](https://grafana.com/docs/loki/latest/operations/storage/retention/)
- [Grafana Tempo와 로그 연결](https://grafana.com/docs/grafana/latest/datasources/tempo/configure-tempo-data-source/)
5 changes: 5 additions & 0 deletions deploy/observability/app-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
LOKI_URL=http://<observability-private-ip>:3100/loki/api/v1/push
APP_ENVIRONMENT=dev
LOG_HOST=beach-app-ec2
# Set to DockerRootDir/containers when Docker uses a non-default data root.
DOCKER_LOG_ROOT=/var/lib/docker/containers
114 changes: 114 additions & 0 deletions deploy/observability/app-agent/alloy/config.alloy
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
logging {
level = "info"
}

// Fingerprints identify files even after Docker renames them during rotation.
// Both components are public preview in the pinned Alloy v1.19.2 image.
otelcol.storage.file "docker" {
directory = "/var/lib/alloy/docker-offsets"
create_directory = true
fsync = true
}

otelcol.receiver.filelog "docker" {
// v1.19.2's ordering wrapper accepts "lexicographic", whereas its bundled
// stanza reader requires "alphabetical". Configure the file_input operator
// directly until that upstream mapping is fixed. The wrapper input is idle.
include = ["/var/log/docker/__unused_wrapper_input__"]
storage = otelcol.storage.file.docker.handler
operators = [
{
type = "json_parser",
id = "parse_docker",
output = "select_backend",
on_error = "drop",
timestamp = {
parse_from = "attributes.time",
layout_type = "gotime",
layout = "2006-01-02T15:04:05.999999999Z07:00",
},
},
{
type = "file_input",
id = "docker_files",
output = "parse_docker",
include = ["/var/log/docker/*/*-json.log", "/var/log/docker/*/*-json.log.[1-4]"],
start_at = "beginning",
// Include Docker timestamps after escaped 16 KiB chunks in the fingerprint.
// A 1 KiB prefix can be identical across containers with repetitive payloads.
fingerprint_size = "128KiB",
include_file_path = true,
include_file_name = false,
// Oldest file first, one reader at a time, across all five files.
max_concurrent_files = 1,
max_batches = 0,
ordering_criteria = {
regex = "/[^/]+/[^/]+-json\\.log(?P<rotation>\\.[1-4])?$",
group_by = "/([^/]+)/[^/]+-json\\.log",
top_n = 5,
sort_by = [{sort_type = "alphabetical", regex_key = "rotation", ascending = false}],
},
attributes = {
app = "beach-complex",
env = sys.env("APP_ENVIRONMENT"),
service = "backend",
host = sys.env("LOG_HOST"),
"loki.attribute.labels" = "app,env,service,host",
"loki.format" = "raw",
},
},
{
type = "filter",
id = "select_backend",
expr = "attributes.attrs == nil or attributes.attrs[\"beach.logs\"] != \"backend\"",
},
{
type = "regex_parser",
parse_from = "attributes[\"log.file.path\"]",
regex = "/(?P<container>[^/]+)/[^/]+-json.log",
on_error = "drop",
},
{
type = "add",
field = "attributes.source",
value = "EXPR(attributes.container + \"/\" + attributes.stream)",
},
{
type = "recombine",
combine_field = "attributes.log",
combine_with = "",
is_last_entry = "attributes.log endsWith \"\\n\"",
source_identifier = "attributes.source",
overwrite_with = "oldest",
force_flush_period = "5s",
max_log_size = 262144,
},
{
type = "move",
from = "attributes.log",
to = "body",
},
]

output {
logs = [otelcol.exporter.loki.backend.input]
}
}

otelcol.exporter.loki "backend" {
forward_to = [loki.process.backend.receiver]
}

loki.process "backend" {
forward_to = [loki.write.observability.receiver]
// Preserve app JSON; IDs and Docker paths never become indexed labels.
stage.label_keep {
values = ["app", "env", "service", "host"]
}
}

loki.write "observability" {
endpoint {
url = sys.env("LOKI_URL")
}
}
12 changes: 12 additions & 0 deletions deploy/observability/app-agent/backend-logging.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Merge with the app server's existing Compose file. Service name must be app.
services:
app:
labels:
beach.logs: backend
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
compress: "false"
labels: "beach.logs"
32 changes: 32 additions & 0 deletions deploy/observability/app-agent/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
services:
alloy:
image: grafana/alloy:v1.19.2
restart: unless-stopped
# Docker log files are root-owned; no Docker socket or host write access.
user: "0:0"
command:
- run
- --stability.level=public-preview
- --server.http.listen-addr=127.0.0.1:12345
- --storage.path=/var/lib/alloy
- /etc/alloy/config.alloy
environment:
LOKI_URL: ${LOKI_URL:?Set the observability private Loki push URL}
APP_ENVIRONMENT: ${APP_ENVIRONMENT:?Set dev, staging or prod}
LOG_HOST: ${LOG_HOST:?Set a stable application host identifier}
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
- ${DOCKER_LOG_ROOT:-/var/lib/docker/containers}:/var/log/docker:ro
# Keep the empty volume root-owned instead of copying image UID ownership.
- type: volume
source: alloy-data
target: /var/lib/alloy
volume:
nocopy: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL

volumes:
alloy-data:
Loading
Loading