When logs cost more than the servers
In practice, the patterns that make log costs spike are almost always the same.
- Debug logs left on — A single service bumped to
DEBUGduring deploy troubleshooting and never reverted ends up accounting for half of total ingest. - Default retention — If you never set a retention period on a CloudWatch Logs log group, the default is "Never expire." Logs from three years ago may still be on the bill.
- Scan-based billing — CloudWatch Logs Insights charges for the volume of data scanned by a query. A single auto-refreshing dashboard can quietly inflate the invoice.
- Index overhead — With Elasticsearch, the index is often larger than the original logs (it varies a lot with field count and mapping design).
This post is not a "Loki is the best" opinion piece. The goal is to give you formulas that produce an answer once you plug in your log volume, and to show you in advance the places people regret after they switch.
⚠️ Pricing notation: This post does not invent a hypothetical rate card. Cloud unit prices differ by region, date, and commitment, and they change often. Instead we treat unit prices as variables (
P_*) and publish the formulas in full. Look up the real numbers on the official pages below after selecting the region you actually use (e.g. ap-northeast-2 Seoul), then plug them in.
- AWS CloudWatch pricing:
https://aws.amazon.com/cloudwatch/pricing/- Amazon S3 pricing:
https://aws.amazon.com/s3/pricing/- Amazon EC2 / EBS pricing:
https://aws.amazon.com/ec2/pricing/- Amazon OpenSearch Service pricing:
https://aws.amazon.com/opensearch-service/pricing/- Grafana Cloud Logs pricing:
https://grafana.com/pricing/Values that vary by environment—compression ratio, query frequency, hourly labor cost—are all marked (estimate). Do not take them at face value; replace them with your own numbers.
Common cost-breakdown formula
Split all three stacks into the same four line items and you get an apples-to-apples comparison.
Monthly total cost = ingest cost + storage cost + query/scan cost + ops laborVariable definitions
| Variable | Meaning | Notes |
|---|---|---|
G_in | Monthly ingest volume (GB, uncompressed original) | Measured |
D | Retention days | Policy |
C_ratio | Compression ratio (stored size ÷ original) | (estimate) ~0.1–0.2 is common for text logs |
I_ratio | Index overhead multiplier | (estimate) ES can add roughly 1× depending on mapping |
Q_scan | Monthly query scan volume (GB) | CloudWatch Insights only |
H_ops | Monthly ops hours | (estimate) |
W | Team hourly rate (KRW) | Annual salary ÷ annual working hours |
P_ingest | Ingest unit price per GB | Check official pricing page |
P_store | Storage unit price per GB-month | Check official pricing page |
P_scan | Query scan unit price per GB | Check official pricing page |
P_node | Compute (EC2/OpenSearch node) hourly unit price | Check official pricing page |
Per-stack expansions
① Self-hosted Loki + S3
Ingest cost = 0 # self-collected; no separate ingest charge
Storage cost = G_stored × P_store_s3
where G_stored = (G_in / 30) × D × C_ratio # daily avg × retention days × compression ratio (estimate)
Query cost ≈ S3 GET request fees + data transfer # transfer = 0 inside the same region; request fees are small
Compute = (EC2 instance count × 730h × P_node) + EBS (WAL/cache)
Labor = H_ops × W
Total cost = storage + request fees + compute + labor② Elasticsearch / OpenSearch (self-hosted or managed)
Storage disk = (G_in / 30) × D × C_ratio × (1 + I_ratio) × (1 + replica count)
# With 1 replica, ×2. This is where disk doubles.
Storage cost = storage disk × P_store_ebs # or managed storage unit price
Compute = node count × 730h × P_node # data nodes + (master) + Kibana/dashboards
Ingest pipeline = Fluent Bit/Logstash running cost (near 0 if sidecar; separate instance if Logstash)
Labor = H_ops × W # includes shard/ILM/rollover management③ CloudWatch Logs
Ingest cost = G_in × P_ingest
Storage cost = (G_in / 30) × D × C_ratio_cw × P_store_cw
# CloudWatch stores compressed; use the storage unit price from the pricing page as-is
Query cost = Q_scan × P_scan # Logs Insights, billed on data scanned
Labor = approaches 0 (managed)Plug-in templates by scenario
The tables below are a worksheet you fill in. Put the unit prices you looked up on the official pages into the unit-price cells.
Scenario A — 20 GB/month / 7-day retention (early-stage startup, 3–5 services)
| Item | Loki+S3 | ES/OpenSearch | CloudWatch |
|---|---|---|---|
| Stored volume | (20/30)×7×0.15 ≈ 0.7 GB (estimate) | 0.7×(1+1)×2 ≈ 2.8 GB (estimate) | ≈ 0.7 GB (estimate) |
| Compute | 1× t-family (single binary) | minimum 1 node + Kibana | none |
| Ingest charge | none | none | 20 GB × P_ingest |
| Query charge | S3 request fees (small) | none (included in compute) | Q_scan × P_scan |
| Labor | 2h × W (estimate) | 4h × W (estimate) | 0 |
Key to band A: stored volume is around 1 GB, so storage cost differences are effectively meaningless. The contest is "cost of keeping one instance + labor" vs "CloudWatch ingest fees." At ~20 GB, CloudWatch is usually cheaper on total cost. What you save by self-hosting is less than one EC2 instance plus someone's time.
Scenario B — 100 GB/month / 30-day retention (the baseline for this post)
| Item | Loki+S3 | ES/OpenSearch | CloudWatch |
|---|---|---|---|
| Stored volume | (100/30)×30×0.15 = 15 GB (estimate) | 15×(1+1)×2 = 60 GB (estimate, 1 replica) | 15 GB (estimate) |
| Apply storage unit price | 15 × P_store_s3 | 60 × P_store_ebs | 15 × P_store_cw |
| Compute | 1–2 small-to-medium instances | 2 data nodes + dashboards | 0 |
| Ingest charge | 0 | 0 | 100 × P_ingest |
| Query charge | S3 GET requests | 0 | Q_scan × P_scan |
| Labor (estimate) | 4h × W | 8h × W | 0.5h × W |
Key to band B: the gap between S3 unit price × 15 GB and EBS unit price × 60 GB becomes noticeable. Absolute dollars are still dominated by compute, though. CloudWatch's
G_in × P_ingestis now 100 GB worth, so this is where a reversal becomes possible because CloudWatch ingest is priced much higher than storage. Look upP_ingestyourself and multiply by 100.
Scenario C — 500 GB/month / 90-day retention (a service with real traffic)
| Item | Loki+S3 | ES/OpenSearch | CloudWatch |
|---|---|---|---|
| Stored volume | (500/30)×90×0.15 = 225 GB (estimate) | 225×2×2 = 900 GB (estimate) | 225 GB (estimate) |
| Compute | 3–5 instances with components split out | 3+ data nodes (shard design required) | 0 |
| Ingest charge | 0 | 0 | 500 × P_ingest ← largest line item |
| Query charge | S3 requests + cache | 0 | spikes if dashboards query repeatedly |
| Labor (estimate) | 8–12h × W | 16–24h × W | 1h × W |
Key to band C: 500 GB × ingest unit price exceeds the cost of several EC2 instances. Self-hosting starts to be economically justifiable in this band. ES, however, comes with 900 GB of disk plus the burden of shard design.
Volumes in the tables above are estimates assuming compression ratio 0.15, index overhead 1×, and 1 replica. Unit prices vary by region, date, and commitment, so re-check them on the official pricing pages.
3×3 summary (total-cost advantage tendency)
| 20 GB/month / 7 days | 100 GB/month / 30 days | 500 GB/month / 90 days | |
|---|---|---|---|
| Loki+S3 | disadvantaged by labor | competitive | tends to be most favorable |
| ES/OpenSearch | overkill | justified if you need search | mandatory if search is a product feature |
| CloudWatch | tends to be most favorable | decide after checking ingest unit price | ingest fees dominate, unfavorable |
Search performance: the index structure is where things get slow
Structure summary
| What is indexed | How body search works | Billing trigger | |
|---|---|---|---|
| Loki | labels only | fetch chunks and scan (grep) | compute/IO load at scan time |
| Elasticsearch | full inverted index of the body | inverted-index lookup | compute and disk always on |
| CloudWatch Insights | managed internal index | scan-based | GB scanned = bill |
Where queries diverge
- Narrow by label, then grep the last 15 minutes → Loki is fast enough. All three stacks are practical.
- Search a specific error-code string across 30 days of everything, with no labels → Loki slows down sharply (it has to read every matching chunk). ES wins easily.
- Field-based aggregations / top-N / percentile stats → ES advantage. Loki can approximate with
unwrapandlabel_format, but both expressiveness and performance are limited. - Cost-aware query habits → CloudWatch forces you to shrink time ranges because "scan volume = bill." That is both a feature and a source of stress.
Same requirement, three query dialects
Requirement: In the last 1 hour of payment-api logs, find HTTP 500s and aggregate counts by endpoint, highest first.
# LogQL (Grafana Loki)
sum by (endpoint) (
count_over_time(
{app="payment-api", env="prod"}
| json
| status = "500"
[1h]
)
)
# Caution: putting endpoint in a label risks a cardinality explosion.
# Aggregating a value extracted at parse time, as above, is safer,
# but you still have to read all matching logs, so it gets slower as the window grows.// Elasticsearch Query DSL
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "term": { "service.keyword": "payment-api" } },
{ "term": { "http.status": 500 } },
{ "range": { "@timestamp": { "gte": "now-1h" } } }
]
}
},
"aggs": {
"by_endpoint": {
"terms": { "field": "http.endpoint.keyword", "size": 20, "order": { "_count": "desc" } }
}
}
}-- CloudWatch Logs Insights
fields @timestamp, endpoint, status
| filter status = 500
| stats count(*) as cnt by endpoint
| sort cnt desc
| limit 20
-- Always narrow the time range to 1 hour before you run this.
-- log groups selected × time range = scan volume = bill.On expressiveness alone, ES is far ahead. Loki is optimized for "quickly scan a narrow window already constrained by labels," and CloudWatch's syntax is concise—but every query is a cost event, which is a different kind of constraint.
The real cost of self-hosting: how many processes have to stay up
| Loki (single binary) | Loki (distributed mode) | ELK/OpenSearch | |
|---|---|---|---|
| Required processes | Loki 1 + Promtail/Alloy | distributor, ingester, querier, query-frontend, compactor | ES data nodes (2–3), (dedicated master), Kibana/OpenSearch Dashboards |
| Collect agents | Promtail / Grafana Alloy / OTel Collector | same | Fluent Bit / Filebeat / Logstash |
| Storage backend | object storage such as S3 | same | local disk / EBS (snapshots go to S3) |
| Ongoing ops work | retention policy (simple), query tuning, cardinality watch | + per-component scaling | ILM policy, shard design, rollover, mapping management, rebalancing |
| Incident-recovery difficulty | data lives in object storage → mostly restart | medium | wait on shard recovery and rebalancing after a node loss |
The core trade-off looks like this.
- Loki: the storage tier is S3, so disk-pool and shard-management stress is almost gone. You do need someone who can tune queries when they are slow, and a bad label design will cardinality-explode and blow ingester memory.
- ES: search is powerful, but ILM, shards, and mappings remain standing work. For a 1–3 person team that is never a small cost.
- CloudWatch: ops hours approach zero. Instead, the bill is the ops cost. The only savings levers here are three: shorten retention, filter at ingest, and reduce query scan volume.
Savings you can apply immediately while keeping CloudWatch:
# 1) Find log groups with no retention set (never expire)
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].[logGroupName,storedBytes]' \
--output table --region ap-northeast-2
# Expected result: a table of never-expire groups.
# Empty result means every group already has a retention policy → go to the next step.
# 2) Set 30-day retention in bulk (run after the policy is decided)
aws logs put-retention-policy \
--log-group-name /aws/lambda/my-func \
--retention-in-days 30 --region ap-northeast-2
# Expected result: no output (exit code 0).
# If you get AccessDeniedException, add logs:PutRetentionPolicy to IAM.
# 3) Sort by size to find the culprits
aws logs describe-log-groups --region ap-northeast-2 \
--query 'reverse(sort_by(logGroups,&storedBytes))[:10].[logGroupName,storedBytes]' \
--output tableDo not forget reducing ingest itself. A one-line drop filter for health-check logs and static-asset access logs at the OpenTelemetry Collector or Fluent Bit stage often beats a stack migration.
# Fluent Bit: example of dropping health-check logs
[FILTER]
Name grep
Match kube.*
Exclude log (GET /healthz|GET /readyz|kube-probe)Korea-environment checklist
- Check regional unit prices: Seoul region (ap-northeast-2) prices can differ from other regions. Always select Seoul on the pricing page before you read a number, and write the check date into your internal docs.
- Data-transfer fees: sending logs out of region (e.g. to an overseas SaaS) incurs outbound transfer fees. That is a hidden term in the formula.
- Korean-language log search: to search Korean body text properly in Elasticsearch you need a morphological analyzer such as
nori. With the default analyzer, "결제실패" may not match the way you expect. Conversely, Loki is substring-based, so morphology is a non-issue — for Korean log search, that simplicity is paradoxically an advantage. - Encoding: confirm application logs are unified on UTF-8. Leftover EUC-KR will break search on every stack.
- Support options: before you sign, check cloud-vendor support plans, domestic MSPs, and the Korean-language support scope of Grafana Cloud / Elastic Cloud.
- Domestic retention requirements: industry and contracts may require logs to stay in a Korea region. Before sending to an overseas-region SaaS, you need internal legal/security review and the actual text of the relevant rules (requirements differ by industry; check official sources).
Conclusion: three-axis decision table
| Team size | Monthly log volume | Dominant query pattern | Choice |
|---|---|---|---|
| 1–2 people | ~50 GB | mostly checking recent logs | Keep CloudWatch + 30-day retention, health-check filtering |
| 1–2 people | 50–200 GB | recent logs narrowed by label | Loki single binary + S3 |
| 2–3 people | 200 GB+ | label-based lookup + simple aggregations | Loki distributed mode + S3 |
| any size | any volume | search/aggregation is a product feature | ES/OpenSearch (no other option) |
| effectively 0 infra owners | ~200 GB | anything | Keep CloudWatch (labor exceeds the savings) |
| 2–3 people | 500 GB+ | hot-log lookup plus long-term analysis | Split Loki (hot) + S3/data lake (cold) |
Where people regret the switch (honest failure branches)
1) Kibana dashboards do not move over as-is.
| Kibana item | Loki portability |
|---|---|
| Label-based log-volume trend | possible |
| Error-rate time series | possible (rate + parsing) |
| Field-based top-N table (long windows) | effectively impossible / very slow |
| Percentiles and multi-dimensional correlation | impossible |
| Free-text global search | close to impossible (scan hell without labels) |
2) Cardinality explosion. The moment you put user_id, trace_id, or request_id in a label, Loki falls over. Labels are only for values with a small, finite set of kinds (app, env, namespace, level); everything else stays in the body and is parsed. This is a rule, not a preference.
3) Aggregation-performance expectations. Build a 30-day aggregation dashboard on Loki and you will hit timeouts often. Long-window aggregations need to be extracted as metrics (Prometheus) or precomputed with a Recording rule.
Three action steps
- Measure — pull current monthly ingest (
G_in), per-log-group stored volume, and monthly query scan volume. Thedescribe-log-groupscommand above is the starting point. - Plug in — put unit prices from the official pricing pages and your team's hourly labor rate into the formulas in this post. Never set the self-hosting labor line to 0.
- Pilot — do not move everything. Migrate one service and run it for 2–4 weeks. Check: response time of your three most-used queries, dashboard portability, and actual weekly ops hours.
FAQ
Q. At 100 GB/month, is moving to Loki always cheaper?
A. No. Storage alone favors S3-backed Loki, but add EC2 instance cost and monthly ops hours (estimate 4+ hours) converted to labor and the comparison can reverse. The 100 GB band is "you have to run the numbers." Look up ingest unit price (P_ingest) on the official pricing page, multiply by 100, and set that next to the EC2 + S3 + labor total.
Q. Fastest way to cut cost while keeping CloudWatch? A. Do these three in order. ① Find never-expire log groups and set them to 30–90 days, ② exclude health-check and static-asset logs at the collect agent, ③ minimize Logs Insights time ranges and log-group selection. ② in particular cuts ingest, storage, and scan fees at once, so it usually has the largest effect.
Q. Isn't running Loki and Elasticsearch together a waste? A. Once you are large enough, it is actually a standard setup. Query the last 7–14 days of hot logs cheaply on Loki, and send only selected logs that need long-term analysis and aggregation (audit logs, payment logs, etc.) to ES or a data lake. The point is not dual-writing everything; it is splitting the stream by purpose, and this compromise also has the lowest migration-failure risk.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.