|
| 1 | +# ext-rdump |
| 2 | + |
| 3 | +[](https://github.com/reliforp/ext-rdump/actions/workflows/build.yml) |
| 4 | + |
| 5 | +Take a memory dump of the **current PHP process** at any point in time, for later |
| 6 | +analysis with [reli](https://github.com/reliforp/reli-prof). |
| 7 | + |
| 8 | +One call writes a snapshot of your process's memory to a file: |
| 9 | + |
| 10 | +```php |
| 11 | +rdump_dump('/tmp/app.rdump'); |
| 12 | +``` |
| 13 | + |
| 14 | +[Feed that file to reli](https://github.com/reliforp/reli-prof/blob/HEAD/docs/memory/memory-dump.md) |
| 15 | +and you can: |
| 16 | + |
| 17 | +- see the **call stack** at the moment of the dump, |
| 18 | +- find out **what objects/strings/arrays exist, how much memory each uses, and |
| 19 | + how they reference each other**, |
| 20 | +- help investigate **memory leaks, resource leaks, circular references, and |
| 21 | + memory bottlenecks**. |
| 22 | + |
| 23 | +`reli` can do all this by attaching from outside, but that needs `ptrace`. |
| 24 | +`ext-rdump` dumps from *inside* the process instead, without needing ptrace. |
| 25 | + |
| 26 | +## Requirements |
| 27 | + |
| 28 | +- 64-bit little-endian Linux (x86-64 / arm64), PHP **7.0 – 8.5** (NTS and ZTS) |
| 29 | +- `reli` (only on the machine where you *analyse* the dump) |
| 30 | + |
| 31 | +## Install |
| 32 | + |
| 33 | +From source: |
| 34 | + |
| 35 | +```bash |
| 36 | +phpize && ./configure --enable-rdump && make && sudo make install |
| 37 | +# then add to php.ini: |
| 38 | +# extension=rdump.so |
| 39 | +``` |
| 40 | + |
| 41 | +Or via [PIE](https://github.com/php/pie) / PECL: |
| 42 | + |
| 43 | +```bash |
| 44 | +pie install reliforp/ext-rdump |
| 45 | +# or |
| 46 | +pecl install package.xml # from a checkout |
| 47 | +``` |
| 48 | + |
| 49 | +## Usage |
| 50 | + |
| 51 | +```php |
| 52 | +bool rdump_dump(string $path, bool $full = false) |
| 53 | +``` |
| 54 | + |
| 55 | +Writes the dump to `$path`; returns `false` (with an `E_WARNING`) on failure. |
| 56 | +Pass `$full = true` for a self-contained dump you can analyse on a host without |
| 57 | +the original binaries. It embeds **every readable mapping** (read-only |
| 58 | +file-backed segments too, not just code), so the dump is noticeably larger. |
| 59 | + |
| 60 | +### On demand from a signal handler |
| 61 | + |
| 62 | +```php |
| 63 | +// kill -USR2 <pid> |
| 64 | +pcntl_async_signals(true); |
| 65 | +pcntl_signal(SIGUSR2, function () { rdump_dump('/tmp/sig.rdump'); }); |
| 66 | +``` |
| 67 | + |
| 68 | +### Capturing the moment of `memory_limit` death |
| 69 | + |
| 70 | +To capture the moment of `memory_limit` exhaustion, enable the auto-dump in |
| 71 | +php.ini: |
| 72 | + |
| 73 | +```ini |
| 74 | +extension=rdump.so |
| 75 | +rdump.oom_dump=/var/log/php-oom-%p.rdump ; empty = disabled |
| 76 | +;rdump.oom_dump_full=1 ; also embed read-only segments |
| 77 | +;rdump.oom_dump_max=1 ; max auto-dumps per worker (0 = unlimited) |
| 78 | +;rdump.oom_dump_min_interval=0 ; min seconds between auto-dumps (0 = off) |
| 79 | +;rdump.oom_dump_max_total=2G ; skip once *.rdump in the dir would exceed this (0 = off) |
| 80 | +;rdump.oom_dump_marker=1 ; also write "<path>.done" when a dump completes |
| 81 | +;rdump.safe_read=1 ; read via /proc/self/mem (crash-safe; default on under ZTS) |
| 82 | +``` |
| 83 | + |
| 84 | +The path may contain `%p` (PID), `%i` (thread id), and `%t` (Unix time), |
| 85 | +expanded when the dump fires; `%%` is a literal `%`. Under PHP-FPM give the |
| 86 | +template a `%p` so workers write distinct files instead of racing to overwrite |
| 87 | +one path; under ZTS (FrankenPHP) add `%i` so threads in one process don't |
| 88 | +collide either. (This expansion applies only to the OOM auto-dump path: |
| 89 | +both the `rdump.oom_dump` INI value and a `rdump_set_oom_dump()` override. It |
| 90 | +does not apply to the literal `$path` you pass to `rdump_dump()`.) |
| 91 | + |
| 92 | +#### Limiting the auto-dump |
| 93 | + |
| 94 | +When application code or data keeps exhausting `memory_limit`, a worker can OOM |
| 95 | +request after request, so an unguarded auto-dump could write a fresh (often |
| 96 | +100 MB+) file every time and fill the disk. Three INI guards, all applied together: |
| 97 | + |
| 98 | +```ini |
| 99 | +rdump.oom_dump_max=1 ; max dumps per worker process (default 1; 0 = unlimited) |
| 100 | +rdump.oom_dump_min_interval=0 ; min seconds between dumps (default 0 = off) |
| 101 | +rdump.oom_dump_max_total=0 ; skip once *.rdump in the dump dir would exceed this (K/M/G/T, e.g. 1.5G; 0 = off) |
| 102 | +``` |
| 103 | + |
| 104 | +`oom_dump_max` is a per-worker count (default 1 = one dump per worker). |
| 105 | +`oom_dump_max_total` bounds the *combined* footprint across workers; it counts |
| 106 | +every `*.rdump` in the directory plus the incoming dump's minimum size. Both are |
| 107 | +best-effort: concurrent workers can still overshoot together. |
| 108 | + |
| 109 | +These guards apply only to the automatic OOM dump; explicit `rdump_dump()` calls |
| 110 | +are never throttled. Give the path a `%p` so each worker writes a distinct file. |
| 111 | + |
| 112 | +#### Shipping dumps with a watcher |
| 113 | + |
| 114 | +Set `rdump.oom_dump_marker=1` and the extension drops an empty `<path>.done` |
| 115 | +once a dump is fully written. A watcher waits for `oom-1234.rdump.done` before |
| 116 | +shipping `oom-1234.rdump`, so it won't start on a half-written file. Give each |
| 117 | +dump a unique path (`%p`, plus `%t` for repeats) so a later dump can't truncate |
| 118 | +one while it is being shipped. |
| 119 | + |
| 120 | +#### Setting the path at runtime |
| 121 | + |
| 122 | +For a per-request or per-PID filename, or when you cannot edit php.ini, set the |
| 123 | +auto-dump from code instead: |
| 124 | + |
| 125 | +```php |
| 126 | +rdump_set_oom_dump("/var/log/oom-" . getmypid() . ".rdump"); |
| 127 | +// rdump_set_oom_dump("/var/log/oom.rdump", true); // full dump |
| 128 | +// rdump_set_oom_dump(""); // force off for this request, even if the INI set a path |
| 129 | +// rdump_set_oom_dump(null); // clear the override, fall back to rdump.oom_dump |
| 130 | +``` |
| 131 | + |
| 132 | +The runtime setting applies to the current request and takes precedence over |
| 133 | +the `rdump.oom_dump` default. It returns `true` on success, or `false` (with an |
| 134 | +`E_WARNING`) if the path could not be stored, leaving the previous default in |
| 135 | +effect rather than silently disabling the dump. |
| 136 | + |
| 137 | +## Analyse with reli |
| 138 | + |
| 139 | +Reading the dump is reli's job; its |
| 140 | +[memory-dump docs](https://github.com/reliforp/reli-prof/blob/HEAD/docs/memory/memory-dump.md) |
| 141 | +have the full story. The easiest way to run it is reli's Docker image, with no |
| 142 | +local PHP 8.4 / FFI setup; the one-liner below installs a `reli` shell function |
| 143 | +backed by it (see reli's |
| 144 | +[getting-started guide](https://github.com/reliforp/reli-prof/blob/HEAD/docs/getting-started.md) |
| 145 | +to persist it across shells): |
| 146 | + |
| 147 | +```bash |
| 148 | +# one-time: install a `reli` command backed by the Docker image |
| 149 | +eval "$(docker run --rm --pull=always reliforp/reli-prof docker:print-wrapper)" |
| 150 | + |
| 151 | +# analyse into reli's .rmem graph (feeds rmem:viz / rmem:explore) |
| 152 | +reli inspector:memory:analyze app.rdump -f rmem -o app.rmem |
| 153 | + |
| 154 | +# report from the .rmem: type breakdown, retention, leaks, cycles, findings |
| 155 | +reli inspector:memory:report app.rmem |
| 156 | +``` |
| 157 | + |
| 158 | +The Docker wrapper only bind-mounts your current directory, so run it from where |
| 159 | +the dump lives. A path outside it, including a `--dependency-root`, needs a bind |
| 160 | +mount via `RELI_DOCKER_EXTRA_ARGS='-v /path:/path'` (see |
| 161 | +[docker-wrapper.md](https://github.com/reliforp/reli-prof/blob/HEAD/docs/docker-wrapper.md)). |
| 162 | + |
| 163 | +Analysing on a different host? Either use `$full = true` at capture time, or |
| 164 | +point reli at a copy of the target's filesystem with `--dependency-root=/path`. |
| 165 | + |
| 166 | +## Security |
| 167 | + |
| 168 | +**A dump is a verbatim copy of your process's memory, so treat the file as highly |
| 169 | +sensitive.** It can contain anything that was resident at the moment of capture: |
| 170 | +environment variables, database and API credentials, session tokens, decrypted |
| 171 | +request and response bodies, private keys, other users' data on a shared |
| 172 | +process, and so on. |
| 173 | + |
| 174 | +- The dump is created `0600` (owner-only) with `O_EXCL` / `O_NOFOLLOW` / |
| 175 | + `O_CLOEXEC`. An existing regular file is unlinked and replaced by a fresh |
| 176 | + `0600` inode (anything that isn't a regular file is refused), so a stale read |
| 177 | + fd can't see the new dump. Write it somewhere only the intended user can read, |
| 178 | + not a world-readable temp dir or a webroot. |
| 179 | +- Delete it once you have finished analysing, and scrub it from any backups or |
| 180 | + log shipping. Treat it like a credential dump, because it can be one. |
| 181 | +- Move dumps to the analysis host over a secure channel; do not email them or |
| 182 | + drop them in shared buckets unencrypted. |
| 183 | + |
| 184 | +## Notes |
| 185 | + |
| 186 | +- Linux only (the dump is built from `/proc/self/maps`). |
| 187 | +- The snapshot is taken synchronously in the calling thread, not stop-the-world. |
| 188 | + On a busy ZTS process (e.g. FrankenPHP worker threads) other threads may change |
| 189 | + memory while it is written, so the dump can be internally inconsistent. In the |
| 190 | + worst case a region is `munmap`/`mprotect`-ed by another thread before its bytes |
| 191 | + are copied, which can **crash** the process mid-dump. To avoid that, |
| 192 | + `rdump.safe_read` reads regions through `/proc/self/mem`, so a concurrent unmap |
| 193 | + yields zero-filled bytes instead of a crash (it can't fix the inconsistency, |
| 194 | + only the crash). It defaults on under ZTS (where the race exists) and off under |
| 195 | + NTS (no other PHP thread runs during the dump, and the direct copy is faster); |
| 196 | + override it either way. Also give concurrent dumps distinct paths with |
| 197 | + `%i` (thread id), e.g. `rdump.oom_dump=/var/log/oom-%p-%i.rdump`, so threads in |
| 198 | + one process don't collide on a file. |
| 199 | +- The output is byte-compatible with reli's RDUMP format (the same file |
| 200 | + `reli inspector:memory:dump` produces). |
| 201 | +- **No steady-state overhead.** The extension installs no executor or allocator |
| 202 | + hooks, so normal execution runs at full speed. The only always-on hook is on |
| 203 | + `zend_error_cb` (the error path), where it adds a couple of comparisons and |
| 204 | + one chained call, only when an error is actually emitted. Cost is incurred |
| 205 | + only when you call `rdump_dump()` or an OOM dump fires; safe to keep resident. |
| 206 | +- Plays nice with other `zend_error_cb` users (e.g. php-memory-profiler): the |
| 207 | + hook always chains to the previous handler, so every extension still fires and |
| 208 | + writes its own dump on OOM. |
| 209 | + |
| 210 | +## License |
| 211 | + |
| 212 | +MIT. See [LICENSE](LICENSE). |
0 commit comments