mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
data/*
|
||||||
|
*.pyc
|
||||||
|
helpers/*.pyc
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
+102
@@ -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
|
||||||
+111
@@ -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)
|
||||||
@@ -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"]))
|
||||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
|||||||
|
loop_wait: 10
|
||||||
|
etcd:
|
||||||
|
scope: batman
|
||||||
|
ttl: 30
|
||||||
|
host: 127.0.0.1:4001
|
||||||
|
postgresql:
|
||||||
|
name: postgresql0
|
||||||
|
listen: 127.0.0.1:5432
|
||||||
|
data_dir: data/postgresql0
|
||||||
|
replication:
|
||||||
|
username: replicator
|
||||||
|
password: rep-pass
|
||||||
|
network: 127.0.0.1/32
|
||||||
|
#recovery_conf:
|
||||||
|
#restore_command: cp ../wal_archive/%f %p
|
||||||
|
parameters:
|
||||||
|
archive_mode: "on"
|
||||||
|
wal_level: hot_standby
|
||||||
|
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
||||||
|
max_wal_senders: 5
|
||||||
|
wal_keep_segments: 8
|
||||||
|
archive_timeout: 1800s
|
||||||
|
max_replication_slots: 5
|
||||||
|
hot_standby: "on"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
loop_wait: 10
|
||||||
|
etcd:
|
||||||
|
scope: batman
|
||||||
|
ttl: 30
|
||||||
|
host: 127.0.0.1:4001
|
||||||
|
postgresql:
|
||||||
|
name: postgresql1
|
||||||
|
listen: 127.0.0.1:5433
|
||||||
|
data_dir: data/postgresql1
|
||||||
|
replication:
|
||||||
|
username: replicator
|
||||||
|
password: rep-pass
|
||||||
|
network: 127.0.0.1/32
|
||||||
|
#recovery_conf:
|
||||||
|
#restore_command: cp ../wal_archive/%f %p
|
||||||
|
parameters:
|
||||||
|
archive_mode: "on"
|
||||||
|
wal_level: hot_standby
|
||||||
|
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
||||||
|
max_wal_senders: 5
|
||||||
|
wal_keep_segments: 8
|
||||||
|
archive_timeout: 1800s
|
||||||
|
max_replication_slots: 5
|
||||||
|
hot_standby: "on"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import sys, os, yaml, time, urllib2, atexit
|
||||||
|
from helpers.etcd import Etcd
|
||||||
|
from helpers.postgresql import Postgresql
|
||||||
|
from helpers.ha import Ha
|
||||||
|
|
||||||
|
f = open(sys.argv[1], "r")
|
||||||
|
config = yaml.load(f.read())
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
print config
|
||||||
|
|
||||||
|
etcd = Etcd(config["etcd"])
|
||||||
|
postgresql = Postgresql(config["postgresql"])
|
||||||
|
ha = Ha(postgresql, etcd)
|
||||||
|
|
||||||
|
# stop postgresql on script exit
|
||||||
|
def stop_postgresql():
|
||||||
|
postgresql.stop()
|
||||||
|
atexit.register(stop_postgresql)
|
||||||
|
|
||||||
|
# wait for etcd to be available
|
||||||
|
etcd_ready = False
|
||||||
|
while not etcd_ready:
|
||||||
|
try:
|
||||||
|
etcd.touch_member(postgresql.name, postgresql.connection_string)
|
||||||
|
etcd_ready = True
|
||||||
|
except urllib2.URLError:
|
||||||
|
print "waiting on etcd"
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# is data directory empty?
|
||||||
|
if postgresql.data_directory_empty():
|
||||||
|
# racing to initialize
|
||||||
|
if etcd.race("/initialize", postgresql.name):
|
||||||
|
postgresql.initialize()
|
||||||
|
etcd.take_leader(postgresql.name)
|
||||||
|
postgresql.start()
|
||||||
|
postgresql.create_replication_user()
|
||||||
|
else:
|
||||||
|
synced_from_leader = False
|
||||||
|
while not synced_from_leader:
|
||||||
|
leader = etcd.current_leader()
|
||||||
|
if leader == None:
|
||||||
|
time.sleep(5)
|
||||||
|
next
|
||||||
|
if postgresql.sync_from_leader(leader):
|
||||||
|
postgresql.write_recovery_conf(leader)
|
||||||
|
postgresql.start()
|
||||||
|
synced_from_leader = True
|
||||||
|
else:
|
||||||
|
time.sleep(5)
|
||||||
|
else:
|
||||||
|
postgresql.write_recovery_conf({"address": "postgres://169.0.0.1:5432"})
|
||||||
|
postgresql.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print ha.run_cycle()
|
||||||
|
|
||||||
|
# create replication slots
|
||||||
|
if postgresql.is_leader():
|
||||||
|
for node in etcd.get_client_path("/members?recursive=true")["node"]["nodes"]:
|
||||||
|
member = node["key"].split('/')[-1]
|
||||||
|
if member != postgresql.name:
|
||||||
|
postgresql.query("DO LANGUAGE plpgsql $$DECLARE somevar VARCHAR; BEGIN SELECT slot_name INTO somevar FROM pg_replication_slots WHERE slot_name = '%(slot)s' LIMIT 1; IF NOT FOUND THEN PERFORM pg_create_physical_replication_slot('%(slot)s'); END IF; END$$;" % {"slot": member})
|
||||||
|
|
||||||
|
time.sleep(config["loop_wait"])
|
||||||
Reference in New Issue
Block a user