Resolving Docker Compose Service Startup Race: Database Readiness on Alpine Linux
Learn why Docker Compose's depends_on isn't enough for database readiness on Alpine Linux and implement robust startup dependency checks.
Learn why Docker Compose's depends_on isn't enough for database readiness on Alpine Linux and implement robust startup dependency checks.
When deploying multi-service applications with Docker Compose, especially those involving a database, it's a common misconception that depends_on guarantees service readiness. Many developers encounter a race condition where their application container starts, attempts to connect to the database, but fails because the database container, while started, hasn't fully initialized and isn't ready to accept connections. This issue is particularly prevalent and sometimes more challenging to debug on minimal Linux distributions like Alpine, due to their stripped-down nature. This guide will meticulously detail the root cause and provide robust, production-ready solutions.
Symptom & Error Signature
The primary symptom is that your application container fails to start correctly, crashes, or enters a restart loop shortly after your docker-compose up command, while the database container appears to be running. You might observe output similar to the following in your application's logs:
my-app_1 | Traceback (most recent call last):
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
my-app_1 | self.dialect.do_connect(self.__connection)
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/default.py", line 737, in do_connect
my-app_1 | dbapi_connection = self.dbapi.connect(*cargs, **cparams)
my-app_1 | File "/usr/local/lib/python3.9/site-packages/psycopg2/__init__.py", line 122, in connect
my-app_1 | conn = _connect(dsn, connection_factory=connection_factory, **kwasync)
my-app_1 | psycopg2.OperationalError: could not connect to server: Connection refused
my-app_1 | Is the server running on host "db" (172.18.0.2) and accepting
my-app_1 | TCP/IP connections on port 5432?
my-app_1 |
my-app_1 | The above exception was the direct cause of the following exception:
my-app_1 |
my-app_1 | Traceback (most recent call last):
my-app_1 | File "/app/app.py", line 10, in <module>
my-app_1 | db.create_all()
my-app_1 | File "/usr/local/lib/python3.9/site-packages/flask_sqlalchemy/__init__.py", line 1030, in create_all
my-app_1 | self.db.create_all()
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/schema.py", line 4768, in create_all
my-app_1 | _create_all(self, tables, checkfirst=checkfirst)
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/schema.py", line 4741, in _create_all
my-app_1 | conn.execute(
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1966, in execute
my-app_1 | return self._exec_single_context(
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 2102, in _exec_single_context
my-app_1 | self._handle_dbapi_exception(
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 2283, in _handle_dbapi_exception
my-app_1 | util.raise_(
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/util/compat.py", line 207, in raise_
my-app_1 | raise exception
my-app_1 | File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
my-app_1 | self.dialect.do_connect(self.__connection)
my-app_1 | sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect to server: Connection refused
my-app_1 | Is the server running on host "db" (172.18.0.2) and accepting
my-app_1 | TCP/IP connections on port 5432?
This log snippet from a Python application using SQLAlchemy and Psycopg2 clearly indicates a Connection refused error when trying to connect to the db service on port 5432 (PostgreSQL).
Root Cause Analysis
The core of this problem lies in a common misunderstanding of how Docker Compose's depends_on functionality works:
depends_ononly ensures container start order: When you specifydepends_onin yourdocker-compose.yml, Docker Compose ensures that the dependent service's container is started before the service that depends on it. It does not wait for the service inside that container to be fully initialized and ready to accept connections.- Database initialization takes time: Database management systems like PostgreSQL or MySQL need time to perform various tasks upon container startup:
- Initializing data directories (if not persistent).
- Applying migrations or schema changes.
- Starting the database daemon.
- Listening on the specified port.
- Authenticating users. These processes can take anywhere from a few seconds to several minutes, depending on the database size and server load.
- Race condition: Your application container starts, sees that the
dbcontainer is running (asdepends_onensured), and immediately tries to establish a connection. If the database daemon hasn't finished its startup routine and isn't listening on the port, the connection attempt fails, leading to theConnection refusederror. - Alpine Linux considerations: Alpine is a minimal distribution. While this is great for small image sizes, it means many common utilities (like
bash,curl,netcat,iputils) are not pre-installed. If your readiness script relies onbash-specific syntax or tools not available by default, you'll encounter additional failures. Ensuringshcompatibility or explicitly installing dependencies (likebashorbusybox-extrasfornc) is crucial.
Step-by-Step Resolution
The most robust solution involves implementing a custom "wait-for-it" mechanism directly within your application's startup sequence. This ensures that the application only proceeds once the database port is actually open and responsive.
1. Implement a wait-for-it Script
A widely adopted and effective solution is to use a small shell script that repeatedly tries to connect to the database host and port until successful. We will use a POSIX-compliant script that works well on Alpine's default ash shell.
a. Add wait-for-it.sh to your project
Create a file named wait-for-it.sh in the root of your application's directory (or a scripts subdirectory):
#!/usr/bin/env sh
# wait-for-it.sh
set -e
host="$1"
port="$2"
shift 2
cmd="$@"
until nc -z "$host" "$port"; do
>&2 echo "Database is unavailable - sleeping"
sleep 1
done
>&2 echo "Database is up - executing command"
exec "$cmd"
The
nc(netcat) utility is crucial for this script. Alpine Linux's defaultbusyboxdistribution often providesnc, but if you encounter "nc: not found", you may need to explicitly installbusybox-extrasin your Dockerfile, which often includes a more feature-richnc.apk add --no-cache busybox-extras
b. Update your application's Dockerfile
Modify your application's Dockerfile to copy this script into the container and make it executable.
# Use an Alpine-based image for your application
FROM python:3.9-alpine
# Install any dependencies your application needs.
# For Python, this usually involves pip, virtualenv, etc.
# Also, install busybox-extras for 'nc' if not already present,
# and potentially 'postgresql-dev' or 'mysql-client-dev' for database drivers.
RUN apk add --no-cache python3-dev
gcc
musl-dev
postgresql-dev
# Ensure nc is available for the wait-for-it script
busybox-extras
WORKDIR /app
# Copy the wait-for-it script and make it executable
COPY wait-for-it.sh /usr/local/bin/wait-for-it.sh
RUN chmod +x /usr/local/bin/wait-for-it.sh
# Copy your application requirements and install them
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy your application code
COPY . .
# Expose ports, define entrypoint, etc.
EXPOSE 8000
# The CMD will be overridden by docker-compose.yml
# CMD ["python", "app.py"]
c. Modify your docker-compose.yml
Now, update the command or entrypoint for your application service in docker-compose.yml to execute wait-for-it.sh before starting your main application.
version: '3.8'
services:
db:
image: postgres:13-alpine # Or mysql:8-alpine
restart: always
environment:
POSTGRES_DB: mydatabase
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
volumes:
- db-data:/var/lib/postgresql/data # Or /var/lib/mysql for MySQL
ports:
- "5432:5432" # Expose for local access if needed
my-app:
build: . # Build from the Dockerfile in the current directory
restart: always
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydatabase
# Use depends_on for orchestration, but rely on wait-for-it for readiness
depends_on:
- db
# The crucial part: run wait-for-it.sh before your app command
command: ["/usr/local/bin/wait-for-it.sh", "db", "5432", "--", "python", "app.py"]
ports:
- "8000:8000"
volumes:
db-data:
In the
commandformy-app,dbis the service name (which resolves to the container's IP address) and5432is the database port. The--argument separates thewait-for-it.sharguments from the command that should be executed after the database is ready (python app.pyin this case).
2. Consider Application-Level Retry Logic (Most Robust)
While wait-for-it scripts are excellent for orchestrating startup, the most robust solution for production environments is to implement connection retry logic directly within your application code. This handles not only initial startup but also transient network issues or database restarts during runtime.
Here's a conceptual example in Python using a common retry library:
# app.py
import os
import time
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import OperationalError
from tenacity import retry, stop_after_attempt, wait_fixed, before_log, after_log
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/mydb")
engine = create_engine(DATABASE_URL)
Session = sessionmaker(bind=engine)
@retry(stop=stop_after_attempt(10), # Try 10 times
wait=wait_fixed(3), # Wait 3 seconds between attempts
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.INFO),
retry_error_cls_types=(OperationalError,)) # Only retry on OperationalError
def initialize_database():
logger.info("Attempting to connect to database...")
try:
# This will raise OperationalError if connection fails
with engine.connect() as connection:
connection.execute("SELECT 1")
logger.info("Database connection successful!")
# Example: create tables
# Base.metadata.create_all(engine)
except OperationalError as e:
logger.warning(f"Database connection failed: {e}. Retrying...")
raise # Re-raise to trigger tenacity retry
if __name__ == "__main__":
initialize_database()
logger.info("Application starting...")
# Your main application logic here
while True:
time.sleep(10) # Keep the app running for demonstration
Implementing application-level retry logic requires modifying your application's source code. This is often preferred in production as it provides the most granular control and resilience, but it's more involved than shell scripts for initial setup. Ensure your application's base image includes any necessary development headers (e.g.,
postgresql-devforpsycopg2) for database driver compilation if you're building a Python or Ruby application from source on Alpine.
3. Using dockerize (Alternative to wait-for-it.sh)
dockerize is another popular utility specifically designed for this problem. It's a Go binary that you can add to your Docker image.
a. Add dockerize to your Dockerfile
FROM python:3.9-alpine
# Install any dependencies your application needs.
RUN apk add --no-cache python3-dev gcc musl-dev postgresql-dev
WORKDIR /app
# Download and install dockerize
ENV DOCKERIZE_VERSION v0.6.1
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz
&& tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz
&& rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
b. Modify your docker-compose.yml
version: '3.8'
services:
db:
image: postgres:13-alpine
restart: always
environment:
POSTGRES_DB: mydatabase
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
volumes:
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
my-app:
build: .
restart: always
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydatabase
depends_on:
- db
# Use dockerize to wait for the database before starting your app
command: ["dockerize", "-wait", "tcp://db:5432", "-timeout", "60s", "python", "app.py"]
ports:
- "8000:8000"
volumes:
db-data:
dockerizeprovides more features like waiting for HTTP endpoints or files, making it a versatile tool. Ensure you use a version compatible with Alpine Linux (dockerize-alpine-linux-amd64). The-timeoutflag is crucial to prevent your service from waiting indefinitely if the database truly fails to start.
By implementing one of these solutions, you effectively decouple the container startup order from the service readiness, thus resolving the common race condition and ensuring your application starts reliably with a ready database.
Our Production Verification Guarantee
Encountering a bug not covered here or running a non-standard kernel configuration? Our solutions are continually refined against real production incidents. Submit an environment trace for our editorial team to replicate.