Kubernetes 部署应用最佳实践
Dockerfile 最佳实践
现阶段 docker-cli 仍然是最流行的容器镜像构建工具之一,因此本文以 Docker 作为构建工具举例,其它构建工具同样适用。
选择合适的基础镜像
为减少镜像大小和提高安全性,应选择一个包含应用程序所需最小依赖项的基础镜像。同时,确保该基础镜像得到及时更新和维护。
以下是一些建议帮助你选择合适的基础镜像:
- 最小化基础镜像:选择 Alpine Linux 等轻量级发行版作为基础镜像,可以显著减小最终镜像的大小。
- 官方镜像:优先考虑使用官方提供的镜像,如在 Docker Hub 上的官方镜像仓库,如
docker.io/library/<name>
。官方镜像是经过良好测试和维护的,通常包含了稳定的基础环境和常用的工具包。 - 语言和框架特定镜像:对于特定编程语言或框架,如 Node.js、Python、Java、Ruby 等,使用对应的语言环境镜像,如
node:<version>
,python:<version>
,openjdk:<version>
,ruby:<version>
等。这些镜像包含了运行语言应用所需的基本环境和工具。 - 安全更新:关注基础镜像的安全更新。官方镜像通常会定期更新补丁,选择最新的稳定版本有助于确保安全性。
- 应用需求:根据应用的需求选择基础镜像,例如,若应用需要 Apache 或 Nginx 服务器,则可以选择相应的官方镜像作为基础。
- OS 特性:考虑到 OS 的特性,有些应用可能需要完整的操作系统环境,例如 apt-get 或 yum 包管理器等,此时可能需要 Debian 或 CentOS 类似的完整发行版镜像。
- 尺寸与便利权衡:虽然较小的基础镜像有利于减少部署时间和节省磁盘空间,但是过于精简的镜像可能缺乏某些常用工具或库,需要根据具体情况权衡。
- 社区支持与成熟度:选择活跃社区支持且较为成熟的镜像,以便于遇到问题时能找到更多资源和支持。
需要注意的是,使用 Alpine Linux 时,应注意以下几点:
- Alpine Linux 使用 musl libc 替代 glibc 作为 C 标准库,这可能导致一些依赖 glibc 特定行为的应用程序无法直接运行。如果你的应用程序或第三方库依赖 glibc 特性,需要评估是否兼容 musl 或寻找替代方案。
- 由于 musl 和 glibc 的差异,以及 Alpine 的轻量化特点,某些软件发行的二进制包可能无法在 Alpine Linux 上运行,可能需要自行编译源码或寻找专门为 Alpine 构建的版本。
- Alpine Linux 镜像极其小巧,这也意味着默认安装的工具集比一般发行版更为精简,如果需要额外工具,需要手动添加。
缓存构建过程
Dockerfile 中的指令会按照顺序执行,并且每个指令都会生成一个新的镜像层。为了加速镜像的构建过程,可以利用 docker build 的缓存机制。通过将一些不变的指令放在 Dockerfile 的前面,并尽量减少在构建过程中发生更改的部分,你可以确保 Docker 能够重用缓存层,从而减少镜像的构建时间。
充分利用中间层缓存可以显著提高构建速度,尤其是在大型项目中,每次改动都会触发一系列构建步骤时。以下是一些利用中间层缓存的有效措施:
维护有序的指令:
- Dockerfile 中的每条指令都会形成一层中间层。确保将经常变动的指令放在文件后面,将不常变动的指令放在前面。这样,当代码或配置文件发生变化时,只会导致后面的中间层失效,而前面未变动部分的缓存仍可复用。
排序 COPY 和 ADD 指令:
- 将不会频繁更改的静态文件复制操作放在前面,变动频繁的代码文件放在后面。例如,先把基础配置文件复制进去,然后再拷贝源代码。
使用 .dockerignore 文件:
- 使用
.dockerignore
文件排除不必要的构建上下文文件,避免不必要的缓存失效。
- 使用
精确的文件列表:
- 在
COPY
或ADD
指令中指定具体的文件名而不是通配符,减少因无关文件修改导致的缓存失效。
- 在
版本化依赖:
- 对于软件包管理器(如
apt
、yum
、npm
、pip
等)安装的依赖,确保每次都指向相同的版本或锁定文件,以维持缓存一致性。
- 对于软件包管理器(如
参数化构建
使用 ARG 变量:
- 使用
ARG
指令提前声明变量,并在之后的RUN
或COPY
指令中使用这些变量,以便于在依赖版本改变时只需更新变量值而不必改动 Dockerfile 的结构。 通过
--build-arg
传递变量给构建器FROM openjdk:11-jre-slim ARG VERSION ENV DISTRO_NAME=canal.deployer-${VERSION}.tar.gz # Download RUN set -eux; \ apt-get install -y --no-install-recommends wget file; \ wget https://github.com/alibaba/canal/releases/download/canal-${VERSION}/${DISTRO_NAME}; \ ......
使用合适的标签和版本控制
在Dockerfile中,为镜像添加有意义的标签和版本控制是很重要的。标签可以帮助你标识镜像的版本和用途,而版本控制则可以让你追踪镜像的变更历史。
- 使用语义化版本号 (
major.minor.patch
) 或 Git commit hash 为镜像打标签。 - 使用时间字符串为镜像打标签,精确到秒,避免标签重复,如
20240326104327
。 - 制定清晰的镜像标签策略,方便回滚和追踪变更历史。
每次构建都有唯一的标签,避免标签重复。因为相同的标签,至少存在两个问题:
- 部署清单不变,不会重新部署;
- 即使重新部署,如果拉取策略是 IfNotPresent 且本地已经存在了相同标签的镜像,则不会重新拉取镜像。
安全最佳实践
在制作容器镜像时,以下是一些强化镜像安全性的措施:
最小化基础镜像:
- 使用最小化且安全记录良好的基础镜像,如 Alpine Linux,这样可以减少潜在的安全漏洞。
保持软件最新:
- 在 Dockerfile 中明确指定软件包的版本,尤其是针对依赖项和库。使用固定的版本有助于防止因依赖项版本过时而导致的安全风险。
- 在安装软件包时,确保执行
apk update
或apt-get update
更新软件源列表,并随后安装最新的安全补丁和更新。
移除无用软件:
- 清理构建过程中安装但不再需要的工具和库,避免遗留不必要的攻击面。
- 使用
RUN
命令末尾的--no-install-recommends
(适用于 Debian/Ubuntu 系统)以避免安装不必要的推荐软件包。
避免 root 用户:
- 避免在镜像中以 root 用户运行应用,除非确实必要。创建非 root 用户并赋予适当权限,降低潜在的权限滥用风险。
安全配置:
- 对于安装的软件,确保它们配置得当,例如禁用不必要的服务、关闭不必要的网络端口、启用安全相关的设置等。
避免暴露敏感信息:
- 不要在 Dockerfile 中明文写入密码、密钥或其他敏感信息。可以使用环境变量、Kubernetes Secrets 或其它途径安全地传递这些信息。
多阶段构建:
- 使用多阶段构建分离编译环境和运行环境,确保最终镜像中不包含编译时生成的临时文件和构建工具,进一步减小攻击面。
镜像签名与验证:
- 对构建完成的镜像进行数字签名,确保分发和运行时能验证镜像来源的可信性。
审计与扫描:
- 使用 Trivy 或其它安全扫描工具对构建好的镜像进行漏洞扫描和安全审计。
容器安全基线:
- 遵循行业标准和最佳实践,如 CIS Docker Benchmark,确保镜像符合安全配置基线要求。
合并多个 RUN 指令
在 Dockerfile 中,经常需要安装多个软件包或执行多个命令,这通常意味着需要使用多个 RUN 指令。然而,频繁地使用 RUN 指令会导致容器镜像的构建过程变得低效,因为每个 RUN 指令都会创建一个新的镜像层,这会增加镜像的大小和构建时间。为了解决这个问题,合并多个 RUN 指令,以减少镜像层数和提高构建效率。
合并多个 RUN 指令的一种常见方法是使用单个 RUN 指令,并在其中使用分号(;)或逻辑运算符(&&)来分隔多个命令。这样可以确保所有命令都在同一个镜像层中执行,从而减少了镜像的大小和构建时间。
以下是一个示例,演示了如何将多个 RUN 指令合并为一个:
FROM alpine:3.19.1
ENV JAVA_ALPINE_VERSION 8.402.06-r0
RUN set -x \
&& sed -i 's#https://dl-cdn.alpinelinux.org#http://mirrors.tencentyun.com#g' /etc/apk/repositories \
&& apk add --no-cache \
tini \
ttf-dejavu \
fontconfig \
tzdata \
openjdk8="$JAVA_ALPINE_VERSION" \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& apk del tzdata \
&& [ "$JAVA_HOME" = "$(docker-java-home)" ]
在这个示例中,将原本需要多个 RUN 指令才能完成的任务合并为一个 RUN 指令。通过使用逻辑运算符(&&)来分隔命令,确保每个命令都按顺序执行,并且只有在前一个命令成功执行后,下一个命令才会执行。
合并多个 RUN 指令不仅可以减少镜像层数和构建时间,还可以提高容器镜像的缓存效率。当 Dockerfile 中的指令发生更改时,镜像构建工具会根据指令的哈希值来确定是否需要重新构建镜像层。由于合并后的 RUN 指令具有相同的哈希值,因此当指令内容未发生更改时,镜像构建工具会重复使用之前构建的镜像层,从而避免了不必要的构建操作。
清除不需要的文件
在构建镜像的过程中,经常需要执行一些命令来安装软件、配置环境等。然而,这些命令执行完毕后,可能会留下一些不必要的文件,例如临时文件、日志文件、缓存文件等。这些文件不仅增加容器的体积,还可能对镜像的安全性造成潜在威胁。因此,在构建完镜像后,需要及时清除这些不必要的文件。
FROM alpine:3.19.1
RUN set -x \
&& wget https://xxx.com/xxx.tar.gz \
&& tar xf xxx.tar.gz \
&& rm -f xxx.tar.gz
在这个例子中,构建阶段从互联网下载了一个压缩包并解压。解压后这个压缩包就没有其它用途了,因此需要顺便将压缩包给删除掉,以减少镜像的体积。
需要注意的是清理命令的执行时机很重要,清理命令需要和产生临时文件的命令合并到同一个 RUN 指令中执行,才能达到减少体积的效果。如果清理命令单独写一条 RUN 指令,则临时文件已经固化在上一个镜像层了,并不会达到减少体积的效果,你只是看不见这个文件而已。
多阶段构建
多阶段构建是一种在 Dockerfile 中使用多个 FROM 指令来构建单个镜像的方法。这种方法可以帮助你减少镜像的大小,并提高镜像的构建效率。通过将构建和运行过程分离,可以只将必要的文件和依赖项包含在最终的镜像中。
FROM golang:alpine3.19.1
RUN set -x \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git make \
&& cd /go \
&& git clone https://github.com/awesome-proxy/awesome-proxy.git \
&& cd /go/awesome-proxy \
&& export GOPROXY=https://proxy.golang.com.cn,direct \
&& make
FROM alpine:3.19.1
RUN set -x \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache bash curl coreutils
COPY --from=0 /go/awesome-proxy/bin/awesome-proxy-linux-amd64 /usr/local/bin/awesome-proxy
COPY --from=0 /go/awesome-proxy/conf/conf.yaml /etc/awesome-proxy.yaml
CMD ["/usr/local/bin/awesome-proxy", "--config", "/etc/awesome-proxy.yaml"]
在这个编译 Golang 代码的例子中,使用了多阶段构建。
第一阶段:
- 使用 golang 基础镜像,此镜像包含 golang 编译工具
- 下载 git、make 等构建工具链
- git clone 下载代码
- make 编译代码,编译过程还会下载很多 golang 的依赖库
第二阶段:
- 使用 alpine 作为基础镜像
- 从第一阶段复制可执行二进制文件和配置文件
由此可见,编译 Golang 代码需要的特定的工具链,而执行 Golang 编译后的二进制,则一般不需要任何的依赖,因此使用多阶段构建可以极大地减少镜像体积。
预留扩展空间
entrypoint 脚本
ENTRYPOINT ["docker-entrypoint.sh"]
在 Dockerfile 中,ENTRYPOINT 指令用来定义容器启动时的默认执行程序。
使用 ENTRYPOINT 脚本可以让容器更具扩展性,因为你可以在脚本中添加额外的逻辑,比如环境变量检查、配置文件验证、日志记录等。此外,ENTRYPOINT 脚本还可以用于启动多个进程,这对于需要同时运行多个服务的容器来说非常有用。
通过使用 ENTRYPOINT 脚本,你可以轻松地扩展容器的功能,而无需修改 Dockerfile 再重新构建。此外,
docker run
命令中的参数可以传递给 ENTRYPOINT 脚本,因此你可以在启动容器时传入自定义参数,从而进一步增加容器的灵活性。可扩展的初始化脚本
在 entrypoint 脚本中,使用以下技巧可以更进一步增强容器的扩展性。例子如下:
if [[ -d /docker-entrypoint.d ]]; then for f in /docker-entrypoint.d/*.sh; do /bin/sh $f done fi
在这个例子中
/docker-entrypoint.d
是一个目录,目录可以包含多个初始化脚本。每个脚本都应该是.sh
格式的 shell 脚本,并且应该设计为能够单独运行,不依赖于其他脚本的执行顺序。通过这种方式,你可以在容器启动时执行多个初始化任务,而这些任务可以独立地更新、添加或删除,而不会影响其他任务。这提供了很大的灵活性,使得容器可以根据需要扩展其功能。
举个例子,你可能有一个脚本用于配置网络设置,另一个用于安装某些软件包,还有另一个用于初始化数据库。通过将这些脚本放在
/docker-entrypoint.d
目录下,你可以很容易地添加、删除或修改这些脚本,而无需更改 entrypoint 脚本。
Pod 最佳实践
资源配额
在 Kubernetes 中为 Pod 设置合理的资源配额是确保集群稳定性和资源有效利用的关键步骤。资源配额主要是通过在 Pod 的定义中为每个容器设置资源请求(requests)和资源限制(limits)。具体步骤如下:
资源请求(requests):
请求表示容器运行时需要保证能够获得的最低资源量。Kubernetes 调度器会确保只有在节点上有足够的资源可供分配时才会将 Pod 调度到该节点上。
示例:apiVersion: v1 kind: Pod spec: containers: - name: my-app-container image: my-app:v1 resources: requests: cpu: 0.5 # 请求0.5核CPU memory: 512Mi # 请求512MB内存
资源限制(limits):
限制是容器可以使用的最大资源量。一旦达到这个限制,Kubernetes 就会采取措施来阻止容器进一步消耗资源,例如对超出 CPU 限制的容器进行节流(throttling)。
示例:apiVersion: v1 kind: Pod spec: containers: - name: my-app-container image: my-app:v1 resources: requests: cpu: 0.5 memory: 512Mi limits: cpu: 1 # 限制最多只能使用1核CPU memory: 1Gi # 限制最多只能使用1GB内存
当 Pod 内存占用超过 resources.limits.memory
设置的值时,操作系统会启动 Out-Of-Memory (OOM) Killer 机制将进程杀掉。意味着 Pod 将会被重启,因此设置 resources.limits.memory
时需要额外小心,Java 程序需要合理设置最大堆内存,一般的经验是最大堆内存设置为限额的 70% 左右。
对于其它编程语言编写的程序,比如 Go,与 Java 程序不同的是,Go 程序并没有像 JVM 那样可以直接设置最大堆内存大小,要合理设置内存限制,可以采用以下方法:
基准测试与性能分析:
- 使用 Go 自带的
testing
包编写基准测试(benchmarks),模拟程序在不同负载条件下的内存使用情况。 - 使用内存分析工具(如pprof)收集程序运行时的内存分配信息,找出内存峰值和常驻内存大小。
- 使用 Go 自带的
历史数据参考:
- 如果程序已经在生产环境中运行,可以收集一段时间内的内存使用统计数据,以此作为设置内存限制的依据。
预留额外空间:
- 设置内存限制时,除了程序的正常工作内存需求外,还要预留一部分内存作为缓冲,以应对突发的内存使用增长或潜在的内存泄漏问题。
观察容器行为:
- 在部署到容器环境后,先尝试一个较为宽松的内存限制,然后逐步调整至合适值,观察程序在受到内存限制后的表现,包括是否存在被系统OOME(Out of Memory Error)终结的风险。
遵循最佳实践:
- 设计程序时遵循良好的内存管理实践,例如避免长时间持有大对象,合理设计数据结构,及时关闭不再使用的资源等。
除了 CPU 和内存以外,Kubernetes 还支持:
- ephemeral-storage:这种资源代表了Pod可以使用的临时存储空间,包括容器的工作目录、临时文件和其他非持久化的磁盘使用。
- hugepages-
:特殊的大页内存资源,用于某些高性能计算场景 - 扩展资源:作为插件扩展,如 nvidia.com/gpu
设置合理的资源配额时,需要考虑的因素包括:
- 应用程序的实际需求:基于负载测试结果或历史数据,确定应用在正常运行和峰值负载下的资源消耗情况。
- 集群整体容量:根据集群中节点的总资源量来合理分配各个 Pod 的资源,确保不会因过度分配而导致节点资源耗尽。
- 业务优先级:根据业务重要性设置不同的资源限额,高优先级应用可以分配更多的资源保障。
此外,对于命名空间级别的资源管理,可以使用 ResourceQuota
对象来限制整个命名空间内资源的总体使用量,确保各个团队或项目之间的资源公平分配。同时,使用 LimitRange
可以强制要求命名空间内的所有 Pod 都具有某种最低或最高资源标准,从而保证整体的资源使用一致性。
探针
在 Kubernetes 中,探针(Probes)是用来检测容器健康状况的重要机制,主要包括两种类型:Liveness Probe 和 Readiness Probe。正确合理地使用探针能确保你的服务始终保持稳定和可靠。
Liveness Probe(存活探针):
Liveness 探针用于判断容器是否还在正常运行。当 liveness probe 失败时,kubelet 会认为容器已经死亡,此时 Kubernetes 会杀掉该容器并重新启动一个新的容器实例。因此,liveness 探针应该指向容器中能够快速反映服务是否处于不可恢复错误状态的检查点。设置合适的初始延迟(initialDelaySeconds)和检查间隔(periodSeconds)很重要,避免容器刚启动还未完全准备好就被误判为不健康。示例配置:
apiVersion: v1 kind: Pod spec: containers: - name: myapp-container image: myapp:v1 livenessProbe: exec: command: - /health-check-script.sh # 自定义的健康检查脚本 initialDelaySeconds: 30 # 在容器启动后30秒开始探测 periodSeconds: 10 # 每隔10秒执行一次探测
Readiness Probe(就绪探针):
Readiness 探针用于决定容器是否准备好接收请求。当 readiness probe 失败时,kubelet 会将容器标记为未就绪,而服务代理(如 kube-proxy 或 Ingress 控制器)将不再路由任何流量至该容器。这意味着容器可以进行内部初始化、加载数据等操作,直到它通过 readiness 探针的检查为止。示例配置:
apiVersion: v1 kind: Pod spec: containers: - name: myapp-container image: myapp:v1 readinessProbe: httpGet: path: /readiness # 健康检查接口 port: 8080 initialDelaySeconds: 5 # 在容器启动后5秒开始探测 periodSeconds: 10 # 每隔10秒执行一次探测
另外,在 Kubernetes v1.16 及以后版本中,新增了 Startup Probe(启动探针)。Startup Probe 主要是用来解决容器在启动过程中由于初始化时间较长,导致 Liveness Probe 和 Readiness Probe 误判容器不健康的问题。
启动探针的作用在于,在容器启动初期进行探测,直到它通过此探针的检查为止。在使用 Startup Probe 时,建议设置合理的 initialDelaySeconds
和 periodSeconds
,确保在容器启动过程中对其进行适当的探测,避免在初始化阶段被不必要的重启。一旦 Startup Probe 成功,后续的 Liveness 和 Readiness 探针将会按照各自配置的规则继续执行用。
使用启动探针可以给容器充分的启动时间,而不必担心在此期间被错误重启或流量涌入。
总结一下,合理使用探针的最佳实践包括:
- 根据应用程序的具体行为和启动时间设置适当的
initialDelaySeconds
。 - 确保探针检查的路径或端口能在容器内正确响应,反映出服务的真实状态。
- 对于一些慢启动服务,尤其要注意设置合理的就绪探针策略,避免服务在未准备好时就开始接收流量。
- 适时调整
timeoutSeconds
和failureThreshold
参数,以适应不同的检查场景。
通过以上方式,你可以确保 Kubernetes 能够准确及时地识别并处理容器健康状况的变化,从而维持整个集群的稳定运行。
生命周期(lifecycle)
Kubernetes 中的生命周期(Lifecycle)管理机制指的是对容器从创建、运行到最终销毁这一完整过程中的各个阶段进行控制与干预的能力。通过生命周期钩子(Lifecycle Hooks),用户可以自定义在不同阶段执行的操作,以确保容器在整个生命周期中的行为可控且满足特定的应用需求。
以下是 Kubernetes 中容器生命周期的主要组成部分:
容器状态(Container States):
Waiting
:容器正在等待外部条件满足才能继续,比如镜像拉取、资源限制等。Running
:容器已经启动并正在运行。Terminated
:容器已结束运行,可能因为成功完成、异常退出或其他原因。
Pod 状态(Pod Phase):
Pending
:Pod 正在调度或正在等待依赖资源准备就绪。Running
:Pod 已经调度并至少有一个容器正在运行,但可能还有容器尚未启动。Succeeded
:所有容器均正常退出,并且退出码为0。Failed
:有任何容器以非0退出码退出或被系统终止。Unknown
:无法获取Pod的状态信息。
容器生命周期钩子(Lifecycle Hooks):
PostStart
: 在容器启动后的第一时间执行,无论容器是否真正准备好接受请求。通常用于初始化工作或健康检查。PreStop
: 在容器被终止前执行,提供了一个执行清理操作或优雅退出的机会。
这些钩子可以通过 exec
(执行命令)或 httpGet
(发送HTTP请求)的方式定义操作。
例如,在 Pod 的 spec.containers.lifecycle
字段中定义 preStop
钩子:
apiVersion: v1
kind: Pod
metadata:
name: lifecycle-example
spec:
containers:
- name: my-container
image: my-app
lifecycle:
preStop:
exec:
command: ["./cleanup.sh"] # 执行一个清理脚本
这样,在 Kubernetes 准备终止 my-container
容器之前,会先执行 ./cleanup.sh
脚本来执行必要的清理动作。这有助于保持集群的健壮性和资源的有效利用。
亲和性调度
亲和性调度(Affinity and Anti-Affinity)是一项强大的特性,它允许管理员或开发人员控制 Pod 在节点上的分布方式,以实现更好的资源利用、高可用性、负载均衡以及满足特定的硬件或网络需求。亲和性和反亲和性调度主要分为两种类型:
节点亲和性(Node Affinity):
- 节点亲和性允许用户指定 Pod 应该运行在哪种类型的节点上,可以根据节点标签进行筛选。例如,您可以设置规则使得具有特定标签的 Pod 只部署在具有相同标签的节点上。
- 硬亲和性(RequiredDuringSchedulingIgnoredDuringExecution):如果不能满足亲和性规则,Kubernetes 将不会调度 Pod 到任何节点上。
- 软亲和性(PreferredDuringSchedulingIgnoredDuringExecution):这是一种灵活的策略,如果能满足亲和性规则,Kubernetes 将优先在满足条件的节点上调度 Pod,但如果所有节点都不满足条件,仍有可能在其他节点上调度。
Pod 亲和性(Pod Affinity/Anti-Affinity):
- Pod 亲和性指定了 Pod 之间的关联性,允许 Pod 倾向于或排斥与具有特定标签的其他 Pod 位于同一节点或不同的节点上。
- Pod 亲和性:可以设置让某些 Pod 尽可能与具有特定标签的 Pod 部署在同一节点上,实现资源的紧密合作或者共享。
- Pod 反亲和性:为了避免单点故障或者提高资源利用率,可以设置规则使得某些 Pod 不应与其他特定标签的 Pod 部署在同一节点上。
- 硬亲和性(RequiredDuringSchedulingIgnoredDuringExecution):这种亲和性是强制性的约束条件。当设置了硬亲和性规则时,Kubernetes 调度器必须确保 Pod 被调度到满足特定条件(即拥有特定标签)的节点上。如果集群中没有符合条件的节点,Pod 将不会被调度,直到有合适的节点出现为止。
- 软亲和性(PreferredDuringSchedulingIgnoredDuringExecution):这种亲和性是非强制性的偏好设置。当设置了软亲和性规则时,Kubernetes 尽力将 Pod 调度到满足特定条件的节点上,但它并不阻止 Pod 在不符合条件的节点上运行。也就是说,如果集群中有满足条件的节点,调度器会选择这样的节点,但如果所有节点都不满足条件,Pod 仍然会被调度到任何可用节点上。
通过合理配置亲和性和反亲和性规则,可以在大规模集群中有效管理 Pod 分布,确保服务的稳定性和可用性,同时也能根据业务需求优化资源分配和负载均衡。例如,可以避免将高 CPU 需求的 Pod 部署在同一节点上,或者保证服务的前端和后端组件部署在一起以降低网络延迟。
利用亲和性调度让多个服务副本分布在不同的节点
要实现一个服务的多个副本分布在不同的节点上,可以使用 Kubernetes 的 PodAntiAffinity 特性。下面是一个示例,展示了如何在 Deployment 的 Pod 模板中配置 PodAntiAffinity 规则,以确保每个 Pod 尽可能地分散在不同的节点上:
apiVersion: apps/v1 kind: Deployment metadata: name: my-deployment spec: replicas: 3 # 假设我们要部署3个副本 selector: matchLabels: app: my-app template: metadata: labels: app: my-app spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app operator: In values: - my-app topologyKey: kubernetes.io/hostname containers: - name: my-app-container image: my-app-image ports: - containerPort: 80
在上述 YAML 配置中,
affinity
部分定义了 PodAntiAffinity 规则。这里的关键是topologyKey
字段,它的值kubernetes.io/hostname
表示我们希望基于节点的 hostname 来进行分散。labelSelector
则匹配当前 Deployment 中的 Pod 标签(在这种情况下,都是app: my-app
)。这个配置的作用是:在调度新 Pod 时,Kubernetes 会尽量避免将新的 Pod 调度到已存在同标签 Pod 的节点上,从而实现 Pod 在不同节点上的分布。
Init Containers
Init Containers 是 Kubernetes 引入的一个强大特性,用于在 Pod 中主容器启动前执行一系列预置操作。这些特殊的容器在 Pod 中其他应用容器启动之前按顺序执行,并且必须全部成功完成后,主应用容器才会开始启动。Init Containers 适用于那些需要在应用启动前完成某些预备工作的场景,例如:
- 资源准备:下载必要的数据或配置文件,如从远程存储下载数据库 schema 文件,从密钥管理系统获取加密密钥等。
- 依赖服务检查:等待某个服务(如数据库、消息队列服务)上线并准备好接收连接,可以通过不断检查服务的可用性来实现。
- 环境初始化:创建数据库表结构、初始化数据、设置权限等。
- 安全检查:验证运行环境的安全性,如确认证书的有效性,安装信任根证书等。
以下是一个简单的 Init Containers 示例:
apiVersion: v1
kind: Pod
metadata:
name: my-pod-with-init-containers
spec:
initContainers:
- name: init-myservice
image: busybox
command: ['sh', '-c', 'until nslookup myservice; do echo waiting for myservice; sleep 2; done;']
- name: init-mydb
image: postgres-tools
command: ['sh', '-c', 'pg_isready -h mydb && echo Database is ready || (echo Waiting for database to be ready && sleep 2)']
containers:
- name: main-app-container
image: myapp:v1
ports:
- containerPort: 8080
在这个例子中:
init-myservice
Init Container 会循环检查myservice
是否可以被 DNS 解析,直到解析成功为止。init-mydb
Init Container 会尝试连接到名为mydb
的 PostgreSQL 数据库,直到数据库准备好接收连接为止。
只有当所有的 Init Containers 完成它们的任务并且退出状态为0时,Kubernetes 才会继续启动 main-app-container
。这样可以确保应用在开始执行时,所依赖的外部条件已经得到满足。
Init Containers 在 Kubernetes 中还有很多其他应用场景:
- 依赖注入:向主容器注入一些额外的运行依赖,而不需要修改主容器。
- 生成动态配置:基于环境变量、配置映射或 secrets 生成动态配置文件,然后挂载到主容器的工作目录中,确保主容器启动时已有最新的配置文件。
- 持久卷初始化:在 PersistentVolumeClaim 挂载到主容器之前,对持久卷进行格式化、创建文件夹结构或写入初始数据。
- 安全上下文设置:在容器启动前,执行安全相关的操作,比如根据 secret 自动生成 SSH 密钥对、设置 Kerberos 凭证等。
- 服务注册:在应用容器启动前,向服务发现系统注册服务元数据,如将服务信息提前写入 ZooKeeper 或 Consul。
- 数据迁移或备份:在升级或回滚应用之前,执行数据迁移或备份操作,确保在容器变更时数据安全。
通过 Init Containers,开发者可以更好地组织和控制容器的启动顺序和依赖关系,增强应用的可移植性和可靠性。同时,也能够简化主容器镜像的构建和维护,降低运维复杂度。
下面的例子,将 Skywalking Agent 注入到主容器中,而不需要修改主容器,就可以实现外部依赖的注入,从而简化主容器镜像的维护:
apiVersion: v1
kind: Pod
metadata:
name: agent-as-sidecar
spec:
restartPolicy: Never
volumes:
- name: skywalking-agent
emptyDir: { }
initContainers:
- name: agent-container
image: apache/skywalking-java-agent:9.1.0-alpine
volumeMounts:
- name: skywalking-agent
mountPath: /agent
command: [ "/bin/sh" ]
args: [ "-c", "cp -R /skywalking/agent /agent/" ]
containers:
- name: app-container
image: springio/gs-spring-boot-docker
volumeMounts:
- name: skywalking-agent
mountPath: /skywalking
env:
- name: JAVA_TOOL_OPTIONS
value: "-javaagent:/skywalking/agent/skywalking-agent.jar"
Sidecar Container
边车容器(Sidecar Container)是一种在 Kubernetes 中常见的设计模式,它与主容器一起在一个 Pod 中运行,为主容器提供辅助功能或共享资源。边车容器的设计理念是解耦应用程序的功能,将部分独立于核心业务逻辑但又必需的服务模块分离出来,放在一个单独的容器里运行。
边车容器的一些典型应用场景包括:
- 日志收集与监控:如使用 Fluentd、Logstash、Prometheus 代理(如 cAdvisor 或 node-exporter)等边车容器收集主容器的日志和监控指标,并将它们发送到集中式日志存储或监控系统。
- 服务代理与通信:例如 Istio 的 Envoy 代理作为一个边车容器,负责处理进出主容器的网络流量,提供服务网格中的服务发现、负载均衡、熔断限流等功能。
- 配置管理与更新:如使用 ConfigMap Refresher 或 HashiCorp Vault 代理容器定期从配置中心拉取最新配置,并注入到主容器环境中。
- 数据预处理与缓存:在数据密集型应用中,边车容器可能负责数据的预处理、缓存或批处理任务,减轻主容器的数据处理压力。
- 安全组件:如使用 Linkerd、Istio 提供的 sidecar 容器实现服务间通信的加密和身份认证,或是使用 Cert-Manager 之类的工具自动管理 TLS 证书。
- 环境初始化:如同前面讨论过的 Init Containers,虽然并非严格意义上的边车容器,但在应用启动前完成环境初始化也可视为一种边车模式的运用。
下面是一个使用 ConfigMap 和边车容器配合的例子,说明如何在主应用容器的配置发生变化时,自动触发配置的重新加载。
首先,创建一个 ConfigMap 来保存应用的配置文件:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
application.properties: |-
server.port=8080
some.setting=value
然后,创建一个 Deployment,其中包含主应用容器和一个边车容器。边车容器负责监听 ConfigMap 的变化,并在变化发生时将新配置推送到主应用容器中。
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-deployment
spec:
replicas: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
volumes:
- name: config-volume
configMap:
name: app-config
containers:
- name: main-app
image: myapp:v1
ports:
- containerPort: 8080
volumeMounts:
- name: config-volume
mountPath: /app/config
subPath: application.properties
- name: config-reloader-sidecar
image: myconfigreloader:v1
env:
- name: CONFIG_PATH
value: /app/config/application.properties
volumeMounts:
- name: config-volume
mountPath: /app/config
command: ["config-reload.sh"] # 这是自定义的脚本,监听 ConfigMap 变化并重载配置到主应用容器
这里的 config-reloader-sidecar
容器使用了一个假设存在的镜像 myconfigreloader:v1
,它包含一个 config-reload.sh
脚本,这个脚本负责:
- 监听
/app/config/application.properties
文件的变化(这可以通过 inotifywait 或者类似的工具实现); - 当文件内容发生变化时,通知主应用容器重新加载配置(具体方法取决于主应用容器支持何种配置热加载机制,可能是发送信号、调用API接口或者是重启进程的一部分)。
通过这种方式,当我们更新 ConfigMap 时,边车容器能够感知到变化并采取相应措施使主应用容器使用最新的配置。