From 37498833283ad5cdca55ec73572626a459b42bab Mon Sep 17 00:00:00 2001 From: Christopher Winslett Date: Sun, 15 Mar 2015 23:16:48 -0700 Subject: [PATCH] first commit --- .gitignore | 3 + README.md | 16 +++++ helpers/__init__.py | 0 helpers/errors.py | 13 ++++ helpers/etcd.py | 102 ++++++++++++++++++++++++++++ helpers/ha.py | 111 ++++++++++++++++++++++++++++++ helpers/postgresql.py | 152 ++++++++++++++++++++++++++++++++++++++++++ postgres-ha.pdf | Bin 0 -> 20035 bytes postgres0.yml | 24 +++++++ postgres1.yml | 24 +++++++ run.py | 68 +++++++++++++++++++ 11 files changed, 513 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 helpers/__init__.py create mode 100644 helpers/errors.py create mode 100644 helpers/etcd.py create mode 100644 helpers/ha.py create mode 100644 helpers/postgresql.py create mode 100644 postgres-ha.pdf create mode 100644 postgres0.yml create mode 100644 postgres1.yml create mode 100755 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..df367db5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/* +*.pyc +helpers/*.pyc diff --git a/README.md b/README.md new file mode 100644 index 00000000..05578085 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# PostgreSQL HA with etcd + +To get started, do the following from different terminals: + +``` +> etcd --data-dir=data/etcd +> run.py postgresql0.yml +> run.py postgresql1.yml +``` + +From there, you will see a high-availability cluster start up. Test +different settings in the YAML files to see how behavior changes. Kill +some of the different components to see how the system behaves. + +Cheers, +Chris diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/helpers/errors.py b/helpers/errors.py new file mode 100644 index 00000000..1aa0e32b --- /dev/null +++ b/helpers/errors.py @@ -0,0 +1,13 @@ +class CurrentLeaderError(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) + +class HealthiestMemberError(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/helpers/etcd.py b/helpers/etcd.py new file mode 100644 index 00000000..909639ad --- /dev/null +++ b/helpers/etcd.py @@ -0,0 +1,102 @@ +import urllib2, json, os, time +from urllib import urlencode +import helpers.errors + +class Etcd: + def __init__(self, config): + self.scope = config["scope"] + self.host = config["host"] + self.ttl = config["ttl"] + + def get_client_path(self, path, max_attempts = 1): + attempts = 0 + response = None + + while True: + try: + response = urllib2.urlopen(self.client_url(path)).read() + break + except (urllib2.HTTPError, urllib2.URLError) as e: + attempts += 1 + if attempts < max_attempts: + print "Failed to return %s, trying again. (%s of %s)" % (path, attempts, max_attempts) + time.sleep(3) + else: + raise e + try: + return json.loads(response) + except ValueError: + return response + + def put_client_path(self, path, data): + opener = urllib2.build_opener(urllib2.HTTPHandler) + request = urllib2.Request(self.client_url(path), data=urlencode(data).replace("false", "False")) + request.get_method = lambda: 'PUT' + opener.open(request) + + def client_url(self, path): + return "http://%s/v2/keys/service/%s%s" % (self.host, self.scope, path) + + def xlog_position(member): + try: + return self.get_client_path("/service/postgresql/xlog-position/%s" % member)["node"]["value"] + except urllib2.HTTPError: + return None + + def current_leader(self): + try: + hostname = self.get_client_path("/leader")["node"]["value"] + address = self.get_client_path("/members/%s" % hostname)["node"]["value"] + + return {"hostname": hostname, "address": address} + except urllib2.HTTPError as e: + if e.code == 404: + return None + raise helpers.errors.CurrentLeaderError("Etcd is not responding properly") + + def touch_member(self, member, connection_string): + self.put_client_path("/members/%s" % member, {"value": connection_string}) + + def take_leader(self, value): + return self.put_client_path("/leader", {"value": value, "ttl": self.ttl}) == None + + def attempt_to_acquire_leader(self, value): + try: + return self.put_client_path("/leader", {"value": value, "ttl": self.ttl, "prevExist": False}) == None + except urllib2.HTTPError as e: + if e.code == 412: + print("Could not take out TTL lock: %s" % e) + return False + + def update_leader(self, value): + try: + self.put_client_path("/leader", {"value": value, "ttl": self.ttl, "prevValue": value}) + except urllib2.HTTPError: + print "Error updating TTL on ETCD for primary." + return False + + def leader_unlocked(self): + try: + self.get_client_path("/leader") + return False + except urllib2.HTTPError as e: + if e.code == 404: + return True + return False + except ValueError as e: + return False + + def am_i_leader(self, value): + #try: + reponse = self.get_client_path("/leader") + print "Lock owner: %s; I am %s" % (reponse["node"]["value"], value) + return reponse["node"]["value"] == value + #except Exception as e: + #return False + + + def race(self, path, value): + try: + return self.put_client_path(path, {"prevExist": False, "value": value}) == None + except urllib2.HTTPError: + return False diff --git a/helpers/ha.py b/helpers/ha.py new file mode 100644 index 00000000..4750f063 --- /dev/null +++ b/helpers/ha.py @@ -0,0 +1,111 @@ +import sys, time, re, urllib2, json, psycopg2 +from base64 import b64decode + +import helpers.errors + +import inspect + +def lineno(): + """Returns the current line number in our program.""" + return inspect.currentframe().f_back.f_lineno + +class Ha: + def __init__(self, state_handler, etcd): + self.state_handler = state_handler + self.etcd = etcd + + def acquire_lock(self): + return self.etcd.attempt_to_acquire_leader(self.state_handler.name) + + def update_lock(self): + return self.etcd.update_leader(self.state_handler.name) + + def is_unlocked(self): + return self.etcd.leader_unlocked() + + def has_lock(self): + return self.etcd.am_i_leader(self.state_handler.name) + + def fetch_current_leader(self): + return self.etcd.current_leader() + + def run_cycle(self): + try: + print lineno() + if self.state_handler.is_healthy(): + print lineno() + if self.is_unlocked(): + print lineno() + if self.state_handler.is_healthiest_node(): + print lineno() + if self.acquire_lock(): + print lineno() + if not self.state_handler.is_leader(): + print lineno() + self.state_handler.promote() + return "promoted self to leader by acquiring session lock" + + print lineno() + return "acquired session lock as a leader" + else: + print lineno() + if self.state_handler.is_leader(): + print lineno() + self.state_handler.demote(self.fetch_current_leader()) + return "demoted self due after trying and failing to obtain lock" + else: + print lineno() + self.state_handler.follow_the_leader(self.fetch_current_leader()) + return "following new leader after trying and failing to obtain lock" + else: + print lineno() + if self.state_handler.is_leader(): + print lineno() + self.state_handler.demote(self.fetch_current_leader()) + return "demoting self because i am not the healthiest node" + else: + print lineno() + self.state_handler.follow_the_leader(self.fetch_current_leader()) + return "following a different leader because i am not the healthiest node" + + else: + print lineno() + if self.has_lock(): + print lineno() + self.update_lock() + + if not self.state_handler.is_leader(): + print lineno() + self.state_handler.promote() + return "promoted self to leader because i had the session lock" + else: + print lineno() + return "no action. i am the leader with the lock" + else: + print lineno() + print "does not have lock" + if self.state_handler.is_leader(): + print lineno() + self.state_handler.demote(self.fetch_current_leader()) + return "demoting self because i do not have the lock and i was a leader" + else: + print lineno() + self.state_handler.follow_the_leader(self.fetch_current_leader()) + return "no action. i am a secondary and i am following a leader" + else: + print lineno() + return "no action. not healthy enough to do anything." + except helpers.errors.CurrentLeaderError: + print lineno() + print "failed to fetch current leader from etcd" + except psycopg2.OperationalError: + print lineno() + print "Error communicating with Postgresql. Will try again." + except helpers.errors.HealthiestMemberError: + print lineno() + print "failed to determine healthiest member fromt etcd" + + def run(self): + while True: + self.run_cycle() + time.sleep(10) diff --git a/helpers/postgresql.py b/helpers/postgresql.py new file mode 100644 index 00000000..a2464925 --- /dev/null +++ b/helpers/postgresql.py @@ -0,0 +1,152 @@ +import os, psycopg2, re, time +from urlparse import urlparse + +class Postgresql: + + def __init__(self, config): + self.name = config["name"] + self.host, self.port = config["listen"].split(":") + self.data_dir = config["data_dir"] + self.replication = config["replication"] + + self.config = config + + self.cursor_holder = None + self.connection_string = "postgres://%s:%s@%s:%s/postgres" % (self.replication["username"], self.replication["password"], self.host, self.port) + + self.conn = None + + def cursor(self): + if self.cursor_holder == None: + self.conn = psycopg2.connect("postgres://%s:%s/postgres" % (self.host, self.port)) + self.conn.autocommit = True + self.cursor_holder = self.conn.cursor() + + return self.cursor_holder + + def disconnect(self): + try: + self.conn.close() + except Exception as e: + print "Error disconnecting: %s" % e + + def query(self, sql): + max_attempts = 0 + while True: + try: + self.cursor().execute(sql) + break + except psycopg2.OperationalError as e: + if self.conn != None: + self.disconnect() + self.cursor_holder = None + if max_attempts > 4: + raise e + max_attempts += 1 + time.sleep(5) + return self.cursor() + + def data_directory_empty(self): + return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == [] + + def initialize(self): + if os.system("initdb -D %s" % self.data_dir) == 0: + self.write_pg_hba() + + return True + + return False + + def sync_from_leader(self, leader): + leader = urlparse(leader["address"]) + + f = open("./pgpass", "w") + f.write("%(hostname)s:%(port)s:*:%(username)s:%(password)s\n" % + {"hostname": leader.hostname, "port": leader.port, "username": leader.username, "password": leader.password}) + f.close() + + os.system("chmod 600 pgpass") + + + return os.system("PGPASSFILE=pgpass pg_basebackup -R -D %(data_dir)s --host=%(host)s --port=%(port)s -U %(username)s" % + {"data_dir": self.data_dir, "host": leader.hostname, "port": leader.port, "username": leader.username}) == 0 + + def is_leader(self): + return not self.query("SELECT pg_is_in_recovery();").fetchone()[0] + + def is_running(self): + return os.system("pg_ctl status -D %s" % self.data_dir) == 0 + + def start(self): + command_code = os.system("postgres -D %s %s &" % (self.data_dir, self.server_options())) + time.sleep(5) + return command_code != 0 + + def stop(self): + return os.system("pg_ctl stop -w -D %s -m fast -w" % self.data_dir) != 0 + + def reload(self): + return os.system("pg_ctl reload -w -D %s" % self.data_dir) == 0 + + def restart(self): + return os.system("pg_ctl restart -w -D %s -m fast" % self.data_dir) == 0 + + def server_options(self): + options = "-c listen_addresses=%s -c port=%s" % (self.host, self.port) + for setting, value in self.config["parameters"].iteritems(): + options += " -c \"%s=%s\"" % (setting, value) + return options + + def is_healthy(self): + if not self.is_running(): + print "Postgresql is not running." + return False + + return True + + def is_healthiest_node(self): + return True + + def replication_slot_name(self): + member = os.environ.get("MEMBER") + (member, _) = re.subn(r'[^a-z0-9]+', r'_', member) + return member + + def write_pg_hba(self): + f = open("%s/pg_hba.conf" % self.data_dir, "a") + f.write("host replication %(username)s %(network)s md5" % + {"username": self.replication["username"], "network": self.replication["network"]}) + f.close() + + + def write_recovery_conf(self, leader_hash): + leader = urlparse(leader_hash["address"]) + + f = open("%s/recovery.conf" % self.data_dir, "w") + f.write(""" +standby_mode = 'on' +primary_slot_name = '%(recovery_slot)s' +primary_conninfo = 'user=%(user)s password=%(password)s host=%(hostname)s port=%(port)s sslmode=prefer sslcompression=1' +recovery_target_timeline = 'latest' +""" % {"recovery_slot": self.name, "user": leader.username, "password": leader.password, "hostname": leader.hostname, "port": leader.port}) + if "recovery_conf" in self.config: + for name, value in self.config["recovery_conf"].iteritems(): + f.write("%s = '%s'" % (name, value)) + f.close() + + def follow_the_leader(self, leader_hash): + leader = urlparse(leader_hash["address"]) + if os.system("grep 'host=%(hostname)s port=%(port)s' %(data_dir)s/recovery.conf" % {"hostname": leader.hostname, "port": leader.port, "data_dir": self.data_dir}) != 0: + self.write_recovery_conf(leader_hash); + self.restart() + return True + + def promote(self): + return os.system("pg_ctl promote -w -D %s" % self.data_dir) == 0 + + def demote(self, leader): + self.write_recovery_conf(leader) + self.restart() + + def create_replication_user(self): + self.query("CREATE USER \"%s\" WITH REPLICATION ENCRYPTED PASSWORD '%s';" % (self.replication["username"], self.replication["password"])) diff --git a/postgres-ha.pdf b/postgres-ha.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e2eba17b2b74bc7a428b78f5399957efba61c610 GIT binary patch literal 20035 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

sIYZb6P*R;Id}!aH(jA>-<|!Xyp|5YlhUju zwOo{hG3cj?{(ZwauGN}lilM3FFa!N)bO}G=R}KeH=o7%6SB+^9ix2EQ*GyU(1Oce} zZD&U^JzlmsjcKKchesJh%vk^JMzp-!C57T=!*WR{YzN$Po_m=i&0%$lz$NIlLCuzx zy2vI%r1+|F@BkIP2dTCT+Mz#jW`)KcO7|2n9m`LNNj>swdwRYJ=R@zV<>5r1+N2C# z16*1V8;H&=``d9bT-R=dFW#l#>IY3snuY@CJU4BP6@+4oQ6(u{Ti{(iO=~Rs`4BpP}TI zu|FZ0$B&V@L%NmFt%|Yq}cy=3)32{LA!r`inPb}jlz)QkM7v0B6MiAiv z>VbjSs0mc<0->-LyP2aZ$6G$k5fClJ=f5X{1wOoOBWx-<9)OME+z9UR`)6})6O$F8LL`$Gxmqk4%Gabg=#Je#rt`4x-#eJ#j z&EqcnU*0Twp6Vvy$Yt{zFzp_{_lGKq9#l`hhb9Hxhreon5Pv{?{P2|d{pFbLMEeBh z#NwpxzR)p|*-YfyC$@Da^2T@eE*;fWNhZf?-p)75X&l`H<{mki_fQ5Ot|{R|&~#as zrWWA}Cv~gRDYGvosB0!SP&PEAcq%Sc2~>s_f)|DsD4pbaa_|*@l)N#B&il1tFtu}0 z*?sa>ST~J2*j^`#R>aF6qij{-O2}ghecRJ9o=P@CgV;;oa+t)<>|G+Af2@V%4Q!m{ z8o;Fq46tTKHo#n8Y~!@Q5ZGKw7$&=ck&h)k4HMflT=rCWe9>lKnTyU}FB}bEtTL2P z0#9Tx66P3XFH7W7R(jNThMfvEd<@CjzNhf(k~7Wh^A4vtdV%TX-GFTTX!{kBG+;A3 z8&<`^U4RvpFQVj2R?&@or)rh*Ofc>x?A?j^p{cZ1okq*B)uce>6nU)k+7|5o-vHbn zO!^z(*xCODUVoyq{{nD-0PQ~j?qB`>1b6?AH68w^=jgp~~cNf`foOd&l-Jxd#tzXMr^e7LGStF;<*zr%Bu#tm-y_v0}jXgBOKTLHI>rZmJ znf2!kWb_OaZLIXH|7{U9vv+V5GS#yOFfo2I-1YvcFfe}B)XWSWP5)XZ8$0x$5ca?O zFtPtx06l<(N(fpR-~6^uR{{XSe?TFtYsL<9r6~pU<(eeoA(h zzjPKh=D&K;12`D}^7S8cVxj-bHxnCxg@Xye@*k3sgB9?n=O5pUpFTfl$iVTJ#?JEj zh{;@K%(|@J~|313EHu%4u-M^3E?<0}b zv-&)o|KZ3i^-MnR#OH4RIXuD7j0IYgmV<=>K+DX|0AS!?XZw6Dv$1_9BNCst$jm^% z+Qic6b2@Ya4hDbnAr6+$u74c>;O9Tzn=iGGP3-+6)t}r^`Bk&tp1tEXX;_Z2w?cL&5HlI;6H|cE%;xF%s(m7 z-__4K({uc_oc~AiGVu+@Qgg|b_vl(C7L_t?_svz}duV~U0xE%_x{-%h{A9BjPCD2?@wQiCEZvpBpElz!b4 z2;6-vG-rjEExkLK{`|4-aFlxZU^LjE{bSQ<3-7dX=PY8tMuW@gy})h4E4-;A2>v1M zDPN`{ljF(x;oB2a!`Y8aZ7VMu^}dH4rlGCPjBM=zr~Au26WP+I;;rzev#k23EQ{}J zGn?L1>2fSrN8v4-)hbBS4WBA5oO@j^66gt6Jm{u5zV>i)AczhYU)E9l# zw!uOB`f?Y%qHyi{UZ*cxvTQ^_)n-yH%q(K^Su>VeV>LN95+2^0*DOXP2dRf*&i<{8 z9w>5W(01CA`JdRXXsvg^p>HPyT6o@~xmg5tC=hOC&;vcG1`uKK~5_JHLW4)cC9 zoztriH3}?}IgS%$8NqvGX0GTKwGNPT{H`yA^Sa={L7{Pknl)FAdekkkWae~r)6XR6 z3-r#ycCtKwM3SPu9MN$k`P^0uZbbI=b*>X z#M^|rY%E7h3#X4u8|g1*`!+xtab0u>X_{E|j&40`WRiE5How06zWe$T+WorRuLwX7 zm#xyt($wnWMhc;&-TC^_R1lMrR6T0xGycll$yS!X9>zf5KRXc4OjadK)#UZEJJ2ms zA$$_6kCVZ|RioG@7N4=cZQu}ozmS3oBOirQzANK zc9(?F%I~tiF1r0(yqLRWjo2T(PCtVmLTRM@1p!dX=m^?xjn6=1A!C+F7Rv(n6Jf#p zU51o50lnlRkx=XPa(8ydJ?hfW>Vo6-8xyPov{!y|Wx0G~gUfvN5_-Dv^X8|P5w8hX z$EmLC?b(sK8IT0}WUufYGVgvI)7URv^x~`cQ?N4#o^fnGOv-tikC2>WatDXtMjq@8Di>Cd?*TW!3{6iz&LlYo$VV#h` zo#lL*!Ch&6s3b#5vih%!kyo=}Z$&^w*H`pQcNhchEO=?!X-<`DYsEYz8}l2L7RP)A zThm>J$BeMp_W* zh~am{^$dH$BJQrjY>3A(mnp+|Y?EF4!DD9Nf4hPubOqqB(B&_ALD>bd.Y(6>VJ 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