-
Notifications
You must be signed in to change notification settings - Fork 1.6k
/
heroku-php-nginx
executable file
·684 lines (610 loc) · 37 KB
/
heroku-php-nginx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
#!/usr/bin/env bash
# fail hard
set -o pipefail
# fail harder
set -eu
# for ${DOCUMENT_ROOT%%*(/)} pattern further down
shopt -s extglob
# for detecting when -l 'logs/*.log' matches nothing
shopt -s nullglob
if ! type -p "realpath" > /dev/null; then
# macOS doesn't have realpath
# readlink is not an option because BSD readlink does not have the GNU -f option
# must be a function so subshells, including $(…), can use it
realpath() {
python -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$@"
}
fi
# we very likely got called via a symlink, so we have to realpath $0 first to find the base buildpack directory
bp_dir=$(cd $(dirname $(realpath $0)); cd ..; pwd)
verbose=
conftest=
php_passthrough() {
local dir=$(dirname "$1")
local file=$(basename "$1")
local out=$(basename "$file" .php)
if [[ "$out" != "$file" ]]; then
[[ $verbose ]] && echo "Interpreting ${1#$HEROKU_APP_DIR/} to $out" >&2
out="$dir/$out"
php "$1" > "$out"
echo "$out"
else
echo "$1"
fi
}
check_exists() {
if [[ ! -f "$HEROKU_APP_DIR/$1" ]]; then
echo "Cannot read -$2 '$1' (relative to '$HEROKU_APP_DIR')" >&2
exit 1
else
echo "$HEROKU_APP_DIR/$1"
fi
}
touch_log() {
mkdir -p $(dirname "$1") && touch "$1"
}
findconfig() {
local dir=$(dirname "$2")
local file=$(basename "$2")
IFS='.' read -r -a version <<< "$1" # read the parts of $1 (version number) into an array, e.g. (7 2 33)
# iterate "backwards" over version parts so we try "7/2/11" first, then "7/2", then "7" for a version "7.2.11"
# we go down to 0, which will yield an empty string, for the last fallback layer without any version number
for (( i = ${#version[@]}; i >= 0; i--)); do
version_dir=$(IFS=/; echo "${version[*]:0:$i}") # set IFS to "/" for merging, but echo is a builtin, so it must be a subshell and a separate command
full_path="${dir}/${version_dir}${version_dir:+/}${file}" # concat, but if scandir is empty (last fallback), don't produce a double slash
if [[ -f "$full_path" ]]; then
echo "$full_path";
return 0;
fi
done
echo "$2"
return 1;
}
wait_pidfile() {
i=0
while ! test -f "$1" && (( i < 25 )); do
[[ $verbose && ${2:-} ]] && echo "$2" >&2
sleep 0.1
((++i)) # don't do i++, that is a post increment operation, and will return status 1, since the expression evaluates to 0...
done
if (( i == 25 )); then
# we timed out
return 1;
fi
}
wait_pid_and_pidfile() {
i=0
while ! test -f "$2" && (( i < 25 )); do
if ! kill -0 "$1" 2> /dev/null; then # kill -0 checks if process exists
break;
fi
[[ $verbose && ${3:-} ]] && echo "$3" >&2
sleep 0.1
((++i)) # don't do i++, that is a post increment operation, and will return status 1, since the expression evaluates to 0...
done
if (( i == 25 )); then
# we timed out
return 1;
fi
}
print_help() {
cat >&2 <<-EOF
${1:-Boots PHP-FPM together with Nginx on Heroku and for local development.}
Usage:
heroku-php-nginx [options] [<DOCUMENT_ROOT>]
Options:
-C <nginx.inc.conf> The path to the configuration file to include inside
the Nginx server config (see option -c below). Will
be included inside the 'server { ... }' block just
after the 'listen', 'root' etc directives.
Recommended approach when customizing Nginx's config
in most cases, unless you need to set http or
fundamental server level options.
[default: <BPDIR>/conf/nginx/default_include.conf.php,
or a more version-specific file from a subdirectory]
-c <nginx.conf> The path to the full configuration file that is
included after Heroku's main Nginx config has been
loaded. It must contain an 'http { ... }' block with a
'server { ... }' inside that contains 'listen' and
'root' (see option -C above) directives.
[default: <BPDIR>/conf/nginx/heroku.conf.php,
or a more version-specific file from a subdirectory]
-F <php-fpm.inc.conf> The path to the configuration file to include at the
end of php-fpm.conf (see option -f below), in the
'[www]' pool section. Recommended approach when
customizing PHP-FPM's configuration in most cases,
unless you need to set global options.
-f <php-fpm.conf> The path to the full PHP-FPM configuration file.
[default: <BPDIR>/conf/php/php-fpm.conf,
or a more version-specific file from a subdirectory]
-h, --help Display this help screen and exit.
-i <php.ini> The path to the php.ini file to use.
[default: <BPDIR>/conf/php/php.ini]
-l <tailme.log> Path to additional log file to tail to STDERR so its
contents appear in 'heroku logs'. If the file does not
exist, it will be created. Wildcards are allowed, but
must be quoted and must match already existing files.
Note: this option can be repeated multiple times.
-p <PORT> Port to listen on for HTTP traffic. If this argument
is not given, then the port number to use is read from
the \$PORT environment variable, or a random port is
chosen if that variable does not exist.
-t, --test Test PHP-FPM and Nginx configuration. When repeated,
will dump PHP-FPM config (-tt), Nginx config (-ttt),
or both (-tttt).
-v, --verbose Be more verbose during startup.
The <BPDIR> placeholder above represents the base directory of this buildpack:
$bp_dir
All file paths must be relative to '$HEROKU_APP_DIR'.
Any file name that ends in '.php' will be run through the PHP interpreter first.
You may use this for templating; this is, for instance, necessary for Nginx,
where environment variables cannot be referenced in configuration files.
If you would like to use the -C and -c or -F and -f options together, make sure
you retain the appropriate include mechanisms (see default configs for details).
EOF
}
# we need this in configs
export HEROKU_APP_DIR=$(pwd)
export DOCUMENT_ROOT="$HEROKU_APP_DIR"
# set a default port if none is given
export PORT=${PORT:-$(( $RANDOM+1024 ))}
# add conf/php/apm-nostart-overrides/ to end of list of PHP INI scan dirs
# that way our special extra INI which prevents startup of a possible newrelic etc is loaded for all the PHP and Composer calls that follow
# an empty value for PHP_INI_SCAN_DIR means nothing is scanned at all, not even compile time default scan dirs
# the variable can hold a list of paths delimited by colon, and if one segment is empty (e.g. "/foo::/bar" or just "/foo:" or ":/foo"), then the compile time default scan dir is used there
if [[ -z ${PHP_INI_SCAN_DIR-} && ${PHP_INI_SCAN_DIR+x} ]]; then
# PHP_INI_SCAN_DIR was defined, but empty
# this means the user wants no scan dir, so we have to just place our own value into the variable, without a path separator
_PHP_INI_SCAN_DIR=
PHP_INI_SCAN_DIR=
elif [[ -z ${PHP_INI_SCAN_DIR-} ]]; then
# PHP_INI_SCAN_DIR was not defined at all
# this means we want to load the compile time default scan dir first, then our special INIs
# we don't back up to _PHP_INI_SCAN_DIR; that way, we will know to unset it later and won't export an empty string
unset _PHP_INI_SCAN_DIR # just in case it's around
PHP_INI_SCAN_DIR=":" # leading empty segment will cause compile time default scan dir to be used
else
# PHP_INI_SCAN_DIR was defined and not empty
# we want to append our path to the end of the list, or, if not defined at all, have the first list item be blank, which causes the default scan dir to be used
_PHP_INI_SCAN_DIR=$PHP_INI_SCAN_DIR
PHP_INI_SCAN_DIR+=":" # leading empty segment will cause compile time default scan dir to be used
fi
# now append
export PHP_INI_SCAN_DIR="${PHP_INI_SCAN_DIR}${bp_dir}/conf/php/apm-nostart-overrides/"
# init logs array here as empty before parsing options; -l could append to it, but the default list gets added later since we use $PORT in there and that can be set using -p
declare -a logs
optstring=":-:C:c:F:f:i:l:p:vth"
# process flags first
while getopts "$optstring" opt; do
case $opt in
-)
case "$OPTARG" in
verbose)
verbose=1
;;
test)
let "conftest++" || echo "$(basename "$0"): Config test mode" >&2 # increment, so it can be repeated (exits 1 for foo=)
;;
help)
print_help 2>&1
exit
;;
*)
echo "Invalid option: --$OPTARG" >&2
exit 2
;;
esac
;;
v)
verbose=1
;;
t)
let "conftest++" || echo "$(basename "$0"): Config test mode" >&2 # increment, so it can be repeated (exits 1 for foo=)
;;
h)
print_help 2>&1
exit
;;
esac
done
OPTIND=1 # start over with options parsing
while getopts "$optstring" opt; do
case $opt in
C)
nginx_config_include=$(check_exists "$OPTARG" "C")
;;
c)
nginx_config=$(check_exists "$OPTARG" "c")
;;
F)
fpm_config_include=$(check_exists "$OPTARG" "F")
;;
f)
fpm_config=$(check_exists "$OPTARG" "f")
;;
i)
php_config=$(check_exists "$OPTARG" "i")
;;
l)
logarg=( $OPTARG ) # must not quote this or wildcards won't get expanded into individual values
if [[ ${#logarg[@]} -eq 0 ]]; then # we set nullglob to detect if a pattern matched nothing (then the array is empty)
echo "Pattern '$OPTARG' passed to option -l matched no files" >&2
exit 1
fi
for logfile in "${logarg[@]}"; do
if [[ -d "$logfile" ]]; then
echo "-l '$logfile': is a directory" >&2
exit 1
fi
touch_log "$logfile" || { echo "Could not touch '$logfile'; permissions problem?" >&2; exit 1; }
[[ $verbose ]] && echo "Tailing '$logfile' to stderr" >&2
logs+=("$logfile") # must quote here in case a wildcard matched a file with a space in the name
done
;;
p)
PORT="$OPTARG"
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 2
;;
:)
echo "Option -$OPTARG requires an argument" >&2
exit 2
;;
esac
done
# clear processed arguments
shift $((OPTIND-1))
if [[ "$#" -gt "1" ]]; then
print_help "$(cat <<-EOF
$(basename $0): too many arguments. If you're using options,
make sure to list them before any document root argument you're providing.
EOF
)"
exit 2
fi
# our standard logs
logs+=( "/tmp/heroku.php-fpm.$PORT.log" "/tmp/heroku.php-fpm.www.$PORT.log" "/tmp/heroku.php-fpm.$PORT.www.slowlog" "/tmp/heroku.nginx_access.$PORT.log" )
# a bunch of checks; don't load any INIs to prevent newrelic etc from loading, we don't need all that stuff
php -r 'exit((int)version_compare(PHP_VERSION, "5.5.11", "<"));' || { echo "This program requires PHP 5.5.11 or newer; check your 'php' command." >&2; exit 1; }
{ php-fpm -n -v | php -r 'exit((int)version_compare(preg_replace("#PHP (\S+) \(fpm-fcgi\).+$#sm", "\\1", file_get_contents("php://stdin")), "5.5.11", "<"));'; } || { echo "This program requires PHP 5.5.11 or newer; check your 'php-fpm' command." >&2; exit 1; }
php_version="$(php-fpm -n -v | php -r 'echo preg_replace("#PHP (\S+) \(fpm-fcgi\).+$#sm", "\\1", file_get_contents("php://stdin"));')"
nginx_version="$(nginx -v 2>&1 | php -r 'echo preg_replace("#.*nginx/([\d\.]+)$#sm", "\\1", file_get_contents("php://stdin"));')"
# make sure we try a local composer.phar if no global installation is available
composer_bin=$(command -v composer ./composer.phar) || { echo "This program requires 'composer' on \$PATH or a 'composer.phar' in the CWD." >&2; exit 1; }
composer_bin=$(head -n1 <<< "$composer_bin") # if both were there, we'd have two lines of output, no good for invoking something :)
composer() {
# check if the composer binary is executable by PHP
if file --brief --dereference "$composer_bin" | grep -e "shell" -e "bash" > /dev/null ; then # newer versions of file return "data" for .phar
# run it directly; it's probably a bash script or similar (homebrew-php does this)
# re-set COMPOSER_AUTH to ensure a malformed `heroku config:set` will not cause immediate outage
COMPOSER_AUTH= "$composer_bin" "$@"
else
COMPOSER_AUTH= php "$composer_bin" "$@"
fi
}
# these exports are used in default web server configs to lock down access to composer directories
COMPOSER_VENDOR_DIR=$(composer config vendor-dir 2> /dev/null | tail -n 1) && export COMPOSER_VENDOR_DIR || { echo "Unable to determine Composer vendor-dir setting; is 'composer' executable on path or 'composer.phar' in current working directory?" >&2; exit 1; } # tail, as composer echos outdated version warnings to STDOUT; export after the assignment or exit status will that be of 'export
COMPOSER_BIN_DIR=$(composer config bin-dir 2> /dev/null | tail -n 1) && export COMPOSER_BIN_DIR || { echo "Unable to determine Composer bin-dir setting; is 'composer' executable on path or 'composer.phar' in current working directory?" >&2; exit 1; } # tail, as composer echos outdated version warnings to STDOUT; export after the assignment or exit status will that be of 'export
if [[ "$#" == "1" ]]; then
DOCUMENT_ROOT="$HEROKU_APP_DIR/$1"
if [[ ! -d "$DOCUMENT_ROOT" ]]; then
echo "DOCUMENT_ROOT '$1' does not exist" >&2
exit 1
else
# strip trailing slashes if present
DOCUMENT_ROOT=${DOCUMENT_ROOT%%*(/)} # powered by extglob
if [[ $verbose ]]; then
echo "DOCUMENT_ROOT changed to '$DOCUMENT_ROOT'" >&2
else
echo "DOCUMENT_ROOT changed to '${1%%*(/)}/'" >&2
fi
fi
fi
if [[ -n ${fpm_config_include:-} ]]; then
echo "Using PHP-FPM configuration include '${fpm_config_include#$HEROKU_APP_DIR/}'" >&2
fpm_config_include=$(php_passthrough "$fpm_config_include")
export HEROKU_PHP_FPM_CONFIG_INCLUDE="$fpm_config_include"
fi
if [[ -n ${fpm_config:-} || ( ${fpm_config:=$(findconfig "$php_version" "$bp_dir/conf/php/php-fpm.conf")} && $verbose ) ]]; then
echo "Using PHP-FPM configuration file '${fpm_config#$HEROKU_APP_DIR/}'" >&2
fi
fpm_config=$(php_passthrough "$fpm_config")
# we explicitly use the INI file php-fpm would load if no INI is given
# this is to ensure that e.g. our WEB_CONCURRENCY calculation doesn't pick up a php-cli.ini
fpm_ini=$(php-fpm -i | awk -F' => ' '/^Loaded Configuration File/{print $2}')
if [[ -n ${php_config:-} || ( "$fpm_ini" != "(none)" && ${php_config:=$fpm_ini} && $verbose ) ]]; then
echo "Using PHP configuration (php.ini) file '${php_config#$HEROKU_APP_DIR/}'" >&2
fi
[[ -n ${php_config:-} ]] && php_config=$(php_passthrough "$php_config")
if [[ -n ${nginx_config_include:-} || ( ${nginx_config_include:=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/default_include.conf.php")} && $verbose ) ]]; then
echo "Using Nginx server-level configuration include '${nginx_config_include#$HEROKU_APP_DIR/}'" >&2
fi
nginx_config_include=$(php_passthrough "$nginx_config_include")
export HEROKU_PHP_NGINX_CONFIG_INCLUDE="$nginx_config_include"
if [[ -n ${nginx_config:-} || ( ${nginx_config:=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/heroku.conf.php")} && $verbose) ]]; then
echo "Using Nginx configuration file '${nginx_config#$HEROKU_APP_DIR/}'" >&2
fi
nginx_config=$(php_passthrough "$nginx_config")
nginx_main=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/main.conf")
fpm_pidfile=$(mktemp -t "heroku.php-fpm.pid-$PORT.XXXXXX" -u)
nginx_pidfile=$(mktemp -t "heroku.nginx.pid-$PORT.XXXXXX" -u)
# we need to dump the FPM config to fetch possible values for memory_limit in php_value and php_admin_value pool declarations
# parsing this by hand is cumbersome - there might be recursive includes in the config, glob patterns, etc
# also, the FPM behavior isn't quite correct (https://github.com/php/php-src/issues/13249), but might be fixed at some point
# so we use php-fpm -tt to dump the config, and extract the values from there
# we need to ensure the log level is NOTICE, since FPM uses its logging for dumping - but we default to WARNING
# that's why we're making a temporary config with the log level overridden
# (the same temporary file is also used for our own -tt and -tttt config test modes)
fpm_config_tmp=$(mktemp "$fpm_config.XXXXX")
cp "$fpm_config" "$fpm_config_tmp"
trap 'trap - EXIT; rm -f "$fpm_config_tmp"' EXIT
echo -e "\n[global]\nlog_level = notice" >> "$fpm_config_tmp"
# build command string arrays for PHP-FPM and Nginx
# we're using an array, because we need to correctly preserve quoting, spaces, etc
fpm_command=( php-fpm --pid "$fpm_pidfile" --nodaemonize -y "$fpm_config_tmp" ${php_config:+-c "$php_config"} )
nginx_command=( nginx -c "$nginx_main" -g "pid $nginx_pidfile; include $nginx_config;" )
mlib="/sys/fs/cgroup/memory/memory.limit_in_bytes"
if [[ -f "$mlib" ]]; then
[[ $verbose ]] && echo "Reading available RAM from '$mlib'" >&2
ram="$(cat "$mlib")"
else
[[ $verbose ]] && echo "No '$mlib' with RAM info found" >&2
ram="512M"
echo "Assuming RAM to be ${ram} Bytes" >&2
fi
# read number of available processor cores in a portable (Linux, macOS, BSDs) fashion (leading underscore is not always there)
cores=$(getconf -a | grep -E '_?NPROCESSORS_ONLN' | head -n1 | tr -s ' ' | cut -d" " -f2) || {
echo "WARNING: failed to determine _NPROCESSORS_ONLN via getconf" >&2
cores=1
echo "Assuming number of CPU cores to be $cores" >&2
}
if [[ -z ${WEB_CONCURRENCY:-} ]]; then
if [[ -n ${DYNO:-} && $(ulimit -u) == "32768" && ( "$php_version" == 5.* || "$php_version" == 7.[0-3].* ) ]]; then
# on a Performance-L dyno, limit to 6 GB of RAM for backwards compatibility for PHP versions before 7.4
# this is to ensure that apps previously running on PX dynos (6 GB RAM) do not get a sudden jump in the number of worker processes
ram="6G"
echo "Limiting RAM usage to ${ram} Bytes" >&2
fi
[[ $verbose ]] && echo "Calculating WEB_CONCURRENCY..." >&2
# dump the FPM config and parse out memory_limit declarations
# for that to work, we need the WEB_CONCURRENCY env var set, as the FPM config references it
export WEB_CONCURRENCY=1
# inside this subshell, we want pipefail on to ensure that a failing FPM command causes an exit
# grep might not match anything, so if it doesn't (status 1), we carry on, since that's fine (but other exit statuses will bubble up)
fpm_limits=$(set -o pipefail; "${fpm_command[@]}" -tt 2>&1 | { grep -E $'NOTICE: \tphp(_admin)?_value\[memory_limit\]' || test $? = 1; } | cut -f2) || {
# on failure, we should output something meaningful
echo "PHP-FPM config test failed with status $?:" >&2
# restore the original config to avoid any potential for confusion
cp "$fpm_config" "$fpm_config_tmp"
# single -t is enough this time
"${fpm_command[@]}" -t
exit 1
}
# determine number of FPM processes to run
# we feed it the PHP config we found much earlier (it must be the one FPM loads, not the CLI one!), docroot (for .user.ini), detected RAM, and RAM cap
# on STDIN, we also pass it any php_value or php_admin_value lines from the FPM config dump that may have a memory_limit (via a herestring, easier than echo)
autotune_verbose=
if [[ $verbose ]]; then
autotune_verbose="--verbose" # like -vv
elif [[ -z ${DYNO:-} ]]; then
autotune_verbose="-v" # only print a little more info on non-dynos
fi
WEB_CONCURRENCY=$(php ${php_config:+-c "$php_config"} -f "$bp_dir/bin/util/autotune.php" -- ${autotune_verbose} -t "$DOCUMENT_ROOT" "$ram" "$cores" <<<"$fpm_limits")
export WEB_CONCURRENCY
else
echo '$WEB_CONCURRENCY env var is set, skipping automatic calculation' >&2
fi
if ! [[ "$WEB_CONCURRENCY" =~ ^[1-9][0-9]*$ ]] || (( WEB_CONCURRENCY < 1 )); then
WEB_CONCURRENCY=1
echo "Setting WEB_CONCURRENCY=1 (was outside allowed range)" >&2;
fi
if [[ $conftest ]]; then
case $conftest in
1)
fpm_conftest="-t"
nginx_conftest="-t"
;;
2)
fpm_conftest="-tt"
nginx_conftest="-t"
;;
3)
fpm_conftest="-t"
nginx_conftest="-T"
;;
*)
fpm_conftest="-tt"
nginx_conftest="-T"
;;
esac
echo -e "\n$(basename "$0"): Config testing php-fpm using ${fpm_command[@]} ${fpm_conftest}:" >&2
"${fpm_command[@]}" ${fpm_conftest}
echo -e "\n$(basename "$0"): Config testing nginx using ${nginx_command[@]} ${nginx_conftest}:" >&2
"${nginx_command[@]}" ${nginx_conftest}
echo -e "\n$(basename "$0"): All configs okay." >&2
exit 0
fi
# we're done dumping stuff; restore original FPM config
cp "$fpm_config" "$fpm_config_tmp"
# make a shared pipe; we'll write the name of the process that exits to it once that happens, and wait for that event below
# this particular call works on Linux and Mac OS (will create a literal ".XXXXXX" on Mac, but that doesn't matter).
wait_pipe=$(mktemp -t "heroku.waitpipe-$PORT.XXXXXX" -u)
rm -f $wait_pipe
mkfifo $wait_pipe
exec 3<> $wait_pipe
pids=()
graceful_sigterm=
if [[ -n "${DYNO:-}" && "${HEROKU_PHP_GRACEFUL_SIGTERM:-1}" != "0" ]]; then
export HEROKU_PHP_GRACEFUL_SIGTERM=1
graceful_sigterm=1
fi
# trap SIGTERM (for when we get killed) and EXIT (upon failure of any command due to set -e, or because of the exit at the very end), we then
# 1) ignore the trap for the signal in question so it doesn't fire again should the signal arrive a second time (this happens, e.g. when 'timeout' terminates something)
# 1a) such a second identical signal arriving may occasionally cause a "warning: run_pending_traps: bad value in trap_list[15]: 0x1" due to a race condition in Bash versions before 5.1, see 4b)
# 2) call cleanup() to
# 2a) kill all the subshells we've spawned, in reverse order - they in turn have their own traps to kill their respective subprocesses
# 2b) send STDERR to /dev/null so we don't see "no such process" errors - after all, one of the subshells may be gone
# 2c) || true so that set -e doesn't cause a mess if the kill returns 1 on "no such process" cases
# 2d) 'wait' on that killed subprocess, sending wait's output to /dev/null - this prevents the logs getting cluttered with "vendor/bin/heroku-...: line 309: 96 Terminated" messages (we can't use 'disown' after launching programs for that purpose because that removes the jobs from the shell's jobs table and then we can no longer 'wait' on the program)
# 2e) remove our FIFO from above
# 3) as the signal to subshells, we use SIGUSR1
# 3a) this is so subshells can tell whether we sent them the signal and they should in turn stop gracefully (SIGUSR1)...
# 3b) ... or if something crashed, or e.g. Heroku's process manager sent SIGTERM to all processes in the group, in which case a graceful shutdown attempt is futile anyway
# 3c) SIGINT oder SIGQUIT are not an option since backgrounded subshells cannot trap them (http://vaab.blog.kal.fr/2016/07/30/trapping-sigint-sigquit-in-asynchronous-tasks/) in Bash 3 (it works in Bash 4+ in interactive shells at least)
# 3d) because we're sending SIGUSR1 via cleanup(), we have to ignore that signal right before - otherwise, the backgrounded subshells exiting upon this exit status would cause our main process to terminate due to set -e
# 3e) we do not simply set USR1 to ignored globally, because then we would not catch the case where something external sends a USR1 to our subshell - instead, our exit trap will fire in these cases where we did not initiate the cleanup, and tear everything down
# 4) kill ourselves with the correct signal in case we're handling SIGTERM or, further down, SIGINT (http://www.cons.org/cracauer/sigint.html and http://mywiki.wooledge.org/SignalTrap#Special_Note_On_SIGINT1)
# 4a) we have to re-set the trap handler to the default (using "-") for that
# 4b) we still exit 0 as a last resort, because Bash has a race condition bug (see run_pending_traps in trap.c) where if another signal arriving during the 'trap "" TERM' call (to ignore the same signal and prevent multiple executions), the trap would hang at the end, despite the 'kill -TERM $$'
cleanup() {
signal=${1:-TERM}
# reverse through list of child processes and kill them
# we want that order so the web server stops accepting connections and drains properly, then the app is terminated, and then finally the logs redirect, which we want alive up until the very last moment
for (( i=${#pids[@]}-1 ; i>=0 ; i-- )) ; do
kill -"$signal" "${pids[$i]}" 2> /dev/null || true # redirect stderr and || true the call because the process might be gone
wait "${pids[$i]}" 2> /dev/null || true # redirect stderr so that the Bash's confusing "Terminated: …" lines are suppressed and || true the call because wait returns the exit status of the program, and not 0
done
rm -f ${wait_pipe} || true;
echo "Shutdown complete." >&2
}
# we always try to shut down gracefully on SIGTERM
# this may not work depending on env vars and platform behavior, but the subshells will take care of those details
trap 'trap "" TERM; trap - EXIT; echo "SIGTERM received, attempting graceful shutdown..." >&2; trap "" USR1; cleanup USR1; trap - TERM; kill -TERM $$; exit 0' TERM
# on "regular" EXIT (i.e. when we reached the end of the script because a process exited unexpectedly) just clean up and let the exit status bubble through (see last line)
# this will also fire when any unhandled signal is received, in which case $exitproc will be unset
trap 'echo "Process exited unexpectedly: ${exitproc-$(basename $0)}, shutting down..." >&2; trap "" USR1; cleanup USR1' EXIT
# if FD 1 is a TTY (that's the -t 1 check), trap SIGINT/Ctrl+C, then same procedure as for TERM above
if [[ -t 1 ]]; then
trap 'trap "" INT; trap - EXIT; echo "SIGINT received, initiating graceful shutdown..." >&2; trap "" USR1; cleanup USR1; trap - INT; kill -INT $$; exit 0' INT;
# if FD 1 is not a TTY (e.g. when we're run through 'foreman start'), do nothing on SIGINT; the assumption is that the parent will send us a SIGTERM or something when this happens. With the trap above, Ctrl+C-ing out of a 'foreman start' run would trigger the INT trap both in Foreman and here (because Ctrl+C sends SIGINT to the entire process group, but there is no way to tell the two cases apart), and while the trap is still doing its shutdown work triggered by the SIGTERM from the Ctrl+C, Foreman would then send a SIGTERM because that's what it does when it receives a SIGINT itself.
else
trap '' INT;
fi
# we are now launching a subshell for each of the tasks (log tail, app server, web server)
# 1) each subshell has an echo as an EXIT trap that echos the command name to FD 3 (see the FIFO set up above)
# 1a) a 'read' at the end of the script will block on reading from that FD and, when something is written to the pipe, will unblock, which finishes the script, which triggers the exit trap further above, which does some cleanup
# 2) each subshell also has traps that on TERM or USR1 kill the processes inside the subshell
# 2a) if a SIGTERM or SIGUSR1 is received, this will unblock the first 'wait' and execute the corresponding trap
# 2b) a second 'wait' then ensures that the subshell remains alive until the process inside has shut down
# 2c) both 'wait' calls use || true to prevent 'set -e' failures: 'wait' returns the exit status of the given program, and that won't be zero, because it's getting killed
# 2d) we restore traps before the second wait, because restoring them inside the trap handler occasionally caused bizarre signal handling failures
# 2e) inside the TERM and USR1 traps, we ignore both of those signals as the very first thing, to ensure another similar signal arriving does not interrupt our shutdown
# 3) each subshell has another trap on INT which does nothing (to prevent Ctrl+C interference)
# 4) we execute the command in the background, recording its PID(s) for the subshell's TERM trap
# 5) we also execute the subshell in the background, recording its PID for the main TERM/INT traps
(
# the TERM trap here is special, because
# 1) there is a pipeline from tail to sed
# 2) we thus need to kill several children
# 3) job control (set -m, where we could then kill %% instead) has weird side effects e.g. on ctrl+c (kills the parent terminal after that too)
# 4) so we try to kill all PIDs that we recorded
# 4a) gracefully, by redirecting STDERR to /dev/null - one of the children may already be gone
# 5) the sed with the Darwin/GNU sed arg case used to be a function, but that was even worse with an extra wrapping subshell for sed
# FIXME: when that sed is killed or crashes, it'll take until the next log line, when tail fails to write to sed, to error and bring everything down
trap 'echo "log redirection" >&3; wait 2> /dev/null' EXIT
trap '' INT;
[[ $verbose ]] && echo "Starting log redirection..." >&2
unset logs_procs
logs_pipe=$(mktemp -t "heroku.logspipe-$$.XXXXXX" -u)
trap 'rm -f $logs_pipe; echo "log redirection" >&3;' EXIT
rm -f $logs_pipe
mkfifo $logs_pipe
touch "${logs[@]}"
if [[ $(uname) == "Darwin" ]]; then
sedbufarg="-l" # mac/bsd sed: -l buffers on line boundaries
else
sedbufarg="-u" # unix/gnu sed: -u unbuffered (arbitrary) chunks of data
fi
# we need 'tail' and 'sed' to ignore SIGTERM as well
# we do that by setting the handler for SIGTERM to SIG_IGN, then exec-ing the program, which preserves the signal handler (see https://pubs.opengroup.org/onlinepubs/9699919799/functions/execle.html):
# "Signals set to the default action (SIG_DFL) in the calling process image shall be set to the default action in the new process image. Except for SIGCHLD, signals set to be ignored (SIG_IGN) by the calling process image shall be set to be ignored by the new process image. Signals set to be caught by the calling process image shall be set to the default action in the new process image (see <signal.h>)."
# this of course doesn't work in more complex cases like Nginx or Apache or PHP, which install their own signal handlers on startup, but tail and sed don't do that
(
[[ $graceful_sigterm ]] && trap '' TERM
exec tail -qF -n 0 "${logs[@]}" > "$logs_pipe"
) & logs_procs+=($!)
(
[[ $graceful_sigterm ]] && trap '' TERM
# messages that are too long are cut off using "..." by FPM instead of closing double quotation marks; we want to preserve those three dots but not the closing double quotes
exec sed $sedbufarg -E -e 's/^\[[^]]+\] WARNING: \[pool [^]]+\] child [0-9]+ said into std(err|out): "(.*)("|...)$/\2\3/' -e 's/"$//' < "$logs_pipe" 1>&2
) & logs_procs+=($!)
# after the kill, we are redirecting stderr to /dev/null using exec to suppress "… Terminated …" messages from the shell, which it would print right before the next command is invoked
# we have to send SIGHUP to each process (could also use SIGUSR1 or any other process where the default behavior is termination, see https://man7.org/linux/man-pages/man7/signal.7.html); SIGQUIT or SIGINT doesn't work - this is because of the SIG_IGN workaround above, which is done using a backgrounded subshell and an exec - and backgrounded subshells cannot trap SIGTERM or SIGINT: http://vaab.blog.kal.fr/2016/07/30/trapping-sigint-sigquit-in-asynchronous-tasks/
trap 'trap "" USR1; trap - EXIT; [[ $verbose ]] && echo "Stopping log redirection..." >&2; kill -HUP "${logs_procs[@]}" 2> /dev/null || true; exec 2> /dev/null; wait "${logs_procs[@]}" 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
# ignore process group wide SIGTERM for this subshell if that env var is set
if [[ $graceful_sigterm ]]; then
trap '' TERM
else
trap 'trap "" TERM; [[ $verbose ]] && echo "Stopping log redirection..." >&2; kill -TERM "${logs_procs[@]}" 2> /dev/null || true; exec 2> /dev/null; wait "${logs_procs[@]}" 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
fi
wait 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
trap - USR1
[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
wait 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)
(
trap 'echo "php-fpm" >&3; wait 2> /dev/null' EXIT
trap '' INT;
echo "Starting php-fpm with ${WEB_CONCURRENCY} workers..." >&2
# earlier, we added special INIs that prevent newrelic etc from starting to the scan dir
# we do not want that to apply to php-fpm, which uses the same scan dir for its configs as the regular PHP CLI SAPI
# we now restore the original env var from the backup variable, if it existed (important, because empty string and unset are different)
# doing this in this subshell means any later code can still invoke PHP CLI with the special INIs applied
unset PHP_INI_SCAN_DIR
if [[ -n ${_PHP_INI_SCAN_DIR+x} ]]; then
export PHP_INI_SCAN_DIR=$_PHP_INI_SCAN_DIR
fi
# execute the command we built earlier, with the correct quoting etc expanded
"${fpm_command[@]}" & pid=$!
# wait for the pidfile in the trap; otherwise, a previous subshell failing may result in us getting a SIGTERM and forwarding it to the child process before that child process is ready to process signals
trap 'trap "" USR1; trap - EXIT; echo "Stopping php-fpm gracefully..." >&2; wait_pid_and_pidfile $pid "$fpm_pidfile"; kill -QUIT $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
# we always want to try and stop gracefully, especially since on Heroku we might be getting a process group wide SIGTERM but running a patched PHP-FPM that ignores SIGTERM; so if that env var is set, we ignore SIGTERM in this subshell as well
if [[ $graceful_sigterm ]]; then
trap '' TERM
else
trap 'trap "" TERM; echo "Stopping php-fpm..." >&2; wait_pid_and_pidfile $pid "$fpm_pidfile"; kill -TERM $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
fi
wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
trap - USR1
[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)
(
trap 'echo "nginx" >&3; wait 2> /dev/null' EXIT
trap '' INT;
# wait for FPM to write its PID file, which it does after initialization
# if we didn't wait, the web server would start sending traffic to the FCGI backend before it's ready (or before the socket is even created)
# if FPM fails to start, the FPM subshell will exit, causing the main cleanup procedure to run, which sends this subshell a SIGTERM
# there is however a possible race condition where this happens exactly the moment we hit the function below, which is why it internally has a timeout of 2500 ms, and we use that to exit 1
wait_pidfile "$fpm_pidfile" "Waiting for php-fpm to finish initializing..." || { echo "Timed out waiting for php-fpm." >&2; exit 1; }
echo "Starting nginx..." >&2
# execute the command we built earlier, with the correct quoting etc expanded
"${nginx_command[@]}" & pid=$!
# wait for the pidfile in the trap; otherwise, a previous subshell failing may result in us getting a SIGTERM and forwarding it to the child process before that child process is ready to process signals
trap 'trap "" USR1; trap - EXIT; echo "Stopping nginx gracefully..." >&2; wait_pid_and_pidfile $pid "$nginx_pidfile"; kill -QUIT $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
# we always want to try and stop gracefully, especially since on Heroku we might be getting a process group wide SIGTERM but running a patched Nginx that ignores SIGTERM; so if that env var is set, we ignore SIGTERM in this subshell as well
if [[ $graceful_sigterm ]]; then
trap '' TERM
else
trap 'trap "" TERM; echo "Stopping nginx..." >&2; wait_pid_and_pidfile $pid "$nginx_pidfile"; kill -TERM $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
fi
# first, we wait for the pid file to be written, so that we can emit a ready message
wait_pid_and_pidfile $pid "$nginx_pidfile"
# on Heroku, there is a "state changed from starting to up", but for local execution, we want a "ready" message
[[ -z ${DYNO:-} || $verbose ]] && kill -0 $pid 2> /dev/null && echo "Application ready for connections on port $PORT." >&2
wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
trap - USR1
[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)
# wait for something to come from the FIFO attached to FD 3, which means that the given process was killed or has failed
# this will be interrupted by a SIGTERM or SIGINT in the traps further up
# if the pipe unblocks and this executes, then we won't read it again, so if the traps further up kill the remaining subshells above, their writing to FD 3 will have no effect
read exitproc <&3
# we'll only reach this if one of the processes above has terminated
# this will trigger the main EXIT trap further up and kill all remaining children
# code 70 is EX_SOFTWARE from sysexits.h
# Ctrl+C/SIGINT or SIGTERM exits will, through the respective trap's 'kill $$', exit with the correct 128+$signalcode status
exit 70