Lesson 10 / 10
The Deployment Workflow
Release directories and the symbolic link switch when taking code to production: why the old version runs after an atomic switch, and which call brings the new version.
Published Updated
In this lesson
- Explain how release directories and an atomic symbolic link switch make a zero-downtime deployment possible
- Show by measurement that in the production profile the old version can keep running after the switch
- Compare whether the realpath(), opcache_reset() and opcache_invalidate() calls bring the new version
Contents
The previous five lessons each made a single setting or measurement: the code cache, the process manager, the configuration directives. A deployment tests all of them at once, because new code does not reach production just by being written to disk. In the arrangement called zero-downtime deployment, every release is installed in its own directory, and the path that serves requests is turned to the new directory in a single step. This lesson builds that step and measures how the production settings affect it.
The measuring question is again this: does this behavior reach production? In the DevOps and Platform Engineering curriculum, every deviation between development and production was counted as a hidden defect and the goal was to close it. Here most of the difference is deliberate: PHP is distributed with two configuration files, and the difference comes from those files. This lesson does not close the difference; it names it. The side.php below is the shared core of the course; in this lesson too it is only written to disk and has no output.
<?php // side.php — does this behavior reach production? declare(strict_types=1); // The two configuration files PHP ships (php.ini-development, php.ini-production) differ // in seven directives; all seven are copied here verbatim. The last two production lines // are comments in the shipped file: code cache on for the command line too, file checks off. const PROFILES = [ 'development' => [ 'display_errors' => '1', 'display_startup_errors' => '1', 'error_reporting' => '32767', 'mysqlnd.collect_memory_statistics' => '1', 'zend.assertions' => '1', 'zend.exception_ignore_args' => '0', 'zend.exception_string_param_max_len' => '15', ], 'production' => [ 'display_errors' => '0', 'display_startup_errors' => '0', 'error_reporting' => '24575', 'mysqlnd.collect_memory_statistics' => '0', 'zend.assertions' => '-1', 'zend.exception_ignore_args' => '1', 'zend.exception_string_param_max_len' => '0', 'opcache.enable_cli' => '1', 'opcache.validate_timestamps' => '0', ], ]; /** Runs the script in a separate process with no configuration file, only the profile's directives. */ function run(string $script, array $directives): bool { $command = ['php', '-n']; foreach ($directives as $name => $value) { array_push($command, '-d', "$name=$value"); } $command[] = $script; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); $output = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($process); $lines = array_values(array_filter(array_map('trim', explode("\n", $output)))); return end($lines) === 'yes'; } /** Derives the side from whether the behavior was observed in the two runs. */ function side(bool $inDevelopment, bool $inProduction): string { return match (true) { $inDevelopment && $inProduction => 'both sides', $inDevelopment => 'development only', $inProduction => 'production only', default => 'not in the run', }; } function sideReport(string $name, string $script): string { $inDevelopment = run($script, PROFILES['development']); $inProduction = run($script, PROFILES['production']); $finalSide = side($inDevelopment, $inProduction); echo "$name → $finalSide\n development ", $inDevelopment ? 'yes' : 'no', ' · production ', $inProduction ? 'yes' : 'no', "\n"; return $finalSide; }
Release Directories and the Atomic Switch
The arrangement is this: each release lives in its own directory, such as release-1 and release-2, and the name current, which requests use, is a symbolic link that points to the release directory currently live. The new release is prepared on the side, and the running release is never touched. Going live consists of the link pointing to another directory.
The switch has to be atomic: a request reading the link must never see an intermediate state in which it points to neither the old nor the new directory. symlink() does not overwrite an existing name; so a new link is first created under the name current-new, and then rename() moves it over the name current. Within the same file system, rename() completes in a single step: the name current points to either the old or the new directory at every moment. Two helper functions build this arrangement. In this lesson the file is only written to disk.
<?php // setup.php — two release directories, a link to the first, and the atomic switch declare(strict_types=1); function setUpReleases(): void { foreach ([1, 2] as $n) { @mkdir(__DIR__ . "/release-$n"); $path = __DIR__ . "/release-$n/hello.php"; file_put_contents($path, "<?php return $n;"); touch($path, time() - 60); } @unlink(__DIR__ . '/current'); symlink('release-1', __DIR__ . '/current'); } function switchOver(): void { @unlink(__DIR__ . '/current-new'); symlink('release-2', __DIR__ . '/current-new'); rename(__DIR__ . '/current-new', __DIR__ . '/current'); }
The hello.php file of each release only returns the release number. touch() moves the files’ time into the past; because the cache does not take a file changed in the last seconds, the probes do this step at the start of the code. The first probe asks whether the switch worked on disk, and the second asks what happens when the cache is on. Each probe runs in its own process, in directories set up from scratch with setUpReleases().
<?php // deploy.php — which version runs after the switch? declare(strict_types=1); require __DIR__ . '/side.php'; file_put_contents('switch.php', <<<'PHP' <?php // switch.php — does the link point to the new directory after the switch? require __DIR__ . '/setup.php'; setUpReleases(); switchOver(); echo is_link(__DIR__ . '/current') && readlink(__DIR__ . '/current') === 'release-2' ? "yes\n" : "no\n"; PHP); file_put_contents('stale.php', <<<'PHP' <?php // stale.php — does the old version run from the same path after the switch? require __DIR__ . '/setup.php'; setUpReleases(); $before = include __DIR__ . '/current/hello.php'; switchOver(); $after = include __DIR__ . '/current/hello.php'; echo $after === $before ? "yes\n" : "no\n"; PHP); sideReport('the link points to the new directory after the switch', 'switch.php'); sideReport('the old version runs from the same path after the switch', 'stale.php');
the link points to the new directory after the switch → both sides
development yes · production yes
the old version runs from the same path after the switch → production only
development no · production yes
The first probe gave yes in both profiles: the switch completes on disk in the same way everywhere. The second probe gave yes only in production (PO8. The measurement was made in a command line process, with a single process; in a real deployment the switch happens alongside the worker processes that are still serving, and each process sees its own request through the new path; that arrangement was not set up in this lesson). Although the symbolic link pointed to the new directory, the second load from the path current/hello.php returned the same value as the first: the cache already knew this path and did not look at the disk. In development the code cache is off, so every load reads the disk and the new version arrives immediately.
The Calls That Bring the New Version
The problem is that the cache keeps a path as its key and that this path goes through the link. The deployment script has three candidate calls: clearstatcache(true), which clears the file system check cache, opcache_reset(), which empties the whole code cache, and opcache_invalidate(), which invalidates a single file. The fourth is not a call but a path: resolving the link in code with realpath(). The four probes run with the same setup; in each, yes means that what its name says happened.
<?php // fixes.php — which call brings the new version after the switch? declare(strict_types=1); require __DIR__ . '/side.php'; file_put_contents('stat.php', <<<'PHP' <?php // stat.php — does the old version remain after clearstatcache(true)? require __DIR__ . '/setup.php'; setUpReleases(); include __DIR__ . '/current/hello.php'; switchOver(); clearstatcache(true); $after = include __DIR__ . '/current/hello.php'; echo $after === 1 ? "yes\n" : "no\n"; PHP); file_put_contents('reset.php', <<<'PHP' <?php // reset.php — opcache_reset() returns true, but does the old version remain in the same request? require __DIR__ . '/setup.php'; setUpReleases(); include __DIR__ . '/current/hello.php'; switchOver(); $returned = opcache_reset(); $after = include __DIR__ . '/current/hello.php'; echo $returned === true && $after === 1 ? "yes\n" : "no\n"; PHP); file_put_contents('linkpath.php', <<<'PHP' <?php // linkpath.php — does the new version arrive after invalidating through the link path? require __DIR__ . '/setup.php'; setUpReleases(); $path = __DIR__ . '/current/hello.php'; include $path; switchOver(); opcache_invalidate($path, true); $after = include $path; echo $after === 2 ? "yes\n" : "no\n"; PHP); file_put_contents('oldpath.php', <<<'PHP' <?php // oldpath.php — does invalidating with the resolved path of the old version bring the new one? require __DIR__ . '/setup.php'; setUpReleases(); $path = __DIR__ . '/current/hello.php'; include $path; $oldPath = realpath($path); switchOver(); $returned = opcache_invalidate($oldPath, true); $after = include $path; echo $returned === true && $after === 2 ? "yes\n" : "no\n"; PHP); file_put_contents('resolved.php', <<<'PHP' <?php // resolved.php — does the new version arrive when the link is resolved with realpath() before include? require __DIR__ . '/setup.php'; setUpReleases(); include realpath(__DIR__ . '/current') . '/hello.php'; switchOver(); $after = include realpath(__DIR__ . '/current') . '/hello.php'; echo $after === 2 ? "yes\n" : "no\n"; PHP); sideReport('the old version remains after clearstatcache(true)', 'stat.php'); sideReport('the new version arrives after invalidating through the link path', 'linkpath.php'); sideReport('invalidating with the resolved path of the old version brings the new version', 'oldpath.php'); sideReport('the new version arrives from the path resolved with realpath()', 'resolved.php'); sideReport('opcache_reset() returns true but the old version remains in the same request', 'reset.php');
the old version remains after clearstatcache(true) → production only
development no · production yes
the new version arrives after invalidating through the link path → development only
development yes · production no
invalidating with the resolved path of the old version brings the new version → production only
development no · production yes
the new version arrives from the path resolved with realpath() → both sides
development yes · production yes
opcache_reset() returns true but the old version remains in the same request → production only
development no · production yes
Three results stand out. First, clearstatcache(true) did not work: in production the second load again gave the old version. This call clears the cache of file system queries and does not touch the code cache. Second, opcache_reset() returned true in production but the old version remained in the same request; a call looking successful does not mean that the new version will arrive in that request. This measurement did not show the next request. Third, which path the invalidating call takes matters: after the call made with the link path (current/hello.php), the old version remained in production; only development, where the cache is off, saw the new version. The call made with the resolved path of the old version (the result of realpath()) brought the new version in production. In the same way, the path resolved with realpath() before the include brought the new version in both profiles. These three results are consistent with the assumption that the cache keeps the file by the resolved path and not by the name of the link. In development, opcache_reset() and opcache_invalidate() return false because the cache is off, so the calls of the deployment script cannot be tested in development.
The Bounding Measurement
The realpath() solution depends on when the path is resolved. The probe resolves the path once before the switch and stores it; after the switch it loads from the stored path. This is what would happen if a long-lived process resolved the path at start-up and kept it in memory.
<?php // bound.php — does a path resolved before the switch still give the old version after it? declare(strict_types=1); require __DIR__ . '/side.php'; file_put_contents('stored.php', <<<'PHP' <?php // stored.php — the resolved path is stored before the switch and used after it require __DIR__ . '/setup.php'; setUpReleases(); $stored = realpath(__DIR__ . '/current'); $before = include $stored . '/hello.php'; switchOver(); $after = include $stored . '/hello.php'; echo $after === $before ? "yes\n" : "no\n"; PHP); sideReport('a path resolved before the switch still gives the old version after it', 'stored.php');
a path resolved before the switch still gives the old version after it → both sides
development yes · production yes
The probe gave yes in both profiles: because the stored path points to the old directory, the old version arrived after the switch too, whether the code cache was on or not. So realpath() is not a solution by itself but a timing rule: the resolution must be done on every request, immediately before the include. The limit of the measurement is here too: the probe is limited to one process and two releases, and how long a stored path lives in a real worker process was not measured.
Summary
- A zero-downtime deployment puts each release in a separate directory and moves a new link created with
symlink()over the namecurrentwithrename(); the switch (the atomic switch) completes on disk in both profiles. - The side of this lesson is production only: with
opcache.validate_timestamps=0, the old version runs from the same link path after the switch; in development the code cache is off and the new version arrives. clearstatcache(true)does not fix this;opcache_reset()returnstruebut the old version remains in the same request; in development bothopcache_*calls returnfalse.- Two ways that bring the new version were measured: the path resolved with
realpath()before the include, andopcache_invalidate($path, true)with the resolved path of the old version. After invalidating through the link path, the old version remained in production. - The limit: if the result of
realpath()is stored before the switch, the old version arrives on both sides; the resolution must be done on every request.
Course Wrap-Up
The course closes with the measurement of seven directives and four classes of behavior that carry code to production. The measured behavior, the side and the bounding measurement of each lesson are below.
| Lesson | Behavior measured | Side | Bounding measurement |
|---|---|---|---|
| Static Analysis | an undefined function in an uncalled branch does not run, the syntax check looks only at syntax, the token checker finds the summarize call |
not in the run | if the name is called from a variable the checker found 0, a direct run threw Error |
| Code Style Checking | unformatted code runs the same in both profiles, the token-based checker counts line endings, double spaces and indentation | not in the run | a fix without tokens for the trailing whitespace inside a nowdoc shortens the string, a formatter that rewrites only T_WHITESPACE leaves the length at 13 |
| Testing Frameworks | an assert()-based test catches in development, is not compiled in production (zend.assertions=-1) |
development only | zend.assertions=0 does not catch either and can be turned to 1 at run time, with -1 the path is closed |
| The Debugger | card-0042 is visible in the exception trace in development and hidden in production |
development only | if the value is written into the message, getMessage() carries card-0042 in both profiles |
| Code Caching | with validate_timestamps=0 a rewritten file runs with the old version, opcache_invalidate($path, true) brings the new version |
production only | validate_timestamps=1 with revalidate_freq=0 removes the old version problem |
| Profiling | 500 calls, total 249500 and an equal memory difference; in the chain of three functions read 400, convert 200, aggregate 5 |
both sides | the counter inside assert() is 500 in development, 0 in production |
| Memory and Connection Management | when memory_limit is exceeded the request stops, the destructor runs when the request ends |
both sides | in a circular reference unset() does not run the destructor, gc_collect_cycles() does |
| The Process Manager and the Web Server | in two worker processes the in-memory counter stays 1 on every request, a warning page returns 200 in both profiles |
both sides | if the counter is kept in a file 1 2 3, in memory 1 1 1 |
| Configuration Tuning | a difference of seven directives; ini_set('zend.assertions','1') is accepted in development, false in production |
production only | display_errors could be turned back on with ini_set() in production |
| The Deployment Workflow | after the symbolic link switch the old version runs from the same path, the new version arrives from the path resolved with realpath() |
production only | from a path resolved and stored before the switch the old version arrives in both profiles |
The distribution of the lessons: 3 both sides · 2 development only · 3 production only · 2 not in the run. The core (behaviors.php, run in the Static Analysis lesson): 4 · 4 · 2 · 2. The two numbers count different things: the first counts the side of the main measurement of ten lessons, the second counts the side of twelve separate behaviors; because a lesson measures more than one behavior and a behavior concerns more than one lesson, the two distributions are not expected to come out the same.
M12 closes here; five courses took PHP from a language to a production system. The PHP Fundamentals course measured through which channel a problem is heard (through which channel is this problem heard: silent · return value · warning · stops, 3/4/4/3). The Object-Oriented PHP and Standards course measured where a change reaches (where does this change reach: in place · in the link · breaking · implicit, 3/3/3/3). The PHP on the Web: State, Data and Security course measured who can change the result (who can change this result: server · environment · client · third party, 5/4/4/2). The Web Application Framework course measured whether a part ran in that request (did this part run in this request: ran · deferred · skipped · not bound, 6/5/5/5). The PHP Quality, Performance and Deployment course measured whether a behavior reaches production (does this behavior reach production: both sides · development only · production only · not in the run, 3/2/3/2). The next curriculum is Ruby.
To keep your progress and take notes, Log in
My notes
Log in to take notes.