Load testing in Laravel Sail
Use this overlay when you need to plot k6 HTTP latency (especially p95/p99) against MySQL InnoDB row-lock depth in the same Grafana window while load-testing a Laravel Sail app.
It is not started by Sail, not used in production, and not a general application APM stack. k6 itself is not part of the overlay — you run k6 on the host (or from a sibling load-test repo) and push metrics into the Prometheus this overlay starts.
Architecture
Section titled “Architecture”┌─────────────────────────┐ scrape every 5s ┌──────────────────┐│ mysqld-exporter :9104 │◄─────────────────────────────────│ ││ (prom/mysqld-exporter) │ │ Prometheus │└───────────┬─────────────┘ │ :9090 │ │ TCP 3306 as user `exporter` │ │ ▼ │ TSDB volume │┌─────────────────────────┐ remote write /api/v1/write │ prometheus-data ││ Sail MySQL │ │ ││ hostname: mysql │ └────────▲─────────┘│ network: your-project_sail │└─────────────────────────┘ PromQL queries │ ┌────────┴─────────┐┌─────────────────────────┐ POST http://localhost:9090/ │ Grafana :3000 ││ k6 on the host │ api/v1/write │ anonymous Admin ││ │─────────────────────────────────►│ grafana-data │└─────────────────────────┘ └──────────────────┘Two ingestion paths land in the same Prometheus:
| Path | Direction | Use this |
|---|---|---|
Scrape mysqld-exporter:9104 | Prometheus pulls | Always, while the overlay is up |
Remote write POST /api/v1/write | k6 pushes | Preferred way to connect k6 |
Scrape host.docker.internal:5656 | Prometheus pulls | Optional / unused unless you run an xk6 Prometheus HTTP exporter |
Grafana is a viewer only. It does not scrape anything itself.
Overlay files
Section titled “Overlay files”Four files make up the stack. Sail’s compose.yaml is unchanged. The SQL is not mounted into MySQL’s /docker-entrypoint-initdb.d, so you must apply it yourself.
- docker-compose.monitoring.yml Overlay compose file — run with docker compose, not Sail
Directorydocker/
Directorymysqld-exporter/
- .my.cnf Exporter MySQL client credentials
Directorymysql/
- create-exporter-user.sql One-shot grants for the exporter user
Directoryprometheus/
- prometheus.yml Scrape config
1. docker-compose.monitoring.yml
Section titled “1. docker-compose.monitoring.yml”Overlay compose file. Run it with docker compose -f, not via Sail.
It starts three services on Sail’s existing Docker network, publishes Grafana / Prometheus / exporter ports to the host, and keeps TSDB + Grafana state in named volumes.
Replace your-project_sail with your Compose network name. Docker names networks after the repo folder plus _sail (for example a folder named shop becomes shop_sail). If you set COMPOSE_PROJECT_NAME, use that instead of the folder name.
services: mysqld-exporter: image: prom/mysqld-exporter restart: unless-stopped environment: DATA_SOURCE_NAME: "exporter:password@(mysql:3306)/" command: - '--config.my-cnf=/etc/.my.cnf' - '--collect.info_schema.innodb_metrics' - '--collect.info_schema.processlist' - '--collect.perf_schema.eventswaits' - '--collect.perf_schema.tablelocks' - '--collect.global_status' - '--collect.global_variables' volumes: - ./docker/mysqld-exporter/.my.cnf:/etc/.my.cnf ports: - '9104:9104' networks: - sail
prometheus: image: prom/prometheus restart: unless-stopped command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--web.enable-remote-write-receiver' volumes: - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus ports: - '9090:9090' networks: - sail
grafana: image: grafana/grafana restart: unless-stopped environment: - GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin volumes: - grafana-data:/var/lib/grafana ports: - '3000:3000' networks: - sail
networks: sail: external: true name: your-project_sail
volumes: prometheus-data: grafana-data:Notes on this file:
networks.sail.external: true— the overlay does not create the Sail network.vendor/bin/sail upmust already have created it.--web.enable-remote-write-receiver— this is what makes k6’sPOST /api/v1/writework. A stock Prometheus without that flag returns 404.DATA_SOURCE_NAME— leftover from the pre-v0.15 exporter config style. Currentprom/mysqld-exporteruses--config.my-cnf; the env var can be ignored.- Anonymous Grafana Admin — anyone who can reach
localhost:3000is Admin. Fine for a laptop load-test session; unsafe anywhere else. restart: unless-stopped— overlay containers come back after a Docker restart until youdownthem.
2. docker/mysqld-exporter/.my.cnf
Section titled “2. docker/mysqld-exporter/.my.cnf”MySQL client credentials for the exporter. Mounted into the exporter container at /etc/.my.cnf.
[client]user=exporterpassword=passwordhost=mysqlport=3306host=mysql is the Sail service name, not localhost. It only resolves because the exporter is attached to the Sail network.
3. docker/mysql/create-exporter-user.sql
Section titled “3. docker/mysql/create-exporter-user.sql”One-shot SQL to create the MySQL user the exporter logs in as. Matches the mysqld-exporter required grants.
CREATE USER IF NOT EXISTS 'exporter'@'%' IDENTIFIED BY 'password' WITH MAX_USER_CONNECTIONS 3;GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%';FLUSH PRIVILEGES;| Piece | Meaning |
|---|---|
'exporter'@'%' | Any client host. Needed because the exporter container is not localhost from MySQL’s point of view |
password | Hardcoded local secret. Same value as .my.cnf. Do not reuse in production |
MAX_USER_CONNECTIONS 3 | Caps the exporter so scrapes cannot exhaust the server under load |
PROCESS | Required to read the processlist |
REPLICATION CLIENT | Required by the default slave_status collector even on a standalone server |
SELECT ON *.* | Required for SHOW GLOBAL STATUS, innodb_metrics, performance_schema |
Re-running is safe (IF NOT EXISTS). Init scripts in /docker-entrypoint-initdb.d only run when the sail-mysql volume is first created, and this file is not mounted there, so an existing Sail volume will never pick it up automatically.
4. docker/prometheus/prometheus.yml
Section titled “4. docker/prometheus/prometheus.yml”Prometheus scrape config. Mounted into the Prometheus container at /etc/prometheus/prometheus.yml.
global: scrape_interval: 5s
scrape_configs: - job_name: 'mysqld' static_configs: - targets: ['mysqld-exporter:9104']
- job_name: 'k6' static_configs: - targets: ['host.docker.internal:5656']scrape_interval: 5s is much faster than Prometheus’s default of 1m. Lock spikes last hundreds of milliseconds to a few seconds; a 1m scrape would miss them.
mysqld-exporter:9104 is Docker DNS on the Sail network. Do not change this to localhost:9104 inside Prometheus — that would mean Prometheus’s own localhost.
The k6 scrape job is leftover config for the optional pull path. You do not need it for the recommended remote-write path.
Exporter collectors
Section titled “Exporter collectors”These flags are extra collectors on top of the exporter defaults (global_status, global_variables, and slave_status are already on). They were chosen for hot-row contention during load tests, not a generic MySQL dashboard.
| Flag | What it reads | Why it is here |
|---|---|---|
--collect.global_status | SHOW GLOBAL STATUS | Innodb_row_lock_current_waits, Innodb_row_lock_time, Innodb_row_lock_waits |
--collect.global_variables | SHOW GLOBAL VARIABLES | Server config snapshot (innodb lock wait timeout, max connections, …) |
--collect.info_schema.innodb_metrics | information_schema.innodb_metrics | Deadlocks (mysql_info_schema_innodb_metrics_lock_lock_deadlocks_total) |
--collect.info_schema.processlist | information_schema.processlist | Thread-state counts (sessions sitting in lock wait) |
--collect.perf_schema.eventswaits | events_waits_summary_global_by_event_name | Wait-event time, including lock waits |
--collect.perf_schema.tablelocks | table_lock_waits_summary_by_table | Table-level lock waits |
Series names Prometheus stores:
mysql_global_status_innodb_row_lock_current_waitsmysql_global_status_innodb_row_lock_timemysql_global_status_innodb_row_lock_waitsmysql_info_schema_innodb_metrics_lock_lock_deadlocks_total
Bring the overlay up
Section titled “Bring the overlay up”All commands are from the Laravel repo root. PHP / Artisan / Sail go through vendor/bin/sail. The overlay is plain docker compose because it is not part of Sail.
Prerequisites
Section titled “Prerequisites”- Docker Desktop running
- The Laravel repo cloned, Sail installed (
vendor/bin/sail) - Ports 3000, 9090, and 9104 free on the host (Grafana / Prometheus / exporter). Sail already uses 80, 3306, 6379, 8025, 8080, 5173
- Start Sail first. The overlay joins Sail’s network and talks to the
mysqlservice. If Sail is down, the overlay fails with “networkyour-project_sailnot found” or the exporter cannot resolvemysql.
vendor/bin/sail up -dConfirm the network exists (replace your-project with the repo directory name):
docker network ls | grep _sailConfirm MySQL is healthy:
vendor/bin/sail ps- Create the exporter user (once per MySQL volume).
vendor/bin/sail mysql < docker/mysql/create-exporter-user.sqlIf sail mysql is not available, exec in as root and paste the three statements from docker/mysql/create-exporter-user.sql.
Re-run is safe. You only need to redo this after vendor/bin/sail down -v (which destroys sail-mysql).
- Start the monitoring overlay. Not via Sail:
docker compose -f docker-compose.monitoring.yml up -dFirst pull can take a minute (prom/mysqld-exporter, prom/prometheus, grafana/grafana).
Check the three containers are up:
docker compose -f docker-compose.monitoring.yml psYou should see mysqld-exporter, prometheus, and grafana with state running.
- Check health.
| Check | Expect |
|---|---|
| http://localhost:9090/-/healthy | Prometheus up |
| http://localhost:9090/targets | mysqld UP; k6 DOWN until something listens on host port 5656 (expected if you use remote write) |
| http://localhost:9104/metrics | Prometheus text with mysql_global_status_* |
| http://localhost:3000/api/health | Grafana up |
Prometheus graph: mysql_global_status_innodb_row_lock_current_waits | A gauge, usually 0 at idle |
If mysqld is DOWN: Sail not on the expected network, exporter user missing, or password mismatch between the SQL and .my.cnf.
-
Add the Grafana datasource (once per
grafana-datavolume). The overlay does not provision a datasource or dashboard as files. They live in thegrafana-datavolume after you create them.- Open http://localhost:3000. There is no login (anonymous Admin).
- Connections → Data sources → Add data source → Prometheus.
- Set URL to
http://prometheus:9090— the Docker service name, notlocalhost. Grafana queries from inside the Sail network;localhostinside that container is Grafana itself. - Save & test. It should succeed.
- Build or import panels (see Grafana panels).
Port 3000 will clash if another Grafana (or anything else) is already bound there.
docker compose -f docker-compose.monitoring.yml down # keep TSDB + Grafana volumesdocker compose -f docker-compose.monitoring.yml down -v # wipe metrics and dashboardsLeaving the overlay up is fine. Tear it down if you need ports 3000 / 9090 / 9104.
Sail is independent:
vendor/bin/sail stop # app + MySQL + Redis still stopped; overlay can keep running but MySQL scrapes will failvendor/bin/sail down # does not remove the overlayConnecting k6
Section titled “Connecting k6”k6 runs on the host, not in this compose file. Prometheus is published at localhost:9090, which is the address k6 should use.
There are two ways this stack can ingest k6 metrics. Use remote write unless you have a reason not to.
Path A (recommended): k6 remote write
Section titled “Path A (recommended): k6 remote write”k6’s built-in output -o experimental-prometheus-rw pushes samples to Prometheus. That is why Prometheus is started with --web.enable-remote-write-receiver.
Official docs: k6 Prometheus remote write.
- Install k6. You do not need Grafana Cloud, xk6, or a custom k6 binary for remote write.
brew install k6Follow k6 install.
- Point k6 at this Prometheus. Default remote-write URL is already
http://localhost:9090/api/v1/write, which matches the published port. Set it explicitly anyway, and set trend stats — withoutK6_PROMETHEUS_RW_TREND_STATS, k6 only sends p(99).
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \K6_PROMETHEUS_RW_TREND_STATS="min,avg,med,max,p(90),p(95),p(99)" \k6 run -o experimental-prometheus-rw script.jsk6 must be able to reach localhost:9090 (overlay up, port published). It does not need to be on the Docker network.
If Prometheus was started without --web.enable-remote-write-receiver, this POST returns 404.
- Hit the Sail app. Anything that produces k6 HTTP metrics will show up. Example against local Sail (
APP_PORTdefaults to 80):
import http from 'k6/http';import { check, sleep } from 'k6';
export const options = { vus: 10, duration: '30s', thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'], },};
export default function () { const res = http.get('http://localhost/'); check(res, { 'status is 200': (r) => r.status === 200 }); sleep(1);}Replace the URL with whatever flow you care about (checkout, login, a hot write path). Tag a run so Grafana can overlay experiments:
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \K6_PROMETHEUS_RW_TREND_STATS="min,avg,med,max,p(90),p(95),p(99)" \k6 run -o experimental-prometheus-rw --tag testid=load-$(date +%Y%m%d-%H%M) script.jstestid becomes a Prometheus label on the k6_* series.
- Confirm metrics arrived. In http://localhost:9090/graph:
k6_http_req_duration_p99k6_http_req_duration_p95k6_http_req_failed_ratek6_vusUseful series after a run (seconds for duration stats):
| k6 metric | Prometheus series (with the TREND_STATS above) |
|---|---|
http_req_duration | k6_http_req_duration_p99, _p95, _p90, _med, _avg, _min, _max |
http_req_failed | k6_http_req_failed_rate |
checks | k6_checks_rate |
vus | k6_vus |
If those queries are empty: overlay not up, wrong K6_PROMETHEUS_RW_SERVER_URL, or you omitted -o experimental-prometheus-rw.
The k6 target on /targets staying DOWN is normal for this path. Remote-write samples do not go through that scrape job.
Optional npm wrapper if you keep scripts in another repo:
{ "scripts": { "prom": "K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write K6_PROMETHEUS_RW_TREND_STATS=min,avg,med,max,p(90),p(95),p(99) k6 run -o experimental-prometheus-rw script.js" }}That is all this stack requires from k6: host process, remote-write URL, trend stats, overlay running.
Path B (optional): Prometheus scrapes k6 on port 5656
Section titled “Path B (optional): Prometheus scrapes k6 on port 5656”prometheus.yml already has:
- job_name: 'k6' static_configs: - targets: ['host.docker.internal:5656']That is the listen address used by the older xk6-prometheus HTTP exporter: Prometheus pulls /metrics from the host.
Built-in k6 does not expose port 5656. Until something listens there, the k6 target is DOWN. That is expected.
Only use this path if you build a custom k6 with xk6-prometheus (or another exporter) and bind it to 0.0.0.0:5656. host.docker.internal is provided by Docker Desktop on macOS. The Prometheus service does not set extra_hosts (Sail’s laravel.test does). On Linux you may need:
# under the prometheus service in docker-compose.monitoring.ymlextra_hosts: - 'host.docker.internal:host-gateway'Prefer Path A. Path B is leftover scrape config, not required to connect k6.
Native histograms (optional, not enabled here)
Section titled “Native histograms (optional, not enabled here)”k6 can send trends as Prometheus native histograms (K6_PROMETHEUS_RW_TREND_AS_NATIVE_HISTOGRAM=true). That needs Prometheus ≥ 2.40 with --enable-feature=native-histograms. This overlay does not set that flag. Stick to K6_PROMETHEUS_RW_TREND_STATS gauges unless you change the Prometheus command.
Grafana panels
Section titled “Grafana panels”Watch these together during a load test. Averages and p50 alone hide the lock queue.
| Signal | PromQL | Healthy | Contention |
|---|---|---|---|
| Request p50 vs p95/p99 | k6_http_req_duration_med, k6_http_req_duration_p95, k6_http_req_duration_p99 | Lines close together | Tail peels away from p50 while p50 still looks fine |
| Current row-lock waiters | mysql_global_status_innodb_row_lock_current_waits | ~0 | Tracks concurrent waiters on hot rows |
| Lock wait rate | rate(mysql_global_status_innodb_row_lock_waits[1m]) | Quiet | Rises with herd windows |
| Deadlocks | mysql_info_schema_innodb_metrics_lock_lock_deadlocks_total | Flat | Steps up if deadlocks start |
| Failed HTTP | k6_http_req_failed_rate | ~0 | Climbs if the flow starts 5xx / timeouts |
| Virtual users | k6_vus | Matches your script | Confirms the herd actually ramped |
k6 duration series are in seconds. Multiply by 1000 in Grafana if you want milliseconds.
| Port | Service | Who uses it |
|---|---|---|
| 3000 | Grafana UI | You (browser) |
| 9090 | Prometheus UI and remote write | You (browser); k6 (/api/v1/write) |
| 9104 | mysqld-exporter /metrics | Prometheus on the Docker network; host port is only for debugging |
What this is not
Section titled “What this is not”- Not production monitoring. Credentials, anonymous Grafana Admin, and host-published ports are local conveniences.
- Not Laravel / Redis / PHP-FPM metrics. Only MySQL (scraped) and whatever you push from k6.
- Not started by
vendor/bin/sail. Forgetting the overlay is a common reason Grafana is empty. - Not auto-creating the
exporteruser. A fresh Sail volume still needs the SQL. - Not provisioning Grafana dashboards in git. Those live in
grafana-data.