Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki
---
title: "Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki"
published: true
tags: observability, grafana, prometheus, aws
---
Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki
*IT 3200 · Jayden Backus
1. Introduction
In an earlier module I put a small Flask app in a Docker container and ran it on an AWS EC2 instance. It shows one page, and gunicorn serves it on port 3000.
A public IP draws attention fast. Three minutes after I started the app on a new instance, my logs showed requests for /api/v1/version, a path my app has never had. Something was scanning for exposed services.
Before this project I could not have seen that. I would have learned of an outage only when someone told me, and to read a log I had to SSH in. I wanted to know, without logging in, whether the app is up and fast, whether the server is healthy, and what happened when something failed. I also wanted the system to warn me.
2. Why I chose these tools
I used four open-source tools:
- Grafana Alloy collects metrics and logs and forwards them.
- Prometheus stores metrics and checks alert rules.
- Loki stores logs, indexing only their labels.
- Grafana draws dashboards from both.
Alloy was the key choice. Many tutorials run three collectors: node_exporter for host metrics, cAdvisor for container metrics, and Promtail for logs. Grafana's Promtail migration guide says Promtail reached end of life on March 2, 2026, and that new work happens in Alloy. Alloy also contains node_exporter and cAdvisor as built-in components, so one agent and one config file replace all three. On a 2 GB server, that matters.
Prometheus and Loki fit together. Both label data the same way, so service="app" finds the app's metrics and its logs. A one-line library, prometheus-flask-exporter, makes Flask speak Prometheus's format. Loki's label-only index keeps it far lighter than Elasticsearch.
Limitations. I host it all myself, on the same server as the app, so if the server dies, so does its monitoring. Alloy needs privileged access (see section 6). Alerts fire but don't page anyone yet; that needs Alertmanager.
What I rejected. AWS CloudWatch needs no servers, but its container features target ECS and EKS rather than plain Docker, it bills per custom metric, and it ties me to AWS. The Elastic Stack searches logs well but would strain a 2 GB server.
3. Installation and configuration
!Architecture diagram
Alloy scrapes the app's /metrics every 15 seconds, reads host and container statistics, and tails every container's logs through the Docker socket. It pushes metrics to Prometheus and logs to Loki. Grafana queries both.
1. Launch EC2. I used Amazon Linux 2023 on a t3.small (2 GB; five containers won't fit in a t3.micro) with a 20 GiB disk. The security group allows SSH (22) and Grafana (3001) from my IP only, and the app (3000) from anywhere.
Lesson learned: key pairs belong to one region. My old key didn't exist in mine, so SSH refused me. I imported my public key (ssh-keygen -y -f key.pem) under Key pairs → Import key pair and relaunched.
2. Install Docker. Amazon Linux's Docker package lacks the Compose and Buildx plugins, so I added them as Docker's Compose install guide describes:
sudo dnf install -y docker git && sudo systemctl enable --now docker
P=/usr/local/lib/docker/cli-plugins && sudo mkdir -p $P
sudo curl -fsSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o $P/docker-compose
BX=$(curl -fsSI https://github.com/docker/buildx/releases/latest | grep -i '^location' | sed 's#.*/tag/##' | tr -d '\r')
sudo curl -fsSL https://github.com/docker/buildx/releases/download/$BX/buildx-$BX.linux-amd64 -o $P/docker-buildx
sudo chmod +x $P/*
3. Start the stack.
git clone https://github.com/jaydenbackus/it-3200-m6.git && cd it-3200-m6
cp .env.example .env # then set GRAFANA_ADMIN_PASSWORD to a long random value
chmod 600 .env
sudo docker compose up -d --build
Grafana loads its data sources and dashboard from files at startup, following its provisioning docs, so nothing needs clicking.
The Alloy config reads like a pipeline: each component's output feeds the next. Here is the log path:
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
relabel_rules = discovery.relabel.container_logs.rules
forward_to = [loki.process.redact.receiver]
}
// Blank out anything that looks like a credential before Loki stores it
loki.process "redact" {
stage.replace {
expression = "(?i)(?:password|passwd|secret|token|api[_-]?key)=(\\S+)"
replace = "<redacted>"
}
forward_to = [loki.write.local.receiver]
}
4. Implementation
The app. I added prometheus-flask-exporter, turned on gunicorn's access log, and added a /health route. I group metrics by Flask endpoint rather than by URL, so scanners requesting random paths can't create endless new time series. Two demo routes, /simulate/error and /simulate/slow, let me trigger failures. They work only when ENABLE_SIMULATION=true and only from the server itself.
What I collect:
| Signal | Source | Stored in | Kept |
|---|---|---|---|
| Request rate, errors, latency | Flask /metrics | Prometheus | 15 days |
| CPU, memory, disk | EC2 host, via Alloy | Prometheus | 15 days |
| CPU and memory per container | Docker, via Alloy | Prometheus | 15 days |
| App, access, and stack logs | Docker, via Alloy | Loki | 7 days |
Dashboard. One Grafana dashboard shows app status, request rate, error ratio, latency percentiles, per-container and host resources, and live logs.
Alerts. Prometheus checks six alerting rules:
| Alert | Fires when |
|---|---|
| StudentAppDown | Alloy can't scrape the app for 1 minute |
| StudentAppHighErrorRate | over 5% of requests fail for 2 minutes |
| StudentAppHighLatency | p95 latency exceeds 1 second for 5 minutes |
| HostHighCpu, HostLowMemory, HostDiskAlmostFull | the server runs short of CPU, memory, or disk |
The waiting period keeps one slow request from raising an alarm.
5. Show it working
I sent ten minutes of mixed traffic, about one failed and one slow request in every nine, then stopped the app for two and a half minutes. Prometheus's ALERTS series recorded what followed (UTC):
| Time | Event |
|---|---|
| 04:43 | Traffic starts |
| 04:46 | StudentAppHighErrorRate fires |
| 04:49 | StudentAppHighLatency fires |
| 04:53:15 | I stop the app |
| 04:54:45 | StudentAppDown fires |
| 04:55:46 | I restart the app |
| 04:56:00 | StudentAppDown resolves |
| 04:58 | Error and latency alerts resolve |
At the peak, PromQL showed an 11% error ratio and a 1.5-second p95 latency, both well past their thresholds. Logs and metrics agreed: Loki held 251 lines for HTTP 500 responses, and Prometheus counted 248.
One surprise: the error alert kept firing during the outage, though a stopped app returns no errors. The rule averages the last five minutes, which still held the earlier failures.
The test also exposed a bug in my load generator. It quit when the app went down, because set -e treated one failed request as fatal. I removed it.
6. Security considerations
- Exposure. Only the app is open to the internet; Grafana and SSH accept my IP alone. Prometheus, Loki, and Alloy listen on 127.0.0.1, and a port scan from outside found them closed. I reach them through an SSH tunnel.
- Unauthenticated internals. Loki and Prometheus accept data without a password. That is safe only because nothing outside the server can reach them.
- App endpoints. /metrics returns 403 to the public, and the demo routes return 404, so outsiders can neither read internal numbers nor trigger slow requests to tie up the app.
- Grafana. A random admin password lives in .env, which Git ignores. Sign-up and anonymous access are off. Grafana runs over plain HTTP; a real deployment would add TLS.
- Privileged collector. Alloy's cAdvisor component docs require privileged mode, the Docker socket, and host mounts, and control of the Docker socket means control of the host. I mount everything read-only, pin the image version, and keep Alloy's web page private.
- Logs. Alloy blanks credential-like values before storage. Access logs hold client IPs, which count as personal data, so Loki deletes logs after seven days, configured per its retention docs.
- Repository. The repository holds no keys, passwords, or personal IDs; .env supplies them on the server, and .gitignore excludes .env and *.pem.
7. Conclusion
One docker compose up gave my app metrics, logs, a dashboard, and alerts that fired and cleared as designed. The biggest lesson was how much Alloy simplifies collection: one agent replaced three, which made the system easier to explain and to secure. Next I would send alerts to email, add HTTPS, and add tracing with OpenTelemetry.
References
The official documentation I used:
- Grafana Alloy, Migrate from Promtail to Grafana Alloy: grafana.com
- Grafana Alloy, prometheus.exporter.cadvisor: grafana.com
- Grafana Loki, Log retention: grafana.com
- Prometheus, Alerting rules: prometheus.io
- Grafana, Provision Grafana: grafana.com
- Docker, Install the Docker Compose plugin: docs.docker.com
docker builder prune freed 0 B: two places Docker keeps disk space that no prune reaches
Disclosure: this article was drafted with AI assistance (Claude) from measurements recorded in the dockdu repository. Every number comes from the measurement log and can be reproduced on a disposable docker:dind container.
docker system df says the build cache is empty. docker system prune -af reports Total reclaimed space: 0B. df -h still shows the disk filling up. Here are two cases where all three statements are true at once, both measured on Docker Engine 29.9.0, with the commands to check each by hand.
1. The build cache of a buildx builder is a volume
docker buildx create --driver docker-container starts BuildKit in its own container, buildx_buildkit_<node>. The builder keeps its whole state, including its build cache, in a volume named buildx_buildkit_<node>_state (buildx source). The node is usually the builder name plus 0.
Measured: one builder ci, one build that added 30 MB.
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 2 1 430.9MB 73.34MB (17%)
Containers 1 1 40.96kB 0B (0%)
Local Volumes 2 1 73.31MB 114.7kB (0%)
Build Cache 0 0 0B 0B
The 73 MB of build cache is in the Local Volumes row. Then the two prune commands, with du -sk on the state volume after each:
| Command | State volume on disk | Prune printed |
|---|---|---|
| (before) | 72452 KiB | |
| docker builder prune -af | 72452 KiB | Total: 0B |
| docker buildx prune --builder=ci -af | 740 KiB | Total: 73.36MB |
docker builder prune works on the selected builder, which here was the daemon's own. A CI host that once ran docker buildx create --use and was later switched back keeps growing in a volume that docker builder prune never touches.
There is one more trap. docker buildx rm --keep-state removes the builder but keeps the volume. Nothing then refers to it, so it only shows up as an unused volume.
Check by hand:
docker volume ls --filter name=buildx_buildkit_ # one per docker-container node
docker system df -v | grep buildx_buildkit_ # sizes
docker buildx ls # which builder each node belongs to
docker buildx du --builder <name> # that builder's cache, record by record
Free it: docker buildx prune --builder <name>. Add --keep-storage 10gb to keep a working set. Or set a GC policy in that builder's buildkitd.toml.
2. Switching to the containerd image store hides your old images
Docker Desktop and fresh Engine 29 installs use the containerd image store. If a daemon is switched to it, the images and containers of the classic overlay2 store are not deleted. Docker's docs say they become hidden: "Your overlay2 data remains on disk but becomes hidden."
Measured: one data root with alpine:3.22 and nginx:1.29-alpine pulled under the graph driver, then the same data root started with --feature containerd-snapshotter=true:
$ docker images # (empty)
$ docker system df # every row 0B
$ docker system prune -af
Total reclaimed space: 0B
$ sudo du -sh /var/lib/docker/overlay2
71.8M /var/lib/docker/overlay2
On a real machine that number is whatever you had before the switch, often many gigabytes.
Check by hand:
docker info --format '{{.Driver}} {{json .DriverStatus}}' # io.containerd.snapshotter.v1 = containerd store
sudo ls /var/lib/docker/image/overlay2/layerdb/sha256 | wc -l # layers the classic store still records
sudo du -sh /var/lib/docker/overlay2
Free it: switch the daemon back to overlay2. The old images and containers reappear and can be removed normally. docker save anything you want to keep first.
What does not hide space: containerd's compressed content
A common suspicion is that the containerd store's second copy of each layer (compressed blobs next to unpacked snapshots) is space df ignores. It is not, at least on 29.9:
$ docker system df → Images 296.5MB
content (compressed) 72.4 MB
snapshots (unpacked) 224.4 MB sum 296.8 MB
The Images row includes both.
Leftover overlay2 directories
The third classic case, moby#47089: overlay2/ is far larger than images, containers and build cache together. The graph driver records which directory belongs to which image layer (image/overlay2/layerdb/sha256/*/cache-id) and to which container (layerdb/mounts/*/mount-id). A directory that appears in neither is a BuildKit snapshot or a leftover. Comparing the size of those directories with the private build cache shows whether leftovers are the likely cause. Don't delete them by hand while the daemon runs.
Tooling
dockdu, a read-only CLI that explains Docker disk usage, does all three checks since 0.4.0:
- buildx builders are listed under BUILD CACHE, with the prune command for each;
- the hidden classic store is reported from a single directory listing;
- dockdu --disk splits overlay2/ into image layers, container layers and directories with no record.
It never deletes anything, and its Docker client refuses any request other than GET/HEAD on four read endpoints. The manual commands above work without it.
1 reaction
Mentions
GitHub ActionsとDockerのビルドを1本のトレースにしてGrafana Cloudで見る ! 2026年10月27日に、GitHubホストランナーのUbuntu 24.04で次のバージョンを使って確認した内容です。送信するシグナルはトレースだけです。 Docker Engine 28.0.4 Buildx v0.29.1 BuildKit v0.33.1 OpenTelemetry Collector contrib v0.164.0 GitHubレシーバーのトレース機能は、このバージョンではalphaです。後述する親スパンのID計算もレシーバーの実装に依存します。 はじめに 先日書いたDockerのビルドを遅くしたコミットをGrafana Tempoのトレースで見つけるでは、手元のビルドをトレースにして、遅くなったコミットやDockerfileの手順を調べました。次のステップはCIです。GitHub Actionsの画面で「Dockerのビルドが遅い」とわかっても、そのステップの中で何に時間がかかったかを調べるには、ビルドのログや記録を別に開く必要があります。ワークフロー全体からDockerfileの手順まで、同じトレースの中でたどれるようにしてみました。 TL;DR BuildKitからGrafana CloudのOTLPエンドポイントへトレースを送信できることを確認しました。CIのステップにつなぐには、OpenTelemetry CollectorのGitHubレシーバーがステップに付けるIDをビルドの前に計算し、TRACEPARENTでBuildxへ渡します。CI側のスパンを送るのは、完了した実行の情報をGitHub APIで取得し、Actionsの中で一時的に動かすCollectorです。その結果、ワークフローと2つのジョブが1本のトレースにまとまり、buildジョブのビルドステップの下にBuildKitのスパンがつながりました。 2つの送信経路 今回の構成では、ビルドのトレースとGitHub Actionsのトレースを別々に送ります。両方でトレースIDと親スパンIDを合わせると、Grafana Cloudで1本に表示できます。 ビルド側の送信元は、OpenTelemetryでトレースを送る機能を持つBuildKitです。Dockerfileの各手順のスパンに加えて、クライアントであるBuildxのスパンも、BuildKitへ転送してまとめて送ります。 一方、GitHub Actions側には、BuildKitのようにトレースを送る送信元がありません。ワークフローの実行の記録はWebhookのイベントやREST APIで取得できますが、トレースの形式にはなっていないためです。この記録をスパンに変換するのが、OpenTelemetry CollectorのGitHubレシーバーです。GitHubレシーバーは、GitHubから届くworkflow_runとworkflow_jobのWebhookのイベントを受け取り、ワークフロー、ジョブ、ステップのスパンを作ります。レシーバーはCollectorに組み込んで動かすコンポーネントなので、GitHub Actions側のスパンはCollectorを通し、エクスポーターでGrafana Cloudへ送ります。 今回は、CI側のイベントの取得から送信までをActionsの中で実行する構成を選びました1。ワークフローの完了後に別のワークフローを起動し、そのランナーで一時的にCollectorを動かします。補助スクリプトが完了した実行の情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立てて、ランナー内のGitHubレシーバーへ渡す構成です。BuildKitからの送信だけを試す段階では、このCollectorは使いません。 図1: 矢印はデータの移動を表します。左側では計算したTRACEPARENTをBuildxへ渡します。右側は完了後の別ワークフローで、補助スクリプトがGitHub APIの情報をイベント形式へ変換し、OpenTelemetry Collectorへ渡します。Collector内のGitHubレシーバーでCIスパンを生成して送信します。 題材はBusyBox 1.37.0の小さなイメージです。ファイルをコピーしてRUNで別ファイルへ書き出し、ビルドしたイメージを実行して出力も確かめました。実証用のファイル一式を公開しています。 BuildKitからGrafana Cloudへ まず、Actionsの親スパンを付けず、BuildKitからの送信だけを確認しました。Grafana Cloudで取得した接続情報を、リポジトリのActionsシークレットに次の名前で登録します。 シークレット 設定する値 GRAFANA_CLOUD_OTLP_ENDPOINT /otlpまでを含むOTLPのベースURL GRAFANA_CLOUD_OTLP_HEADERS Grafana Cloudの接続画面から取得したAuthorization=...の認証ヘッダー Actionsのステップでは、これらをOTEL_EXPORTER_OTLP_ENDPOINTとOTEL_EXPORTER_OTLP_HEADERSという環境変数に渡しています。scripts/setup-builder.shの内容は次のとおりです。 #!/usr/bin/env bash set -euo pipefail : "${OTEL_EXPORTER_OTLP_ENDPOINT:?Missing OTLP endpoint}" : "${OTEL_EXPORTER_OTLP_HEADERS:?Missing OTLP authentication headers}" docker buildx create --name ci-otel --driver docker-container \ --driver-opt image=moby/buildkit:v0.33.1 \ --driver-opt env.OTEL_TRACES_EXPORTER=otlp \ --driver-opt env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT%/}/v1/traces" \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}" \ --bootstrap >/dev/null docker-containerドライバーではBuildKitが専用のコンテナで動くので、BuildKitのOTLP設定は--driver-opt env.…でそのコンテナの環境変数として渡します。プロトコルはhttp/protobufで、OTLP/HTTPを使います。トレース専用のエンドポイントは、ベースURLの末尾に/v1/tracesを足したURLです。 このbuilderを選んでdocker buildx build --builder ci-otel --load …を実行すると、BuildxとBuildKitの両方のスパンがGrafana Cloudへ届きました。ベースラインの実行から取得したのは、build .をルートにした87スパンのトレースです。イメージのビルドと実行テストも成功しました。 図2: Grafana Cloudで取得したベースラインのトレース。BuildxとBuildKitの87スパンを確認できます2。 Actionsのステップを親にする ここまでのトレースのルートはbuild .です。GitHub Actionsのビルドステップを親にするには、Buildxを実行するステップに環境変数TRACEPARENTを設定します。Buildxは環境変数からトレースコンテキストを読み込む処理でこの値を取り込み、build .を指定された親の子にします。TRACEPARENTに入れるのは、接続先のトレースIDと、親になるステップのスパンIDです。 ただし、ビルドの時点では親になるステップのスパンはまだありません。CI側のスパンは、ワークフローの完了後に作るからです。それでも親のIDを指定できるのは、GitHubレシーバーがトレースIDとスパンIDを乱数ではなく実行IDやジョブIDから決まった規則で計算するためです。ビルドの前に同じ規則で計算すれば、あとで届くステップのスパンと同じIDが得られます。 補助スクリプトscripts/ci-parent.pyは、今回の実行ID、再実行回数、ジョブIDから、レシーバーのID生成の実装と同じ規則でIDを計算します3。計算部分を抜粋すると次のとおりです。 trace_id = hashlib.sha256(f"{run}{attempt}t".encode()).hexdigest()[:32] step_id = hashlib.sha256(f"{job_id}-Build Docker image-s".encode()).hexdigest()[16:32] traceparent = f"00-{trace_id}-{step_id}-01" TRACEPARENTの値は、W3C Trace Contextのtraceparentヘッダーと同じ00-トレースID-親スパンID-01の形式です。スクリプトは、この値をGITHUB_OUTPUTへ書き出します。 ワークフローでは、このスクリプトの出力をビルドステップだけに渡します。build.ymlから該当部分を抜粋すると、次のようになります。 - name: Calculate build step parent id: build_context env: GH_TOKEN: ${{ github.token }} run: python3 scripts/ci-parent.py - name: Build Docker image env: TRACEPARENT: ${{ steps.build_context.outputs.traceparent }} OTEL_RESOURCE_ATTRIBUTES: vcs.ref.head.revision=${{ github.sha }},ci.run.id=${{ github.run_id }},ci.run.attempt=${{ github.run_attempt }},ci.repository=${{ github.repository }},experiment.mode=linked run: docker buildx build --builder ci-otel --load --tag buildkit-otel-spike:ci . ステップのスパンIDはジョブIDとステップ名から決まるので、ステップ名はスクリプトの計算に使ったBuild Docker imageと一致させます。また、ステップ名はジョブ内で一意にしておきます。同じジョブに同名のステップが2つあると、レシーバーが両方に同じスパンIDを付けてしまうからです。 OTEL_RESOURCE_ATTRIBUTESでは、検索に使う実行IDやコミットを付けています。この属性が付くのはBuildxのスパンだけで、BuildKitのスパンには付きません。 CI側のスパンの送信 ビルドステップが終わっても、ワークフロー全体はまだ実行中です。CI側のスパンは、workflow_runのcompletedで起動する送信用のワークフローが作ります。 送信用のワークフローは、完了通知に含まれる実行IDと再実行回数を環境変数で補助スクリプトへ渡します。 OBSERVED_RUN_ID: ${{ github.event.workflow_run.id }} OBSERVED_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} 再実行回数を指定するのは、取得した時点で最新の再実行ではなく、通知された回のジョブを読むためです。補助スクリプトは、その回のジョブとステップの情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立ててランナー内のCollectorへPOSTします。変換の処理はscripts/export-workflow.pyで確認できます。 collector.yamlの内容は次のとおりです4。 receivers: github: webhook: endpoint: 0.0.0.0:19418 path: /events health_path: /health service_name: github-actions scrapers: scraper: github_org: ymotongpoo processors: batch: timeout: 1s exporters: otlp_http/cloud: endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT} headers: Authorization: ${env:GRAFANA_AUTHORIZATION} timeout: 10s sending_queue: enabled: false retry_on_failure: enabled: false service: telemetry: logs: encoding: json pipelines: traces: receivers: [github] processors: [batch] exporters: [otlp_http/cloud] エクスポーターのendpointはベースURLのままです。otlp_httpのエクスポーターが/v1/tracesを付けるので、BuildKitにトレース専用のURLを渡したのとは指定方法が違います。認証ヘッダーには、BuildKitと同じシークレットからAuthorization=の後ろの値を取り出して渡します。 このCollectorは送信のたびに起動して停止する実証用の設定で、送信キューと再試行を無効にしています5。 自分の環境で試すには、2つのシークレットを登録し、ビルド用と送信用のワークフローをデフォルトブランチに置きます。そのうえでBuildKit linked traceを手動で実行し、ビルドとExport completed workflow traceの両方の完了を待ちます。APIの呼び出しに使うのはcontents: readとactions: readの権限を付けたGITHUB_TOKENで、GitHub AppやPATは不要です。 1本につながった結果 ビルド側の実行とCI側のスパンの送信は、どちらも成功しました。ワークフロー全体とビルドの内部が1本のトレースにつながっています。 図3: 同じ実行のワークフロー、2つのジョブ、各ステップが1本のトレースになりました。Build Docker imageの下にbuild .が接続しており、ビルド内部は折りたたんでいます。 サービスごとの内訳は次のとおりでした。 service.name スパン数 記録している処理 github-actions 21 ワークフロー、ジョブ、待ち時間、ステップ buildx 33 ビルドクライアントの処理 buildkitd 54 ビルドデーモンとDockerfileの手順 合計 108 同一トレースIDのスパン ビルド用のbuildと、その完了後に別のランナーで実行するverify-sourceの2ジョブが、同じルートにつながっています。CIの待ち時間やビルド内部の細部を省くと、親子関係は次のようになります。 BuildKit linked trace ├── build │ └── Build Docker image │ └── build . │ └── … │ └── [4/4] RUN cat message.txt > result.txt └── verify-source └── Verify source fixture build .の親IDはBuild Docker imageのスパンIDと一致し、取得した108スパンはすべて同じワークフローのルートへつながっていました6。 GrafanaのExploreでTempoのデータソースを選び、実行した時間帯に絞って、次のTraceQLで検索します。ci.run.idは、OTEL_RESOURCE_ATTRIBUTESでBuildxのスパンに付けた属性です。 { resource.ci.run.id = "40437818776" && name = "build ." } 前回の記事では、ルートのスパンだけを選ぶ条件(span:parentID = "")でビルドの所要時間を集計しました。今回のビルドはbuild .にActionsの親があるので、この条件では選べません。手元のビルドとCIのビルドをまとめて選ぶ条件は、{ resource.service.name = "buildx" && name = "build ." }です。ベースラインとCIの両方のトレースが、この条件の検索結果に入ることを確認しました。 CI側のスパンは、送信用のワークフローが完了してから届きます。トレースを開くのは、両方のワークフローが完了してからにしてください7。 今回確認した範囲 BuildKitからGrafana Cloudへの送信と、2ジョブのワークフローへのビルド内部の接続までを確認しました。ただし、IDの生成規則はOpenTelemetryの共通の仕様ではなく、GitHubレシーバー v0.164.0の実装です。Collectorのバージョンやレシーバーのfeature gateを変えるときは、取得したトレースでbuild .が親のステップにつながっているかを確かめ直してください。 CI側の時刻はGitHub APIが秒単位で記録した値です。そのため、親子の接続が正しくても、BuildKitのスパンが親の時間範囲からわずかにはみ出すことがあります。今回の実行では、build .の終了が親のBuild Docker imageの終了を約0.84秒超えていました。 今回は信頼できるリポジトリでの手動実行だけで、matrix、reusable workflow、forkからのPR、実際の再実行、実行間でのキャッシュ再利用は検証していません8。 おわりに GitHub Actionsのワークフローとステップから、Dockerfileのどの手順やレジストリへの通信に時間を使ったかまで、1本のトレースでたどれるようになりました。自分のCIへ取り込むときには、まずBuildKitの送信だけを確認し、必要に応じてCIの親スパンを追加する順で試せます。 脚注 GitHubレシーバーは、常時稼働するCollectorでGitHubからのWebhookを受け取る使い方を想定しています。組織のCIを継続して記録するなら、その構成のほうが向いています。常時稼働のCollectorなら、送信キューと再試行を有効にして、Grafana Cloudへの送信に失敗したスパンを再送することもできます。ただし、GitHubからWebhookを受けるために、インターネットから届くエンドポイントを用意して運用する必要があります。今回はその準備をせずに試すため、Actionsの中で完結させました。 ↩︎ 図の上部に赤く表示されているHEAD HTTP HEADのエラーは、Docker Registryのトークン認証で最初に返る401の認証要求です。今回の実行では、トークン取得後の再試行が200となり、ビルドも成功しています。 ↩︎ ジョブの数値IDは、GitHub APIで今回の実行のジョブの一覧を取得し、名前がGITHUB_JOBと一致するジョブから取り出します。ジョブの表示名を変えたりmatrixを使ったりする場合は、別の方法でジョブを特定する必要があります。 ↩︎ v0.164.0のGitHubレシーバーは、トレースだけを使う場合でも、scrapersを1つ以上設定することを求めます。パイプラインはトレースだけなので、github_orgを使ったメトリクスの収集は動きません。 ↩︎ Collectorはbatchプロセッサーを通して送るので、レシーバーがHTTP 200を返しても、Grafana Cloudへの送信が成功したとは限りません。そこで補助スクリプトは、停止後のCollectorがJSON形式で出力したログを読み、levelがエラーを示していれば失敗として扱います。 ↩︎ 検証時のスクリプトと検証記録を公開しています。取得した108スパンは、スパンIDがすべて一意で、ルートが1つ、親が見つからないスパンは0でした。この検査では、送信したすべてのスパンがGrafana Cloudに届いたかまでは判定できません。 ↩︎ Tempoの最初の検索結果では、ルートのサービス名が<root span not yet received>と表示されました。その後にトレースIDで取得すると、CI側を含む108スパンがそろっていました。 ↩︎ 送信用のワークフローは、元の実行のコミットにある補助スクリプトを実行します。外部のPRが起点になる構成へ広げるときは、GitHubが説明するworkflow_runの権限と注意点を確認してください。 ↩︎
Dockerのビルドを遅くしたコミットをGrafana Tempoのトレースで見つける ! 2026年10月20日に確認した内容です。動作確認には次のバージョンを使いました。 Docker Engine 29.0.0、buildx v0.29.1(Ubuntu 26.04.1) BuildKit v0.33.1(builderのイメージをmoby/buildkit:v0.33.1に固定) grafana/otel-lgtm 0.37.0(Grafana 13.2.3、Tempo 3.1.0を含む) mcp-grafana 2.0.2 Claude Code 2.1.303 gcx 0.3.0 記事中のコマンドは、手元で使ったポート番号をotel-lgtmのデフォルトに戻して書いています。 はじめに こんにちは、Grafana Labsでデベロッパーアドボケイトをしているものです。 2026年11月3日にシンガポールで開かれるGrafana & Friends Singapore x Dockerで話すことになりました。Dockerとの共催というせっかくの機会なので、題材には以前から話そうと思っていたDockerのビルドのトレースを選んでいます。Docker自身もビルドのトレースを画面に出せますが、Grafana Tempoに送って溜めておくと、ビルドをまたいだ調査が可能です。この記事は、その手順と、溜めたトレースをAIに調べさせた結果のまとめです。 TL;DR Dockerのビルドを実行するBuildKitは、ビルドごとにOpenTelemetryのトレースを出しています。そのトレースはdocker buildx history traceでJaeger UIに開けるので、ビルドを1回調べるならその画面で十分です。ただし、BuildKitが残す履歴はデフォルトで48時間か50回分までです。同じトレースをGrafana Tempoに送ってコミットのIDを付けておけば、どのコミットからビルドが遅くなったのかをグラフで特定できます。コーディングエージェントやGrafana Assistantにトレースを読ませれば、遅くしたコミットと手順を見つけるところまで任せられます。 BuildKitが出すトレース docker buildの実体は、buildxというCLIのプラグインと、ビルドを実行するデーモンのBuildKitです。BuildKitはOpenTelemetryでトレースを出す機能を持っていて、Dockerfileの手順ひとつひとつがスパンになります。計装を足す必要はありません。 Dockerの公式ドキュメントが示しているのは、環境変数JAEGER_TRACEでJaegerへ送る方法だけです。一方、BuildKitのREADMEには、OTLPで送る方法が載っています。環境変数OTEL_TRACES_EXPORTER=otlpとOTEL_EXPORTER_OTLP_TRACES_ENDPOINTを使う方法で、後でTempoへ送るときにもこちらを使います。 トレースをどこにも送っていなくても、BuildKitはビルドの履歴にトレースを残しています。docker buildx history traceは、その履歴からトレースを取り出し、一時的に立ち上げたJaeger UIで開くコマンドです1。 docker buildx history trace --builder gf-demo --addr 127.0.0.1:16686 docker buildx history traceで開いたJaeger UIの画面。上部はトレースの概要で、下はDockerfileの手順のスパンのあたりを切り出した この画面では、全体の20.27秒のうち19.24秒を[build 4/4] RUN go mod download && go buildの手順が占めていることがわかります。ビルドが遅いと感じたときに、どの手順を疑えばよいかは、この画面だけで判断できます。 Tempoへ送信 同じトレースをGrafana Tempoへ送るには、buildxのbuilderを作るときに環境変数を2つ渡すだけです。送り先にはgrafana/otel-lgtmを使いました。GrafanaとTempoに加えてPrometheusとOpenTelemetry Collectorも1つにまとめたコンテナで、そのOTLPのポートへ送ります。 docker run -d --name lgtm -p 3000:3000 -p 4317:4317 -p 4318:4318 grafana/otel-lgtm:0.37.0 docker buildx create --name gf-demo --driver docker-container \ --driver-opt image=moby/buildkit:v0.33.1 \ --driver-opt network=host \ --driver-opt env.OTEL_TRACES_EXPORTER=otlp \ --driver-opt env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=127.0.0.1 \ --bootstrap docker buildx build --builder gf-demo --load . docker-containerドライバーのbuilderでは、BuildKitはコンテナの中で動きます。network=hostを付けたのは、そのBuildKitからホストのループバックアドレスに公開したOTLPのポートへ届かせるためです2。 トレースには、buildx(サービス名buildx)とBuildKit(サービス名buildkitd)のスパンが1本につながって入ります。ルートのスパンはbuild .です3。クライアント側のbuildxには環境変数を渡していませんが、buildxのスパンもTempoに届きます4。 同じ種類のビルドのトレースをGrafanaのExploreで開いた画面。上部はトレースの概要で、下はDockerfileの手順のスパンのあたりを切り出した ビルドを遅くしてみる ここまでなら、Dockerの画面で十分です。Tempoへ送る価値が出てくるのは、ビルドが1回ではなく、CI/CDのように継続的に多数行われるときです。 BuildKitの設定では、ビルドの履歴はデフォルトでmaxAge = 172800(48時間)、maxEntries = 50です。docker buildx history traceで開けるのは、この履歴に残っているビルドだけで、--compareで2回分を並べて比べる場合も同じ制約がかかります。 使い捨てのCIのrunnerでは、ジョブが終わればbuilderごと履歴も消えます。DockerはGitHub Actions向けに、ビルドの記録をZIPのアーティファクトとして残す機能を持っています。ただ、それをDocker Desktopに取り込んで見るのは、CIの実行1回分ずつです。「どのコミットからビルドが遅くなったのか」を答えるには、何十回分ものビルドを取得して比較しなければなりません。 そこで、コミットを重ねながら何度もビルドし、全部のトレースをTempoに溜めました。題材は、Prometheusのクライアントライブラリに依存する小さなGoのHTTPサーバーです。はじめは、依存のダウンロードとビルドのキャッシュをキャッシュマウントで再利用するDockerfileでビルドしました。 FROM golang:1.26-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go build -o /out/hello . FROM gcr.io/distroless/static-debian13 COPY --from=build /out/hello /hello ENTRYPOINT ["/hello"] 途中で、Dockerfileを「整理した」コミットを入れて、キャッシュマウントをわざと外しました。あわせてCOPY . .も依存のダウンロードより前に移した状態です。この順序だと、ソースを1行変えるたびに依存のダウンロードと全パッケージのコンパイルがやり直しになります。buildのステージは以下のように変わっています。 FROM golang:1.26-alpine AS build WORKDIR /src COPY . . RUN go mod download && go build -o /out/hello . 前後には、Dockerfileにラベルを足すだけのコミットを1つずつ混ぜました。git logにはDockerfileを変えたコミットが3つ並ぶので、どれでビルドが遅くなったのかはトレースを見ないとわかりません。 各ビルドには、そのときのコミットのIDを付けました。buildxはOTEL_RESOURCE_ATTRIBUTESを読むので、ここにリソース属性vcs.ref.head.revisionとして入れて渡せば済みます。ただし、この属性が付くのはbuildxのスパンだけで、buildkitdのスパンは対象外です。 OTEL_RESOURCE_ATTRIBUTES="vcs.ref.head.revision=$(git rev-parse --short HEAD)" \ docker buildx build --builder gf-demo . ここまでの各ビルドの所要時間を、TraceQLのメトリクスのクエリでグラフにしました。ルートのスパンだけを選んで、30秒ごとの最大値を取っています5。 { resource.service.name = "buildx" && span:parentID = "" } | max_over_time(duration) 各ビルドの所要時間をTraceQLのmax_over_timeでGrafanaに描いた画面。点の時刻は30秒の区間の終わりを表す トレースで見ると、キャッシュマウントを外す前のビルドは1.9〜2.5秒で終わり、外した後は20.3〜21.7秒に延びて戻りません6。跳ねた直後のトレースを開けば、vcs.ref.head.revisionからコミットがわかります。このデモでは「整理した」コミットでした。遅くなる前後のトレースを並べると、どの手順が延びたのかもわかります。 遅くなる前のビルドのトレースをGrafanaのExploreで開いた画面。依存のダウンロードはキャッシュから読まれ、go buildは約0.9秒で終わっている 遅くなる前は、依存のダウンロードの手順がload cache:で始まるスパンになり、キャッシュから読まれています。遅くなった後は、手順がRUN go mod download && go buildの1つにまとまり、その手順だけで19.36秒かかっています。 手順のスパン名には、RUNのコマンドがそのまま入ります。Dockerfileを変えるとスパン名も変わるので、変更の前後を1本の系列で比べるなら、ルートのスパンの所要時間を使ってください。 エージェントに修正させる Tempoに溜めたトレースは、人だけでなくプログラムからも問い合わせられます。mcp-grafanaは、GrafanaをMCPのサーバーとして公開するツールです。Tempo向けには次のようなツールを持っています。 search_tempo_traces:TraceQLでトレースを検索する query_tempo_metrics:TraceQLのメトリクスを計算する diff_tempo_traces:2本のトレースを比べる これをコーディングエージェントにつなぎ、ビルドが遅くなった原因を調べさせました。mcp-grafanaの起動方法とMCPのクライアントへの登録方法は、mcp-grafanaの公式ドキュメントを参照してください。書き込み系のツールは--disable-writeで外せるので、調査に使うだけなら外しておくと安心です。 今回はClaude Codeを使いましたが、MCPのサーバーを使えるほかのコーディングエージェント(Codexなど)でも同じようにできるでしょう。Gitの履歴の参照やdocker buildx buildの実行など、必要なツールは適宜許可してください。頼んだ内容は次のとおりです。 コンテナのビルドが最近遅くなりました。ビルドのトレースはTempo(Grafanaのデータソース UID "tempo")に送っています。各ビルドにはサービス buildx の "build ." という名前のスパンがあり、そのスパンのリソース属性 vcs.ref.head.revision にビルドしたコミットが入っています。Tempoのトレースを使って、どのコミットでなぜビルドが遅くなったのかを調べ、このディレクトリのDockerfileを直してください。直したら docker buildx build --builder gf-demo --output type=cacheonly . で再ビルドし、新しいビルドが速くなったことをTempoで確かめてください。新しいトレースが検索できるようになるまで1〜2分かかることがあります。 エージェントは、まずGitの履歴を読み、search_tempo_tracesでコミットごとの所要時間を並べました。そこから導いた結論は、所要時間が約2秒から約21秒に跳ねたのは「整理した」コミットだというものです。遅くなる前と後のトレースをdiff_tempo_tracesで比べ、RUN go mod download && go buildの手順に約19.9秒かかるようになったことと、以前は依存のダウンロードがキャッシュから読まれていたことも確かめました。 修正では、go.modとgo.sumを先にコピーする順序とキャッシュマウントを元に戻し、後から足されたラベルは残しました。再ビルドのあとは新しいトレースが検索できるようになるのを待ち、所要時間が2.31秒に戻ったことをTempoで確かめています。全体で約3分かかり、そのうち約1分半は新しいトレースを待つ時間でした。 オブザーバビリティのSaaSに送る利点 ここまでは、手元のotel-lgtmにトレースを送ってきました。otel-lgtmは、イメージの説明にあるとおり開発、デモ、テストのための環境で、コンテナを消せばトレースも消えます。ビルドをまたいで問いを立てるには、トレースを長く残せて、CIのrunnerからも送れる場所が必要です。Grafana CloudのようなオブザーバビリティのSaaSに送ると、その場所を自分で運用せずに済みます。たとえばGrafana Cloudでも、BuildKitで保持する量よりも圧倒的に多くのトレースを保持できます7。 Grafana Cloudへ送るときも、builderに渡す環境変数を変えるだけです。Grafana CloudのOTLPのゲートウェイにはOTLP/HTTPで送るので、BuildKitのデフォルトのgRPCからhttp/protobufに切り替えます。エンドポイントと認証のヘッダーは、Grafana CloudのポータルのOpenTelemetryのタイルで生成した値をそのまま使いました。 docker buildx create --name gf-cloud --driver docker-container \ --driver-opt image=moby/buildkit:v0.33.1 \ --driver-opt env.OTEL_TRACES_EXPORTER=otlp \ --driver-opt env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces" \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}" \ --bootstrap トレース専用の変数(名前にTRACESが入るもの)だけを渡しているのは、BuildKitがOTEL_EXPORTER_OTLP_ENDPOINTを見るとメトリクスの送信も始めるためです8。このbuilderで手元と同じ20回のビルドを流すと、21本(動作確認の1回を含む)のトレースが全部Grafana CloudのTempoに届きました9。トークンはbuilderのコンテナの環境変数とbuildxの設定ファイルに残るので、使い終わったらdocker buildx rm gf-cloudでbuilderを消してください。 Grafana Assistantに調査させる SaaSの側には、溜めたトレースを調べる機能も最初から用意されています。その例が、Grafana Cloudに組み込まれたAIアシスタントのGrafana Assistantです。ドキュメントによると、AssistantはTempoを含むデータソースに問い合わせ、TraceQLのクエリを書いて実行できます。トレースのIDを渡せば1本のトレースをウォーターフォールで表示でき、実験的な機能として、あるトレースと比べる候補のトレースを探すこともできます。 前の節でエージェントに調べさせたときは、mcp-grafanaを起動してエージェントに登録する準備が必要でした。Assistantは最初からスタックのデータソースにつながっているので、この準備は要らないのが利点です。ビルドが遅くなったことに気づいた人が、Grafanaの画面からそのまま「ビルドの所要時間はいつから延びたか」と聞けば済みます。原因の調査まではAssistantに任せ、Dockerfileを直して再ビルドするところはリポジトリを編集できるコーディングエージェントに任せる、という分担です。 実際に、Grafana Cloudへ送った20回分のビルドについてAssistantに聞きました。AssistantはGrafanaの画面のほか、Grafanaのコマンドラインツールのgcxからも呼べるので、今回はgcxを使いました。伝えたのは、コーディングエージェントに渡したのと同じ前提だけです。 gcx assistant prompt 'コンテナのビルドが最近遅くなりました。ビルドのトレースはTempo(データソース grafanacloud-shophub-traces)に送っています。各ビルドにはサービス buildx の "build ." という名前のスパンがあり、そのスパンのリソース属性 vcs.ref.head.revision にビルドしたコミットが入っています。過去1時間のトレースから、ビルドの所要時間がいつから延びたのか、どのコミットからなのか、ビルドのどの手順が遅くなったのかを調べてください。' Assistantはデータソースへの問い合わせを17回行い、132秒で答えを返しました。答えの冒頭は次のとおりです。 結論: ビルド時間は、およそ 07:09 UTC に約2秒から約20秒へ延びました。最初に遅くなったコミットは 9eac8b9 です。遅くなった手順は Dockerfile の RUN go mod download && go build -o /out/hello . です。 9eac8b9は「整理した」コミットで、答えは正しいものでした。手順についても、速いビルドの6ステップと遅いビルドの4ステップを比べた結果、遅いほうではRUN go mod download && go buildの1つの手順が約19.4秒、全体の約95%を占めるという答えです。依存の取得のキャッシュが効かなくなったことを原因として推測しつつ、「このデータにはコミットの親子順がありません」と書き、時間の順で最初に遅くなったコミットだという限定も付けていました。動作確認のために送った30.2秒のビルドも、コミットの属性がないことに気づいて、ほかのビルドとは別に扱っていました。 Assistantは、自前で運用しているGrafanaからGrafana CloudのAssistantにつないで使うこともできます10。 おわりに Dockerは、ビルドのトレースを出す仕組みも、それを確認する画面も持っています。ビルドを1回調べるならDockerの画面で十分で、Tempoに送る意味が出てくるのはビルドをまたいだ問いを立てるときだと確認しました。 この記事では、長くなるので、ビルドのトレースをGrafanaに送り、溜めたトレースをAIに調べさせるところまでにしました。次は、これを踏まえてCIの設定も含めて書きたいと考えています。CIのジョブがTRACEPARENTを渡せば、ビルドのスパンをジョブのスパンの下に入れて、パイプライン全体の中でビルドを見られるはずです。 ビルドが遅くなったと感じたことがあるなら、まず手元のbuilderに環境変数を2つ足して、トレースを溜めるところから始めてみてください。コンテナビルドでもオブザーバビリティを高めていきましょう。 脚注 標準出力が端末でないときは、Jaeger UIを起動せずに、トレースをJaegerの形式のJSONとして標準出力に書き出しました。 ↩︎ 確認したのはLinuxのDocker Engineだけです。Docker Desktopではnetwork=hostの意味が変わるので、この設定のままでは届かない可能性があります。 ↩︎ buildxは、環境変数TRACEPARENTがあると、それを親のトレースコンテキストとして扱います。この変数が設定されたシェルからビルドすると、ビルドがその親のトレースに入り、build .がルートになりません。ビルドを1回ずつ別のトレースにしたいなら、TRACEPARENTを外してから実行してください。buildx v0.29.1は、cmd/buildx/tracing.goでBuildKit v0.25.0のutil/tracing/envを読み込んでいます。このパッケージがTRACEPARENTとTRACESTATEを読みます。buildxのもっと新しいバージョンでは、同じ役割のパッケージがutil/tracing/childprocessに変わっています。 ↩︎ buildx v0.29.1は、BuildKitに接続するときにclient.WithTracerDelegateを指定しています(driver/manager.go)。buildxは自分のスパンをBuildKitに渡し、BuildKitがまとめて送ります。BuildKitのREADMEにも、buildctlはスパンをbuildkitdに転送するので、exporterはデーモン側だけに設定すればよいとあります。また、docker-containerドライバーではbuildctl dial-stdioというトレースがビルドとは別に毎回届くので、検索するときはresource.service.name = "buildx"で絞ると見やすくなります。 ↩︎ quantile_over_time(duration, .5)で中央値を取ると、約20秒のビルドが、区間を60秒にすると24.3秒、30秒にすると34.4秒になります。quantile_over_timeは所要時間を2のべき乗の幅のバケットに入れて近似するので、値がバケットの境目に寄ります。max_over_timeは、区間に入ったトレースの所要時間の最大値をそのまま返します。 ↩︎ グラフの範囲から外した最初のビルドは、キャッシュが空だったので38.1秒かかりました。 ↩︎ Grafana Cloudの料金のページによると、Tracesの保持期間はFreeプランで14日、Proプランで30日です。BuildKitの履歴の48時間か50回分より長く、手元の開発機のビルドとCIのビルドを同じTempoに入れて、同じクエリで比べられます。 ↩︎ BuildKit v0.33.1のutil/tracing/detect/otlp.goは、OTLPのクライアントをオプションなしで作っています。OpenTelemetryのGoのexporterは、送り先、プロトコル、ヘッダーを環境変数から読みます。 ↩︎ Grafana CloudのTempoでビルドのトレースを検索するときは、name = "build ."で絞ると確実に見つかります。 ↩︎ Grafana Assistantの紹介のページによると、自前で運用しているGrafanaから使う場合も、チャット、ダッシュボードの作成、クエリ、ナビゲーションは使えます。ただし、複数のデータソースを調べてレポートにまとめるInvestigationsと、Grafana CloudのMCPへの接続は、Grafana Cloudに限られた機能です。 ↩︎
devcontainer のビルドが「--allow=fs.read」で止まるときの回避策(features を使っていなくても起きる) Docker Desktop を更新したあと、いつもどおり VS Code で WSL 環境の devcontainer を開いたら、ビルドがはじまった直後に失敗しました。Dockerfile も compose.yml も変えていません。ログを見ると、使っていないはずの features のファイル名が出ています。 additional privileges requested: pass "--allow=fs.read=/tmp/devcontainercli-<user>/container-features/0.89.0-1792734723773/Dockerfile-with-features" to grant requested privileges AI に聞くと「features を Dockerfile に移しましょう」と返ってきましたが、そもそも devcontainer.json に features は書いていません。調べると、Docker 側のセキュリティ修正と Dev Containers の作りがぶつかって起きていたようです。 この記事では、原因と回避策をまとめます。上記の additional privileges requested の行がログに出ていれば、同じ原因の可能性があります。 結論 ホストに環境変数を 1 つ設定し、devcontainer をリビルドします。 export BUILDX_BAKE_ENTITLEMENTS_FS=0 features を使っていなくても、devcontainer.json で Docker Compose を使い、サービスを Dockerfile からビルドしていれば起きます。 この環境変数は、作業ディレクトリの外のファイルを読み書きするときの確認を無効にします。Docker Compose の修正版が出たら削除してください。 検証環境 項目 内容 ホスト WSL の Ubuntu Docker Docker Desktop 4.96.3(Engine 29.9.2) Docker Buildx 0.38.1 Docker Compose 5.6.1 VS Code 1.126.0(Dev Containers CLI 0.469.0) devcontainer の構成 Docker Compose、build: で Dockerfile からビルド、features なし エラーの読み方 ログは長いですが、見るのは次の 3 か所だけです。 Start: Run: docker compose --project-name app -f /home/<user>/apps/app/.devcontainer/compose.yml -f /tmp/devcontainercli-<user>/docker-compose/docker-compose.devcontainer.build-1792734723775.yml build => [internal] load local bake definitions => => reading from stdin 679B additional privileges requested: pass "--allow=fs.read=/tmp/devcontainercli-<user>/container-features/0.89.0-1792734723773/Dockerfile-with-features" to grant requested privileges Dev Containers は、プロジェクトの compose.yml に加えて、/tmp に作った 2 つ目の Compose ファイルを -f で重ねて docker compose build を実行しています。 load local bake definitions は、docker compose build が中で Docker Buildx の bake を使ってビルドしていることを示しています。 bake が /tmp の Dockerfile-with-features を読む許可を求めて、止まっています。 この後に続く at Dp (...devContainersSpecCLI.js...) のようなスタックトレースは、Dev Containers の CLI が「docker compose build が失敗した」と報告しているだけです。原因は書かれていないので、読み飛ばしてかまいません。 AI の見立ては外れた 原因にたどり着く前に、AI の見立てが外れました。ログだけを見れば、もっともらしい見立てでした。 AI が挙げた原因は「features を使っているから」です。エラーに出ているファイル名が Dockerfile-with-features なので、そう読むのは自然です。ただ、devcontainer.json に features は書いていないので、この見立ては外れでした。 そこで、別の AI に、Dev Containers の CLI のソースを確かめてもらいました。features がないときに足される処理を読むと、入っていたのはイメージにラベルを付ける 1 行でした。何が足されているかは、次の節で説明します。 なぜ起きるか bake とは bake(docker buildx bake)は、Docker Buildx のビルド機能の 1 つです。ビルドの設定をコマンドの引数で並べる代わりに、ファイルに書いておき、複数のイメージをまとめて(並行して)ビルドできます(Docker のドキュメント「Bake」)。設定ファイルは HCL か JSON で書くのが基本で、Compose ファイルをそのまま読むこともできます(Building with Bake from a Compose file)。 target "app" { context = "." dockerfile = "Dockerfile" tags = ["myapp:latest"] } この記事で bake が関係するのは、docker compose build が中で bake を使っているからです。Compose は、BuildKit が有効で Buildx のプラグインが入っていれば、compose.yml のビルドの設定を bake 用の JSON に変換します。その JSON を docker buildx bake --file - --progress rawjson に標準入力で渡して、ビルドします(docker/compose の build_bake.go)。エラーのログにあった load local bake definitions と reading from stdin 679B は、この JSON を受け取った bake の出力です。 つまり、docker compose build を実行しているつもりでも、実際にビルドの可否を判断しているのは bake です。bake の確認に引っかかれば、Compose のビルドも止まります。Compose が渡している設定は、docker compose build --print で表示できます(docker compose build のリファレンス)。 Buildx が、作業ディレクトリの外のファイルを読むときに許可を求めるようになった bake には「エンタイトルメント」という仕組みがあります。ビルドが作業ディレクトリの外のファイルを読み書きするときは、--allow fs.read=<パス> で許可するか、確認のプロンプトで許可しないと進みません(docker buildx bake のリファレンスの --allow)。他人のリポジトリにある bake の定義を実行したときに、手元のファイルを勝手に読まれないようにするためのものです。 ところが、Buildx v0.21.0 から v0.37.1 までは、--progress=rawjson で実行するとこの確認が飛ばされていました。これは脆弱性として報告され(GHSA-gwr2-q96m-6682)、2026 年 9 月 30 日の Buildx v0.37.2 で修正されました。 Docker Compose は、bake を必ず --progress rawjson で呼び出します。今まで確認が飛ばされていたので問題になりませんでしたが、v0.37.2 以降は確認が効くようになりました。しかも Compose は、ビルドコンテキストには読み取りの許可を付けていましたが、Dockerfile には付けていませんでした(docker/compose#14285)。そのため、Dockerfile がビルドコンテキストの外にあると、Compose のビルドが止まるようになりました。 Docker Desktop を更新しただけで、設定を何も変えていないのに起きたのはこのためです。 Dev Containers は、features がなくても /tmp に Dockerfile を作る Dev Containers は、Compose のサービスを Dockerfile からビルドするとき、元の Dockerfile をそのまま使いません。元の Dockerfile の前後に数行を足したものを、/tmp/devcontainercli-<user>/container-features/<バージョン>-<数字>/Dockerfile-with-features に書き出します。そして 2 つ目の Compose ファイルで、dockerfile: をこのファイルに差し替えてビルドします(devcontainers/cli の dockerCompose.ts)。 features を使っていれば、足される数行に features のインストール処理が入ります。features を使っていなくても、次の処理は足されます(devcontainers/cli の containerFeatures.ts)。 FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage LABEL devcontainer.metadata="..." devcontainer.json の設定を、イメージのラベルとして焼き込んでいます。ファイル名に features と付いているのは、features 用の仕組みを使い回しているからです。features を使っているかどうかとは関係ありません。 まとめると、次の 2 つが重なって、ビルドが止まっています。 devcontainer を使わなくても再現できる 原因がこの 2 つの組み合わせなら、Dev Containers を使わなくても、Dockerfile をビルドコンテキストの外に置くだけで同じエラーになるはずです。docker/compose#14285 の再現手順で試しました。Compose の修正はまだリリースされていないので、今の Compose でも再現します。 mkdir -p /tmp/outside && echo 'FROM alpine' > /tmp/outside/Dockerfile mkdir -p /tmp/repro && cd /tmp/repro cat > compose.yaml <<'YAML' services: app: build: context: . dockerfile: /tmp/outside/Dockerfile YAML 環境変数なしでビルドすると、devcontainer のときと同じ行で止まります。 $ env -u BUILDX_BAKE_ENTITLEMENTS_FS docker compose build [+] Building 0.0s (1/1) FINISHED => [internal] load local bake definitions 0.0s => => reading from stdin 458B 0.0s additional privileges requested: pass "--allow=fs.read=../outside/Dockerfile" to grant requested privileges BUILDX_BAKE_ENTITLEMENTS_FS=0 を付けると通ります。 $ BUILDX_BAKE_ENTITLEMENTS_FS=0 docker compose build [+] Building 0.2s (7/7) FINISHED => [internal] load local bake definitions 0.0s => => reading from stdin 458B 0.0s => [internal] load build definition from Dockerfile 0.0s (中略) ✔ Image repro-app Built 試し終わったら rm -rf /tmp/outside /tmp/repro で削除してください。 回避策 エラーメッセージは --allow=fs.read=... を付けるよう求めていますが、docker compose build は Dev Containers が組み立てて実行するので、利用者が引数を足すことはできません。代わりに、bake のファイルシステムの確認を無効にする環境変数 BUILDX_BAKE_ENTITLEMENTS_FS を 0 にします。 この環境変数は、Dev Containers が docker compose を実行するプロセスに届いている必要があります。 BUILDX_BAKE_ENTITLEMENTS_FS とは BUILDX_BAKE_ENTITLEMENTS_FS は、bake のエンタイトルメントのうち、ファイルシステムに関するもの(fs・fs.read・fs.write)を確認するかどうかを切り替える Buildx の環境変数です。この環境変数を読むのは Buildx です。Compose は bake を子プロセスとして起動するので、Compose を実行するプロセスに設定しておけば Buildx まで届きます。 値ごとの動きは次のとおりです(buildx の bake/entitlements.go)。 値 動き 設定しない 確認する(既定) 0・false など ファイルシステムの確認をしない 1・true など 確認する no・off など、上記以外 許可が必要なビルドのときに failed to parse BUILDX_BAKE_ENTITLEMENTS_FS value のエラーで止まる 値は Go の strconv.ParseBool で読まれるので、使えるのは、確認しない 0・f・F・false・FALSE・False と、確認する 1・t・true などだけです。no や off は使えません。 この環境変数は、Docker のドキュメントのビルド用の環境変数の一覧には載っていません。Buildx のソースと、そこから出るメッセージ(To disable filesystem entitlements checks, you can set BUILDX_BAKE_ENTITLEMENTS_FS=0)に出てくるだけです。 シェルの設定ファイルに書く VS Code(Dev Containers)が docker compose を実行するマシン(WSL なら Ubuntu)の ~/.bashrc に追記します。 export BUILDX_BAKE_ENTITLEMENTS_FS=0 追記したら、コマンドパレットから「Dev Containers: Rebuild Container」を実行します。私の環境(WSL に接続した VS Code)では、VS Code を再起動しなくても、リビルドをもう一度実行するだけでエラーが消えました。 BUILDX_BAKE_ENTITLEMENTS_FS=0 にしたときの影響 無効になるのはファイルシステムの確認だけ 0 にして無効になるのは、bake を実行したディレクトリ(作業ディレクトリ)の外のファイルを読み書きするときの確認です。network.host や security.insecure など、ほかのエンタイトルメントの確認は残ります。 無効になる確認には、読み取り(fs.read)だけでなく書き込み(fs.write)も含まれます。 読み取り:ビルドコンテキスト、Dockerfile、secrets に file: で指定したファイル、SSH の鍵、ローカルのキャッシュなど 書き込み:ビルドの結果をローカルのディレクトリに書き出す出力先や、キャッシュの書き出し先など これらが作業ディレクトリの外を指していても、エラーにも確認のプロンプトにもならず、そのまま進みます。 有効範囲は、環境変数が届くすべてのビルド ~/.bashrc に書くと、そのシェルから起動したプロセスのビルドすべてに効きます。Dev Containers の /tmp の Dockerfile だけを許可するのではありません。ターミナルで実行する docker compose build や docker buildx bake も、同じように確認なしで進みます。 たとえば、他人のリポジトリの docker-bake.hcl にホームディレクトリの ~/.ssh をビルドコンテキストにする設定が書かれていたり、compose.yml に ~/.aws/credentials を secret として読む設定が書かれていたりしても、気付かないままビルドできてしまいます。Compose のビルドについては、Buildx v0.37.2 より前と同じように、ファイルシステムの確認が飛ばされる状態に戻ります。 環境変数をいつ削除するか 根本の修正は、Compose が Dockerfile のパスにも読み取りの許可を付けるようにする docker/compose#14325 です。2026 年 10 月 12 日にマージされました。ただ、執筆時点の最新版 v5.6.1(10 月 21 日)には入っていません。VS Code 側の microsoft/vscode-remote-release#11896 も、執筆時点では open のままです。修正を含む Compose がリリースされたら、環境変数を削除してもう一度「Rebuild Container」を実行し、エラーが出ないことを確かめてください。 まとめ エラーの原因は、Buildx v0.37.2 で bake のファイル読み取りの確認が効くようになったことと、Dev Containers が /tmp に Dockerfile を作ってビルドすることの組み合わせです。 features を使っていなくても、Compose で Dockerfile からビルドしていれば起きます。 回避策として BUILDX_BAKE_ENTITLEMENTS_FS=0 を設定し、devcontainer をリビルドします。 この 1 行は、環境変数が届くすべてのビルドで、作業ディレクトリの外のファイルを読み書きするときの確認を無効にします。Compose の修正版が出たら削除してください。 参考 GHSA-gwr2-q96m-6682: Bake filesystem entitlement consent is skipped under --progress=rawjson(docker/buildx) Buildx v0.37.2 のリリース(docker/buildx) docker/buildx の bake/entitlements.go Build fails with fs.read entitlement error when dockerfile is outside the build context(docker/compose#14285) fix(bake): grant permissions for configured build inputs(docker/compose#14325) Docker Compose v5.6.1 のリリース(docker/compose) docker/compose の pkg/compose/build_bake.go Buildx v0.37.2 breaks compose builds(microsoft/vscode-remote-release#11896) devcontainers/cli の src/spec-node/containerFeatures.ts devcontainers/cli の src/spec-node/dockerCompose.ts Bake(Docker) Building with Bake from a Compose file(Docker) docker buildx bake(Docker) docker compose build(Docker) Build variables(Docker) strconv.ParseBool(Go)
Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki --- title: "Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki" published: true tags: observability, grafana, prometheus, aws --- Monitoring a Flask App on EC2 with Grafana Alloy, Prometheus, and Loki *IT 3200 · Jayden Backus 1. Introduction In an earlier module I put a small Flask app in a Docker container and ran it on an AWS EC2 instance. It shows one page, and gunicorn serves it on port 3000. A public IP draws attention fast. Three minutes after I started the app on a new instance, my logs showed requests for /api/v1/version, a path my app has never had. Something was scanning for exposed services. Before this project I could not have seen that. I would have learned of an outage only when someone told me, and to read a log I had to SSH in. I wanted to know, without logging in, whether the app is up and fast, whether the server is healthy, and what happened when something failed. I also wanted the system to warn me. 2. Why I chose these tools I used four open-source tools: - Grafana Alloy collects metrics and logs and forwards them. - Prometheus stores metrics and checks alert rules. - Loki stores logs, indexing only their labels. - Grafana draws dashboards from both. Alloy was the key choice. Many tutorials run three collectors: node_exporter for host metrics, cAdvisor for container metrics, and Promtail for logs. Grafana's Promtail migration guide says Promtail reached end of life on March 2, 2026, and that new work happens in Alloy. Alloy also contains node_exporter and cAdvisor as built-in components, so one agent and one config file replace all three. On a 2 GB server, that matters. Prometheus and Loki fit together. Both label data the same way, so service="app" finds the app's metrics and its logs. A one-line library, prometheus-flask-exporter, makes Flask speak Prometheus's format. Loki's label-only index keeps it far lighter than Elasticsearch. Limitations. I host it all myself, on the same server as the app, so if the server dies, so does its monitoring. Alloy needs privileged access (see section 6). Alerts fire but don't page anyone yet; that needs Alertmanager. What I rejected. AWS CloudWatch needs no servers, but its container features target ECS and EKS rather than plain Docker, it bills per custom metric, and it ties me to AWS. The Elastic Stack searches logs well but would strain a 2 GB server. 3. Installation and configuration !Architecture diagram Alloy scrapes the app's /metrics every 15 seconds, reads host and container statistics, and tails every container's logs through the Docker socket. It pushes metrics to Prometheus and logs to Loki. Grafana queries both. 1. Launch EC2. I used Amazon Linux 2023 on a t3.small (2 GB; five containers won't fit in a t3.micro) with a 20 GiB disk. The security group allows SSH (22) and Grafana (3001) from my IP only, and the app (3000) from anywhere. Lesson learned: key pairs belong to one region. My old key didn't exist in mine, so SSH refused me. I imported my public key (ssh-keygen -y -f key.pem) under Key pairs → Import key pair and relaunched. 2. Install Docker. Amazon Linux's Docker package lacks the Compose and Buildx plugins, so I added them as Docker's Compose install guide describes: sudo dnf install -y docker git && sudo systemctl enable --now docker P=/usr/local/lib/docker/cli-plugins && sudo mkdir -p $P sudo curl -fsSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o $P/docker-compose BX=$(curl -fsSI https://github.com/docker/buildx/releases/latest | grep -i '^location' | sed 's#.*/tag/##' | tr -d '\r') sudo curl -fsSL https://github.com/docker/buildx/releases/download/$BX/buildx-$BX.linux-amd64 -o $P/docker-buildx sudo chmod +x $P/* 3. Start the stack. git clone https://github.com/jaydenbackus/it-3200-m6.git && cd it-3200-m6 cp .env.example .env # then set GRAFANA_ADMIN_PASSWORD to a long random value chmod 600 .env sudo docker compose up -d --build Grafana loads its data sources and dashboard from files at startup, following its provisioning docs, so nothing needs clicking. The Alloy config reads like a pipeline: each component's output feeds the next. Here is the log path: loki.source.docker "containers" { host = "unix:///var/run/docker.sock" targets = discovery.docker.containers.targets relabel_rules = discovery.relabel.container_logs.rules forward_to = [loki.process.redact.receiver] } // Blank out anything that looks like a credential before Loki stores it loki.process "redact" { stage.replace { expression = "(?i)(?:password|passwd|secret|token|api[_-]?key)=(\\S+)" replace = "<redacted>" } forward_to = [loki.write.local.receiver] } 4. Implementation The app. I added prometheus-flask-exporter, turned on gunicorn's access log, and added a /health route. I group metrics by Flask endpoint rather than by URL, so scanners requesting random paths can't create endless new time series. Two demo routes, /simulate/error and /simulate/slow, let me trigger failures. They work only when ENABLE_SIMULATION=true and only from the server itself. What I collect: | Signal | Source | Stored in | Kept | |---|---|---|---| | Request rate, errors, latency | Flask /metrics | Prometheus | 15 days | | CPU, memory, disk | EC2 host, via Alloy | Prometheus | 15 days | | CPU and memory per container | Docker, via Alloy | Prometheus | 15 days | | App, access, and stack logs | Docker, via Alloy | Loki | 7 days | Dashboard. One Grafana dashboard shows app status, request rate, error ratio, latency percentiles, per-container and host resources, and live logs. Alerts. Prometheus checks six alerting rules: | Alert | Fires when | |---|---| | StudentAppDown | Alloy can't scrape the app for 1 minute | | StudentAppHighErrorRate | over 5% of requests fail for 2 minutes | | StudentAppHighLatency | p95 latency exceeds 1 second for 5 minutes | | HostHighCpu, HostLowMemory, HostDiskAlmostFull | the server runs short of CPU, memory, or disk | The waiting period keeps one slow request from raising an alarm. 5. Show it working I sent ten minutes of mixed traffic, about one failed and one slow request in every nine, then stopped the app for two and a half minutes. Prometheus's ALERTS series recorded what followed (UTC): | Time | Event | |---|---| | 04:43 | Traffic starts | | 04:46 | StudentAppHighErrorRate fires | | 04:49 | StudentAppHighLatency fires | | 04:53:15 | I stop the app | | 04:54:45 | StudentAppDown fires | | 04:55:46 | I restart the app | | 04:56:00 | StudentAppDown resolves | | 04:58 | Error and latency alerts resolve | At the peak, PromQL showed an 11% error ratio and a 1.5-second p95 latency, both well past their thresholds. Logs and metrics agreed: Loki held 251 lines for HTTP 500 responses, and Prometheus counted 248. One surprise: the error alert kept firing during the outage, though a stopped app returns no errors. The rule averages the last five minutes, which still held the earlier failures. The test also exposed a bug in my load generator. It quit when the app went down, because set -e treated one failed request as fatal. I removed it. 6. Security considerations - Exposure. Only the app is open to the internet; Grafana and SSH accept my IP alone. Prometheus, Loki, and Alloy listen on 127.0.0.1, and a port scan from outside found them closed. I reach them through an SSH tunnel. - Unauthenticated internals. Loki and Prometheus accept data without a password. That is safe only because nothing outside the server can reach them. - App endpoints. /metrics returns 403 to the public, and the demo routes return 404, so outsiders can neither read internal numbers nor trigger slow requests to tie up the app. - Grafana. A random admin password lives in .env, which Git ignores. Sign-up and anonymous access are off. Grafana runs over plain HTTP; a real deployment would add TLS. - Privileged collector. Alloy's cAdvisor component docs require privileged mode, the Docker socket, and host mounts, and control of the Docker socket means control of the host. I mount everything read-only, pin the image version, and keep Alloy's web page private. - Logs. Alloy blanks credential-like values before storage. Access logs hold client IPs, which count as personal data, so Loki deletes logs after seven days, configured per its retention docs. - Repository. The repository holds no keys, passwords, or personal IDs; .env supplies them on the server, and .gitignore excludes .env and *.pem. 7. Conclusion One docker compose up gave my app metrics, logs, a dashboard, and alerts that fired and cleared as designed. The biggest lesson was how much Alloy simplifies collection: one agent replaced three, which made the system easier to explain and to secure. Next I would send alerts to email, add HTTPS, and add tracing with OpenTelemetry. References The official documentation I used: - Grafana Alloy, Migrate from Promtail to Grafana Alloy: grafana.com - Grafana Alloy, prometheus.exporter.cadvisor: grafana.com - Grafana Loki, Log retention: grafana.com - Prometheus, Alerting rules: prometheus.io - Grafana, Provision Grafana: grafana.com - Docker, Install the Docker Compose plugin: docs.docker.com
docker builder prune freed 0 B: two places Docker keeps disk space that no prune reaches Disclosure: this article was drafted with AI assistance (Claude) from measurements recorded in the dockdu repository. Every number comes from the measurement log and can be reproduced on a disposable docker:dind container. docker system df says the build cache is empty. docker system prune -af reports Total reclaimed space: 0B. df -h still shows the disk filling up. Here are two cases where all three statements are true at once, both measured on Docker Engine 29.9.0, with the commands to check each by hand. 1. The build cache of a buildx builder is a volume docker buildx create --driver docker-container starts BuildKit in its own container, buildx_buildkit_<node>. The builder keeps its whole state, including its build cache, in a volume named buildx_buildkit_<node>_state (buildx source). The node is usually the builder name plus 0. Measured: one builder ci, one build that added 30 MB. $ docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 2 1 430.9MB 73.34MB (17%) Containers 1 1 40.96kB 0B (0%) Local Volumes 2 1 73.31MB 114.7kB (0%) Build Cache 0 0 0B 0B The 73 MB of build cache is in the Local Volumes row. Then the two prune commands, with du -sk on the state volume after each: | Command | State volume on disk | Prune printed | |---|---|---| | (before) | 72452 KiB | | | docker builder prune -af | 72452 KiB | Total: 0B | | docker buildx prune --builder=ci -af | 740 KiB | Total: 73.36MB | docker builder prune works on the selected builder, which here was the daemon's own. A CI host that once ran docker buildx create --use and was later switched back keeps growing in a volume that docker builder prune never touches. There is one more trap. docker buildx rm --keep-state removes the builder but keeps the volume. Nothing then refers to it, so it only shows up as an unused volume. Check by hand: docker volume ls --filter name=buildx_buildkit_ # one per docker-container node docker system df -v | grep buildx_buildkit_ # sizes docker buildx ls # which builder each node belongs to docker buildx du --builder <name> # that builder's cache, record by record Free it: docker buildx prune --builder <name>. Add --keep-storage 10gb to keep a working set. Or set a GC policy in that builder's buildkitd.toml. 2. Switching to the containerd image store hides your old images Docker Desktop and fresh Engine 29 installs use the containerd image store. If a daemon is switched to it, the images and containers of the classic overlay2 store are not deleted. Docker's docs say they become hidden: "Your overlay2 data remains on disk but becomes hidden." Measured: one data root with alpine:3.22 and nginx:1.29-alpine pulled under the graph driver, then the same data root started with --feature containerd-snapshotter=true: $ docker images # (empty) $ docker system df # every row 0B $ docker system prune -af Total reclaimed space: 0B $ sudo du -sh /var/lib/docker/overlay2 71.8M /var/lib/docker/overlay2 On a real machine that number is whatever you had before the switch, often many gigabytes. Check by hand: docker info --format '{{.Driver}} {{json .DriverStatus}}' # io.containerd.snapshotter.v1 = containerd store sudo ls /var/lib/docker/image/overlay2/layerdb/sha256 | wc -l # layers the classic store still records sudo du -sh /var/lib/docker/overlay2 Free it: switch the daemon back to overlay2. The old images and containers reappear and can be removed normally. docker save anything you want to keep first. What does not hide space: containerd's compressed content A common suspicion is that the containerd store's second copy of each layer (compressed blobs next to unpacked snapshots) is space df ignores. It is not, at least on 29.9: $ docker system df → Images 296.5MB content (compressed) 72.4 MB snapshots (unpacked) 224.4 MB sum 296.8 MB The Images row includes both. Leftover overlay2 directories The third classic case, moby#47089: overlay2/ is far larger than images, containers and build cache together. The graph driver records which directory belongs to which image layer (image/overlay2/layerdb/sha256/*/cache-id) and to which container (layerdb/mounts/*/mount-id). A directory that appears in neither is a BuildKit snapshot or a leftover. Comparing the size of those directories with the private build cache shows whether leftovers are the likely cause. Don't delete them by hand while the daemon runs. Tooling dockdu, a read-only CLI that explains Docker disk usage, does all three checks since 0.4.0: - buildx builders are listed under BUILD CACHE, with the prune command for each; - the hidden classic store is reported from a single directory listing; - dockdu --disk splits overlay2/ into image layers, container layers and directories with no record. It never deletes anything, and its Docker client refuses any request other than GET/HEAD on four read endpoints. The manual commands above work without it.