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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions deploy/observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Trace가 샘플링되지 않았거나 Tempo 보존 기간이 지났으면 해당
## 구현 상세

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

### 실행 권한

Expand All @@ -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)에 반영됐다.

</details>

Expand Down
64 changes: 29 additions & 35 deletions deploy/observability/app-agent/alloy/config.alloy
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,42 @@ 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
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
include = ["/var/log/docker/*/*-json.log", "/var/log/docker/*/*-json.log.[1-4]"]
storage = otelcol.storage.file.docker.handler
start_at = "beginning"
Comment thread
PHJ2000 marked this conversation as resolved.
Resolved
// 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<rotation>\\.[1-4])?$"
group_by = "/([^/]+)/[^/]+-json\\.log"
top_n = 5
sort_by {
sort_type = "alphabetical"
regex_key = "rotation"
ascending = false
}
}
operators = [
{
type = "json_parser",
Expand All @@ -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<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",
Expand Down
2 changes: 1 addition & 1 deletion deploy/observability/app-agent/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
48 changes: 48 additions & 0 deletions deploy/observability/scripts/verify-log-pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
Loading