代码优先 Kubernetes(1):部署安全的 etcd 集群

最近在抽空学习 Kubernetes。我学东西通常比较慢,喜欢一步步折腾和琢磨。所以尽管我知道 K8s 可能有 n 多种快速部署的方法,但我还是喜欢用慢的方式一点点死磕。

死磕的过程如果不是困难的,那至少也是艰辛的,通常要搜索和翻阅很多资料才能搞明白一些问题。而另一方面,可能是内存条比大家要小些的缘故,我经常把曾经学会的东西忘的一干二净。然后每次都跟完全新学一样,再重新把那些资料找出来学习一遍,非常浪费生命。

所以这次我想试下把学到的稍微记录下来。如果哪天又忘记了要再学习和熟悉,估计这个记录能帮我加快些进度。另外,我发现代码的表达几乎总是能比任何自然语言更准确和具有逻辑,所以这个记录以代码(包括命令行)为主,因此叫「代码优先 Kubernetes」系列。

OK,转入正题。K8s 集群需要依赖一个 etcd 集群,所以我先从 etcd 开始。

准备节点

本文的操作涉及 3 个节点,分别为 master-1master-2master-3。它们将组成一个 etcd 集群。下文的内容均假设这三个节点是 CentOS 1810 最小化安装的节点。如果应用于其他 Linux 发行版,可能需要调整部分命令。

此外,下文内容中的各项操作都是在一个安装有 CentOS 1810 操作系统的管理节点(admin)上验证和测试通过的。管理节点和 3 个 master 节点都不需要使用 root 账号,但需要用到 sudo。

安装和配置 Ansible

使用 Ansible 主要是避免多节点批量操作的麻烦,以及让每个步骤都可重复。在 CentOS 上,我使用默认 RPM 源里的 Ansible 包:

sudo yum install -y ansible

装完后,将 3 个 master 节点的信息写到 Ansible hosts 文件中(注意替换其中的 IP 信息):

sudo bash -c 'cat >> /etc/ansible/hosts' << EOF
[masters]
master-1 ansible_host=10.0.2.21
master-2 ansible_host=10.0.2.22
master-3 ansible_host=10.0.2.23
EOF

写一个空的 playbook 来测试下 hosts 的配置是否正确:

cat > p1-pb1.yml << EOF
- hosts: masters
  become: true
  tasks: []
EOF
ansible-playbook -k -K p1-pb1.yml

这里用到了 ansible-playbook 的 -k-K 选项。前者表示我需要一会手动输入 SSH 密码,而后者表示我需要一会手动输入 sudo 密码。如果觉得手动输入密码太过麻烦,那么可以配置下从管理节点到 3 个 master 节点的无密码 SSH,以及 3 个 master 节点上的无密码 sudo。

配置 hostname 和 hosts 解析

方便起见,在后面的 etcd 配置中,我将使用 hostname 来指定节点,而不是 IP。这就要求 3 个 master 节点都能根据 hostname 解析到正确的 IP:

cat > p1-pb2.yml << EOF
- hosts: masters
  become: true
  tasks:
    - hostname:
        name: "{{ inventory_hostname }}"
    - lineinfile:
        regexp: ".*{{ item }}$"
        line: "{{ hostvars[item].ansible_host }} {{ item }}"
        state: present
        dest: /etc/hosts
      with_items: "{{ ansible_play_hosts_all }}"
EOF
ansible-playbook -k -K p1-pb2.yml

此外,也需要在管理节点配置下 3 个 master 节点的 hosts 解析(注意替换其中的 IP 信息):

sudo bash -c 'cat >> /etc/hosts' << EOF
10.0.2.21 master-1
10.0.2.22 master-2
10.0.2.23 master-3
EOF

安装 etcd

CentOS 的默认 RPM 源中有 etcd 包,我这里直接用 yum 安装它:

cat > p1-pb3.yml << EOF
- hosts: masters
  become: true
  tasks:
    - yum:
        name: etcd
        state: latest
EOF
ansible-playbook -k -K p1-pb3.yml

配置不安全的 etcd 集群

默认的 etcd 配置是可以直接拉起一个单节点的 etcd 集群的。因为我要配置 3 节点的集群,所以需要改动 etcd 的配置项以组成集群:

cat > p1-pb4.yml << EOF
- hosts: masters
  become: true
  tasks:
    - lineinfile:
        regexp: .*ETCD_LISTEN_PEER_URLS=.*
        line: ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_LISTEN_CLIENT_URLS=.*
        line: ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_NAME=.*
        line: ETCD_NAME="{{ inventory_hostname }}"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_INITIAL_ADVERTISE_PEER_URLS=.*
        line: ETCD_INITIAL_ADVERTISE_PEER_URLS="http://{{ inventory_hostname }}:2380"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_ADVERTISE_CLIENT_URLS=.*
        line: ETCD_ADVERTISE_CLIENT_URLS="http://{{ inventory_hostname }}:2379"
        state: present
        dest: /etc/etcd/etcd.conf
    - set_fact:
        etcd_members: "{{ (etcd_members | default([])) + [item+'=http://'+item+':2380'] }}"
      with_items: "{{ ansible_play_hosts_all }}"
    - lineinfile:
        regexp: .*ETCD_INITIAL_CLUSTER=.*
        line: ETCD_INITIAL_CLUSTER="{{ etcd_members | join(',') }}"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_INITIAL_CLUSTER_TOKEN=.*
        line: ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-1"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_INITIAL_CLUSTER_STATE=.*
        line: ETCD_INITIAL_CLUSTER_STATE="new"
        state: present
        dest: /etc/etcd/etcd.conf
EOF
ansible-playbook -k -K p1-pb4.yml

配置完后,还不能直接启动各节点的 etcd 服务。如果尝试启动的话,会发现 etcd 告诉我无法连接其他节点。这是因为 firewalld 屏蔽了我要用的 2379 和 2380 端口。粗暴一点的解决方案是直接关掉 firewalld 服务。这里我采用斯文一点的做法,告诉 firewalld 为我打开这两个端口:

cat > p1-pb5.yml << EOF
- hosts: masters
  become: true
  tasks:
    - firewalld:
        port: 2379-2390/tcp
        state: enabled
        permanent: yes
        immediate: yes
EOF
ansible-playbook -k -K p1-pb5.yml

OK,这样就可以启动 etcd 集群了:

cat > p1-pb6.yml << EOF
- hosts: masters
  become: true
  tasks:
    - systemd:
        name: etcd.service
        state: started
        enabled: yes
EOF
ansible-playbook -k -K p1-pb6.yml

用 etcdctl 来检查下 etcd 集群的成员:

sudo yum install -y etcd
ETCDCTL_API=3 etcdctl --endpoints=master-1:2379,master-2:2379,master-3:2379 member list

到目前为止,我已经配置并拉起了一个三节点的 etcd 集群。但是值得注意的是,它是有很大的安全隐患的,因为所有节点用的都是无加密的 HTTP 协议,而且也没有使用任何的身份验证。这使得任何和这三个节点处于一个网络的节点可以轻易获得 etcd 中的数据,或者恶意加入 etcd 集群,甚至破坏其中的数据。

通过启用 SSL 加密甚至双向认证可以大大提高 etcd 集群的安全性。不过正确理解和配置 etcd 的各种 SSL 证书并不是一件容易的事。下面我将逐步配置 etcd 启用 SSL 加密和认证功能,来尝试把这个不容易的事情解释清楚。

加密 peer 信道

etcd 服务有两个信道,默认 2380 端口的 peer 信道,和默认 2379 的 client-server 信道。前者用来 etcd 的节点之间进行通信,实现同步协议;后者则用来支持集群面向客户端的 API。

这两个信道是可以独立配置 SSL 加密和认证的。这里我先配置 peer 信道的加密:

cat > p1-pb7.yml << EOF
- hosts: masters
  become: true
  serial: 1
  tasks:
    - lineinfile:
        regexp: .*ETCD_LISTEN_PEER_URLS=.*
        line: ETCD_LISTEN_PEER_URLS="https://0.0.0.0:2380"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_PEER_AUTO_TLS=.*
        line: ETCD_PEER_AUTO_TLS="true"
        state: present
        dest: /etc/etcd/etcd.conf
    - shell: ETCDCTL_API=3 etcdctl member list | grep {{ inventory_hostname }} | awk -F , '{print \$1}'
      register: etcd_member_id
    - shell: ETCDCTL_API=3 etcdctl member update {{ etcd_member_id.stdout }} --peer-urls="https://{{ inventory_hostname }}:2380"
    - systemd:
        name: etcd.service
        state: restarted
EOF
ansible-playbook -k -K p1-pb7.yml

这里值得一提的有三点。第一点是我开启了 etcd peer 信道的自动 TLS,并且把 peer 端的协议换到了 HTTPS,也就是让 etcd 使用内置的一对证书和私钥来加密传输的数据。第二点是 peer 端从 HTTP 切换到 HTTPS 之后,需要通过 etcdctl 来操作一个全局的节点信息更新。这个更新操作要求 etcd 集群必须处于可用状态(三节点下需保证两个节点正常)。这就带来了第三点,我需要用 Ansible 中的 serial: 1 配置来实现对 etcd 节点的滚动重启,保证每个节点变更的时候,etcd 集群都是处于可用的状态。

开启 peer 认证

Peer 信道加密后,可以避免数据被窃听。但是一些非法的节点依然可以随意加入的 etcd 集群中,窃取数据或者破坏集群。解决的方法是用 SSL 的客户端认证机制,来形成一种类似白名单的效果。

这一步就没有类似自动 TLS 这样的偷懒办法了,我得手动生成一些证书。方便(和省钱)起见,我决定自己充当 CA 来签发我所需要的证书:

openssl genrsa -out etcd-peer-ca.key 2048
openssl req -new -x509 -key etcd-peer-ca.key -out etcd-peer-ca.crt

生成证书的过程一般要输入一些对应的信息,例如国家、省、城市、单位等,这些就照实填写好,随便填也应该没啥问题。需要留意的是 Common Name 字段。对于用于服务器的证书,通常需要 Common Name 和域名(或者在我的 case 下 hostname)相对应。

有了 CA 后,为每个 master 节点生成用于 peer 信道的证书和私钥对(Common Name 建议填对应节点的 hostname,例如 master-1):

for i in {1..3}
do
  openssl genrsa -out etcd-peer-master-$i.key 2048
  openssl req -new -key etcd-peer-master-$i.key -out etcd-peer-master-$i.csr
  openssl x509 -req -in etcd-peer-master-$i.csr -CA etcd-peer-ca.crt -CAkey etcd-peer-ca.key -CAcreateserial -out etcd-peer-master-$i.crt
done

有了证书后,将它们拷贝到对应的节点,并且修改 etcd 的配置,以开启 peer 认证。

不过,这里有一点需要特别注意一下。如果要求整个变更过程中,etcd 服务不中断,那么这里就需要分两步走。第一步是把 peer 证书先给每个节点都配上,但是先不开启 peer 认证,然后滚动重启 3 个节点。第二步再逐个节点开启 peer 认证,然后滚动重启。否则如果两步合并成一步进行滚动更新重启的话,在变更第一个节点的时候,就会因其他两个节点无法和它建立连接而导致 etcd 服务无法重新拉起的问题。

我这里采用两步走的方式来保证 etcd 服务的延续。第一步先拷贝和配置证书,并且滚动重启:

cat > p1-pb8.yml << EOF
- hosts: masters
  become: true
  serial: 1
  tasks:
    - copy:
        src: etcd-peer-{{ inventory_hostname }}.crt
        dest: /etc/etcd/peer.crt
    - lineinfile:
        regexp: .*ETCD_PEER_CERT_FILE=.*
        line: ETCD_PEER_CERT_FILE="/etc/etcd/peer.crt"
        state: present
        dest: /etc/etcd/etcd.conf
    - copy:
        src: etcd-peer-{{ inventory_hostname }}.key
        dest: /etc/etcd/peer.key
    - lineinfile:
        regexp: .*ETCD_PEER_KEY_FILE=.*
        line: ETCD_PEER_KEY_FILE="/etc/etcd/peer.key"
        state: present
        dest: /etc/etcd/etcd.conf
    - copy:
        src: etcd-peer-ca.crt
        dest: /etc/etcd/peer-ca.crt
    - lineinfile:
        regexp: .*ETCD_PEER_TRUSTED_CA_FILE=.*
        line: ETCD_PEER_TRUSTED_CA_FILE="/etc/etcd/peer-ca.crt"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_PEER_AUTO_TLS=.*
        line: ETCD_PEER_AUTO_TLS="false"
        state: present
        dest: /etc/etcd/etcd.conf
    - systemd:
        name: etcd.service
        state: restarted
EOF
ansible-playbook -k -K p1-pb8.yml

然后再滚动开启 peer 认证:

cat > p1-pb9.yml << EOF
- hosts: masters
  become: true
  serial: 1
  tasks:
    - lineinfile:
        regexp: .*ETCD_PEER_CLIENT_CERT_AUTH=.*
        line: ETCD_PEER_CLIENT_CERT_AUTH="true"
        state: present
        dest: /etc/etcd/etcd.conf
    - systemd:
        name: etcd.service
        state: restarted
EOF
ansible-playbook -k -K p1-pb9.yml

可能对 SSL 这套东西比较熟悉的同学到这里会发现一个问题:从理论上讲,每个 etcd 节点在 peer 信道上都是服务器端和客户端双重角色,要做到双向认证的话,应该需要 4 个证书,分别是服务器端证书、客户端证书、用于验证其他客户端证书的根证书和用于验证其他服务器端证书的根证书。但是这里我只配置了两个证书,有点意外。针对这个问题,我没有翻阅过 etcd 相关的代码,我猜是 etcd 这里做了一个前提假设,就是针对 peer 信道的特点,把两套证书系统合并成一套是完全合适的。也就是说服务器端证书和客户端证书共用一个,两个根证书也共用一个。这样证书还是能被正确验证,整个加密和认证都是一样的,只不过去掉了将两个信任链独立的可能性(实际上应该也没几个这么蛋疼的吧)。

加密 client-server 信道

和 peer 信道加密类似,可以使用 etcd 的自动 TLS 功能为 client-server 信道加密。这个更改不涉及全局的节点信息更新,因此比增加 peer 信道加密的操作要简单:

cat > p1-pb10.yml << EOF
- hosts: masters
  become: true
  serial: 1
  tasks:
    - lineinfile:
        regexp: .*ETCD_LISTEN_CLIENT_URLS=.*
        line: ETCD_LISTEN_CLIENT_URLS="https://0.0.0.0:2379"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_ADVERTISE_CLIENT_URLS=.*
        line: ETCD_ADVERTISE_CLIENT_URLS="https://{{ inventory_hostname }}:2379"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_AUTO_TLS=.*
        line: ETCD_AUTO_TLS="true"
        state: present
        dest: /etc/etcd/etcd.conf
    - systemd:
        name: etcd.service
        state: restarted
EOF
ansible-playbook -k -K p1-pb10.yml

etcdctl 默认用的是 HTTP 协议。在完成上面的步骤后,需要为 etcdctl 加上两个 flag 才能正常访问 etcd 集群:

ETCDCTL_API=3 etcdctl --endpoints=master-1:2379,master-2:2379,master-3:2379 --insecure-transport=false --insecure-skip-tls-verify=true member list

开启 client-server 双向认证

再一次的,又要生成证书了。虽然这里可以沿用第 6 步中已经生成的 CA,但是为了更清楚的理解众多证书之间的关系,我这里还是用一个新的 CA:

openssl genrsa -out etcd-client-ca.key 2048
openssl req -new -x509 -key etcd-client-ca.key -out etcd-client-ca.crt

这个 CA 用来签所有的客户端证书,这里我仅需要为管理节点签一个:

for i in {1..1}
do
  openssl genrsa -out etcd-client-admin.key 2048
  openssl req -new -key etcd-client-admin.key -out etcd-client-admin.csr
  openssl x509 -req -in etcd-client-admin.csr -CA etcd-client-ca.crt -CAkey etcd-client-ca.key -CAcreateserial -out etcd-client-admin.crt
done

然后生成一个新的 CA 来专门签服务器端证书:

openssl genrsa -out etcd-server-ca.key 2048
openssl req -new -x509 -key etcd-server-ca.key -out etcd-server-ca.crt

用服务器端 CA 为三个节点分别签服务器端证书(Common Name 建议填对应的 hostname,例如 master-1):

for i in {1..3}
do
  openssl genrsa -out etcd-server-master-$i.key 2048
  openssl req -new -key etcd-server-master-$i.key -out etcd-server-master-$i.csr
  openssl x509 -req -in etcd-server-master-$i.csr -CA etcd-server-ca.crt -CAkey etcd-server-ca.key -CAcreateserial -out etcd-server-master-$i.crt
done

拷贝证书到各个节点,修改 etcd 配置,并且滚动重启:

cat > p1-pb11.yml << EOF
- hosts: masters
  become: true
  serial: 1
  tasks:
    - copy:
        src: etcd-server-{{ inventory_hostname }}.crt
        dest: /etc/etcd/server.crt
    - lineinfile:
        regexp: .*ETCD_CERT_FILE=.*
        line: ETCD_CERT_FILE="/etc/etcd/server.crt"
        state: present
        dest: /etc/etcd/etcd.conf
    - copy:
        src: etcd-server-{{ inventory_hostname }}.key
        dest: /etc/etcd/server.key
    - lineinfile:
        regexp: .*ETCD_KEY_FILE=.*
        line: ETCD_KEY_FILE="/etc/etcd/server.key"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_CLIENT_CERT_AUTH=.*
        line: ETCD_CLIENT_CERT_AUTH="true"
        state: present
        dest: /etc/etcd/etcd.conf
    - copy:
        src: etcd-client-ca.crt
        dest: /etc/etcd/client-ca.crt
    - lineinfile:
        regexp: .*ETCD_TRUSTED_CA_FILE=.*
        line: ETCD_TRUSTED_CA_FILE="/etc/etcd/client-ca.crt"
        state: present
        dest: /etc/etcd/etcd.conf
    - lineinfile:
        regexp: .*ETCD_AUTO_TLS=.*
        line: ETCD_AUTO_TLS="false"
        state: present
        dest: /etc/etcd/etcd.conf
    - systemd:
        name: etcd.service
        state: restarted
EOF
ansible-playbook -k -K p1-pb11.yml

完成上面的步骤后,需要在 etcdctl 命令中加入服务器端根证书(--cacert)、客户端证书和私钥(--cert & --key)的信息后,才可以正常访问 etcd 集群:

ETCDCTL_API=3 etcdctl --endpoints=master-1:2379,master-2:2379,master-3:2379 --insecure-transport=false --cacert=etcd-server-ca.crt --cert=etcd-client-admin.crt --key=etcd-client-admin.key member list

总结

到这里,我把 etcd 所有能配的证书都配齐了,做到了:

只要保证上面生成的那一堆私钥不暴露,不出现量子计算机这样的黑科技,应该就挺安全了。

另外,也应该注意到,我使用了三个独立的 CA,即三条独立的信任链,分别用于 client 对 server 的信任、server 对 client 的信任,和 peer 间的信任。对于 peer 间信任为什么仅使用了一条信任链,也给了一个不靠谱的猜测,希望后续能得到证实(或证伪)。