[NDC2017 : 박준철] Python 게임 서버...

Preview:

Citation preview

스마트스터디

박준철

몬스터 슈퍼리그 게임 서버

Python 게임 서버 안녕하십니까?

목표

• 몬슈리의 Python 게임 서버에 사용된 각종 라이브러리와 서비스 공유

• 몬슈리의 사례를 통해서 Python 으로 게임 서버를 만들 때 개발자의 의사결정에

도움이 되는 것

• 경험의 공유를 통한 “Python 은 느리지만 빨라”를 전파

몬스터 슈퍼 리그를 개발하면서 빠른 개발 진행을 위해 선택했던 Python 게임

서버, '잘 되면 다시 만들지 뭐'라는 생각에서 시작했지만 다시 만들 일은 영원히

오지 않았습니다... Python으로 게임 서버를 만들었을 때 사용한 것은 무엇인지

또 실제 오픈 했을 때 서버는 안녕했는지 알아봅니다.

from https://ndc.nexon.com

목차1. 몬슈리 Python 게임 서버는 안녕하십니까?

2. 몬슈리 Python 게임 서버

3. 안녕하기 위한 노력

4. 몬슈리 Python 게임 서버는 안녕했나요?

5. Python 게임 서버

몬슈리 Python 게임 서버는

안녕하십니까?

현재

• DAU : 10 만 명

• CCU : 2 만 명

• Request / day = 6,000 만

• Sales in 6 months = $ 2,000 만 +

몬슈리 Python 게임 서버

Python 2.7.11

• 개발 및 유지 보수가 편해야 함

• 다양한 라이브러리와 클라우드 서비스를 지원

• 패키지 관리가 잘 되어야 하고 의존성 문제가 없어야 함

• 서버의 퍼포먼스보다 개발자의 퍼포먼스가 중요

• 이미 사내 backend 개발에 Python 을 사용하고 있음

• 몬슈리 개발팀에 적합한 언어

Python 2.7.11

• Blinker

• GData

• Celery

• Jinja2, bootstrap3

• NewRelic, Sentry

• …

• Flask (nginx + uwsgi)

• SQLAlchemy

• Protobuf (Protocol Buffers)

• Redis

• NumPy

• Requests

졸음 주의

• 몬슈리 게임 서버에 사용된 각종 라이브러리 나열식 설명

기본적인 선택들

• Lightweight Web Application Framework - Flask

• ORM - SQLAlchemy

• KeyValue DB - Redis

• Asynchronous Tasks - Celery

게임 서버는 Flask

• Protobuf message 기준으로 decorator 를 통한 routing@route('ReqUserLogin')def userLogin(reqUserLogin):

@app.route('/api', methods=['POST'])req = request_pb2.Request.FromString(reqBody)…db_begin()try:

rsp = handle(req)db_commit()

except:db_rollback()

finally:db_end()

return rsp

게임 서버는 Flask

• Error 처리

• Status code=500

• 명백한 서버 오류, 클라이언트에서 “재시도” or “재접속” 팝업

• Status code=200 & Error Code 를 나누어 사용

• 서버와 클라이언트의 데이터가 맞지 않는 상황으로 판단

• 오류 메시지 노출 후 계속 진행 or “재접속”

게임 서버는 Flask

• 클라이언트의 Retry 대비

• Response 를 Redis 에 보관 후 동일한 Request 에 대해서 Response 를

바로 Return

• 2 회 이상 발생시 Status Code=200 + Error Code return

DB 는 SQLAlchemy

• Alembic 을 이용한 DB Migration 지원

• Multi DB 지원

• Auto Commit 사용하지 않음

• 모든 User Request 에 대해서 Transaction 사용

• Request 처리 중 서버 오류나 데이터 오류의 경우 Rollback

• 오류 없이 로직이 처리된 경우 Commit

데이터는 Protocol Buffers (ProtoBuf)

• Message 파일을 정의하고 Compile

• Python , 3rd party c# 지원

• optional

• enum

• type checker

• python 을 위한 C++ 구현체 지원

message MsgUserItem{optional fixed32 item_uid = 1;optional uint32 item_count = 2;

}

enum MonsterStatType {MS_None = 0;MS_Attack = 1;MS_Defence = 2;MS_Heal = 3;MS_Balance = 4;MS_Hp = 5;

}

랜덤은 NumPy

• Python Random 보다 빠르고 더 좋은 결과를 주지 않을까?

• 막연한 기대로 선택

• 그러나 실제로는…

랜덤은 NumPy

• Random seed : 1• 10, 1000, 1,000,000 개 random 비교

랜덤은 NumPy

• Random seed : 1• 10, 1000, 1,000,000 개 random 비교

랜덤은 NumPy

• Random seed : 1• 10, 1000, 1,000,000 개 random 비교

랜덤은 NumPy

• randint 는 성능 차이가 10배 정도 나지만 random 은 차이 없음

• python.random.choice vs numpy.random.choice>>> import random, numpy>>> a = ['Miho', 'Anu', 'Leo', 'Lily', 'Seiren', 'Sura', 'Persephone', 'Nightmare']>>> random.choice(a)'Anu'>>> numpy.random.choice(a, 11, p=[0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.2]).tolist()['Lily', 'Anu', 'Persephone', 'Sura', 'Seiren', 'Sura', 'Sura', 'Persephone', 'Anu', 'Lily', 'Anu']

random Time (s)

random.random 0.077058

numpy.random 0.078524

python.randint 1.091723

numpy.randint 0.102347

서버간 연결은 requests

• Python 용 HTTP 라이브러리, 주로 서버간 통신에 사용

• Timeout 설정

• Timeout 설정하지 않으면 exception 없는 한 무한 대기

“Without a timeout, your code may hang for minutes or more.”

• User request 에서 서버간 request timeout : 3~9s

• Scheduler 에서 서버간 request timeout : 30s

>>> r = requests.post(url, json=json, timeout=3, headers=headers)

Quest 는 Blinker 로

• Blinker : Signal(Event) Broadcasting 라이브러리

• Signal(Event) 을 send 하는 즉시 Receiver 에게 데이터 전달

• Quest 에서 사용한 방법

• Quest 종류 별로 미리 Custom Name Signal(Event) 정의

• Signal(Event) 별로 1개의 Receiver 를 connect

• 서버 로직에서 Quest 체크가 필요한 경우 Signal 을 send

Quest 는 Blinker 로# signals.pyimport blinkernamespace = blinker.Namespace()summon = namespace.signal('summon')…

# main.py - subscribe summon signal (event)import signals…@signals.summon.connectdef check_summon(sender, user, **kwargs):

quests = find_quests(user, main_condi=data_pb2.MsgQuest.Summon, **kwargs)if quests:

add_extra_response(quests_inc_count(user, quests))…# summon 에 대한 각종 조건 체크 후 퀘스트 진행상황 업데이트signals.summon.send(user=user, monster=new_monster, summon_mon_egg=True)…

게임 데이터는 gdata 로

• 기획팀에서 사용하는 data 는 google sheet 로 관리

• data sheet 를 protobuf serializing 하여 file 로 저장

• 116 google sheets (11 files) serializing = 15s

$ ls joongom staff 644797 3 16 13:37 string.zh-tw.pbjoongom staff 660737 3 16 13:37 string.zh-cn.pbjoongom staff 1676594 3 16 13:37 string.th.pbjoongom staff 739756 3 16 13:37 string.pt.pbjoongom staff 807264 3 16 13:37 string.ko.pbjoongom staff 881384 3 16 13:37 string.ja.pbjoongom staff 763874 3 16 13:37 string.it.pbjoongom staff 794710 3 16 13:37 string.fr.pbjoongom staff 764741 3 16 13:37 string.es.pbjoongom staff 707415 3 16 13:37 string.en.pbjoongom staff 757907 3 16 13:37 string.de.pbjoongom staff 3413470 3 16 13:37 gamedata.pb

게임 데이터는 gdata 로

• 수정 전 데이터와의 diff 를 확인하는 별도의 툴이 필요

• 데이터 배포 전 반드시 확인하는 절차를 진행

• protobuf 를 dict 으로 변경하여 diff

• protobuf-to-dict : https://github.com/benhodgson/protobuf-to-dict

$ data_diff.py 610 620removed mon.cocomaru.tree.1 substages uid:stage.01.05 normal_display_mons index:2removed mon.slimeb.water.1 substages uid:stage.01.05 normal_display_mons index:3modified mon.jackolittlew.light.3 --> mon.slime.tree.3 substages uid:stage.01.02 hell_display_mons index:0modified mon.jackolittle.dark.3 --> mon.slimeb.water.3 substages uid:stage.01.02 hell_display_mons index:1modified mon.jackolittle.dark.3 --> mon.squir.tree.3 substages uid:stage.01.02 hell_display_mons index:2added mon.slime.tree.3 substages uid:stage.01.02 hell_display_mons index:3

Python Exception 은 Sentry

• Sentry is a modern error logging and aggregation platform.

server.py...sentry = Sentry(dsn='http://public_key:secret_key@example.com/1')

def create_app():app = Flask(__name__)sentry.init_app(app)return app

...

try:...

except:sentry.captureException(extra={’User’: userData, ‘Request’: requestData})

Python Exception 은 Sentry

nginx + uWSGI + supervisor

• Flask

• WSGI 표준 지원, uWSGI 로 nginx 연결

• nginx

• 각종 성능에 도움이 될 수 있는 옵션들 적용

• 배포 서버에서만 사용 (개발할 때는 flask run server)

• Supervisor

• Process 컨트롤 시스템

운영툴 은 flask + Jinja2 + bootstrap3

서버는 AWS ECS

• AWS ECS 로 서버 구축

• 게임 서버를 docker registry 를 통해 배포

• AWS ECS, RDS, ElasticCache (Redis), ELB, EC2 사용

• 강력한 AWS CLI

$ aws ecs register-task-definition --family qa-tool --container-definitions file://./qa-tool.json

$ aws ecs update-service --cluster qa-gs --service msl-tool --task-definition qa-tool

$ aws ecs update-service --cluster qa-gs --service msl-tool --desired-count 1 --deployment-configuration

"maximumPercent=100,minimumHealthyPercent=0”

$ aws ecs update-service --cluster qa-gs --service msl-game --desired-count 0

$ aws ecs update-service --cluster qa-gs --service msl-game --desired-count 1

CBT #1 기본 서버 구성

• Game Server & Game Tool Server

• Flask app x n

• Nginx worker x m

• Redis x 1

• Celery x 1

• AWS RDS(MySQL) x 1

• AWS ElasticCache(Redis) x 1

• AWS ELB x 2

• AWS EC2 x 3

• AWS ECS

• Game server service (2 task)

• Game tool service (1 task)

CBT #1

안녕하기 위한 노력

안녕하세요. 개발자님.

전 퍼블리셔라고 합니다.

안녕하세요.

• 퍼블리셔 : 안녕하세요. “퍼블리셔” 입니다.

• 개발자 : 안녕하세요.

준곰이라고 불러 주세요. ☺

2003 년부터 NEXON, NCSOFT, NEOWIZ 에서 게임을 만들었습니다.

대부분 서비스를 종료했습니다만… 왠지 이번에는 느낌이 좋네요.

그리고 2010년 부터 스마트스터디에서 핑크퐁 앱과 각종 게임들을 만들었습니다.

CTO 를 하고 있는 몬슈리 개발자 박준철 입니다.

Sharding 해주실 수 있나요?

• 개 : 네, Table 마다 다른 DB 를 지정할 수 있습니다.

• 퍼 : 1개의 Table 을 n개의 DB에 저장하는 Sharding 을 해주셔야…

• 개 : ORM 쓰고 foreign key도 썼는데… T_T

DB Sharding

• CBT #1 에서 DB CPU 사용률이 높은 것을 확인

• ‘퍼’ 님이 100배 많이 유저님들을 모시고 올 것이니…

A. Foreign key 를 삭제

B. Flask-SQLAlchemy 코드를 수정 Sharding 구현

a. 1 DB = Shard DB x n

b. User 기준으로 GameDB sharding

c. User 별 Shard DB 정보 - 1 개의 CommonDB

DB ShardingSQLALCHEMY_BINDS = {

'common': 'mysql://msl:msl@localhost/MSLCommonDB','game': 'mysql://msl:msl@localhost/MSLGameDB1','log': 'mysql://msl:msl@localhost/MSLLogDB1','clan': 'mysql://msl:msl@localhost/MSLClanDB',

}

SQLALCHEMY_BINDS = {'common': 'mysql://msl:msl@localhost/MSLCommonDB','game': ['mysql://msl:msl@localhost/MSLGameDB1',

'mysql://msl:msl@localhost/MSLGameDB2'],'log': ['mysql://msl:msl@localhost/MSLLogDB1',

'mysql://msl:msl@localhost/MSLLogDB2'],'clan': 'mysql://msl:msl@localhost/MSLClanDB',

}

use commondb;insert into db (db_idx, host, db_name, db_type, world_idx, shard_no, use_yn, reg_date) \

values (11, 'localhost', 'gdb1', 'U', 1, 1, 'Y', now());insert into db (db_idx, host, db_name, db_type, world_idx, shard_no, use_yn, reg_date) \

values (12, 'localhost', 'gdb2', 'U', 1, 2, 'Y', now());insert into db (db_idx, host, db_name, db_type, world_idx, shard_no, use_yn, reg_date) \

values (21, 'localhost', 'logdb1', 'L', 1, 1, 'Y', now());insert into db (db_idx, host, db_name, db_type, world_idx, shard_no, use_yn, reg_date) \

values (22, 'localhost', 'logdb2', 'L', 1, 2, 'Y', now());

• Shard DB 정보는 commondb.db 에서 관리

DB Sharding

> show create table commondb.account;-----------------------------------------------------…`user_id` bigint(20) NOT NULL,`gamedb_shard_no` smallint(6) NOT NULL DEFAULT `1`,`logdb_shard_no` smallint(6) NOT NULL DEFAULT `1`,…------------------------------------------------------

• user 의 game, log db 를 sharding

• gamedb, logdb 의 sharding 을 구성하는 DB 개수가 다를 수 있음

• gamedb_shard_no, logdb_shard_no 정보를 account 에 추가

DB Sharding

db.set_default_shard(current_app, 'game', gamedb_shard_no)db.set_default_shard(current_app, 'log', logdb_shard_no)q = User.query.set_shard(gamedb_shard_no).filter_by(id=user_id)

• User Request 처리는 대체로 user 의 data 에 접근하므로 shard_no 고정적

• default_shard_no 지정 기능 추가

• 친구 data에 접근하는 경우 다른 shard_no 를 사용하는 경우 발생

• Query 에 shard 지정 기능 추가

Redis CPU 사용률이 너무 높은데요?

• 개 : 성능 좋은 인스턴스로 바꿔서 서비스 해줘요.

• 퍼 : redis 는 싱글 스레드라…

• 개 : 아…

• 퍼 : redis 도 Sharding 해주시면 좋겠습니다 ☺

• 개 : 아…

Redis Sharding

• https://redis.io/topics/benchmarks

• “Redis runs slower on a VM compared to running without virtualization using the same hardware. If you have the chance to run Redis on a physical machine this is preferred. …”

• virtualized vs bare-metal servers (without pipeline)

Intel(R) Xeon(R) CPU E5520 @ 2.27GHz Linode 2048 instance

SET 122556.53 req/sec 35001.75 req/sec

GET 123601.76 req/sec 37481.26 req/sec

$ ./redis-benchmark -r 1000000 -n 2000000 -t get,set,lpush,lpop -q

Redis Sharding

• Redis 저장 데이터

• User Session Token & Data ( expire=3600 )

• User Response Cache Data ( expire=3600 )

• Recommended Friends

• Friend Dungeon Data ( expire=3600 )

• Server Cache Data

Redis Sharding

• 1 User Request

• 1 Read User Session Token + 1 Read User Session Data

• 1 Write User Session Token + 1 Write User Session Data

• 1 Read and 1 Write Serialized Response Cache Data

• CCU 100,000 : 0.07 TPS * 100,000 * 6 = 42,000 / sec

• User data 만 User Session Token 으로 Sharding

redis_shard_no = Hash(User Session Token) % session_redis_count

Monitoring은 뭘로 하나요?

• 퍼 : 서버 Monitoring은 뭘로 하나요?

• 개 : Exception 나면 Sentry 가 와요. Sentry 짱

• 퍼 : Exception 안 날 때는 뭘로 봐요?

• 개 : 잘 돌고 있겠죠…

• 퍼 : …

New Relic

• APM(Application Performance Monitoring)

• 24 시간 이내 Request 의 Transaction 분석 무료

• Database Transaction 분석 유료

• Transaction 별 분석 가능 (set_transaction_name)

…app = newrelic.agent.wsgi_application()(app)…def handle(api_name, req):

…newrelic.agent.set_transaction_name(api_name)…

New Relic

New Relic• 대략 많이 비쌈 (c4.4xlarge x 25 = essentials $7,500/month)

Performance Test 해주세요

• 퍼 : 서버 준비할 수 있도록 Performance Test 해주세요.

• 개 : 그냥 AWS 인데 100대로 시작하면 안되나요?

• 퍼 : 네 =_=

• 개 : 아…

Performance Test

• nGrinder, Locust, New Relic

• Test Server

• DB : AWS RDS db.r3.large x 1 (2 Core, 16 GiB)

• Game Server : AWS ec2 c4.xlarge x 3 (4 Core, 7.5 GiB)

• Test Client

• Master : AWS ec2 c4.xlarge x 1 (4 Core, 7.5 GiB)

• Slave : AWS ec2 c4.xlarge x 6 (4 Core, 7.5 GiB)

Performance Test

• nGrinder

( https://naver.github.io/ngrinder )

• Java 기반, python 지원 안 함

• Master x 1, Slave x 6

• Test Request : Connection Info

• 700 TPS 까지 안정적(233 TPS/server)

• 700 TPS 이상에서는 request 가 쌓이면서

latency 급격히 증가

Performance Test

• Locust ( http://locust.io )

• Python 지원

• Master x 1, Slave x 6

Performance Test

• Locust Bot (15 가지 주요 행동 )

• User Register, Login, Gift, Friend, Quest, Battle 등 구현

• 특정 대기 시간 후 지정된 확률로 행동(Behavior) 을 선택 후 실행

• 이전 Response 정보 반영하여 행동

Performance Test

• 110 TPS (36 TPS/server)

• CPU 사용률은 최대 80%

• 1 Request = 1 core 를 100ms

점유 , 4 core 는 40 TPS

Performance Test

• CBT 를 기준으로 1 user = 0.07 TPS

예측 동접 TPS / User TPS40 TPS(4 core)

80 TPS(8 core)

10,000 0.07 700 17 대 9 대

30,000 0.07 2100 52 대 26 대

50,000 0.07 3500 87 대 43 대

100,000 0.07 7000 174 대 86 대

Performance Test 후

• 개 : 10 만 동접을 위해 8 core 서버를 86 대면 충분하네요.

• 퍼 : …

• 개 : 다들 이정도 쓰시죠? …

• 퍼 : …

Performance Improvement - Protobuf

Dockerfile

ADD protobuf-2.6.1.tar.gz /app/WORKDIR /appRUN \

mv protobuf-2.6.1 protobuf &&\cd protobuf &&\export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=cpp &&\export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION_VERSION=2 &&\./autogen.sh &&\./configure CXXFLAGS="-DNDEBUG -O2" CFLAGS="-DNDEBUG -O2" &&\make &&\make install &&\cd python &&\python setup.py build &&\python setup.py build --cpp_implementation &&\python setup.py install --cpp_implementation &&\

• Protobuf C++ 구현체 사용

• 메모리 사용량 90% 이상 절감

• 연산 속도 30배 이상 향상

• 단, float 사용 주의

Performance Improvement - Protobuf

• 21,724 bytes 의 login response parsing time & peak memory

protobuf Time (1,000) Time(10,000)

python 16.143264 2:24.392930

python protobuf C++ 0.451125 4.211471

pypy 4.446425 28.042325

Python3.6.0 17.954635 3:04.946336

protobuf Memory (1,000) Memory (10,000)

python 1,186 MB 10,089 MB

python protobuf C++ 115 MB 824 MB

pypy 599 MB 5,421 MB

Python3.6.0 781 MB 7,331 MB

Performance Improvement - Redis

with redis_store.pipeline() as pipeline:try:

pipeline.hmset(name, mapping)pipeline.expire(name, expire_time)pipeline.execute()

• Pipeline 적용

• Avg calls 93 5

Performance Improvement - DB

q = db.session.query(User, UserMonster). \set_shard(gamedb_shard_no). \filter(User.id.in_(ids)). \filter(User.monster_id==UserMonster.id). \all()

for u, m in q:…

• ORM 에 의지, Sharding

• QuerySet loop .all()

• Foreign key join

• Avg calls 23 3

Performance Improvement - DB

• 기존의 경우 Friend 를 1회 select 하여 n개의 data를 가져옴

• n 번 commondb 의 Account 를 select

• n 번 gamedb 의 User 를 select

• n 번 gamedb 의 UserMonster 를 select

Performance Improvement - DB

• select 를 줄이기 위해 pk 를 확보하여 in_ 으로 filter

• Shard 별 pk 를 확보하여 loop

Performance Improvement - PyPy

• Pure Python 으로 Python 작성하여 jit 으로 실행 시간에 성능을 끌어 올림

• Dockerfile 만 변경하여 PyPy 설치, PyPy 로 uwsgi 실행

Dockerfile

...ADD pypy2-v5.3.1-linux64.tar.bz2 /app/WORKDIR /app...RUN \mv pypy2-v5.3.1-linux64 pypy &&\./pypy/bin/pypy -m ensurepip &&\./pypy/bin/pip install numpy &&\./pypy/bin/pip install -e ....

uwsgi.ini

...plugins = pypypypy-lib = /app/pypy/bin/libpypy-c.sopypy-home = /app/pypy/binpypy-wsgi = docker-uwsgi:app...

Performance Improvement - PyPy

CPython PyPy

Performance Improvement - PyPy

CPython PyPy

Performance Improvement - PyPy

• 동일한 코드가 동작하는 경우로 테스트

• Process 당 약 10,000 개 이상의 요청 처리 후 성능 향상

• 향상된 성능은 측정하기 힘들 정도 (성능이 너무 좋아짐)

그러나

Performance Improvement - PyPy

• 다양한 패턴의 request 처리에 대해서는 성능이 빠르게 올라가지 않음

• 초반 느린 부트스트래핑(Bootstrapping) 단계로 인해 Latency 증가

• Protobuf C++ 구현체를 사용할 수 없음

• 높은 메모리 사용량

Performance Improvement - PyPy

• 다양한 패턴의 request 처리에 대해서는 성능이 빠르게 올라가지 않음

• 초반 느린 부트스트래핑(Bootstrapping) 단계로 인해 Latency 증가

• Protobuf C++ 구현체를 사용할 수 없음

• 높은 메모리 사용량

• 사용 포기

Performance Improvement - Result

• Protobuf C++ 구현체로 변경

• Redis pipeline 사용

• Database Query 최적화

• 8 Core 15 GiB x 50• Python CPU 점유 시간

• 100ms 50ms

• 4 Core 7.5 GiB x 174

몬슈리 Python 게임 서버는

안녕했나요?

네, 오픈 직후에도 안녕했습니다.

• DAU : 50 만

• CCU : 7 만

• 30만 CCU 이상 가능

네, 오픈 직후에도 안녕했습니다.

그리고 지금도 안녕합니다.

• DAU : 10 만

• CCU : 2 만

• 1 User : 0.06 TPS

• Daily Requests = 6,000 만

• DB CPU 사용률 : 5%

• 6개월 동안 서비스 중

Python 게임 서버

생각해본 것

• 처음부터 PyPy 를 고려한 설계를 했다면?

• 처음부터 Sharding 을 고려했다면?

• MySQL 이 아닌 다른 DB 를 사용했다면?

• Cython 으로 일부를 정적 컴파일하여 사용했다면?

• Protobuf 보다 빠른 data serialization 이 있다면?

Python 게임 서버

• 각종 라이브러리와 클라우드 서비스를 쉽게 이용할 수 있다.

• 느리지만 빠르다.

• Python 게임 서버 안녕합니다. 걱정 안하셔도 됩니다.

• 글로벌 콘텐츠 기업

• 창업 8년차

• 직원 수 143명

• 대표 콘텐츠

• 핑크퐁

• 상어 가족

• 몬스터 슈퍼리그

함께해요http://www.smartstudy.co.kr/withyou/

Recommended