如何使用 Ansible 创建 EC2 实例副本
像我所在公司这样的许多公司都在大量使用 AWS 基础设施即服务 (IaaS)。有时我们想在 EC2 实例上执行可能存在风险的操作。只要我们不使用不可变基础设施,就必须为即时回滚做好准备。
解决方案之一是使用脚本来执行实例复制,但在现代环境中,统一化是本质,因此使用更常见的已知软件而不是编写自定义脚本会更明智。
Ansible 来了!
Ansible 是一种简单的自动化软件。它处理配置管理、应用程序部署、云 provisioning、临时任务执行、网络自动化和多节点编排。它被推销为一种用于进行复杂更改(如零停机滚动补丁)的工具,因此我们已将其用于这项简单的快照任务。
要求
对于此示例,我们只需要 Ansible,在我的情况下,它是 2.9 版本 - 在后续版本中,引入集合是一个重大变化,因此为了简单起见,让我们坚持使用这个版本。
由于要使用 AWS,我们需要一组最少的权限,其中包括创建以下内容的权限
- AWS 快照
- 注册镜像 (AMI)
- 启动和停止 EC2
环境准备
由于我被迫在 Windows 上工作,因此我使用了 Vagrant 实例。请在下面找到 Vagrantfile 内容。
我们正在启动一个虚拟机,其中安装了 Centos 7 和 Ansible。
出于安全原因,默认情况下,Ansible 禁用了从挂载位置读取配置,因此我们必须显式指定路径 /vagrant/ansible.cfg。
代码清单 1. 用于我们研究的 Vagrantfile
Vagrant.configure("2") do |config| config.vm.box = "geerlingguy/centos7" config.vm.hostname = "awx" config.vm.provider "virtualbox" do |vb| vb.name = "AWX" vb.memory = "2048" vb.cpus = 3 end config.vm.provision "shell", inline: "yum install -y git python3-pip" config.vm.provision "shell", inline: "pip3 install ansible==2.9.10" config.vm.provision "shell", inline: "echo 'export ANSIBLE_CONFIG=/vagrant/ansible.cfg' >> /home/vagrant/.bashrc" end
首要任务
在 Ansible 的第一行中,我们指定了一些元数据值。其中大多数(如 name、hosts 和 tasks)是必需的。其他提供辅助功能。
代码清单 2. duplicate_ec2.yml playbook 首行
--- - name: yolo hosts: localhost connection: local gather_facts: false become: false vars: instance_id: i-deadbeef007
tasks
- name: Getting minimal set of facts for datetime setup: gather_subset: 'min' - set_fact: current_datetime: "{{ ansible_date_time.iso8601 }}" - name: Install required pip packages become: yes pip: name: - boto3 - boto
从顶部开始,我们分配一个名称,以确定此 playbook 的用途。
由于这将仅连接到 AWS,因此我们必须限制执行在 localhost 上运行,并且为了避免 SSH 尝试,我们添加了连接类型 local,这实际上意味着无连接,直接在机器上执行。
接下来,我们继续禁用 facts 收集,以加快我们的执行速度。Become 关键字确定 Ansible 是否应使用特权账户(例如 sudo)。由于我们不需要它,因此禁用提升权限是一个很好的习惯。
Vars 部分定义了整个 playbook 中可用的 facts,目前我们只有一个,即实例 ID。
最后,我们开始在 tasks 部分中实际工作。
首先,在 playbook 中,我们将需要日期时间值,因此需要收集最少的 facts 集合。可能的值是
- all - 几乎所有 facts,如果在 meta 部分中未指定 gather_facts,则为默认集合, - min- 大大减少的信息,不需要深入系统设置 - 硬件, - 网络, - 虚拟, - ohai & facter - 两个常用的 facts 提供者,阅读更多关于 Chef 文档中的 ohai 和 Puppet 的 facter 规范。
现在我们可以获取日期时间,并且由于它将在 tasks 中多次使用,因此我们在第二个任务中将其注册为 fact。
最后,用于 AWS 控制的模块需要 boto 和 boto3 Python 模块才能工作,因此我们可以通过执行 pip 模块来确保它们存在。
请注意,我们可以覆盖全局值,在此示例中:我们通过设置 become: true 来提升我们的权限。
身份验证
AWS 身份验证可能具有不同的形式,直接的一种 - 仅登录名和密码,或者更高级的,需要承担角色。
此外,我们可能被迫使用多因素身份验证,这会使身份验证进一步复杂化。
为了克服这一点,我们需要提供:用户登录名、用户密码、MFA 令牌序列号和当前 MFA 代码。
由于 vault 加密的密钥相当长,为了节省空间,它们在所有代码清单中都被截断了。
代码清单 3. 增强了身份验证元素的 duplicate_ec2.yml
(...) vars: instance_id: i-deadbeef007 aws_credentials: aws_region: eu-west-2 aws_access_key: !vault | $ANSIBLE_VAULT;1.1;AES256 34613664316337623136383935636262353361643736666432666331623563636333363431626134 6533636363383231... aws_secret_key: !vault | $ANSIBLE_VAULT;1.1;AES256 39386565376137663934333734316236346232643838623530386538303561393730373662626238 6337636363353938663664... mfa_serial_number: !vault | $ANSIBLE_VAULT;1.1;AES256 66306332383534343338373532633930373536663638303439633837613832643966303236396562 393861366333333562336231666...
tasks
(...) - pause: prompt: "Please enter Your MFA code: " echo: yes register: mfa_code - sts_assume_role: role_arn: "arn:aws:iam::807777736438:role/User" aws_region: "{{ aws_credentials.aws_region }}" aws_access_key: "{{ aws_credentials.aws_access_key }}" aws_secret_key: "{{ aws_credentials.aws_secret_key }}" mfa_serial_number: "{{ aws_credentials.mfa_serial_number }}" mfa_token: "{{ mfa_code.user_input }}" role_session_name: "Snapshotting-{{ instance_id }}" register: assumed_role - set_fact: aws_secrets: &aws_secrets aws_access_key: "{{ assumed_role.sts_creds.access_key }}" aws_secret_key: "{{ assumed_role.sts_creds.secret_key }}" security_token: "{{ assumed_role.sts_creds.session_token }}" region: "{{ aws_credentials.aws_region }}"
首先引起我们注意的是大块数字。这些是 ansible-vault 加密的字符串。我们通过调用 `ansible-vault encrypt_string` 并插入所需数据来创建它们。
请注意,ctrl+d 前面不能有回车键,否则换行符将被包含在密钥的值中!
代码清单 4. ansible-vault 使用示例
[vagrant@awx ~]$ ansible-vault encrypt_string New Vault password: Confirm New Vault password: Reading plaintext input from stdin. (ctrl-d to end input) This is the secret content!vault | $ANSIBLE_VAULT;1.1;AES256 34363966326337613933623331306331613939303661303530613466613036346336613032333637 3632313064336133383036396266633761643664656664620a626165343439393832643236613438 32623339396130323531643862366532623434343931613165633931663739353065396234313034 6165373262393764610a373763623865356131383133316638333635616665313463343563646564 35353161613039303437383135383165393661343132623133663231653035376338 Encryption successful
现在我们需要从用户那里获取授权的最后一个元素 - mfa 代码。
由于它的有效期只有 60 秒,因此必须尽可能晚地提供,因此我们在 facts 收集和模块安装之后,就在需要它之前,使用模块 `pause` 提示输入。我们将此模块的值注册到变量 mfa_code 中以供稍后重用。
使用模块 `sts_assume_role`,我们终于可以承担我们适当的角色。它需要一堆值,其中一些是静态的,一些来自上面的 vars 部分,最后一个是 "{{ mfa_code.user_input }}",来自前一步骤。因此,任务的结果必须存储在另一个变量中。
后续步骤是为了我们的方便,我们将进一步需要的变量组装为一个 fact。
此外:这里我们使用一个名为块引用的 yaml 功能。
我们可以通过使用带有名称的 `&` 字符来命名一个块,并在稍后在需要它的地方使用它。
获取实例详细信息
由于我们想要制作实例的克隆,因此我们需要收集一些信息。
使用 Ansible,只需调用模块 ec2_instance_info(警告:此模块在过去 2 年内更改了 3 次名称)。
必需的我们需要:aws_access_key, aws_secret_key:, security_token 和 region。
幸运的是,我们在 Yaml 块名称 `aws_secrets` 下拥有所有这些。我们可以使用块名称之前的 `*` 字符访问它。为了使用它,我们输入块插入运算符 `<<` 并使用星号引用该块。瞧!不再需要逐行重新输入所有信息。当然,我们还需要澄清我们想要的实例。
为此,我们使用 filters 关键字并提供我们用于搜索的值。
代码清单 5. 收集实例数据
- name: Get data about ec2 instance {{ instance_id }} ec2_instance_info: <<: *aws_secrets filters: instance-id: "{{ instance_id }}" register: instance_facts - set_fact: instance_data: "{{ instance_facts.instances[0] }}" - debug: msg: "{{ instance_data | to_nice_json }}"
再次为了方便,我们将有趣的数据分配到新的 fact 下。键入 `instance_data` 而不是 `instance_facts.instances[0]` 更容易。
最后,我们将有趣的详细信息打印到屏幕上。
为了更好的可读性,我建议在打印 JSON 时使用过滤器 `to_nice_json`,并且在 ansible.cfg 中,我们可以将 stdout_callback 的值定义为 debug,这将为我们的错误提供漂亮的打印输出。
代码清单 6. ansible.cfg 内容
[defaults] stdout_callback = debug #This will make our output look much better.
创建快照和 AMI
为了制作快照,我们必须对连接到原始实例的每个卷调用模块 `ec2_snapshot`。
不久前,Ansible 团队引入了关键字 `loop`,而在较旧的 playbooks 中,我们可以找到 `with_items`、`with_list` 等,实际上是任何带有 `with_*` 的东西。
Loop 具有统一的界面,并在过滤器的帮助下可以履行任何 `with_*` 的角色。
最基本的 loop 接受一个列表,例如字符串或字典,并在每次迭代中将后续值分配给变量 `item`。
在此任务中,值得注意的是异步调用的使用,通过使用关键字 async - 指定每次并行执行的等待时间,以及 poll - 定义检查完成情况的间隔。两者都以秒为单位定义。
代码清单 7. 创建快照的任务
- name: Create snapshot of volumes ec2_snapshot: instance_id: "{{ instance_id }}" device_name: "{{ item.device_name }}" <<: *aws_secrets snapshot_tags: Name: "{{ instance_data.tags.Name | default(instance_id, true) }}-{{ item.device_name }}" id_instance: "{{ instance_id }}" volume: "{{ item.device_name }}" date_created: "{{ current_datetime }}" loop: "{{ instance_data.block_device_mappings }}" register: snapshots_list async: 1200 poll: 5 - debug: msg: "{{ snapshots_list.results | to_nice_json }}" - name: Snapshot data modification set_fact: # the most ugly piece of code I ever wrote in Ansible snapshots_2_reuse: "{{ snapshots_2_reuse | default([]) + [ { 'device_name': item.item.device_name, 'snapshot': item.snapshot_id } ] }}" loop: "{{ snapshots_list.results }}" - debug: msg: "{{ snapshots_2_reuse | to_nice_json }}" - name: Create AMI from snapshot ec2_ami: <<: *aws_secrets name: "{{ instance_data.tags.Name | default(instance_id, true) | replace(' ','_') }}-{{ current_datetime | replace(':','-') }}" root_device_name: "{{ snapshots_2_reuse[0].device_name }}" device_mapping: - device_name: "{{ snapshots_2_reuse[0].device_name }}" snapshot_id: "{{ snapshots_2_reuse[0].snapshot }}" delete_on_termination: true register: created_ami - debug: msg: "{{ created_ami | to_nice_json }}"
之后,我们需要处理有关已创建快照的输出,因为我们只需要字典形式的数据子集。
让我们将这个表达式分解成几部分,以便更好地理解
snapshots_2_reuse: "{{ snapshots_2_reuse | default([]) + ...
- 首先,我们获取变量 `snapshots_2_reuse` 的当前值,如果它未定义,我们用一个空列表替换它。
... + [ { 'device_name': item.item.device_name, 'snapshot': item.snapshot_id } ] }}
接下来,我们将一个动态创建的映射添加到列表中,其中包含正确命名的参数。
最后,我们将创建的对象分配给变量 `snapshots_2_reuse`,然后继续处理原始列表中的下一个元素。
请注意,`item.item.device_name` 是正确的,因为我们使用变量 `item` 进行迭代,但每个元素都有自己的名为 `item` 的子元素 - 请参阅上一个调试任务的输出。
因为不可能直接从快照创建实例,所以我们需要有一个基于根设备快照的 AMI。
请注意在获取 AMI 名称的值时广泛使用过滤器。
我们尝试使用标签 `Name` 的值,如果它未定义或为空(过滤器中的 `true` 第二个参数),我们用 `instance_id` 替换它,它总是必须存在的。
我们还需要确保没有空格,这在 `Name` 中很常见。
此外,在日期时间中,我们需要去除冒号,因为它们也不能放在 AMI 名称中。
创建具有回退的实例
接下来,在我们通过 playbook 的旅程中,我们遇到了一个 block / rescue 结构。
它是 Ansible 版本的异常捕获。
如果 `block` 部分中的任何步骤失败,它将调用下面的 `rescue` 任务。
在这个特定的例子中,在 block 中我们有两个任务:停止原始实例并启动新实例。
如果 Ansible 未能实现其中任何一个,它将立即继续打印警告消息并启动原始实例 - 如果停止任务失败,Ansible 将通知实例正在运行并将此 rescue 任务标记为 OK。
当然,块可以嵌套,例如,rescue 部分也可以有另一个 block/resue 结构。
应该注意的是,如果 rescue 部分成功完成,play 会继续,因为它“擦除”了错误状态(但不包括报告),这意味着它不会触发 max_fail_percentage 也不会触发 any_errors_fatal 配置,但会出现在 playbook 统计信息中。
代码清单 8. 实例创建
- name: Stop original instance and start new one block: - name: Stop original running instance ec2: <<: *aws_secrets state: stopped instance_id: "{{ instance_id }}" - name: Launch new instance ec2: <<: *aws_secrets key_name: "{{ instance_data.key_name }}" group_id: "{{ instance_data.network_interfaces[0].groups | map(attribute='group_id') | list }}" instance_type: "{{ instance_data.instance_type }}" image: "{{ created_ami.image_id }}" wait: yes wait_timeout: 600 instance_tags: "{{ instance_data.tags }}" volumes: "{{ snapshots_2_reuse[1:] }}" vpc_subnet_id: "{{ instance_data.subnet_id }}" rescue: - debug: msg: "We've caught an error during new instance creation. Reverting by restoring previous instance" - name: Starting back the original running instance ec2: <<: *aws_secrets state: running instance_id: "{{ instance_id }}"
总结
在本演示中,我们逐步介绍了设置 Ansible playbook 以登录到 AWS、创建给定 EC2 实例的快照并基于原始实例创建一个新实例的过程。
我们还介绍了一些技巧和窍门,以增强 playbooks,从而提高它们的性能、稳定性和可读性。
本文证明,Ansible 由于其简单性,可以胜过 Chef 或 Puppet,对于这个简单的任务来说,Chef 或 Puppet 可能是矫枉过正。换句话说:Ansible 就是保持简单。