-
Notifications
You must be signed in to change notification settings - Fork 43
/
chapter9.txt
1799 lines (1382 loc) · 81.1 KB
/
chapter9.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Chapter 9 - Ansible Cookbooks
Until now, most of this book has demonstrated individual aspects of Ansible---inventory, playbooks, ad-hoc tasks, etc. But this chapter synthesizes everything we've gone over in the previous chapters and shows how Ansible is applied to real-world infrastructure management scenarios.
## Highly-Available Infrastructure with Ansible
Real-world web applications require redundancy and horizontal scalability with multi-server infrastructure. In the following example, we'll use Ansible to configure a complex infrastructure on servers provisioned either locally (via Vagrant and VirtualBox) or on a set of automatically-provisioned instances (running on either DigitalOcean or Amazon Web Services):
{width=60%}
![Highly-Available Infrastructure.](images/9-highly-available-infrastructure.png)
**Varnish** acts as a load balancer and reverse proxy, fronting web requests and routing them to the application servers. We could just as easily use something like **Nginx** or **HAProxy**, or even a proprietary cloud-based solution like an Amazon's **Elastic Load Balancer** or Linode's **NodeBalancer**, but for simplicity's sake and for flexibility in deployment, we'll use Varnish.
**Apache** and mod_php run a PHP-based application that displays the entire stack's current status and outputs the current server's IP address for load balancing verification.
A **Memcached** server provides a caching layer that can be used to store and retrieve frequently-accessed objects in lieu of slower database storage.
Two **MySQL** servers, configured as a master and slave, offer redundant and performant database access; all data will be replicated from the master to the slave, and in addition, the slave can be used as a secondary server for read-only queries to take some load off the master.
### Directory Structure
In order to keep our configuration organized, we'll use the following structure for our playbooks and configuration:
{lang="text",linenos=off}
```
lamp-infrastructure/
inventories/
playbooks/
db/
memcached/
varnish/
www/
provisioners/
configure.yml
provision.yml
requirements.yml
Vagrantfile
```
Organizing things this way allows us to focus on each server configuration individually, then build playbooks for provisioning and configuring instances on different hosting providers later. This organization also keeps server playbooks completely independent, so we can modularize and reuse individual server configurations.
### Individual Server Playbooks
Let's start building our individual server playbooks (in the `playbooks` directory). To make our playbooks more efficient, we'll use some contributed Ansible roles on Ansible Galaxy rather than install and configure everything step-by-step. We're going to target CentOS 7 servers in these playbooks, but only minimal changes would be required to use the playbooks with Ubuntu, Debian, or later versions of CentOS.
**Varnish**
Create a `main.yml` file within the `playbooks/varnish` directory, with the following contents:
{lang="yaml"}
```
---
- hosts: lamp_varnish
become: yes
vars_files:
- vars.yml
roles:
- geerlingguy.firewall
- geerlingguy.repo-epel
- geerlingguy.varnish
tasks:
- name: Copy Varnish default.vcl.
template:
src: "templates/default.vcl.j2"
dest: "/etc/varnish/default.vcl"
notify: restart varnish
```
We're going to run this playbook on all hosts in the `lamp_varnish` inventory group (we'll create this later), and we'll run a few simple roles to configure the server:
- `geerlingguy.firewall` configures a simple iptables-based firewall using a couple variables defined in `vars.yml`.
- `geerlingguy.repo-epel` adds the EPEL repository (a prerequisite for varnish).
- `geerlingguy.varnish` installs and configures Varnish.
Finally, a task copies over a custom `default.vcl` that configures Varnish, telling it where to find our web servers and how to load balance requests between the servers.
Let's create the two files referenced in the above playbook. First, `vars.yml`, in the same directory as `main.yml`:
{lang="yaml"}
```
---
firewall_allowed_tcp_ports:
- "22"
- "80"
varnish_use_default_vcl: false
```
The first variable tells the `geerlingguy.firewall` role to open TCP ports 22 and 80 for incoming traffic. The second variable tells the `geerlingguy.varnish` we will supply a custom `default.vcl` for Varnish configuration.
Create a `templates` directory inside the `playbooks/varnish` directory, and inside, create a `default.vcl.j2` file. This file will use Jinja syntax to build Varnish's custom `default.vcl` file:
{lang="text"}
```
vcl 4.0;
import directors;
{% for host in groups['lamp_www'] %}
backend www{{ loop.index }} {
.host = "{{ host }}";
.port = "80";
}
{% endfor %}
sub vcl_init {
new vdir = directors.random();
{% for host in groups['lamp_www'] %}
vdir.add_backend(www{{ loop.index }}, 1);
{% endfor %}
}
sub vcl_recv {
set req.backend_hint = vdir.backend();
# For testing ONLY; makes sure load balancing is working correctly.
return (pass);
}
```
We won't study Varnish's VCL syntax in depth but we'll run through `default.vcl` and highlight what is being configured:
1. (1-3) Indicate that we're using the 4.0 version of the VCL syntax and import the `directors` varnish module (which is used to configure load balancing).
2. (5-10) Define each web server as a new backend; give a host and a port through which varnish can contact each host.
3. (12-17) `vcl_init` is called when Varnish boots and initializes any required varnish modules. In this case, we're configuring a load balancer `vdir`, and adding each of the `www[#]` backends we defined earlier as backends to which the load balancer will distribute requests. We use a `random` director so we can easily demonstrate Varnish's ability to distribute requests to both app backends, but other load balancing strategies are also available.
4. (19-24) `vcl_recv` is called for each request, and routes the request through Varnish. In this case, we route the request to the `vdir` backend defined in `vcl_init`, and indicate that Varnish should *not* cache the result.
According to #4, we're actually *bypassing Varnish's caching layer*, which is not helpful in a typical production environment. If you only need a load balancer without any reverse proxy or caching capabilities, there are better options. However, we need to verify our infrastructure is working as it should. If we used Varnish's caching, Varnish would only ever hit one of our two web servers during normal testing.
In terms of our caching/load balancing layer, this should suffice. For a true production environment, you should remove the final `return (pass)` and customize `default.vcl` according to your application's needs.
**Apache / PHP**
Create a `main.yml` file within the `playbooks/www` directory, with the following contents:
{lang="yaml"}
```
---
- hosts: lamp_www
become: yes
vars_files:
- vars.yml
roles:
- geerlingguy.firewall
- geerlingguy.repo-epel
- geerlingguy.apache
- geerlingguy.php
- geerlingguy.php-mysql
- geerlingguy.php-memcached
tasks:
- name: Remove the Apache test page.
file:
path: /var/www/html/index.html
state: absent
- name: Copy our fancy server-specific home page.
template:
src: templates/index.php.j2
dest: /var/www/html/index.php
- name: Ensure required SELinux dependency is installed.
package:
name: libsemanage-python
state: present
- name: Configure SELinux to allow HTTPD connections.
seboolean:
name: "{{ item }}"
state: true
persistent: true
with_items:
- httpd_can_network_connect_db
- httpd_can_network_memcache
when: ansible_selinux.status == 'enabled'
```
As with Varnish's configuration, we'll configure a firewall and add the EPEL repository (required for PHP's memcached integration), and we'll also add the following roles:
- `geerlingguy.apache` installs and configures the latest available version of the Apache web server.
- `geerlingguy.php` installs and configures PHP to run through Apache.
- `geerlingguy.php-mysql` adds MySQL support to PHP.
- `geerlingguy.php-memcached` adds Memcached support to PHP.
Two first two tasks remove the default `index.html` home page included with Apache, and replace it with our PHP app.
The last two tasks ensure SELinux is configured to allow Apache to communicate with the database and memcached servers over the network. For more discussion on how to configure SELinux, please see chapter 11.
As in the Varnish example, create the two files referenced in the above playbook. First, `vars.yml`, alongside `main.yml`:
{lang="yaml"}
```
---
firewall_allowed_tcp_ports:
- "22"
- "80"
```
Create a `templates` directory inside the `playbooks/www` directory, and inside, create an `index.php.j2` file. This file will use Jinja syntax to build a (relatively) simple PHP script to display the health and status of all the servers in our infrastructure:
{lang="php"}
```
<?php
/**
* @file
* Infrastructure test page.
*
* DO NOT use this in production. It is simply a PoC.
*/
$mysql_servers = array(
{% for host in groups['lamp_db'] %}
'{{ host }}',
{% endfor %}
);
$mysql_results = array();
foreach ($mysql_servers as $host) {
if ($result = mysql_test_connection($host)) {
$mysql_results[$host] = '<span style="color: green;">PASS\
</span>';
$mysql_results[$host] .= ' (' . $result['status'] . ')';
}
else {
$mysql_results[$host] = '<span style="color: red;">FAIL</span>';
}
}
// Connect to Memcached.
$memcached_result = '<span style="color: red;">FAIL</span>';
if (class_exists('Memcached')) {
$memcached = new Memcached;
$memcached->addServer('{{ groups['lamp_memcached'][0] }}', 11211);
// Test adding a value to memcached.
if ($memcached->add('test', 'success', 1)) {
$result = $memcached->get('test');
if ($result == 'success') {
$memcached_result = '<span style="color: green;">PASS</span>';
$memcached->delete('test');
}
}
}
/**
* Connect to a MySQL server and test the connection.
*
* @param string $host
* IP Address or hostname of the server.
*
* @return array
* Array with 'success' (bool) and 'status' ('slave' or 'master').
* Empty if connection failure.
*/
function mysql_test_connection($host) {
$username = 'mycompany_user';
$password = 'secret';
try {
$db = new PDO(
'mysql:host=' . $host . ';dbname=mycompany_database',
$username,
$password,
array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
// Query to see if the server is configured as a master or slave.
$statement = $db->prepare("SELECT variable_value
FROM information_schema.global_variables
WHERE variable_name = 'LOG_BIN';");
$statement->execute();
$result = $statement->fetch();
return array(
'success' => TRUE,
'status' => ($result[0] == 'ON') ? 'master' : 'slave',
);
}
catch (PDOException $e) {
return array();
}
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Host {{ inventory_hostname }}</title>
<style>* { font-family: Helvetica, Arial, sans-serif }</style>
</head>
<body>
<h1>Host {{ inventory_hostname }}</h1>
<?php foreach ($mysql_results as $host => $result): ?>
<p>MySQL Connection (<?php print $host; ?>):
<?php print $result; ?></p>
<?php endforeach; ?>
<p>Memcached Connection: <?php print $memcached_result; ?></p>
</body>
</html>
```
T> Don't try transcribing this example manually; you can get the code from this book's repository on GitHub. Visit the [ansible-for-devops](https://github.com/geerlingguy/ansible-for-devops) repository and download the source for [index.php.j2](https://github.com/geerlingguy/ansible-for-devops/blob/master/lamp-infrastructure/playbooks/www/templates/index.php.j2)
This application is a bit more complex than most examples in the book, but here's a quick run through:
- (9-23) Iterate through all the `lamp_db` MySQL hosts defined in the playbook inventory and test the ability to connect to them---as well as whether they are configured as master or slave, using the `mysql_test_connection()` function defined later (40-73).
- (25-39) Check the first defined `lamp_memcached` Memcached host defined in the playbook inventory, confirming the ability to connect with the cache and to create, retrieve, or delete a cached value.
- (41-76) Define the `mysql_test_connection()` function, which tests the ability to connect to a MySQL server and also returns its replication status.
- (78-91) Print the results of all the MySQL and Memcached tests, along with `{{ inventory_hostname }}` as the page title, so we can easily see which web server is serving the viewed page.
At this point, the heart of our infrastructure---the application that will test and display the status of all our servers---is ready to go.
**Memcached**
Compared to the earlier playbooks, the Memcached playbook is quite simple. Create `playbooks/memcached/main.yml` with the following contents:
{lang="yaml"}
```
---
- hosts: lamp_memcached
become: yes
vars_files:
- vars.yml
roles:
- geerlingguy.firewall
- geerlingguy.memcached
```
As with the other servers, we need to ensure only the required TCP ports are open using the simple `geerlingguy.firewall` role. Next we install Memcached using the `geerlingguy.memcached` role.
In our `vars.yml` file (again, alongside `main.yml`), add the following:
{lang="yaml"}
```
---
firewall_allowed_tcp_ports:
- "22"
firewall_additional_rules:
- "iptables -A INPUT -p tcp --dport 11211 -s \
{{ groups['lamp_www'][0] }} -j ACCEPT"
- "iptables -A INPUT -p tcp --dport 11211 -s \
{{ groups['lamp_www'][1] }} -j ACCEPT"
memcached_listen_ip: "0.0.0.0"
```
We need port 22 open for remote access, and for Memcached, we're adding manual iptables rules to allow access on port 11211 for the web servers *only*. We add one rule per `lamp_www` server by drilling down into each item in the generated `groups` variable that Ansible uses to track all inventory groups currently available. We also bind Memcached to all interfaces so it will accept connections through the server's network interface.
W> The **principle of least privilege** "requires that in a particular abstraction layer of a computing environment, every module ... must be able to access only the information and resources that are necessary for its legitimate purpose" (Source: [Wikipedia](http://en.wikipedia.org/wiki/Principle_of_least_privilege)). Always restrict services and ports to only those servers or users that need access!
**MySQL**
The MySQL configuration is more complex than the other servers because we need to configure MySQL users per-host and configure replication. Because we want to maintain an independent and flexible playbook, we also need to dynamically create some variables so MySQL will get the right server addresses in any potential environment.
Let's first create the main playbook, `playbooks/db/main.yml`:
{lang="yaml"}
```
---
- hosts: lamp_db
become: yes
vars_files:
- vars.yml
pre_tasks:
- name: Create dynamic MySQL variables.
set_fact:
mysql_users:
- name: mycompany_user
host: "{{ groups['lamp_www'][0] }}"
password: secret
priv: "*.*:SELECT"
- name: mycompany_user
host: "{{ groups['lamp_www'][1] }}"
password: secret
priv: "*.*:SELECT"
mysql_replication_master: "{{ groups['a4d.lamp.db.1'][0] }}"
roles:
- geerlingguy.firewall
- geerlingguy.mysql
```
Most of the playbook is straightforward, but in this instance, we're using `set_fact` as a `pre_task` (to be run before the `geerlingguy.firewall` and `geerlingguy.mysql` roles) to dynamically create variables for MySQL configuration.
`set_fact` allows us to define variables at runtime, so we can have all server IP addresses available, even if the servers were freshly provisioned at the beginning of the playbook's run. We'll create two variables:
- `mysql_users` is a list of users the `geerlingguy.mysql` role will create when it runs. This variable will be used on all database servers so both of the two `lamp_www` servers get `SELECT` privileges on all databases.
- `mysql_replication_master` is used to indicate to the `geerlingguy.mysql` role which database server is the master; it will perform certain steps differently depending on whether the server being configured is a master or slave, and ensure that all the slaves are configured to replicate data from the master.
We'll need a few other normal variables to configure MySQL, so we'll add them alongside the firewall variable in `playbooks/db/vars.yml`:
{lang="yaml"}
```
---
firewall_allowed_tcp_ports:
- "22"
- "3306"
mysql_replication_user:
name: replication
password: secret
mysql_databases:
- name: mycompany_database
collation: utf8_general_ci
encoding: utf8
```
We're opening port 3306 to anyone, but according to the **principle of least privilege** discussed earlier, you would be justified in restricting this port to only the servers and users that need access to MySQL (similar to the memcached server configuration). In this case, the attack vector is mitigated because MySQL's own authentication layer is used through the `mysql_user` variable generated in `main.yml`.
We are defining two MySQL variables: `mysql_replication_user` to be used for master and slave replication, and `mysql_databases` to define a list of databases that will be created (if they don't already exist) on the database servers.
With the configuration of the database servers complete, the server-specific playbooks are ready to go.
### Main Playbook for Configuring All Servers
A simple playbook including each of the group-specific playbooks is all we need for the overall configuration to take place. Create `configure.yml` in the project's root directory, with the following contents:
{lang="yaml"}
```
---
- import_playbook: playbooks/varnish/main.yml
- import_playbook: playbooks/www/main.yml
- import_playbook: playbooks/db/main.yml
- import_playbook: playbooks/memcached/main.yml
```
At this point, if you had some already-booted servers and statically defined inventory groups like `lamp_www`, `lamp_db`, etc., you could run `ansible-playbook configure.yml` and have a full HA infrastructure at the ready!
But we're going to continue to make our playbooks more flexible and useful.
### Getting the required roles
As mentioned in the Chapter 6, Ansible allows you to define all the required Ansible Galaxy roles for a given project in a `requirements.yml` file. Instead of having to remember to run `ansible-galaxy role install -y [role1] [role2] [role3]` for each of the roles we're using, we can create `requirements.yml` in the root of our project, with the following contents:
{lang="yaml"}
```
---
roles:
- name: geerlingguy.firewall
- name: geerlingguy.repo-epel
- name: geerlingguy.varnish
- name: geerlingguy.apache
- name: geerlingguy.php
- name: geerlingguy.php-mysql
- name: geerlingguy.php-memcached
- name: geerlingguy.mysql
- name: geerlingguy.memcached
```
To make sure all the required dependencies are installed, run `ansible-galaxy install -r requirements.yml` from within the project's root.
### Vagrantfile for Local Infrastructure via VirtualBox
As with many other examples in this book, we can use Vagrant and VirtualBox to build and configure the infrastructure locally. This lets us test things as much as we want with zero cost, and usually results in faster testing cycles, since everything is orchestrated over a local private network on a (hopefully) beefy workstation.
Our basic Vagrantfile layout will be something like the following:
1. Define a base box (in this case, CentOS 7) and VM hardware defaults.
2. Define all the VMs to be built, with VM-specific IP addresses and hostname configurations.
3. Define the Ansible provisioner along with the last VM, so Ansible can run once at the end of Vagrant's build cycle.
Here's the Vagrantfile in all its glory:
{lang="ruby"}
```
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
# Base VM OS configuration.
config.vm.box = "geerlingguy/centos7"
config.ssh.insert_key = false
config.vm.synced_folder '.', '/vagrant', disabled: true
# General VirtualBox VM configuration.
config.vm.provider :virtualbox do |v|
v.memory = 512
v.cpus = 1
v.linked_clone = true
v.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
v.customize ["modifyvm", :id, "--ioapic", "on"]
end
# Varnish.
config.vm.define "varnish" do |varnish|
varnish.vm.hostname = "varnish.test"
varnish.vm.network :private_network, ip: "192.168.56.2"
end
# Apache.
config.vm.define "www1" do |www1|
www1.vm.hostname = "www1.test"
www1.vm.network :private_network, ip: "192.168.56.3"
www1.vm.provision "shell",
inline: "sudo yum update -y"
www1.vm.provider :virtualbox do |v|
v.customize ["modifyvm", :id, "--memory", 512]
end
end
# Apache.
config.vm.define "www2" do |www2|
www2.vm.hostname = "www2.test"
www2.vm.network :private_network, ip: "192.168.56.4"
www2.vm.provision "shell",
inline: "sudo yum update -y"
www2.vm.provider :virtualbox do |v|
v.customize ["modifyvm", :id, "--memory", 512]
end
end
# MySQL.
config.vm.define "db1" do |db1|
db1.vm.hostname = "db1.test"
db1.vm.network :private_network, ip: "192.168.56.5"
end
# MySQL.
config.vm.define "db2" do |db2|
db2.vm.hostname = "db2.test"
db2.vm.network :private_network, ip: "192.168.56.6"
end
# Memcached.
config.vm.define "memcached" do |memcached|
memcached.vm.hostname = "memcached.test"
memcached.vm.network :private_network, ip: "192.168.56.7"
# Run Ansible provisioner once for all VMs at the end.
memcached.vm.provision "ansible" do |ansible|
ansible.playbook = "configure.yml"
ansible.inventory_path = "inventories/vagrant/inventory"
ansible.limit = "all"
ansible.extra_vars = {
ansible_user: 'vagrant',
ansible_ssh_private_key_file: \
"~/.vagrant.d/insecure_private_key"
}
end
end
end
```
Most of the Vagrantfile is straightforward, and similar to other examples used in this book. The last block of code, which defines the `ansible` provisioner configuration, contains three extra values that are important for our purposes:
{lang="ruby"}
```
ansible.inventory_path = "inventories/vagrant/inventory"
ansible.limit = "all"
ansible.extra_vars = {
ansible_user: 'vagrant',
ansible_ssh_private_key_file: "~/.vagrant.d/insecure_private_key"
}
```
1. `ansible.inventory_path` defines the inventory file for the `ansible.playbook`. You could certainly create a dynamic inventory script for use with Vagrant, but because we know the IP addresses ahead of time, and are expecting a few specially-crafted inventory group names, it's simpler to build the inventory file for Vagrant provisioning by hand (we'll do this next).
2. `ansible.limit` is set to `all` so Vagrant knows it should run the Ansible playbook connected to all VMs, and not just the current VM. You could technically use `ansible.limit` with a provisioner configuration for each of the individual VMs, and just run the VM-specific playbook through Vagrant, but our live production infrastructure will be using one playbook to configure all the servers, so we'll do the same locally.
3. `ansible.extra_vars` contains the vagrant SSH user configuration for Ansible. It's more standard to include these settings in a static inventory file or use Vagrant's automatically-generated inventory file, but it's easiest to set them once for all servers here.
Before running `vagrant up` to see the fruits of our labor, we need to create an inventory file for Vagrant at `inventories/vagrant/inventory`:
{lang="text"}
```
[lamp_varnish]
192.168.56.2
[lamp_www]
192.168.56.3
192.168.56.4
[a4d.lamp.db.1]
192.168.56.5
[lamp_db]
192.168.56.5 mysql_replication_role=master
192.168.56.6 mysql_replication_role=slave
[lamp_memcached]
192.168.56.7
```
Now `cd` into the project's root directory, run `vagrant up`, and after ten or fifteen minutes, load `http://192.168.56.2/` in your browser. Voila!
{width=80%}
![Highly Available Infrastructure - Success!](images/9-ha-infrastructure-success.png)
You should see something like the above screenshot. The PHP app displays the current app server's IP address, the individual MySQL servers' status, and the Memcached server status. Refresh the page a few times to verify Varnish is distributing requests randomly between the two app servers.
We now have local infrastructure development covered, and Ansible makes it easy to use the exact same configuration to build our infrastructure in the cloud.
### Provisioner Configuration: DigitalOcean
In Chapter 8, we learned provisioning and configuring DigitalOcean droplets in an Ansible playbook is fairly simple. But we need to take provisioning a step further by provisioning multiple droplets (one for each server in our infrastructure) and dynamically grouping them so we can configure them after they are booted and online.
For the sake of flexibility, let's create a playbook for our DigitalOcean droplets in `provisioners/digitalocean.yml`. This will allow us to add other provisioner configurations later, alongside the `digitalocean.yml` playbook. As with our example in Chapter 7, we will use a local connection to provision cloud instances. Begin the playbook with:
{lang="yaml"}
```
---
- hosts: localhost
connection: local
gather_facts: false
```
Next we need to define some metadata to describe each of our droplets. For simplicity's sake, we'll inline the `droplets` variable in this playbook:
{lang="yaml",starting-line-number=6}
```
vars:
droplets:
- { name: a4d.lamp.varnish, group: "lamp_varnish" }
- { name: a4d.lamp.www.1, group: "lamp_www" }
- { name: a4d.lamp.www.2, group: "lamp_www" }
- { name: a4d.lamp.db.1, group: "lamp_db" }
- { name: a4d.lamp.db.2, group: "lamp_db" }
- { name: a4d.lamp.memcached, group: "lamp_memcached" }
```
Each droplet is an object with two keys:
- `name`: The name of the Droplet for DigitalOcean's listings and Ansible's host inventory.
- `group`: The Ansible inventory group for the droplet.
Next we need to add a task to create the droplets, using the `droplets` list as a guide, and as part of the same task, register each droplet's information in a separate dictionary, `created_droplets`:
{lang="yaml",starting-line-number=15}
```
tasks:
- name: Provision DigitalOcean droplets.
digital_ocean_droplet:
state: "{{ item.state | default('present') }}"
name: "{{ item.name }}"
private_networking: yes
size: "{{ item.size | default('1gb') }}"
image: "{{ item.image | default('centos-7-x64') }}"
region: "{{ item.region | default('nyc3') }}"
# Customize this default for your account.
ssh_keys:
- "{{ item.ssh_key | default('138954') }}"
unique_name: yes
register: created_droplets
with_items: "{{ droplets }}"
```
Many of the options (e.g. `size`) are defined as `{{ item.property | default('default_value') }}`, which allows us to use optional variables per droplet. For any of the defined droplets, we could add `size: 2gb` (or another valid value), and it would override the default value set in the task.
T> You could specify an SSH public key per droplet, or use the same key for all hosts by providing a default (as I did above). In this example, I added an SSH key to my DigitalOcean account, then used the DigitalOcean API to retrieve the key's numeric ID (as described in the previous chapter).
T>
T> It's best to use key-based authentication and add at least one SSH key to your DigitalOcean account so Ansible can connect using secure keys instead of insecure passwords---especially since these instances will be created with only a root account.
We loop through all the defined `droplets` using `with_items: droplets`, and after each droplet is created, we add the droplet's metadata (name, IP address, etc.) to the `created_droplets` variable. Next, we'll loop through that variable to build our inventory on-the-fly so our configuration applies to the correct servers:
{lang="yaml",starting-line-number=31}
```
- name: Add DigitalOcean hosts to inventory groups.
add_host:
name: "{{ item.1.data.ip_address }}"
groups: "do,{{ droplets[item.0].group }},{{ item.1.data.droplet.name }}"
# You can dynamically add inventory variables per-host.
ansible_user: root
mysql_replication_role: >-
{{ 'master' if (item.1.data.droplet.name == 'a4d.lamp.db.1')
else 'slave' }}
mysql_server_id: "{{ item.0 }}"
when: item.1.data is defined
with_indexed_items: "{{ created_droplets.results }}"
```
You'll notice a few interesting things happening in this task:
- This is the first time we've used `with_indexed_items`. Though less common, this is a valuable loop feature because it adds a sequential and unique `mysql_server_id`. Though only the MySQL servers need a server ID set, it's more simple to dynamically create the variable for every server so each is available when needed. `with_indexed_items` sets `item.0` to the key of the item and `item.1` to the value of the item.
- In addition to helping us create server IDs, `with_indexed_items` also helps us to reliably set each droplet's group. We could also consider using tags for groups, but this example configures groups manually. By using the `droplets` variable we manually created earlier, we can set the proper group for a particular droplet.
- Finally, we add inventory variables per-host in `add_host`. To do this, we add the variable name as a key and the variable value as that key's value. Simple, but powerful!
T> There are a few different ways you can approach dynamic provisioning and inventory management for your infrastructure. There are ways to avoid using more exotic features of Ansible (e.g. `with_indexed_items`) and complex if/else conditions, especially if you only use one cloud infrastructure provider. This example is slightly more complex because the playbook is being created to be interchangeable with similar provisioning playbooks.
The final step in our provisioning is to make sure all the droplets are booted and can be reached via SSH. So at the end of the `digitalocean.yml` playbook, add another play to be run on hosts in the `do` group we just defined:
{lang="yaml",starting-line-number=44}
```
- hosts: do
remote_user: root
gather_facts: false
tasks:
- name: Wait for hosts to become reachable.
wait_for_connection:
```
Once the server can be reached by Ansible (using the `wait_for_connection` module), we know the droplet is up and ready for configuration.
We're now *almost* ready to provision and configure our entire infrastructure on DigitalOcean, but first we need to create one last playbook to tie everything together. Create `provision.yml` in the project root with the following contents:
{lang="yaml"}
```
---
- import_playbook: provisioners/digitalocean.yml
- import_playbook: configure.yml
```
That's it! Now, assuming you set the environment variable `DO_API_TOKEN`, you can run `$ ansible-playbook provision.yml` to provision and configure the infrastructure on DigitalOcean.
The entire process should take about 15 minutes; once it's complete, you should see something like this:
{lang="text",linenos=off}
```
PLAY RECAP **********************************************************
107.170.27.137 : ok=19 changed=13 unreachable=0 failed=0
107.170.3.23 : ok=13 changed=8 unreachable=0 failed=0
107.170.51.216 : ok=40 changed=18 unreachable=0 failed=0
107.170.54.218 : ok=27 changed=16 unreachable=0 failed=0
162.243.20.29 : ok=24 changed=15 unreachable=0 failed=0
192.241.181.197 : ok=40 changed=18 unreachable=0 failed=0
localhost : ok=2 changed=1 unreachable=0 failed=0
```
Visit the IP address of the varnish server, and you will be greeted with a status page similar to the one generated by the Vagrant-based infrastructure:
{width=80%}
![Highly Available Infrastructure on DigitalOcean.](images/9-ha-infrastructure-digitalocean.png)
Because everything in this playbook is idempotent, running `$ ansible-playbook provision.yml` again should report no changes, and this will help you verify that everything is running correctly.
Ansible will also rebuild and reconfigure any droplets that might be missing from your infrastructure. If you're daring and would like to test this feature, just log into your DigitalOcean account, delete one of the droplets just created by this playbook (perhaps one of the two app servers), and then run the playbook again.
Now that we've tested our infrastructure on DigitalOcean, we can destroy the droplets just as easily as we can create them. To do this, change the `state` parameter in `provisioners/digitalocean.yml` to default to `'absent'` and run `$ ansible-playbook provision.yml` once more.
Next up, we'll build the infrastructure a third time---on Amazon's infrastructure.
### Provisioner Configuration: Amazon Web Services (EC2)
For Amazon Web Services, provisioning is slightly different. Amazon has a broader ecosystem of services surrounding EC2 instances, so for our particular example we will need to configure security groups prior to provisioning instances.
To begin, create `aws.yml` inside the `provisioners` directory and begin the playbook the same way as for DigitalOcean:
{lang="yaml"}
```
---
- hosts: localhost
connection: local
gather_facts: false
```
First, we'll define three variables to describe what AWS resources and region to use for provisioning.
{lang="yaml",starting-line-number=6}
```
vars:
aws_profile: default
aws_region: us-east-1 # North Virginia
aws_ec2_ami: ami-06cf02a98a61f9f5e # CentOS 7
```
EC2 instances use security groups as an AWS-level firewall (which operates outside the individual instance's OS). We will need to define a list of `security_groups` alongside our EC2 `instances`. First, the `instances`:
{lang="yaml",starting-line-number=11}
```
instances:
- name: a4d.lamp.varnish
group: "lamp_varnish"
security_group: ["default", "a4d_lamp_http"]
- name: a4d.lamp.www.1
group: "lamp_www"
security_group: ["default", "a4d_lamp_http"]
- name: a4d.lamp.www.2
group: "lamp_www"
security_group: ["default", "a4d_lamp_http"]
- name: a4d.lamp.db.1
group: "lamp_db"
security_group: ["default", "a4d_lamp_db"]
- name: a4d.lamp.db.2
group: "lamp_db"
security_group: ["default", "a4d_lamp_db"]
- name: a4d.lamp.memcached
group: "lamp_memcached"
security_group: ["default", "a4d_lamp_memcached"]
```
Inside the `instances` variable, each instance is an object with three keys:
- `name`: The name of the instance, which we'll use to tag the instance and ensure only one instance is created per name.
- `group`: The Ansible inventory group in which the instance should belong.
- `security_group`: A list of security groups into which the instance will be placed. The `default` security group is added to your AWS account upon creation, and has one rule to allow outgoing traffic on any port to any IP address.
I> If you use AWS exclusively, it would be best to use autoscaling groups and change the design of this infrastructure. For this example, we just need to ensure that the six instances we explicitly define are created, so we're using particular `name`s and an `exact_count` to enforce the 1:1 relationship.
With our instances defined, we'll next define a `security_groups` variable containing all the required security group configuration for each server:
{lang="yaml",starting-line-number=31}
```
security_groups:
- name: a4d_lamp_http
rules:
- proto: tcp
from_port: 80
to_port: 80
cidr_ip: 0.0.0.0/0
- proto: tcp
from_port: 22
to_port: 22
cidr_ip: 0.0.0.0/0
rules_egress: []
- name: a4d_lamp_db
rules:
- proto: tcp
from_port: 3306
to_port: 3306
cidr_ip: 0.0.0.0/0
- proto: tcp
from_port: 22
to_port: 22
cidr_ip: 0.0.0.0/0
rules_egress: []
- name: a4d_lamp_memcached
rules:
- proto: tcp
from_port: 11211
to_port: 11211
cidr_ip: 0.0.0.0/0
- proto: tcp
from_port: 22
to_port: 22
cidr_ip: 0.0.0.0/0
rules_egress: []
```
Each security group has a `name` (which was used to identify the security group in the `instances` list), `rules` (a list of firewall rules---like protocol, ports, and IP ranges---to limit *incoming* traffic), and `rules_egress` (a list of firewall rules to limit *outgoing* traffic).
We need three security groups: `a4d_lamp_http` to open port 80, `a4d_lamp_db` to open port 3306, and `a4d_lamp_memcached` to open port 11211.
Now that we have all the data we need to set up security groups and instances, our first task is to create or verify the existence of the security groups:
{lang="yaml",starting-line-number=68}
```
tasks:
- name: Configure EC2 Security Groups.
ec2_group:
name: "{{ item.name }}"
description: Example EC2 security group for A4D.
state: present
rules: "{{ item.rules }}"
rules_egress: "{{ item.rules_egress }}"
profile: "{{ aws_profile }}"
region: "{{ aws_region }}"
with_items: "{{ security_groups }}"
```
The `ec2_group` requires a name, region, and rules for each security group. Security groups will be created if they don't exist, modified to match the supplied values if they do exist, or verified if they both exist and match the given values.
With the security groups configured, we can provision the defined EC2 instances by looping through `instances` with the `ec2` module:
{lang="yaml",starting-line-number=75}
```
- name: Provision EC2 instances.
ec2:
key_name: "{{ item.ssh_key | default('lamp_aws') }}"
instance_tags:
Name: "{{ item.name | default('') }}"
Application: lamp_aws
inventory_group: "{{ item.group | default('') }}"
inventory_host: "{{ item.name | default('') }}"
group: "{{ item.security_group | default('') }}"
instance_type: "{{ item.type | default('t2.micro')}}"
image: "{{ aws_ec2_ami }}"
wait: yes
wait_timeout: 500
exact_count: 1
count_tag:
inventory_host: "{{ item.name | default('') }}"
profile: "{{ aws_profile }}"
region: "{{ aws_region }}"
register: created_instances
with_items: "{{ instances }}"
```
This example is slightly more complex than the DigitalOcean example, and a few parts warrant a deeper look:
- EC2 allows SSH keys to be defined by name---in my case, I have a key `lamp_aws` in my AWS account. You should set the `key_name` default to a key that you have in your account.
- Instance tags are tags that AWS will attach to your instance, for categorization purposes. Besides the `Name` tag (which is used for display purposes in the AWS Console), you can add whatever tags you want, to help categorize instances.
- `t2.micro` was used as the default instance type, since it falls within EC2's free tier usage. If you just set up an account and keep all AWS resource usage within free tier limits, you won't be billed anything.
- `exact_count` and `count_tag` work together to ensure AWS provisions only one of each of the instances we defined. The `count_tag` tells the `ec2` module to match on the `inventory_host` value, and `exact_count` tells the module to only provision `1` instance. If you wanted to *remove* all your instances, you could set `exact_count` to 0 and run the playbook again.
Each provisioned instance will have its metadata added to the registered `created_instances` variable, which we will use to build Ansible inventory groups for the server configuration playbooks.
{lang="yaml",starting-line-number=100}
```
- name: Add EC2 instances to inventory groups.
add_host:
name: "{{ item.1.tagged_instances.0.public_ip }}"
groups: "aws,{{ item.1.item.group }},{{ item.1.item.name }}"
# You can dynamically add inventory variables per-host.
ansible_user: centos
host_key_checking: false
mysql_replication_role: >-
{{ 'master' if (item.1.item.name == 'a4d.lamp.db.1')
else 'slave' }}
mysql_server_id: "{{ item.0 }}"
when: item.1.instances is defined
with_indexed_items: "{{ created_instances.results }}"
```
This `add_host` example is slightly simpler than the one for DigitalOcean, because AWS attaches metadata to EC2 instances which we can re-use when building groups or hostnames (e.g. `item.1.item.group`). We don't have to use list indexes to fetch group names from the original `instances` variable.
We still use `with_indexed_items` so we can use the index to generate a unique ID per server for use in building the MySQL master-slave replication.
The final step in provisioning the EC2 instances is to ensure they are booted and able to accept connections.
{lang="yaml",starting-line-number=114}
```
- hosts: aws
gather_facts: false
tasks:
- name: Wait for hosts to become available.
wait_for_connection:
```
Now, modify the `provision.yml` file in the root of the project folder and change the provisioners import to look like the following:
{lang="yaml"}
```
---
- import_playbook: provisioners/aws.yml
- import_playbook: configure.yml
```
Assuming the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are set in your current terminal session, you can run `$ ansible-playbook provision.yml` to provision and configure the infrastructure on AWS.
The entire process should take about 15 minutes, and once it's complete, you should see something like this:
{lang="text",linenos=off}
```
PLAY RECAP **********************************************************
54.148.100.44 : ok=24 changed=16 unreachable=0 failed=0
54.148.120.23 : ok=40 changed=19 unreachable=0 failed=0
54.148.41.134 : ok=40 changed=19 unreachable=0 failed=0
54.148.56.137 : ok=13 changed=9 unreachable=0 failed=0
54.69.160.32 : ok=27 changed=17 unreachable=0 failed=0
54.69.86.187 : ok=19 changed=14 unreachable=0 failed=0
localhost : ok=3 changed=1 unreachable=0 failed=0
```
Visit the IP address of the Varnish server, and you will be greeted with a status page similar to the one generated by the Vagrant and DigitalOcean-based infrastructure:
{width=80%}
![Highly Available Infrastructure on AWS EC2.](images/9-ha-infrastructure-aws.png)
As with the earlier examples, running `ansible-playbook provision.yml` again should produce no changes, because everything in this playbook is idempotent. If one of your instances was somehow terminated, running the playbook again would recreate and reconfigure the instance in a few minutes.
To terminate all the provisioned instances, you can change the `exact_count` in the `ec2` task to `0`, and run `$ ansible-playbook provision.yml` again.
#### AWS EC2 Dynamic inventory plugin
If you'd like to connect to the EC2 servers provisioned in AWS in another playbook, you don't need to get the information in a play and use `add_hosts` to build the inventory inside a playbook.
Like the DigitalOcean example in the previous chapter, you can use the `aws_ec2` dynamic inventory plugin to work with the servers in an AWS account.
Create an `aws_ec2.yml` inventory configuration file in an `inventories/aws` directory under the main playbook directory, and add the following:
{lang=yaml}
```
---
plugin: aws_ec2
regions:
- us-east-1
hostnames:
- ip-address
keyed_groups:
- key: tags.inventory_group
```
To verify the inventory source works correctly, use the `ansible-inventory` command:
{lang=text,linenos=off}
```
$ ansible-inventory -i inventories/aws/aws_ec2.yml --graph
@all:
|--@aws_ec2:
| |--54.148.41.134
| |--54.148.100.44
| |--54.69.160.32
| |--54.69.86.187
| |--54.148.120.23
| |--54.148.56.137
|--@lamp_aws: