Merge pull request #8599 from dokku/dokku-container-healthcheck

Add docker healthcheck to dokku container
This commit is contained in:
Jose Diaz-Gonzalez
2026-05-10 23:50:29 -04:00
committed by GitHub
9 changed files with 257 additions and 3 deletions

View File

@@ -26,6 +26,12 @@ runs:
shell: bash
run: |
docker buildx build \
--load \
--progress plain \
--tag "dokku/dokku:latest" \
.
- name: smoke test image
shell: bash
run: |
make test-image-healthcheck IMAGE_TAG=dokku/dokku:latest

View File

@@ -45,6 +45,7 @@ RUN mkdir -p /etc/apt/keyrings \
&& echo "dokku dokku/hostname string $DOKKU_HOSTNAME" | debconf-set-selections \
&& echo "dokku dokku/skip_key_file boolean $DOKKU_SKIP_KEY_FILE" | debconf-set-selections \
&& echo "dokku dokku/vhost_enable boolean $DOKKU_VHOST_ENABLE" | debconf-set-selections \
&& echo "dokku dokku/install_default_site boolean true" | debconf-set-selections \
&& curl -sSL https://packagecloud.io/dokku/dokku/gpgkey | apt-key add - \
&& echo "deb https://packagecloud.io/dokku/dokku/ubuntu/ noble main" | tee /etc/apt/sources.list.d/dokku.list \
&& mkdir -p /etc/nginx/ \
@@ -81,3 +82,6 @@ RUN \
&& rm -f /usr/local/openresty/nginx/conf/sites-enabled/default /usr/share/openresty/html/index.html \
&& sed -i '/imklog/d' /etc/rsyslog.conf \
&& rm -f /var/log/btmp /var/log/wtmp /var/log/*log /var/log/apt/* /var/log/dokku/*.log /var/log/nginx/* /var/log/openresty/*
HEALTHCHECK --interval=30s --timeout=10s --start-period=5m --retries=3 \
CMD curl -fsS http://127.0.0.1:18080/_dokku/health || exit 1

View File

@@ -226,3 +226,8 @@ vagrant-acl-add:
vagrant-dokku:
vagrant ssh -- "sudo -H -u root bash -c 'dokku $(RUN_ARGS)'"
IMAGE_TAG ?= dokku/dokku:latest
test-image-healthcheck:
IMAGE_TAG=$(IMAGE_TAG) ./tests/image/healthcheck.sh

View File

@@ -35,6 +35,9 @@ main() {
support-userns
rm -f /var/run/dokku/ready
mkdir -p /var/run/dokku
if [[ ! -d /mnt/dokku ]]; then
log-info "Creating missing /mnt/dokku"
mkdir -p /mnt/dokku

View File

@@ -0,0 +1,16 @@
# Internal health endpoint for the official dokku/dokku Docker image.
# Bound to loopback so it is reachable only from inside the container
# and never conflicts with user app vhosts on 80/443.
server {
listen 127.0.0.1:18080;
server_name _;
access_log off;
default_type text/plain;
location = /_dokku/health {
if (-f /var/run/dokku/ready) {
return 200 "ok\n";
}
return 503 "not ready\n";
}
}

View File

@@ -10,6 +10,10 @@ if [[ "$1" -ne 0 ]]; then
fi
echo "Done restoring dokku apps."
exec sleep infinity
exit 0
while ! (echo >/dev/tcp/127.0.0.1/22) >/dev/null 2>&1; do sleep 1; done
while ! (echo >/dev/tcp/127.0.0.1/80) >/dev/null 2>&1; do sleep 1; done
touch /var/run/dokku/ready
echo "Dokku is ready."
exec sleep infinity

View File

@@ -44,7 +44,38 @@ services:
restart: unless-stopped
```
The above command will start a new docker container that is ready when a message similar to `Runit started as PID 12345` appears.
## Container readiness
The image ships with a Docker `HEALTHCHECK` that flips from `starting` to `healthy` once first-boot bootstrap is complete (skel restored, `plugin-list` plugins installed, core install triggers fired), nginx and sshd are accepting connections, and `dokku ps:restore` has finished. Until those conditions hold, the container is considered unhealthy and dependent services should not yet send traffic.
To check the current state from the host:
```shell
docker inspect --format='{{.State.Health.Status}}' dokku
```
Internally, the check is backed by a loopback-only HTTP endpoint:
```shell
docker exec dokku curl -fsS http://127.0.0.1:18080/_dokku/health
```
The endpoint binds to `127.0.0.1:18080` inside the container by design - it is not published to the host and does not interfere with user app vhosts on ports 80/443.
Compose dependents can gate on the healthcheck via `depends_on` with `condition: service_healthy`:
```yaml
services:
dokku:
image: dokku/dokku:0.38.2
# ...
bootstrap:
image: alpine:3.20
command: ["echo", "dokku is ready"]
depends_on:
dokku:
condition: service_healthy
```
Dokku is run in the following configuration:

128
tests/image/healthcheck.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_TAG="${IMAGE_TAG:-dokku/dokku:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-dokku-healthcheck-smoke}"
START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-360}"
UNHEALTHY_TIMEOUT_SECONDS="${UNHEALTHY_TIMEOUT_SECONDS:-180}"
log-info() {
echo "-----> $*"
}
log-fail() {
echo " ! $*" >&2
exit 1
}
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
require-image() {
if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
log-fail "Image '$IMAGE_TAG' not found in local docker daemon. Build it first (eg. 'docker build -t $IMAGE_TAG .')."
fi
}
start-container() {
log-info "Starting container '$CONTAINER_NAME' from image '$IMAGE_TAG'"
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER_NAME" \
--env DOKKU_HOSTNAME=dokku.me \
-v /var/run/docker.sock:/var/run/docker.sock \
"$IMAGE_TAG" >/dev/null
}
wait-for-catchall-vhost() {
local deadline=$((SECONDS + 30))
while ((SECONDS < deadline)); do
if docker exec "$CONTAINER_NAME" test -f /etc/nginx/conf.d/00-default-vhost.conf; then
return 0
fi
sleep 1
done
log-fail "Catch-all default vhost /etc/nginx/conf.d/00-default-vhost.conf was never created. \
Likely cause: a stale build/package/dokku-*.deb that predates the catch-all template shipped in \
plugins/nginx-vhosts/templates/default-site.conf. Rebuild the deb before running this test."
}
wait-for-healthy() {
local deadline=$((SECONDS + START_TIMEOUT_SECONDS))
local status
while ((SECONDS < deadline)); do
status=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
if [[ "$status" == "healthy" ]]; then
log-info "Container reached healthy status"
return 0
fi
sleep 5
done
log-info "Container failed to become healthy within ${START_TIMEOUT_SECONDS}s. Last logs:"
docker logs --tail 100 "$CONTAINER_NAME" || true
log-fail "Container '$CONTAINER_NAME' did not reach healthy status (last status: ${status:-unknown})"
}
assert-endpoint-ok() {
log-info "Verifying /_dokku/health returns ok from inside the container"
local body
body=$(docker exec "$CONTAINER_NAME" curl -fsS http://127.0.0.1:18080/_dokku/health)
if [[ "$body" != "ok" ]]; then
log-fail "Expected health endpoint body 'ok', got: ${body}"
fi
}
assert-port-not-published() {
log-info "Verifying port 18080 is not published to the host"
local ports
ports=$(docker inspect --format='{{json .NetworkSettings.Ports}}' "$CONTAINER_NAME")
if echo "$ports" | grep -q "18080/tcp"; then
log-fail "Port 18080/tcp should not be published to the host. Inspect output: $ports"
fi
if curl --max-time 2 -fsS http://127.0.0.1:18080/_dokku/health >/dev/null 2>&1; then
log-fail "Health endpoint is reachable from the host on 127.0.0.1:18080 - it must be loopback-only inside the container."
fi
}
assert-unhealthy-after-sentinel-removed() {
log-info "Removing /var/run/dokku/ready and confirming endpoint returns failure"
docker exec "$CONTAINER_NAME" rm -f /var/run/dokku/ready
local exit_code=0
docker exec "$CONTAINER_NAME" curl -fsS http://127.0.0.1:18080/_dokku/health >/dev/null 2>&1 || exit_code=$?
if ((exit_code == 0)); then
log-fail "Health endpoint still returned success after the readiness sentinel was removed"
fi
log-info "Waiting for docker to flip the health status to unhealthy"
local deadline=$((SECONDS + UNHEALTHY_TIMEOUT_SECONDS))
local status
while ((SECONDS < deadline)); do
status=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
if [[ "$status" == "unhealthy" ]]; then
log-info "Container flipped to unhealthy"
return 0
fi
sleep 5
done
log-fail "Container did not flip to unhealthy within ${UNHEALTHY_TIMEOUT_SECONDS}s (last status: ${status:-unknown})"
}
main() {
require-image
start-container
wait-for-catchall-vhost
wait-for-healthy
assert-endpoint-ok
assert-port-not-published
assert-unhealthy-after-sentinel-removed
log-info "All healthcheck smoke tests passed."
}
main "$@"

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bats
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
NGINX_CONF="${REPO_ROOT}/docker/etc/nginx/conf.d/zz-dokku-health.conf"
FINISH_SCRIPT="${REPO_ROOT}/docker/etc/runit/runsvdir/default/dokku-restore/finish"
MY_INIT_SCRIPT="${REPO_ROOT}/docker/etc/my_init.d/10_dokku_init"
DOCKERFILE="${REPO_ROOT}/Dockerfile"
@test "(docker-image) [healthcheck] nginx health conf is shipped via the docker overlay" {
[[ -f "$NGINX_CONF" ]]
grep -qE "listen[[:space:]]+127\.0\.0\.1:18080" "$NGINX_CONF"
grep -qE "location = /_dokku/health" "$NGINX_CONF"
grep -qE "/var/run/dokku/ready" "$NGINX_CONF"
grep -qE "default_type[[:space:]]+text/plain" "$NGINX_CONF"
}
@test "(docker-image) [healthcheck] finish script touches sentinel after waiting for sshd and nginx" {
[[ -f "$FINISH_SCRIPT" ]]
local sshd_line nginx_line touch_line
sshd_line=$(grep -nE "/dev/tcp/127\.0\.0\.1/22" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
nginx_line=$(grep -nE "/dev/tcp/127\.0\.0\.1/80" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
touch_line=$(grep -nE "touch /var/run/dokku/ready" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
[[ -n "$sshd_line" ]]
[[ -n "$nginx_line" ]]
[[ -n "$touch_line" ]]
((touch_line > sshd_line))
((touch_line > nginx_line))
}
@test "(docker-image) [healthcheck] my_init clears stale sentinel on container boot" {
[[ -f "$MY_INIT_SCRIPT" ]]
local rm_line mkdir_line plugin_line hostname_line
rm_line=$(grep -nE "rm -f /var/run/dokku/ready" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
mkdir_line=$(grep -nE "mkdir -p /var/run/dokku( |$)" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
plugin_line=$(grep -nE "plugin:install" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
hostname_line=$(grep -nE "domains:set-global" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
[[ -n "$rm_line" ]]
[[ -n "$mkdir_line" ]]
[[ -n "$plugin_line" ]]
[[ -n "$hostname_line" ]]
((rm_line < plugin_line))
((rm_line < hostname_line))
((mkdir_line < plugin_line))
((mkdir_line < hostname_line))
}
@test "(docker-image) [healthcheck] Dockerfile declares HEALTHCHECK with the expected endpoint" {
[[ -f "$DOCKERFILE" ]]
grep -qE "^HEALTHCHECK" "$DOCKERFILE"
grep -qE "http://127\.0\.0\.1:18080/_dokku/health" "$DOCKERFILE"
grep -qE "\-\-start-period" "$DOCKERFILE"
}