Skip to content
Open
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 on lines +14 to +16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block : 기존 버전에서 업그레이드할 때 저장된 읽기 위치를 이어받도록 보완해 주세요.

기존 file_input을 제거하면서 입력기 ID가 docker_files에서 기본값인 file_input으로 바뀌어, 같은 볼륨을 사용해도 기존 읽기 상태를 찾지 못합니다. 실제로 v1.19.2에서 로그를 수집한 뒤 파일과 상태 저장 디렉터리를 유지한 채 이번 설정으로 전환하니, 이미 수집한 로그를 처음부터 다시 읽고 출력했습니다.

현재 테스트는 새 버전에서의 재시작만 확인하므로, 기존 버전에서 수집한 상태로 업그레이드해도 이전 로그를 다시 보내지 않는지 검증하는 테스트도 추가해 주세요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 읽기 상태와의 하위 호환성이 필요한 상황이 있다는 점에는 동의합니다. 다만 이번 변경은 운영 중인 Alloy를 업그레이드하는 작업이 아니며, 서비스 중단 후 이전 상태를 복원해 재개할만큼의 유의미한 데이터도 없습니다. 그러므로 이번 변경에서는 기존 v1.19.2가 저장한 읽기 위치와의 호환성 및 마이그레이션 테스트를 범위에 포함하지 않겠습니다.

// 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