解决 MySQL 官方 Docker 镜像启动前无法连接的问题

解决 MySQL 官方 Docker 镜像启动前无法连接的问题

这篇文章的标题真的超级难起,因为这个问题很绕,但是肯定有人遇到过一时之间还不知道怎么处理。

官方对这个问题的命名是:No connections until MySQL init completes(在 MySQL 初始化完成之前,不能进行任何连接)。

对应的描述是:If the application you're trying to connect to MySQL does not handle MySQL downtime or waiting for MySQL to start gracefully, then putting a connect-retry loop before the service starts might be necessary. (假如你正尝试让应用程序与 MySQL 建立连接,但这个程序没法优雅地应对 MySQL 的暂停或是等待启动的情况,那么或许在启动服务之前设置一个重试连接的循环机制就显得尤为重要了。)

上面那个翻译比较直接,后面这个润色了一下。

我就是这么轴

先说下这个问题的背景,昨天在梳理自己的微服务架构(也不算是真正意义上的微服务,只是借用了微服务架构思维,方便敏捷开发和快速部署项目,复用已有服务),我新增了一个服务,也同时需要新增对应的 MySQL 数据库。

于是我更新了 init.sql 文件中的内容,如下:

-- 创建数据库
CREATE DATABASE IF NOT EXISTS db_system_service;
CREATE DATABASE IF NOT EXISTS db_admin_service;
CREATE DATABASE IF NOT EXISTS db_intelmining_service;

-- 为 moran_ms 分配权限
GRANT ALL PRIVILEGES ON db_system_service.* TO 'moran_ms'@'%';
GRANT ALL PRIVILEGES ON db_admin_service.* TO 'moran_ms'@'%';
GRANT ALL PRIVILEGES ON db_intelmining_service.* TO 'moran_ms'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

我的 MySQL 是用 Docker 部署的,使用的是官方 8.1.0 镜像。

按照正常逻辑,我只需要把上面的 init.sql 文件复制到 /docker-entrypoint-initdb.d/init.sql 即可,Docker 构建镜像的时候会自动执行 init.sql 创建不存在的数据库并分配权限。

问题就出在 docker-entrypoint-initdb.d 中的 sql 文件只在构建镜像且没有关联卷的时候执行,后续哪怕重新构建镜像(未删除已关联卷),不会再自动执行 init.sql 文件。

问题就出现了,我更新了 init.sql ,重新构建并启动容器,不会自动执行 init.sql,因为我没有删除关联卷。

我不可能每次更新 init.sql 都去删除一次卷,那里面可是存了历史数据的我天,也不可能每次都去备份还原数据库吧。

我就想每次运行容器的时候执行一次 sql.init,咋就这么难呢?于是我就轴起来了。

实现路径

先说明一下,我使用的是 Docker Compose,没有使用也不重要,核心逻辑和使用什么没有关系,只不过会影响到理解我给出的代码示例。

按照逻辑,要解决这个问题很简单嘛,写个脚本执行 sql 不就行了,这里先放出我完整的 Dockerfile 和 Docker Compose 配置文件,这部分内容不会变,后面就只改变 start.sh 脚本文件内容:

FROM mysql:8.1.0

COPY init.sql /docker-entrypoint-initdb.d/init.sql
COPY start.sh /usr/local/bin/start.sh

RUN chmod +x /usr/local/bin/start.sh
mysql-service:
    container_name: mysql-service
    build: ./mysql-service
    image: mysql-service:1.0
    env_file:
      - env/.env
      - env/mysql-root.env
      - env/mysql.env
    ports:
      - "3306:3306"
    entrypoint:
      - /bin/sh
      - -c
      - start.sh
    volumes:
      - mysql_data:/var/lib/mysql
    restart: unless-stopped
下面是 start.sh 0.1
#!/bin/bash

set -e

# 执行 mysql 命令,将 init.sql 文件中的 SQL 脚本执行到 MySQL 数据库中
mysql -h "localhost" -u "root" -p"${MYSQL_ROOT_PASSWORD}" < /docker-entrypoint-initdb.d/init.sql

构建镜像启动容器,结果当然是什么都没有发生,为什么呢?这里也是一时没弄明白,毕竟我这种菜鸟,知识储备有限。

什么都没有发生的原因是没有启动数据库,默认情况下 MySQL 镜像会自动执行 docker-entrypoint.sh 脚本,这是默认 entrypoint(入口点),此时我们已经手动更改了 entrypoint,所以需要手动启动数据库。

进入 start.sh 0.2
#!/bin/bash

set -e

# 启动数据库
docker-entrypoint.sh mysqld

# 执行 mysql 命令,将 init.sql 文件中的 SQL 脚本执行到 MySQL 数据库中
echo "Executing SQL scripts..."
mysql -h "localhost" -u "root" -p"${MYSQL_ROOT_PASSWORD}" < /docker-entrypoint-initdb.d/init.sql

这个版本中增加了启动代码,重新构建并启动,容器正常启动,但是没有执行 init.sql。

这段脚本的问题是运行启动脚本后,后面的命令其实并没有执行。

如果将启动命令放在最后,也会报错:

2024-02-22 15:02:13 mysql: [Warning] Using a password on the command line interface can be insecure.
2024-02-22 15:02:13 ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)
2024-02-22 15:02:13 Executing SQL scripts...

忽略这里的 Warning,因为没有使用登录路径连接 MySQL,所以出现了这个提醒。

这个错误直接就让容器退出了,好歹上面的还正常运行了容器,在这里容器中的 MySQL 服务还没有完全启动就开始执行脚本,所以报错退出了。

讲到这里,我这大聪明一下醒悟了,那就加个监测 MySQL 完全启动的脚本呗!!!

来到 start.sh 0.3
#!/bin/bash

set -e

echo 'Waiting for MySQL to be available'
maxTries=10
while [ "$maxTries" -gt 0 ]; do
  if mysqladmin ping -h "localhost" --silent; then
    echo "MySQL is up and running"
    break
  else
    echo "MySQL is not available yet"
    sleep 1
    maxTries=$((maxTries - 1))
  fi
done

if [ "$maxTries" -eq 0 ]; then
  echo "MySQL failed to start"
  exit 1
fi

# 执行 mysql 命令,将 init.sql 文件中的 SQL 脚本执行到 MySQL 数据库中
echo "Executing SQL scripts..."
mysql -h "localhost" -u "root" -p"${MYSQL_ROOT_PASSWORD}" </docker-entrypoint-initdb.d/init.sql

docker-entrypoint.sh mysqld

这段脚本的逻辑如下:

  • 通过 mysqladmin ping -h "localhost" --silent; 命令循环检测 MySQL 服务是否启动;
  • 合计检测 10 次,如果 10 次后都还没有启动,则输出 MySQL failed to start;
  • 如果检测到 MySQL 服务已启动,则运行 init.sql
  • 最后启动数据库

到这里应该发现问题了,数据库启动脚本不管是放在前面还是放在后面,其实都有问题。

放在前面则后面的脚本没有执行,然而放在后面,则是如下结果:

2024-02-22 15:11:19 Waiting for MySQL to be available
2024-02-22 15:11:19 MySQL is not available yet
2024-02-22 15:11:20 MySQL is not available yet
2024-02-22 15:11:22 MySQL is not available yet
2024-02-22 15:11:23 MySQL is not available yet
2024-02-22 15:11:24 MySQL is not available yet
2024-02-22 15:11:25 MySQL is not available yet
2024-02-22 15:11:26 MySQL is not available yet
2024-02-22 15:11:27 MySQL is not available yet
2024-02-22 15:11:28 MySQL is not available yet
2024-02-22 15:11:29 MySQL is not available yet
2024-02-22 15:11:30 MySQL failed to start
正式版 start.sh 1.0

那么让检测脚本和启动脚本同时运行就行了,如下:

#!/bin/bash

set -e

(
  echo 'Waiting for MySQL to be available'
  maxTries=10
  while [ "$maxTries" -gt 0 ]; do
    if mysqladmin ping -h "localhost" --silent; then
      echo "MySQL is up and running"
      break
    else
      echo "MySQL is not available yet"
      sleep 1
      maxTries=$((maxTries - 1))
    fi
  done

  if [ "$maxTries" -eq 0 ]; then
    echo "MySQL failed to start"
    exit 1
  fi

  # 执行 mysql 命令,将 init.sql 文件中的 SQL 脚本执行到 MySQL 数据库中
  echo "Executing SQL scripts..."
  mysql -h "localhost" -u "root" -p"${MYSQL_ROOT_PASSWORD}" </docker-entrypoint-initdb.d/init.sql
) &

exec docker-entrypoint.sh mysqld

这是完整的脚本内容,循环检测和执行 init.sql 的脚本内容作为一个块,加上 & 使其后台运行,这时候就实现了执行默认 entrypoint 的同时执行检测脚本。

官方灵感

上面基本就是我的整个思考过程,写出来的目的主要还是为了分享探索的乐趣,同样也真的很折腾人。

一开始我尝试了很多办法,始终理解不了问题出在哪里,于是就去翻了下官方镜像的说明:

https://hub.docker.com/_/mysql?uuid=4A174430-5E24-4C0D-9ABD-F8EF43E3095D

把整个文档阅读完后,最下面发现了文章开头的 No connections until MySQL init completes,里面提到了两个实现示例,一个是 WordPress,一个是 Bonita。

正是从这两个示例中,给了我解决思路。

WordPress

WordPress 的解决方式是在 sh 脚本中插入了一段 php 代码,代码的目的也是循环检查 MySQL 服务是否启动,我还写了一个类似的 Python 代码:

import os
import sys
import time
import pymysql

# 获取环境变量
host = "localhost"
user = "root"
password = os.getenv("MYSQL_ROOT_PASSWORD")
database = "mysql"

max_tries = 10
while max_tries > 0:
    try:
        connection = pymysql.connect(
            host=host, user=user, password=password, database=database
        )
        print("Connected to MySQL")
        break
    except pymysql.err.OperationalError as e:
        print("Waiting for MySQL to be up...")
        max_tries -= 1
        time.sleep(1)

if max_tries <= 0:
    sys.exit(1)

这里不得不说一下,MySQL 官方镜像是基于 Oracle Linux Server 的,默认自带 Python 3.9,让这个方案得以执行。

正因如此,才想到那就让 sh 脚本也后台执行。

Bonita

Bonita 的实现方式是 MySQL 容器保持默认状态,再新起一个服务,这个服务专门用来启动 MySQL 中的 init.sql 脚本,这里我直接放出官方的 Docker Compose 配置内容:

# Use tech_user/secret as user/password credentials
version: '3'

services:
  db:
    image: postgres:9.3
    environment:
      POSTGRES_PASSWORD: example
    restart: always
    command:
      - -c
      - max_prepared_transactions=100
  bonita:
    image: bonita
    ports:
      - 8080:8080
    environment:
      - POSTGRES_ENV_POSTGRES_PASSWORD=example
      - DB_VENDOR=postgres
      - DB_HOST=db
      - TENANT_LOGIN=tech_user
      - TENANT_PASSWORD=secret
      - PLATFORM_LOGIN=pfadmin
      - PLATFORM_PASSWORD=pfsecret
    restart: always
    depends_on:
      - db
    entrypoint:
      - bash
      - -c
      - |
        set -e
        echo 'Waiting for Postgres to be available'
        export PGPASSWORD="$$POSTGRES_ENV_POSTGRES_PASSWORD"
        maxTries=10
        while [ "$$maxTries" -gt 0 ] && ! psql -h "$$DB_HOST" -U 'postgres' -c '\l'; do
            sleep 1
        done
        echo
        if [ "$$maxTries" -le 0 ]; then
            echo >&2 'error: unable to contact Postgres after 10 tries'
            exit 1
        fi
        exec /opt/files/startup.sh

这个思路也非常有意思,是一个很好的最佳实践,也算是我的备选方案,如果我的服务架构越来越复杂,或许会用这样一个服务来统一管理。


以上。