From c888840e5e9bfe4bacbe31f039fd492a3eb3e982 Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Tue, 20 Oct 2026 04:06:53 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20Alloy=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20v1.21.0=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alloy 앱 이미지 태그를 v1.21.0으로 고정 - 검증: v1.21.0 이미지 확인 및 Alloy 설정 검증 통과 --- deploy/observability/app-agent/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From c45e6984f04c8454635d4057887b5af43b8aa3ad Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Tue, 20 Oct 2026 04:07:01 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20Alloy=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80=EC=9D=84=20alphabetical?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alloy filelog 정렬을 receiver 수준 ordering_criteria로 적용 - 파일 수집 순서를 oldest-to-newest 기준으로 고정 - 검증: Alloy v1.21.0 설정 검증 및 파이프라인 테스트 통과 --- .../app-agent/alloy/config.alloy | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) 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", From 2dcaa98794008118841d26510dbc628195e726ac Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Tue, 20 Oct 2026 04:07:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20Alloy=20=ED=9A=8C=EC=A0=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=95=EB=A0=AC=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 임시 file exporter로 Alloy가 내보낸 로그 순서를 직접 확인 - 110개 회전 로그가 오래된 순서대로 전달되는지 검증 - 검증: Alloy 로그 파이프라인 통합 테스트 통과 --- .../scripts/verify-log-pipeline.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/deploy/observability/scripts/verify-log-pipeline.py b/deploy/observability/scripts/verify-log-pipeline.py index e49bc671..073ca0f6 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, 0o600) + 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. From 3735943d3ff0ce8c910d702d3074a08032e1a649 Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Tue, 20 Oct 2026 05:04:29 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=EB=AF=BC=EA=B0=90=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EC=99=B8=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PEM 키와 Firebase Admin SDK 자격증명 파일을 Git 제외 대상으로 지정 - Terraform plan/state 및 로컬 API 키 백업 파일 제외 - 검증: git check-ignore로 대상 백업 파일 제외 확인 --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From 5c5e3b75c39f9d06d5d708e840eacd0ae5f655d1 Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Tue, 20 Oct 2026 16:20:15 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20Alloy=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 임시 Alloy 설정 파일을 컨테이너에서도 읽을 수 있도록 권한을 0644로 설정 - compose 설정 파일의 0600 권한과 Alloy capability 제한은 유지 - Python 문법 및 diff 검사 통과; WSL E2E는 #267과 같은 Docker 로그 수집 타임아웃으로 종료 --- deploy/observability/scripts/verify-log-pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/observability/scripts/verify-log-pipeline.py b/deploy/observability/scripts/verify-log-pipeline.py index 073ca0f6..a4bfba6d 100644 --- a/deploy/observability/scripts/verify-log-pipeline.py +++ b/deploy/observability/scripts/verify-log-pipeline.py @@ -128,7 +128,7 @@ def main(): format = "json" } ''') - os.chmod(alloy_config_path, 0o600) + 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) From 19c3a5f99807976cf352454e32013dbf0e1aa69a Mon Sep 17 00:00:00 2001 From: Geonwoo Park Date: Fri, 23 Oct 2026 23:21:25 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20Alloy=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=84=A4=EC=A0=95=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alloy v1.21.0 filelog의 alphabetical 정렬 설정으로 문서를 동기화 - file_input 우회 설정 제거와 Alloy #7212 근거를 반영 - 검증: git diff --cached --check --- deploy/observability/README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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)에 반영됐다.