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.