65.9K
CodeProject 正在变化。 阅读更多。
Home

在 Docker Compose 中控制服务启动顺序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2018年9月15日

MIT

10分钟阅读

viewsIcon

20128

确保您的 Docker Compose 服务按正确的顺序启动

目录

引言

如果您使用 Docker Compose 在一台机器上运行多个容器,迟早会遇到需要确保服务 A 在服务 B 之前 运行的情况。典型的例子是需要访问数据库的应用程序;如果这两个 Compose 服务都通过 docker-compose up 命令启动,那么可能会失败,因为应用程序服务可能在数据库服务启动之前启动,而那时数据库服务还无法处理其 SQL 语句。Docker Compose 的开发者们已经考虑到了这个问题,并提供了 depends_on 指令来表达服务之间的依赖关系。

另一方面,仅仅因为数据库服务在应用程序服务之前启动,并不意味着数据库已经准备好处理连接(处于“就绪”状态)。任何关系型数据库系统都需要在能够处理传入连接之前启动其自身的服务(例如,查看 SQL Server 启动步骤的简化视图),并且启动可能需要一段时间,因此除了指定依赖项之外,我们还需要一种更好的机制来检测特定 Compose 服务的“就绪”状态。

在本文中,我将介绍几种受到官方 建议 和其他来源启发的处理方法。每种方法都将使用自己的 Compose 文件,并且这些 Compose 文件中的每一个都至少包含两个服务:一个 Java 8 控制台应用程序和一个 MySQL v5.7 数据库;前者将使用 纯 JDBC 连接后者,读取一些 元数据,然后将它们打印到控制台。

所有 Compose 文件都将使用相同的 Java 应用程序 Docker 镜像

本文最后还有一个奖励部分,请务必查看!

重要事项

  • 我的环境
    • Windows 10 x64 Pro
    • Docker v18.03.1-ce-win65 (17513)
    • Docker Compose v1.21.1, build 7641a569
  • 本文使用的源代码可以在 GitHub 上找到
  • 本文的 .NET Core 版本可以在这里找到: https://github.com/satrapu/iquest-keyboards-and-mice-brasov-2018
  • 以下所有命令都必须从以管理员身份运行的 Powershell 控制台执行
  • 另外,因为我很懒,所以我将 Linux shell 命令嵌入到了 Docker Compose 文件中,这绝对不是一种最佳实践,但由于本文的重点是服务启动顺序而不是 Docker Compose 文件最佳实践,请您谅解 - 请查看 .NET Core 版本以了解正确的方法。
  • 在通过 docker-compose up 启动任何 Compose 服务之前,我将使用 mvndocker-compose downdocker-compose build 命令来确保
    • 我将使用默认的 Maven 目标来运行最新构建的 Java 应用程序;就我而言,这是:clean compile assembly:single
    • 任何正在运行的 Compose 服务都将被停止
    • Compose 文件中声明的任何 Docker 镜像都将被重新构建
  • 上述 Compose 文件使用了在 .env 文件中声明的变量,其内容如下
mysql_root_password=<ENTER_A_PASSWORD_HERE>

mysql_database_name=jdbcwithdocker
mysql_database_user=satrapu
mysql_database_password=<ENTER_A_DIFFERENT_PASSWORD_HERE>

java_jvm_flags=-Xmx512m
java_debug_port=9876

# Use "suspend=y" to ensure the JVM will pause the application, 
# waiting for a debugger to be attached
java_debug_settings=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=9876

# The amount of time between two consecutive health state checks 
# (used by docker-compose-using-healthcheck.yml)
healthcheck_interval=2s

# The maximum amount of time each healthcheck state try must end in
# (used inside docker-compose-using-healthcheck.yml)
healthcheck_timeout=5s

# The maximum amount of retries before giving up and considering 
# the Docker container in an unhealthy state
# (used by docker-compose-using-port-checking.yml and docker-compose-using-api.yml)
healthcheck_retries=20

# The amount of time between two consecutive queries against the database
# (used by docker-compose-using-port-checking.yml)
check_db_connectivity_interval=2s

# The maximum amount of retries before giving up and considering 
# the database is not able to process incoming connections
# (used by docker-compose-using-port-checking.yml)
check_db_connectivity_retries=20

# The Docker API version to use when querying for container metadata
# (used by docker-compose-using-api.yml)
docker_api_version=1.37

由于 .env 文件包含数据库密码等敏感信息,因此不应将其纳入源代码控制。

解决方案 #1:使用 depends_on、condition 和 service_healthy

此解决方案使用了此 Docker Compose 文件:docker-compose-using-healthcheck.yml

使用以下命令运行它

mvn `
;docker-compose --file docker-compose-using-healthcheck.yml down --rmi local `
;docker-compose --file docker-compose-using-healthcheck.yml build `
;docker-compose --file docker-compose-using-healthcheck.yml up 

从版本 1.12 开始,Docker 添加了用于验证容器是否仍在工作的 HEALTHCHECK Dockerfile 指令。Docker Compose 文件自 2.1 版本起增加了对使用健康检查来表达服务依赖关系的支持,如 兼容性矩阵 中所述。

我的数据库服务将通过一个 MySQL 客户端命令定义其 健康检查,该命令将通过 USE SQL 语句定期查询底层 MySQL 数据库是否已准备好处理传入连接。

...
db:
    image: mysql:5.7.20
    healthcheck:
      test: >
        mysql \
          --host='localhost' \
          --user='${mysql_database_user}' \
          --password='${mysql_database_password}' \
          --execute='USE ${mysql_database_name}' \
      interval: ${healthcheck_interval}
      timeout: ${healthcheck_timeout}
      retries: ${healthcheck_retries}
...

请记住,USE 语句不是执行此类检查的唯一方法。例如,可以定期运行一个 SQL 脚本,该脚本将测试数据库是否可访问并且数据库用户是否已获得所有预期的权限(例如,是否可以对特定表执行 INSERT 等)。

一旦数据库服务达到“健康”状态,“我的应用程序服务”便会 启动

...
app:
    image: satrapu/jdbc-with-docker-console-runner
    ...
    depends_on:
      db:
        condition: service_healthy
...

正如您所见,声明 db 和 app 服务之间的依赖关系非常容易,健康检查也是如此。更好的是,这些功能是 Docker Compose 内置的。

而现在是坏消息:由于 Docker Swarm 也使用 Docker Compose 文件格式,开发团队决定从 compose file v3 开始将此功能标记为过时,如 此处 所述;有关此决定的更多理由,请参阅 此处

depends_onconditionservice_healthy 仅在使用较旧的 compose 文件版本(v2.1 到 v2.4)时才可用。

请注意,Docker Compose 可能会在未来版本中删除对这些版本的支持,但只要您不介意使用 v3 之前的 compose 文件版本,此解决方案都非常简单易懂且易于使用。

解决方案 #2:带点转折的端口检查

此解决方案使用了此 Docker Compose 文件:docker-compose-using-port-checking.yml

使用以下命令运行它

mvn `
;docker-compose --file docker-compose-using-port-checking.yml down --rmi local  `
;docker-compose --file docker-compose-using-port-checking.yml build `
;docker-compose --file docker-compose-using-port-checking.yml up --exit-code-from check_db_connectivity check_db_connectivity `
;if ($LASTEXITCODE -eq 0) { docker-compose --file docker-compose-using-port-checking.yml up app } `
else { echo "ERROR: Failed to start service due to one of its dependencies!" }

此解决方案的灵感来自 Dariusz Pasciak 的一篇文章 之一,但我不仅仅是检查 MySQL 端口 3306 是否打开(端口检查),正如 Dariusz 所做的那样:我使用 check_db_connectivity Compose 服务中的 MySQL 客户端来运行上述 USE SQL 语句,以确保底层数据库能够处理传入连接(转折);此外,由于 --exit-code-from check_db_connectivity Compose 选项,将评估 check_db_connectivity 服务的退出代码,如果它不等于 0(这表示 db 服务处于所需的就绪状态),则会打印错误消息,应用程序服务将不会启动。

  • Docker Compose 将尝试启动 check_db_connectivity 服务,但它会发现它依赖于 db 服务

    ...
     db:
        image: mysql:5.7.20
    ...
     check_db_connectivity:
        image: activatedgeek/mysql-client:0.1
        depends_on:
          - db
    ...
  • Docker Compose 将启动 db 服务
  • Docker Compose 将然后启动 check_db_connectivity 服务,该服务将开始一个循环,检查 MySQL 数据库是否可以处理传入连接。
  • Docker Compose 将等待 check_db_connectivity 服务完成其循环,因为该循环是服务 entry point 的一部分。
    check_db_connectivity:
      image: activatedgeek/mysql-client:0.1
      entrypoint: >
        /bin/sh -c "
          sleepingTime='${check_db_connectivity_interval}'
          totalAttempts=${check_db_connectivity_retries}
          currentAttempt=1
    
          echo \"Start checking whether MySQL database \
                "${mysql_database_name}\" is up & running\" \
                \"(able to process incoming connections) 
                each $$sleepingTime for a total amount of $$totalAttempts times\"
    
          while [ $$currentAttempt -le $$totalAttempts ]; do
            sleep $$sleepingTime
              
            mysql \
              --host='db' \
              --port='3306' \
              --user='${mysql_database_user}' \
              --password='${mysql_database_password}' \
              --execute='USE ${mysql_database_name}'
    
            if [ $$? -eq 0 ]; then
              echo \"OK: [$$currentAttempt/$$totalAttempts] MySQL database \
                    "${mysql_database_name}\" is up & running.\"
              return 0
            else
              echo \"WARN: [$$currentAttempt/$$totalAttempts] MySQL database \"
                     ${mysql_database_name}\" is still NOT up & running ...\"
              currentAttempt=`expr $$currentAttempt + 1`
            fi
          done;
    
          echo 'ERROR: Could not connect to MySQL database \"
                ${mysql_database_name}\" in due time.'
          return 1"	    
  • Docker Compose 将然后启动 app 服务;此时,MySQL 数据库已能够处理传入连接。
    app:
        image: satrapu/jdbc-with-docker-console-runner
        depends_on:
          - db

此解决方案与前一个解决方案类似,都是应用程序服务等待直到数据库服务进入特定状态,但它不使用 Docker Compose 的过时功能。

解决方案 #3:调用 Docker Engine API

此解决方案使用了此 Docker Compose 文件:docker-compose-using-api.yml

使用以下命令运行它

$Env:COMPOSE_CONVERT_WINDOWS_PATHS=1 `
;mvn `
;docker-compose --file docker-compose-using-api.yml down --rmi local  `
;docker-compose --file docker-compose-using-api.yml build `
;docker-compose --file docker-compose-using-api.yml up

重要提示

在不包含 COMPOSE_CONVERT_WINDOWS_PATHS 环境变量的情况下运行上述命令将会失败

...
Creating jdbc-with-docker_app_1 ... error

ERROR: for jdbc-with-docker_app_1  Cannot create container for service app: 
b'Mount denied:\nThe source path "\\\\var\\\\run\\\\docker.sock:/var/run/docker.sock"\nis 
not a valid Windows path'
...

此问题及其修复 在此处 有记录。

我非常喜欢通过健康检查来表达 Compose 服务之间依赖关系的想法。由于 depends_oncondition 形式迟早会消失,我考虑实现一些概念上相似的东西,其中一种方法是使用 Docker Engine API

我的方法是从应用程序服务的入口点定期查询数据库服务的健康状态,方法是向 Docker API 端点发出 HTTP 请求,并使用 jq(一个命令行 JSON 处理器)解析响应;Java 应用程序将在数据库服务达到“健康”状态后立即启动。

首先,我将通过一个简单的 curl 命令获取包含所有正在运行的容器信息的 JSON 文档。特殊之处在于使用 curlunix-socket 选项,因为 Docker 守护进程使用这种套接字。此外,我需要将 docker.sock 作为卷暴露给运行 curl 命令的容器,以便它能够与本地 Docker 守护进程通信。

重要提示

共享您的本地 Docker 守护进程套接字应谨慎进行,因为它可能导致安全问题,正如 此处 清楚地描述的那样,因此在使用此方法之前请仔细考虑所有事项!

安全提示已播放完毕,下面是一个示例,展示了用于列出本地主机上所有正在运行的 Docker 容器的独立命令 — 请注意,我正在 byrnedo/alpine-curl 容器中运行 curl,而实际命令是从基于 openjdk:8-jre-alpine Docker 镜像的容器执行的。

# Ensure db service is running before querying its metadata
docker-compose --file docker-compose-using-api.yml up -d db `
;docker container run `
       --rm `
       -v /var/run/docker.sock:/var/run/docker.sock `
       byrnedo/alpine-curl `
          --silent `
          --unix-socket /var/run/docker.sock `
          http://v1.37/containers/json

输出将类似于此

[
   ...
  [  
   {  
      "Id":"5d9108769de3641692a5d636aa361866f09e6403309e6262520447dae9115344",
      "Names":[  
         "/jdbc-with-docker_db_1"
      ],
      "Image":"mysql:5.7.20",
      "ImageID":"sha256:7d83a47ab2d2d0f803aa230fdac1c4e53d251bfafe9b7265a3777bcc95163755",
      "Command":"docker-entrypoint.sh mysqld",
      "Created":1525887950,
      "Ports":[  
         {  
            "IP":"0.0.0.0",
            "PrivatePort":3306,
            "PublicPort":32771,
            "Type":"tcp"
         }
      ],
      "Labels":{  
         "com.docker.compose.config-hash":"cea84824338bc0ea6a7da437084f00a8bfc9647b91dd8de5e41694269498dec6",
         "com.docker.compose.container-number":"1",
         "com.docker.compose.oneoff":"False",
         "com.docker.compose.project":"jdbc-with-docker",
         "com.docker.compose.service":"db",
         "com.docker.compose.version":"1.21.1"
      },
      "State":"running",
      "Status":"Up 6 seconds (healthy)",
      "HostConfig":{  
         "NetworkMode":"jdbc-with-docker_default"
      },
      "NetworkSettings":{  
         "Networks":{  
            "jdbc-with-docker_default":{  
               "IPAMConfig":null,
               "Links":null,
               "Aliases":null,
               "NetworkID":"fd1c60a463a8b39dd3cb9b34c8e5792c069e18cd5076f6321f5554c10ec1765d",
               "EndpointID":"b80cfc9c45e0816cd9af9507f76e3a0f9f1e203d2d2b0e081b8affc1293e8cf4",
               "Gateway":"172.18.0.1",
               "IPAddress":"172.18.0.2",
               "IPPrefixLen":16,
               "IPv6Gateway":"",
               "GlobalIPv6Address":"",
               "GlobalIPv6PrefixLen":0,
               "MacAddress":"02:42:ac:12:00:02",
               "DriverOpts":null
            }
         }
      },
      "Mounts":[  
         {  
            "Type":"volume",
            "Name":"jdbc-with-docker_jdbc-with-docker-mysql-data",
            "Source":"/var/lib/docker/volumes/jdbc-with-docker_jdbc-with-docker-mysql-data/_data",
            "Destination":"/var/lib/mysql",
            "Driver":"local",
            "Mode":"rw",
            "RW":true,
            "Propagation":""
         }
      ]
   },
   ...
]

其次,我将使用 各种 jq 运算符和函数来提取数据库服务的健康状态。

jq '.[] | select(.Names[] | contains("_db_")) | 
select(.State == "running") | .Status | contains("healthy")'

# The output should be "true" in case the db service has reached the healthy state
  • .[]:这将选择给定 JSON 文档中的所有记录。
  • select(.Names[] | contains(“_db_”)):这将选择“Names”数组属性包含“_db_字符串的记录 — Docker Compose 创建的 Docker 容器名称包含服务名称;在本例中是“db”。
  • select(.State == “running”):这将只选择正在运行的 Docker 容器。
  • .Status | contains(“healthy”):这将选择“Status”属性的值,如果容器已达到健康状态,则该值为“true”。

为了获得 Docker Compose 文件中的最终 jq 命令,我曾尝试使用 jq Playground。请注意,这不是从 Docker JSON 中提取健康状态的唯一方法 — 发挥您的想象力来想出更好的 jq 命令。

结论

控制 Docker Compose 中的服务启动顺序是不可忽视的,但我希望本文介绍的方法能帮助大家了解从何开始。

我充分意识到这些并非唯一的选择 — 例如,ContainerPilot,它实现了 autopilot 模式,看起来非常有趣。另一种选择是将延迟启动逻辑移到依赖服务中(例如,让我的 Java 控制台应用程序使用具有更长超时时间的连接池来获取到 MySQL 数据库的连接),但这需要粘合代码来检查每个依赖项(一种方法用于 MySQL,另一种方法用于缓存提供程序,如 Memcache 等)。好消息是,有很多选择,您只需确定哪种最适合您的用例。

奖励

在开发 Java 控制台应用程序的过程中,我遇到了一些挑战,我想在这里也提到它们以及它们的解决方案,因为这可能会帮助到其他人。

Maven Assembly 插件

在 Maven pom.xml 文件中添加依赖项很简单但不是文档齐全,但之后您需要确保依赖项 JAR 文件将正确地与您的控制台应用程序打包在一起。

将所有文件打包到一个 JAR 的一种方法是使用 Maven Assembly 插件 并使用其 assembly:single 目标,就像我 所做的那样

运行此目标后,将在 ./target 文件夹下创建一个 jdbc-with-docker-jar-with-dependencies.jar 文件,而不是通常的 jdbc-with-docker.jar,这就是为什么我在 Dockerfile 中 将 JAR 文件重命名 为一个更短的名称。

调试 Docker 化 Java 应用程序

调试 Java 进程意味着使用一些与调试相关的 参数 来启动该进程。

其中两个参数对调试至关重要

  • address,表示 JVM 监听调试器的端口;启动调试会话时,IDE 端必须配置相同的端口。
  • suspend,它指定 JVM 是否应该阻止并等待调试器连接。

由于我在这款 Java 应用程序的开发过程中使用 Visual Studio Code,因此我需要创建一个调试配置,并在 .env 文件中通过键 java_debug_port(例如,java_debug_port=9876)指定端口。另一方面,由于应用程序将在容器内运行,因此需要将此端口发布到 IDE 正在运行的 Docker 主机。

启动应用程序并观察 JVM 是否在等待调试器

$Env:COMPOSE_CONVERT_WINDOWS_PATHS=1 `
>> ;mvn `
>> ;docker-compose --file docker-compose-using-api.yml down --rmi local  `
>> ;docker-compose --file docker-compose-using-api.yml build `
>> ;docker-compose --file docker-compose-using-api.yml up
# ...
# Creating jdbc-with-docker_db_1 ... done
# Creating jdbc-with-docker_app_1 ... done
# Attaching to jdbc-with-docker_db_1, jdbc-with-docker_app_1
# ...
# db_1   | 2018-05-12T20:46:19.560436Z 0 [Note] Beginning of list of non-natively partitioned tables
# db_1   | 2018-05-12T20:46:19.574074Z 0 [Note] End of list of non-natively partitioned tables
# app_1  | Start checking whether MySQL database jdbcwithdocker is up & running 
# (able to process incoming connections) each 2s for a total amount of 20 times
# app_1  | OK: [1/20] MySQL database jdbcwithdocker is up & running.
# app_1  | Listening for transport dt_socket at address: 9876

Docker Compose 可以通过以下命令获取主机端口

docker-compose --file docker-compose-using-api.yml  port --protocol=tcp app 9876
  # 0.0.0.0:32809

Visual Studio Code 需要在其调试配置中使用端口 32809

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "java",
            "name": "Debug (Attach)",
            "request": "attach",
            "hostName": "localhost",
            "port": 32809
        }
    ]
}

然后启动调试配置,并观察 Java 应用程序生成的以下输出

...
app_1  | JDBC_URL="jdbc:mysql://address=(protocol=tcp)(host=db)(port=3306)/jdbcwithdocker?useSSL=false"
app_1  |
app_1  | JDBC_USER="satrapu"
app_1  |
app_1  | JDBC_PASSWORD="********"
app_1  |
app_1  | --------------------------------------------------------------------------------------------------------------
app_1  | |              TABLE_SCHEMA |                                         TABLE_NAME |                TABLE_TYPE |
app_1  | --------------------------------------------------------------------------------------------------------------
app_1  | |        information_schema |                                     CHARACTER_SETS |               SYSTEM VIEW |
app_1  | |        information_schema |                                         COLLATIONS |               SYSTEM VIEW |
app_1  | |        information_schema |              COLLATION_CHARACTER_SET_APPLICABILITY |               SYSTEM VIEW |
...
app_1  | |        information_schema |                                              VIEWS |               SYSTEM VIEW |
app_1  | --------------------------------------------------------------------------------------------------------------
app_1  | Application was successfully able to fetch data out of the underlying database!
jdbc-with-docker_app_1 exited with code 0

资源

历史

  • 2018 年 9 月 15 日 - 初始版本
© . All rights reserved.