用 Ansible 把「装系统环境 → 装运行时 → 装 K8s 组件 → 拉镜像」全流程脚本化,告别手动敲命令。本文所有 Playbook 均来自本人真实实验环境,可直接复用。
0. 项目概览
目标:用 Ansible 自动化部署一套三节点 K8s 集群(1 master + 2 node),容器运行时用 Docker(经 cri-dockerd 适配),网络用 Calico,镜像走 Harbor 私有仓库 + 阿里云加速。
架构拓扑:
| 主机 | 角色 | IP |
|---|---|---|
| workstation | Ansible 控制机 | 192.168.211.201 |
| k8s-master | 集群主节点 | 192.168.211.202 |
| k8s-node01 | 工作节点 | 192.168.211.203 |
| k8s-node02 | 工作节点 | 192.168.211.204 |
| hb.reg.com | Harbor 私有仓库 | 192.168.211.101 |
技术栈:Ansible + Docker(cri-dockerd) + Calico + Harbor + 阿里云镜像源。
1. 目录结构一览
/home/devops/ansible/
├── ansible.cfg # Ansible 全局配置
├── inventory # 主机清单
├── hosts.yml # 生成 /etc/hosts
├── set_hostname.yml # 设置主机名
├── swapoff.yml # 关闭 swap(含避坑重点)
├── selinux.yml # 关防火墙 + SELinux
├── max_limits.yml # 修改最大连接数
├── ip_forward.yml # 开启路由转发
├── yum_repository.yml # 添加 K8s/Docker 阿里云源
├── yum_basic.yml # 装基础软件
├── yum_docker.yml # 装 Docker
├── docker_daemon.yml # 配置并启动 Docker
├── yum_k8s.yml # 装 kubelet/kubeadm/kubectl
├── install_cri_docker.yml # 装 cri-dockerd
├── pull_images.yml # 从 Harbor 拉 K8s 组件镜像
├── copy_calico.yml # 拷贝并加载 Calico 离线镜像
├── templates/
│ ├── hosts.j2 # hosts 文件模板
│ └── docker_daemon.j2 # Docker 配置模板
└── app/ # 离线安装包
├── calico-images_v3.32.1.tar.gz
└── cri-dockerd-0.4.4.amd64.tgz
2. 基础配置:inventory + ansible.cfg
inventory(主机清单) ------按角色分组,为后续 hosts: xxx 提供目标:
[k8s_master]
192.168.211.202 hostname=k8s-master
[k8s_nodes]
192.168.211.203 hostname=k8s-node01
192.168.211.204 hostname=k8s-node02
[k8s_cluster:children] # 子分组:集群 = master + nodes
k8s_master
k8s_nodes
ansible.cfg------指定清单、免密登录用户:
[defaults]
inventory=/home/devops/ansible/inventory
remote_user=root # 用 root 免密登录目标机
host_key_checking = False # 跳过首次 SSH 指纹确认
前置:控制机已
ssh-copy-id把公钥分发到三台节点,实现 root 免密。
3. 第一阶段:系统初始化(三台都做)
3.1 set_hostname.yml ------ 设置主机名
用 ansible.builtin.hostname 模块给每台机器改名:
- name: set hostname
hosts: k8s_master
tasks:
- ansible.builtin.hostname:
name: k8s-master
- name: node01
hosts: 192.168.211.203
tasks:
- ansible.builtin.hostname:
name: k8s-node01
# ... node02 同理
进阶写法:inventory 里已定义
hostname变量,可直接name: "{``{ hostname }}"遍历,避免写死(文件里这段被注释保留,供参考)。
3.2 hosts.yml ------ 生成 /etc/hosts
用 template 模块 + Jinja2 模板,自动把清单里的机器写进 /etc/hosts:
- name: create hosts
hosts: all
tasks:
- ansible.builtin.template:
src: /home/devops/ansible/templates/hosts.j2
dest: /etc/hosts
hosts.j2 模板(动态遍历所有主机 + 固定写死 Harbor):
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
{% for item in groups.all %}
{{ hostvars[item]['inventory_hostname'] }} {{ hostvars[item]['hostname'] }}
{% endfor %}
192.168.211.101 hb.reg.com
3.3 swapoff.yml ------ 关闭 swap(★ 本篇避坑重点)
K8s 要求关闭 swap,swapoff -a 只是临时生效,重启后 fstab 会重新挂载,所以要永久注释 fstab 里的 swap 行。
- name: 关闭交换分区
hosts: all
tasks:
- name: swapoff -a
ansible.builtin.command: swapoff -a
- name: 永久关闭swap分区
ansible.builtin.lineinfile:
path: /etc/fstab
regexp: '^([^#].*\sswap\s.*)$' # 匹配未注释的 swap 行,并整行捕获
line: '#\1' # 用 # + 捕获组内容替换
backup: yes # 改前自动备份
backrefs: yes # ★ 关键:开启反向引用
- name: mount -a
ansible.builtin.command: mount -a
这里我踩过两次坑,总结成问题档案:
| 次数 | line 写法 | 结果 | 根因 |
|---|---|---|---|
| 第 1 次 | line: '#1' |
fstab 变成垃圾 #1 |
缺反斜杠,1 被当普通字符 |
| 第 2 次 | line: '#\1' |
fstab 变成垃圾 #\1 |
有反斜杠但缺 backrefs: yes |
三个必记知识点:
-
line: '#\1'里的\1表示「regexp 第一个捕获组的内容」,但只有backrefs: yes才会解析它 ,否则\1被当字面字符原样输出。 -
regexp含\s反斜杠,必须用单引号包裹,双引号会被 YAML 转义。 -
backup: yes会生成fstab.<INODE>.<时间戳>~备份,写坏了用它恢复。
更省心的替代写法(K8s 场景够用):
state: absent直接把 swap 行删掉,不保留记录、不踩 backrefs 的坑。
3.4 selinux.yml ------ 关防火墙 + SELinux
- name: 关闭防火墙和selinux
hosts: all
tasks:
- name: Stop service firewall
ansible.builtin.service:
name: firewalld
state: stopped
enabled: no
- name: 临时关闭 SELinux
ansible.builtin.command: setenforce 0
changed_when: false # 避免误报 changed
when: ansible_selinux.status == "enabled"
- name: SELinux 改为 permissive
ansible.builtin.lineinfile:
path: /etc/selinux/config
regexp: '^SELINUX='
line: SELINUX=permissive
亮点:setenforce 0 这类命令每次跑都会报 changed,用 changed_when: false 让它「改了也不报变化」,保持幂等。
3.5 max_limits.yml ------ 修改最大连接数
用 blockinfile 向 limits.conf 追加一个块(可重复执行、不会重复插入):
- name: 修改linux最大连接数
hosts: all
tasks:
- ansible.builtin.blockinfile:
path: /etc/security/limits.conf
block: |
* soft nofile 655350
* hard nofile 655350
* soft nproc 655350
* hard nproc 655350
* soft memlock unlimited
* hard memlock unlimited
3.6 ip_forward.yml ------ 开启路由转发
- ansible.builtin.blockinfile:
path: /etc/sysctl.conf
block: net.ipv4.ip_forward=1
- ansible.builtin.command: sysctl -p
文件里还保留了注释掉的
ansible.posix.sysctl模块写法(更规范、自动 reload),需要ansible-galaxy collection install ansible.posix。
4. 第二阶段:装运行时 + K8s 组件
4.1 yum_repository.yml ------ 加国内源
用 yum_repository 模块添加阿里云 K8s 源和 Docker 源(替代手写 .repo 文件):
- name: 添加k8s源
ansible.builtin.yum_repository:
name: Kubernetes
baseurl: https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/
gpgcheck: yes
gpgkey: https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.36/rpm/repodata/repomd.xml.key
- name: 添加docker源
ansible.builtin.yum_repository:
name: Docker
baseurl: https://mirrors.aliyun.com/docker-ce/linux/rhel/9/x86_64/stable/
gpgcheck: no
4.2 yum_basic.yml ------ 装基础软件
用 dnf 模块批量装:net-tools、nfs-utils、vim、wget、curl、bash-completion、device-mapper-persistent-data、psmisc、tree、ipvsadm、conntrack-tools 等(K8s 部署依赖项一次装齐)。
4.3 yum_docker.yml ------ 装 Docker
- name: Install docker
ansible.builtin.dnf:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
4.4 docker_daemon.yml ------ 配置并启动 Docker
用 template 生成 daemon.json,notify 触发 handler 重启 Docker:
- name: Template daemon.json
ansible.builtin.template:
src: templates/docker_daemon.j2
dest: /etc/docker/daemon.json
notify: restart docker # 配置变了自动重启
- name: 启动docker
ansible.builtin.service:
name: docker
state: started
enabled: yes
handlers:
- name: restart docker
ansible.builtin.service:
name: docker
state: restarted
docker_daemon.j2 模板(关键项:cgroupdriver、数据目录、镜像加速、私有仓库):
{
"data-root": "/data/docker",
"exec-opts": ["native.cgroupdriver=systemd"], # 与 kubelet 的 cgroup 驱动一致
"log-driver": "json-file",
"log-opts": { "max-size": "100m", "max-file": "50" },
"insecure-registries": ["hb.reg.com"],
"registry-mirrors": [
"https://docker.m.daocloud.io", "https://docker.1ms.run",
"https://dockerproxy.net", "https://proxy.vvvv.ee"
]
}
细节观察 :
exec-opts里native.cgroupdriver=systemd是重点------K8s 官方要求 kubelet 与容器运行时的 cgroup 驱动一致,都应是 systemd。另外insecure-registries的规范写法是hb.reg.com或http://hb.reg.com(不带https://),带https://会被 Docker 忽略,仅当 Harbor 是自签 HTTP 时才需要这一项。
4.5 yum_k8s.yml ------ 装 kubelet/kubeadm/kubectl
- name: install kubelet kubeadm kubectl
ansible.builtin.yum:
name: [kubectl, kubeadm, kubelet]
state: present
- name: enable kubelet
ansible.builtin.service:
name: kubelet
state: started
enabled: yes
4.6 install_cri_docker.yml ------ 装 cri-dockerd(为什么需要它)
背景 :K8s 1.24 起移除了 dockershim,Docker 不能再直接作为容器运行时,需要 cri-dockerd 这个「翻译官」把 K8s 的 CRI 调用转给 Docker。
步骤:解压离线包 → 拷贝二进制到 /usr/bin → 写 systemd 服务单元 → 启动。
- name: 解压压缩包
ansible.builtin.unarchive:
src: /home/devops/ansible/app/cri-dockerd-0.4.4.amd64.tgz
dest: /tmp/
- name: 拷贝文件
ansible.builtin.copy:
src: /tmp/cri-dockerd/cri-dockerd
dest: /usr/bin/cri-dockerd
remote_src: yes
- name: 创建cri-docker服务 # 用 copy 的 content 参数直接内联服务单元
ansible.builtin.copy:
dest: /usr/lib/systemd/system/cri-docker.service
content: |
[Unit]
Description=CRI Interface for Docker Application Container Engine
After=network-online.target docker.service
Requires=cri-docker.socket
[Service]
ExecStart=/usr/bin/cri-dockerd --container-runtime-endpoint fd:// \
--network-plugin=cni \
--pod-infra-container-image=registry.aliyuncs.com/google_containers/pause:3.10
[Install]
WantedBy=multi-user.target
# 另有 cri-docker.socket 单元 + daemon-reload + 启动,略
关键点 :--pod-infra-container-image 指向的 pause 镜像要用国内源,否则 kubeadm 起 Pod 时拉 pause 失败。
5. 第三阶段:镜像准备(内网加速的关键)
5.1 pull_images.yml ------ 从 Harbor 预拉镜像
kubeadm init 时默认去 registry.k8s.io 拉镜像,内网根本拉不动。所以提前从私有 Harbor 拉好,kubeadm 检测到本地已有镜像就跳过网络拉取。
- name: k8s-master pull images
hosts: k8s_master
tasks:
- name: pull kube-apiserver:v1.36.3
ansible.builtin.command: docker pull hb.reg.com/k8s/kube-apiserver:v1.36.3
# controller-manager / scheduler / kube-proxy / coredns / pause / etcd 同理
- name: k8s-node pull images
hosts: k8s_nodes
tasks:
- name: pull kube-proxy:v1.36.3
ansible.builtin.command: docker pull hb.reg.com/k8s/kube-proxy:v1.36.3
# coredns / pause 同理
细节观察 :master 的 etcd 任务名写的是
pull etcd:3.6.11-0,但实际命令拉的是etcd:3.6.8-0------task 名称与命令不一致,属于典型的「改名忘同步」小笔误,不影响执行,但排查时容易误导。写 Playbook 时 task name 最好和实际动作保持一致。
5.2 copy_calico.yml ------ 离线加载 Calico 镜像
内网装 Calico 也一样,先把镜像打成 tar 包拷过去,再 docker load:
- name: copy
ansible.builtin.copy:
src: /home/devops/ansible/app/calico-images_v3.32.1.tar.gz
dest: /root/calico-images_v3.32.1.tar.gz
- name: load -i
ansible.builtin.shell: docker load -i /root/calico-images_v3.32.1.tar.gz
register: load_result
changed_when: "'Loaded image' in load_result.stdout" # 只有真的加载了才报 changed
幂等技巧 :docker load 重复加载相同镜像不会报错但也无输出,用 changed_when 判断 stdout 里是否出现 Loaded image,只在真正加载时才标记为「已变更」。
6. 手动收尾:kubeadm init + join + Calico
环境就绪后,剩余三步手动完成(也可再封装成 Playbook):
# master 上初始化(指定阿里云镜像仓库 + Calico 网段)
kubeadm init --pod-network-cidr=10.244.0.0/16 \
--image-repository=registry.aliyuncs.com/google_containers
# node 上用 init 输出的命令加入
kubeadm join 192.168.211.202:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
# master 上部署 Calico 网络(镜像已离线 load 过)
kubectl apply -f calico.yaml
7. 一键执行顺序(完整流程)
cd /home/devops/ansible
ansible-playbook set_hostname.yml # 1 主机名
ansible-playbook hosts.yml # 2 hosts 解析
ansible-playbook swapoff.yml # 3 关 swap
ansible-playbook selinux.yml # 4 防火墙/SELinux
ansible-playbook max_limits.yml # 5 连接数
ansible-playbook ip_forward.yml # 6 路由转发
ansible-playbook yum_repository.yml # 7 加源
ansible-playbook yum_basic.yml # 8 基础软件
ansible-playbook yum_docker.yml # 9 装 Docker
ansible-playbook docker_daemon.yml # 10 配置 Docker
ansible-playbook yum_k8s.yml # 11 装 K8s 组件
ansible-playbook install_cri_docker.yml# 12 装 cri-dockerd
ansible-playbook pull_images.yml # 13 拉 K8s 镜像
ansible-playbook copy_calico.yml # 14 加载 Calico 镜像
# 15 kubeadm init + join + apply calico(手动)
8. 避坑总结
-
lineinfile 注释 swap 三要素 :
regexp捕获组 +line: '#\1'+backrefs: yes,缺一不可;写坏就找带~的备份恢复。 -
cgroup 驱动要一致 :Docker 的
exec-opts和 kubelet 都用 systemd,否则节点起不来。 -
内网拉镜像两条路 :要么
--image-repository指定国内源,要么像本项目一样提前docker pull到本地 / 走 Harbor。 -
pause 镜像别忽略 :cri-dockerd 的
--pod-infra-container-image也要指国内源,否则 Pod 起不来。 -
task name 与命令保持一致:避免 etcd 版本号那种「名字 3.6.11、命令 3.6.8」的误导。
9. 总结
这套 Playbook 的价值在于:把 K8s 部署中「繁琐、重复、易错」的环境准备部分一次性脚本化,通过 Ansible 的幂等性,同一个 playbook 可以反复执行、批量套用到任意数量的节点。配合 inventory 分组 + 变量 + 模板,就是运维自动化「一次编写、处处复用」的完整实践。