diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml index b64856f56..e25c4ec59 100644 --- a/.github/actions/build-image/action.yml +++ b/.github/actions/build-image/action.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 616983a1d..3b2229b5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 10408d299..6fafe4b3c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docker/etc/my_init.d/10_dokku_init b/docker/etc/my_init.d/10_dokku_init index 38948d4af..c740a3a09 100755 --- a/docker/etc/my_init.d/10_dokku_init +++ b/docker/etc/my_init.d/10_dokku_init @@ -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 diff --git a/docker/etc/nginx/conf.d/zz-dokku-health.conf b/docker/etc/nginx/conf.d/zz-dokku-health.conf new file mode 100644 index 000000000..92c1475e3 --- /dev/null +++ b/docker/etc/nginx/conf.d/zz-dokku-health.conf @@ -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"; + } +} diff --git a/docker/etc/runit/runsvdir/default/dokku-restore/finish b/docker/etc/runit/runsvdir/default/dokku-restore/finish index ea80c20c0..39a01df0b 100755 --- a/docker/etc/runit/runsvdir/default/dokku-restore/finish +++ b/docker/etc/runit/runsvdir/default/dokku-restore/finish @@ -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 diff --git a/docs/getting-started/install/docker.md b/docs/getting-started/install/docker.md index 489762f1b..b2d7b0a9d 100644 --- a/docs/getting-started/install/docker.md +++ b/docs/getting-started/install/docker.md @@ -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: diff --git a/tests/image/healthcheck.sh b/tests/image/healthcheck.sh new file mode 100755 index 000000000..6fb2399b4 --- /dev/null +++ b/tests/image/healthcheck.sh @@ -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 "$@" diff --git a/tests/unit/docker-image-healthcheck.bats b/tests/unit/docker-image-healthcheck.bats new file mode 100644 index 000000000..27d75caea --- /dev/null +++ b/tests/unit/docker-image-healthcheck.bats @@ -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" +}