-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocker-bluegreen.sh
More file actions
executable file
·484 lines (427 loc) · 16.3 KB
/
Copy pathdocker-bluegreen.sh
File metadata and controls
executable file
·484 lines (427 loc) · 16.3 KB
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
#!/usr/bin/env bash
# =============================================================================
# docker-bluegreen.sh - universal Blue-Green deployment for Docker
#
# Idea: the app always runs in two copies - "blue" and "green".
# One is active, the other is idle. A new release goes into the idle copy,
# is verified by a health check, and only then is traffic switched.
# If the check fails - automatic rollback, the old version keeps running.
#
# Setup: copy the "CONFIGURATION" block into a separate file
# (e.g. ./deploy.env) or edit it right here. The script automatically
# picks up ./deploy.env if it is located next to the script.
#
# Usage:
# ./docker-bluegreen.sh deploy [-t TAG] # deploy a new version
# ./docker-bluegreen.sh rollback # roll back to the previous release
# ./docker-bluegreen.sh status # show current state
# ./docker-bluegreen.sh logs [name] # show container logs (blue|green)
# ./docker-bluegreen.sh down # stop both containers
# =============================================================================
set -euo pipefail
# =============================================================================
# Output helpers
# =============================================================================
log() { printf '\033[1;36m[bluegreen]\033[0m %s\n' "$*"; }
info() { printf '\033[1;33m[info]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[ok]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; }
# =============================================================================
# Load deploy.env (existing environment variables take priority over it)
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/deploy.env}"
load_env() { # $1 = path to the env file
local _line _key
while IFS= read -r _line; do
_line="${_line//$'\r'/}" # strip CR (Windows line endings)
[[ -z "$_line" ]] && continue
[[ "$_line" == \#* ]] && continue
if [[ "$_line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
_key="${BASH_REMATCH[1]}"
if [[ -n "${!_key+x}" ]]; then # already set by the environment?
continue # -> environment wins over deploy.env
fi
fi
# shellcheck disable=SC2163
eval "$_line" 2>/dev/null || true
done < "$1"
}
if [[ -f "$ENV_FILE" ]]; then
load_env "$ENV_FILE"
info "Loaded settings from $ENV_FILE"
fi
# =============================================================================
# CONFIGURATION
# Priority: environment variable > deploy.env > defaults below.
# =============================================================================
# Short app name (used in container names and labels)
APP_NAME="${APP_NAME:-myapp}"
# Docker image and tag of the new version (tag can be overridden with -t myapp:v2)
IMAGE="${IMAGE:-myapp:latest}"
# Which ports to publish from the HOST into the container. Write as one string, e.g.:
# "8080:80" - single port
# "8080:80 9090:9090" - multiple ports (important: keep inside quotes!)
HOST_PORTS="${HOST_PORTS:-8080:80}"
# When enabled (true), ports are NOT published - traffic is expected to be
# distributed by an external reverse proxy (nginx/traefik/caddy). The script
# then only creates/restarts containers.
USE_REVERSE_PROXY="${USE_REVERSE_PROXY:-false}"
# Extra arguments for docker run (volumes, env, network, etc.)
# Example: DOCKER_RUN_ARGS="-v /data:/data -e FOO=bar --restart unless-stopped"
# Note: the nested quotes are required so the leading "--" of the default
# is preserved (otherwise bash would turn "--restart" into "-restart").
DOCKER_RUN_ARGS="${DOCKER_RUN_ARGS:-"--restart unless-stopped"}"
# Docker network, if needed. Empty - default network.
# (Do not duplicate this in DOCKER_RUN_ARGS.)
DOCKER_NETWORK="${DOCKER_NETWORK:-}"
# URL/command health check for the new container.
# Option 1 - HTTP: HEALTHCHECK_URL="http://localhost:8080/health"
# Option 2 - command: HEALTHCHECK_CMD="wget -q -O - http://localhost/health"
# If both are empty - only checks that the container is started and alive.
HEALTHCHECK_URL="${HEALTHCHECK_URL:-}"
HEALTHCHECK_CMD="${HEALTHCHECK_CMD:-}"
# How many seconds to wait for the new container to start
START_TIMEOUT="${START_TIMEOUT:-60}"
# How many times (with a 2s pause) to repeat the health check. Total ~ RETRIES * 2 seconds.
HEALTH_RETRIES="${HEALTH_RETRIES:-15}"
# Command that "switches" traffic when USE_REVERSE_PROXY=true.
# Runs on the active host after a successful health check.
# Example: "systemctl reload nginx" or "docker exec nginx nginx -s reload"
SWITCH_COMMAND="${SWITCH_COMMAND:-}"
# =============================================================================
# NO NEED TO CHANGE BELOW
# =============================================================================
# Container names
CONTAINER_BLUE="${APP_NAME}-blue"
CONTAINER_GREEN="${APP_NAME}-green"
# State volume names (shared by both containers so that database/files are
# not lost on switch). Add your own if needed.
# IMPORTANT: do not duplicate these volumes in DOCKER_RUN_ARGS.
STATE_VOLUMES=()
# Helper file storing the image of the version replaced by the last deploy
# (i.e. the image `rollback` will restore).
STATE_FILE="/tmp/${APP_NAME}.bluegreen.state"
DOCKER="${DOCKER:-docker}"
# =============================================================================
# Helper functions
# =============================================================================
# Build the ports string for docker run
build_ports_args() {
local out=()
local p
for p in $HOST_PORTS; do
out+=("-p" "$p")
done
printf '%s\n' "${out[@]}" | paste -sd' ' -
}
# Build the state volumes string
build_volume_args() {
local out=()
local v
for v in "${STATE_VOLUMES[@]}"; do
out+=("-v" "$v")
done
printf '%s\n' "${out[@]}" | paste -sd' ' -
}
# Check that docker is available
require_docker() {
if ! command -v "$DOCKER" >/dev/null 2>&1; then
err "docker not found. Install Docker or set DOCKER=/path/to/docker"
exit 1
fi
}
# Container status: running | exited | ... | absent
# Note: uses process substitution instead of a pipe because `grep -q` exits
# early, the producer gets SIGPIPE, and under `set -o pipefail` that would
# wrongly turn a successful match into a failed pipeline.
container_state() { # $1 = container name
local name="$1"
if grep -Fqx "$name" < <("$DOCKER" ps -a --format '{{.Names}}'); then
"$DOCKER" inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo "unknown"
else
echo "absent"
fi
}
run_container() { # $1 = container name, $2 = image
local name="$1" img="$2"
local args net_args=()
args="$(build_ports_args) $(build_volume_args) $DOCKER_RUN_ARGS"
[[ -n "$DOCKER_NETWORK" ]] && net_args=(--network "$DOCKER_NETWORK")
# shellcheck disable=SC2086
"$DOCKER" run -d --name "$name" \
-l "bluegreen.app=$APP_NAME" \
-l "bluegreen.color=${name##*-}" \
-l "bluegreen.active=true" \
"${net_args[@]}" \
$args \
"$img" >/dev/null
}
wait_container_ready() { # $1 = container name
local name="$1" timeout="$START_TIMEOUT" waited=0
log "Waiting for container $name to start (up to ${timeout}s)..."
while [[ "$waited" -lt "$timeout" ]]; do
local st
st="$(container_state "$name")"
if [[ "$st" == "running" ]]; then
return 0
fi
if [[ "$st" == "exited" || "$st" == "dead" || "$st" == "restarting" || "$st" == "paused" ]]; then
err "Container $name stopped working (status: $st). Check the logs:"
"$DOCKER" logs --tail 30 "$name" >&2 || true
return 1
fi
sleep 2
waited=$((waited + 2))
done
err "Timeout waiting for $name to start"
return 1
}
healthcheck() { # $1 = container name
local name="$1" i
local port_url="${HEALTHCHECK_URL}"
# HTTP checks need curl
if [[ -z "$HEALTHCHECK_CMD" && -n "$port_url" ]] && ! command -v curl >/dev/null 2>&1; then
err "curl is required for HTTP health checks but was not found."
err "Install curl or set HEALTHCHECK_CMD instead."
return 1
fi
# If neither URL nor command is set but ports are published, try to guess
# the first host port. Skipped in reverse-proxy mode (no ports published).
if [[ -z "$port_url" && -z "$HEALTHCHECK_CMD" && -n "$HOST_PORTS" && "$USE_REVERSE_PROXY" != "true" ]]; then
local first_pair host_port
first_pair="${HOST_PORTS%% *}" # first "host:container" pair
host_port="${first_pair%:*}" # strip ":containerPort"
host_port="${host_port##*:}" # keep the hostPort (also works for "ip:port:container")
[[ -z "$host_port" ]] && host_port="localhost"
port_url="http://localhost:${host_port}/"
info "HEALTHCHECK_URL not set, checking first port: $port_url"
fi
if [[ -z "$port_url" && -z "$HEALTHCHECK_CMD" ]]; then
info "No health check configured - considering container ready (status running)"
return 0
fi
for (( i=1; i<=HEALTH_RETRIES; i++ )); do
local code=0
if [[ -n "$HEALTHCHECK_CMD" ]]; then
# shellcheck disable=SC2086
"$DOCKER" exec "$name" sh -lc "$HEALTHCHECK_CMD" >/dev/null 2>&1 || code=1
else
curl -fsS --max-time 3 "$port_url" >/dev/null 2>&1 || code=1
fi
if [[ "$code" -eq 0 ]]; then
ok "Health check passed (attempt $i)"
return 0
fi
sleep 2
done
err "Health check failed after $((HEALTH_RETRIES * 2)) seconds"
return 1
}
# Create the container if absent, otherwise recreate it
start_new() { # $1 = container name, $2 = image (defaults to $IMAGE)
local name="$1" img="${2:-$IMAGE}"
local st
st="$(container_state "$name")"
if [[ "$st" == "absent" ]]; then
log "Creating new container $name from image $img"
run_container "$name" "$img"
else
log "Container $name exists (status: $st). Removing and recreating from $img"
"$DOCKER" rm -f "$name" >/dev/null 2>&1 || true
run_container "$name" "$img"
fi
}
# =============================================================================
# Main commands
# =============================================================================
cmd_deploy() {
require_docker
local active inactive
if [[ "$(container_state "$CONTAINER_BLUE")" == "running" && "$(container_state "$CONTAINER_GREEN")" != "running" ]]; then
active="$CONTAINER_BLUE"; inactive="$CONTAINER_GREEN"
else
active="$CONTAINER_GREEN"; inactive="$CONTAINER_BLUE"
fi
# First deployment: no active container yet
if [[ "$(container_state "$active")" == "absent" && "$(container_state "$inactive")" == "absent" ]]; then
log "First deployment - starting $active"
start_new "$active"
if ! wait_container_ready "$active"; then
err "Container $active did not start. Removing it."
"$DOCKER" rm -f "$active" >/dev/null 2>&1 || true
exit 1
fi
if ! healthcheck "$active"; then
err "Health check failed for $active. Removing it."
"$DOCKER" rm -f "$active" >/dev/null 2>&1 || true
exit 1
fi
ok "Deployment finished. Active: $active"
exit 0
fi
log "Current state: active=$active, inactive=$inactive"
info "Deploying new version to the idle container $inactive"
start_new "$inactive"
if ! wait_container_ready "$inactive"; then
err "New container did not start. Rolling back."
"$DOCKER" rm -f "$inactive" >/dev/null 2>&1 || true
exit 1
fi
if ! healthcheck "$inactive"; then
err "Health check failed. Rolling back."
"$DOCKER" rm -f "$inactive" >/dev/null 2>&1 || true
exit 1
fi
# Save the image of the current active container for later rollback,
# BEFORE it is stopped/removed below.
if [[ "$(container_state "$active")" != "absent" ]]; then
"$DOCKER" inspect -f '{{.Config.Image}}' "$active" > "$STATE_FILE" 2>/dev/null || true
fi
# ---- Switch ----
if [[ "$USE_REVERSE_PROXY" == "true" ]]; then
if [[ -n "$SWITCH_COMMAND" ]]; then
log "Switching traffic to $inactive: $SWITCH_COMMAND"
# shellcheck disable=SC2086
eval "$SWITCH_COMMAND"
else
info "USE_REVERSE_PROXY=true, but SWITCH_COMMAND is not set."
info "Hint: point your reverse proxy at the container with label 'bluegreen.active=true'."
fi
"$DOCKER" rm -f "$active" >/dev/null 2>&1 || true
else
log "Stopping old active container $active"
"$DOCKER" stop "$active" >/dev/null 2>&1 || true
fi
ok "Deployment finished. Active: $inactive"
}
cmd_rollback() {
require_docker
local active inactive prev_image
if [[ "$(container_state "$CONTAINER_BLUE")" == "running" && "$(container_state "$CONTAINER_GREEN")" != "running" ]]; then
active="$CONTAINER_BLUE"; inactive="$CONTAINER_GREEN"
else
active="$CONTAINER_GREEN"; inactive="$CONTAINER_BLUE"
fi
if [[ ! -f "$STATE_FILE" ]]; then
info "State file missing ($STATE_FILE) - rollback is not possible"
exit 1
fi
prev_image="$(cat "$STATE_FILE")"
log "Rolling back to image: $prev_image"
# Start the previous version in the inactive container
start_new "$inactive" "$prev_image"
if ! wait_container_ready "$inactive"; then
err "Rollback failed: container $inactive did not start"
"$DOCKER" rm -f "$inactive" >/dev/null 2>&1 || true
exit 1
fi
log "Switching traffic back to $inactive"
if [[ "$USE_REVERSE_PROXY" == "true" && -n "$SWITCH_COMMAND" ]]; then
# shellcheck disable=SC2086
eval "$SWITCH_COMMAND"
fi
# Remember the image we are moving away from, so a second `rollback`
# undoes this one (toggle between the two versions).
if [[ "$(container_state "$active")" != "absent" ]]; then
"$DOCKER" inspect -f '{{.Config.Image}}' "$active" > "$STATE_FILE" 2>/dev/null || true
fi
"$DOCKER" stop "$active" >/dev/null 2>&1 || true
"$DOCKER" rm -f "$active" >/dev/null 2>&1 || true
ok "Rollback finished. Active: $inactive"
}
cmd_status() {
require_docker
printf '%-20s %-12s %s\n' "Container" "Status" "Image"
local name st
for name in "$CONTAINER_BLUE" "$CONTAINER_GREEN"; do
st="$(container_state "$name")"
if [[ "$st" == "absent" ]]; then
printf '%-20s %-12s %s\n' "$name" "$st" "-"
else
local img
img="$("$DOCKER" inspect -f '{{.Config.Image}}' "$name" 2>/dev/null || echo "-")"
printf '%-20s %-12s %s\n' "$name" "$st" "$img"
fi
done
if [[ -f "$STATE_FILE" ]]; then
info "Last version available for rollback: $(cat "$STATE_FILE")"
fi
}
cmd_logs() {
require_docker
local name="${1:-}"
case "$name" in
blue) name="$CONTAINER_BLUE" ;;
green) name="$CONTAINER_GREEN" ;;
"") name="$CONTAINER_BLUE" ;;
esac
if [[ "$(container_state "$name")" == "absent" ]]; then
err "Container $name does not exist"
exit 1
fi
"$DOCKER" logs -f --tail 100 "$name"
}
cmd_down() {
require_docker
log "Stopping all $APP_NAME containers"
"$DOCKER" rm -f "$CONTAINER_BLUE" "$CONTAINER_GREEN" >/dev/null 2>&1 || true
rm -f "$STATE_FILE"
ok "Done"
}
usage() {
cat <<EOF
docker-bluegreen.sh - Blue-Green deployment for Docker
Usage:
$0 deploy [-t TAG] Deploy a new version (tag can be set with -t)
$0 rollback Roll back to the previous version
$0 status Show container status
$0 logs [blue|green] Show container logs
$0 down Stop and remove both containers
Settings priority:
1. Environment variables
2. deploy.env file next to the script
3. Defaults at the top of the script
Example deploy.env:
APP_NAME="web"
IMAGE="myregistry.local/web:1.0.0"
HOST_PORTS="80:80 443:443"
DOCKER_RUN_ARGS="-v /srv/web-data:/data --restart unless-stopped"
HEALTHCHECK_URL="http://localhost/health"
# Or for a reverse proxy:
# USE_REVERSE_PROXY=true
# SWITCH_COMMAND="systemctl reload nginx"
EOF
}
# =============================================================================
# Argument parsing
# =============================================================================
CMD="${1:-}"
shift || true
case "$CMD" in
deploy)
while [[ $# -gt 0 ]]; do
case "$1" in
-t)
if [[ $# -lt 2 ]]; then
err "Option -t requires a value (e.g. -t myapp:v2)"
exit 1
fi
IMAGE="$2"
shift 2
;;
*)
err "Unknown option: $1"
exit 1
;;
esac
done
cmd_deploy
;;
rollback) cmd_rollback ;;
status) cmd_status ;;
logs) cmd_logs "${1:-}" ;;
down) cmd_down ;;
-h|--help|help) usage ;;
*) usage; exit 1 ;;
esac