diff --git a/.gitignore b/.gitignore index 49e2e5e7..d082fe43 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,15 @@ target/ # Firebase service account key (보안상 절대 커밋 금지) **/firebase-service-account.json +**/*firebase-adminsdk*.json + +# Local credentials and Terraform state/plan backups +/*.pem +**/tfplan +**/*.tfplan +**/terraform.tfstate* +**/terraform-state-backup-*.json +/OPENWEATHER_API_KEY-backup-*.txt # Firebase 자동 생성 설정 파일 (환경변수 포함) front/public/firebase-config.js diff --git a/deploy/observability/README.md b/deploy/observability/README.md index dd2ff9cb..458955c5 100644 --- a/deploy/observability/README.md +++ b/deploy/observability/README.md @@ -144,7 +144,7 @@ Trace가 샘플링되지 않았거나 Tempo 보존 기간이 지났으면 해당 ## 구현 상세
-Alloy 실행 권한, 파일 식별 방식과 정렬 우회 설정 +Alloy 실행 권한, 파일 식별 방식과 정렬 설정 ### 실행 권한 @@ -154,19 +154,18 @@ Alloy는 root 소유 Docker 파일을 읽기 위해 UID 0으로 실행하지만, ### 수집기 버전과 로그 처리 -`otelcol.receiver.filelog`와 `otelcol.storage.file`은 고정한 Alloy v1.19.2에서 공개 미리보기 기능이다. +`otelcol.receiver.filelog`와 `otelcol.storage.file`은 고정한 Alloy v1.21.0에서 공개 미리보기 기능이다. Compose와 설정 검사에 `--stability.level=public-preview`를 명시했다. 업그레이드할 때 같은 회귀 테스트를 실행한다. 파일 지문은 파일 내용 일부를 비교해 같은 파일인지 식별하는 값이다. Docker 레코드 끝의 시각까지 비교하도록 크기를 128KiB로 설정했다. 긴 로그 조각의 앞부분이 같아도 다른 파일로 구분한다. 파일 지문으로 이름이 바뀐 파일을 추적하고 `.log.4`부터 `.log`까지 차례로 읽는다. Docker가 나눈 한 줄은 컨테이너 ID와 stdout/stderr를 기준으로 묶어, 줄바꿈이 나올 때까지 구분자 없이 합친다. -### 정렬 옵션의 우회 설정 +### 파일 정렬 설정 -이 버전의 Alloy 정렬 설정은 `lexicographic`을 받지만 내부 Stanza는 `alphabetical`을 요구한다. -이를 피하려고 `operators`의 `file_input`에 정렬을 직접 설정했다. 바깥쪽 입력은 사용하지 않는 경로를 가리킨다. -두 입력이 같은 파일을 읽지 않으며, 실제 입력은 같은 file storage에 읽기 위치를 저장한다. -이 우회 설정은 해당 버전의 매핑이 수정되면 제거할 수 있다. +Alloy v1.21.0에서는 `otelcol.receiver.filelog`가 `alphabetical` 정렬값을 지원하며, 내부 Stanza에도 같은 값을 전달한다. +따라서 receiver의 `ordering_criteria.sort_by`에서 `sort_type = "alphabetical"`을 직접 사용하고, 이전 버전에서 필요했던 `file_input` 우회 설정을 제거했다. +관련 수정은 [Alloy #7212](https://github.com/grafana/alloy/pull/7212)에 반영됐다.
diff --git a/deploy/observability/app-agent/alloy/config.alloy b/deploy/observability/app-agent/alloy/config.alloy index 7bed5db9..d67ec78c 100644 --- a/deploy/observability/app-agent/alloy/config.alloy +++ b/deploy/observability/app-agent/alloy/config.alloy @@ -3,7 +3,7 @@ logging { } // Fingerprints identify files even after Docker renames them during rotation. -// Both components are public preview in the pinned Alloy v1.19.2 image. +// Both components are public preview in the pinned Alloy v1.21.0 image. otelcol.storage.file "docker" { directory = "/var/lib/alloy/docker-offsets" create_directory = true @@ -11,11 +11,34 @@ otelcol.storage.file "docker" { } 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 + include = ["/var/log/docker/*/*-json.log", "/var/log/docker/*/*-json.log.[1-4]"] + storage = otelcol.storage.file.docker.handler + 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 + max_concurrent_files = 1 + max_batches = 0 + 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", + } + ordering_criteria { + regex = "/[^/]+/[^/]+-json\\.log(?P\\.[1-4])?$" + group_by = "/([^/]+)/[^/]+-json\\.log" + top_n = 5 + sort_by { + sort_type = "alphabetical" + regex_key = "rotation" + ascending = false + } + } operators = [ { type = "json_parser", @@ -28,35 +51,6 @@ otelcol.receiver.filelog "docker" { 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\\.[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", diff --git a/deploy/observability/app-agent/docker-compose.yml b/deploy/observability/app-agent/docker-compose.yml index 22547f11..4a536c7c 100644 --- a/deploy/observability/app-agent/docker-compose.yml +++ b/deploy/observability/app-agent/docker-compose.yml @@ -1,6 +1,6 @@ services: alloy: - image: grafana/alloy:v1.19.2 + image: grafana/alloy:v1.21.0 restart: unless-stopped # Docker log files are root-owned; no Docker socket or host write access. user: "0:0" diff --git a/deploy/observability/scripts/verify-log-pipeline.py b/deploy/observability/scripts/verify-log-pipeline.py index e49bc671..a4bfba6d 100644 --- a/deploy/observability/scripts/verify-log-pipeline.py +++ b/deploy/observability/scripts/verify-log-pipeline.py @@ -114,6 +114,27 @@ def main(): services[name]['logging']['options']['max-size'] = '64k' document = {'services': services, 'volumes': volumes} with tempfile.TemporaryDirectory(prefix=PROJECT) as temp: + alloy_config = (OBS / 'app-agent/alloy/config.alloy').read_text() + output = 'logs = [otelcol.exporter.loki.backend.input]' + probe_output = 'logs = [otelcol.exporter.loki.backend.input, otelcol.exporter.file.order_probe.input]' + assert alloy_config.count(output) == 1, 'Could not add isolated file-order probe' + alloy_config_path = Path(temp) / 'config.alloy' + alloy_config_path.write_text(alloy_config.replace(output, probe_output) + ''' + +otelcol.exporter.file "order_probe" { + path = "/tmp/alloy-file-order.jsonl" + append = true + flush_interval = "1s" + format = "json" +} +''') + os.chmod(alloy_config_path, 0o644) + for mount in services['alloy']['volumes']: + if mount['target'] == '/etc/alloy/config.alloy': + mount['source'] = str(alloy_config_path) + break + else: + raise RuntimeError('Alloy config mount was not found') config = Path(temp) / 'compose.json' config.write_text(json.dumps(document)) os.chmod(config, 0o600) @@ -143,6 +164,26 @@ def query(expression): def entries(marker): return [v for stream in query(selector + ' |= "' + marker + '"') for v in stream['values']] + def file_exported_records(marker): + try: + batches = compose('exec', '-T', 'alloy', 'cat', '/tmp/alloy-file-order.jsonl') + except subprocess.CalledProcessError: + return [] + records = [] + for batch_text in batches.splitlines(): + batch = json.loads(batch_text) + for resource_logs in batch.get('resourceLogs', []): + for scope_logs in resource_logs.get('scopeLogs', []): + for record in scope_logs.get('logRecords', []): + body = record.get('body', {}).get('stringValue', '') + try: + event = json.loads(body) + except json.JSONDecodeError: + continue + if event.get('message') == marker: + records.append(event) + return records + trace_id, span_id = secrets.token_hex(16), secrets.token_hex(8) record = {'message': 'pr4-first-' + PROJECT, 'traceId': trace_id, 'spanId': span_id, 'requestId': 'synthetic-request', 'userId': 'synthetic-user', @@ -151,6 +192,8 @@ def entries(marker): request(emitter, line.encode()) found = wait_for('Docker -> Alloy -> Loki', lambda: entries(record['message'])) assert found[0][1].rstrip('\n') == line, 'Original app JSON was altered' + assert wait_for('Alloy file order probe', + lambda: file_exported_records(record['message']), timeout=10) parsed = query(selector + ' | json | traceId="' + trace_id + '"') assert parsed, 'traceId JSON filter did not work' # Query results also contain structured metadata (e.g. detected_level). @@ -257,6 +300,11 @@ def grafana_get(path): recovered = wait_for('all 110 rotated records', lambda: (rows if len(rows := entries(rotation_marker)) == 110 else None)) assert sorted(v[1] for v in recovered) == sorted(expected_rotation) + ordered = wait_for('filelog reader order for all rotated records', + lambda: (records if len(records := file_exported_records(rotation_marker)) == 110 + else None)) + assert [record['number'] for record in ordered] == list(range(110)), \ + 'Alloy did not export rotated files oldest to newest' complete = wait_for('long JSON across rotated files', lambda: entries('offline-long-' + PROJECT)) assert len(complete) == 1 and complete[0][1] == offline_long + '\n', [len(v[1]) for v in complete] # Recreate the collector using the same volume and check that completed files are not replayed.