ITE3068 · HANYANG UNIV · 2017 FALL

소프트웨어 스튜디오

여섯 개의 컨테이너를 띄우고 부하 테스트 보고서 세 장을 제출했지만, 배점이 가장 높은 성능 비교의 숫자는 캐시가 아무 일도 하지 않았다는 사실만 증명한다.

파일31
커밋45
기간2017.11–12
스택Python 3 · Docker
C+종합
소견8 치명적9 중대5 경미합계 22

총평

MySQL 한 대, arcus(zookeeper + memcached 3대), nBase-ARC를 Docker 컨테이너로 띄우고, 그 앞에 Flask API를 놓고, Hubblemon으로 모니터링하고, nGrinder로 부하를 걸어 캐시 유무의 성능 차이를 측정하겠다는 프로젝트다. 범위는 학부 수업 프로젝트치고 넓고, 실제로 컨테이너 6개가 떠 있는 docker ps 캡처와 nGrinder 리포트 3장, Hubblemon 대시보드 1장이 results/에 남아 있다. naver 오픈소스 저장소에 이슈 3건과 PR 3건을 낸 링크도 README에 있다. 껍데기만 있는 레포가 아니다.

문제는 20점이 걸린 성능 비교다. api/app.py:40에서 /init은 nBase-ARC에만 1만 건을 미리 채워 넣고 arcus에는 아무것도 넣지 않는다. 그래서 제출된 세 리포트는 "완전히 워밍업된 캐시(nbase, 291.5 TPS)" vs "완전히 비어 있는 캐시(arcus, 24.0 TPS)" vs "캐시 없음(mysql, 22.2 TPS)"을 비교한 것이다. 더 나아가 nGrinder 스크립트가 id 파라미터를 주지 않으므로 api/app.py:57random.randint(1, testsize)가 매 요청마다 1만 개 키에서 무작위로 하나를 뽑는다. 60초 동안 1,343건밖에 실행되지 않은 arcus 런에서 기대 히트율은 약 6.5%다(200회 시뮬레이션). 즉 이 실험은 설계상 캐시 효과를 보여줄 수 없다. 실제로 mysql의 평균 268.84ms와 nbase의 19.39ms를 6.5% 히트율로 섞으면 252.6ms / 23.8 TPS가 나오는데, 리포트에 찍힌 arcus 값은 249.83ms / 24.0 TPS다. 오차 1%. arcus라는 이름이 붙은 숫자에 arcus 고유의 기여분은 없다.

두 번째로 큰 문제는 README가 시키는 대로 python app.py start를 실행해도 위 시스템이 완성되지 않는다는 것이다. 제출 3시간 전 커밋 f636918 "Update application and depricated"에서 컨테이너 안의 install.sh를 실행하는 docker exec 블록과 기동 대기 루프가 통째로 주석 처리됐다(app.py:61–84). 그 결과 settings.json이 선언한 commands, wait, host, testsize 네 개 키는 코드가 한 번도 읽지 않는 죽은 설정이 됐다. arcus 클러스터 구성도, Hubblemon 에이전트 기동도 사람이 손으로 해야 한다.

보안은 공개 레포 기준으로 여러 건이 걸린다. api/app.py:45에 문자열 포매팅 SQL 인젝션, :780.0.0.0 바인딩 + debug=True(Werkzeug 콘솔 = RCE), :33에 인증 없는 GET이 테이블을 drop, 그리고 DB 자격증명이 네 군데에 하드코딩돼 있다. origin/gh-pages에는 GitHub raw 콘텐츠 토큰이 Jekyll 포스트 front matter에 그대로 커밋돼 있다. 잘한 것을 하나만 꼽자면 arcus/install.sh다 — 컨테이너 이름으로 IP를 역조회해 arcus quicksetup JSON을 생성하는 bash 로직은 직접 돌려 봤을 때 의도대로 동작한다. 다만 그 스크립트는 위에서 말한 이유로 자동 실행되지 않는다.

배점 항목README실제 증거등급
Docker (10)[x]컨테이너 6개 기동은 실증(results/docker process.png). 단 최종 트리에 Dockerfile·compose 없음, 부트스트랩 주석 처리C
성능 비교 (20)[x]세 리포트 모두 실측이나 비교 설계가 무효. arcus 수치는 "캐시 무효과" 모델로 오차 1% 재현F
nBase-ARC (20)[x]포트 6000에 redis 프로토콜로 접속해 16,355건 처리 — 동작은 실증. 게이트웨이/PG/샤딩/페일오버 설정은 전무C+
multi-node (10)[x]memcached 컨테이너 3대 중 2대는 호스트 포트 미게시 → 클라이언트에서 도달 불가D+
Hubblemon (10)[x]타깃별 설정 3벌을 실제로 분화시킴. 단 nbase 설정은 NameError로 기동 불가, 캡처는 1장뿐B-
nGrinder (10)[x]실제 런 3회. 단 테스트 스크립트가 레포에 없어 재현 불가, 68MB war 커밋B-
OSS 기여[x]naver 저장소 이슈 3 / PR 3 링크. 오프라인이라 내용 미검증

성능 비교 (20pts) — 무엇을 쟀는가

제출물은 results/mysql.png, results/arcus.png, results/nbase.png 세 장의 nGrinder 리포트다. 셋 다 Total Vusers 6, Agent 1, Processes/Threads 2/3, Duration 00:01:00, Sample Ignore 0, Errors 0으로 조건이 같다. 조건을 맞춘 것 자체는 잘했다. 문제는 조건이 아니라 비교 대상이다.

치명적

/init이 nBase-ARC만 미리 채워서, 워밍업된 캐시와 빈 캐시를 나란히 놓고 비교했다

초기화 루프는 MySQL에 1만 행을 넣으면서 같은 값을 nbase에도 set한다. arcus에 대한 set은 어디에도 없다. 따라서 부하 테스트 시작 시점에 nBase-ARC는 히트율 100%, arcus는 히트율 0%로 출발한다.

결과가 그대로 나온다. nbase 291.5 TPS / 평균 19.39ms / 16,355건, arcus 24.0 TPS / 249.83ms / 1,343건. 12배 차이는 nBase-ARC가 arcus보다 12배 빠르다는 뜻이 아니라, 한쪽만 캐시가 차 있었다는 뜻이다. 보고서에는 이 비대칭에 대한 언급이 없다.

api/app.py:33–42
@app.route('/init', methods=['GET'])
def init():
    cursor.execute('drop table if exists testset');
    cursor.execute('create table testset ( id int, data int );')
    for i in range(testsize):
        v = random.randint(0, testsize)
        cursor.execute('insert into testset values(%s,%s)'%(i + 1, v))
        nbase.set(i + 1, v)   ← nbase만 워밍업. arcus.set 은 없다
    connection.commit()
    return 'Initialization finished'
치명적

arcus 런의 기대 히트율은 6.5%다. 리포트의 arcus 수치는 "캐시 효과 0" 모델로 오차 1% 안에 재현된다

nGrinder 스크립트가 id 쿼리 파라미터를 보내지 않으므로(에러 0건 = 항상 존재하는 id → 무작위 경로) 매 요청은 1..10000에서 무작위 키를 뽑는다. 60초 동안 실행된 요청은 1,343건. 무작위 추출 1,343회로 1만 개 키 공간을 채울 때 기대 히트율은 6.5%다(N=10000, 60회 시뮬레이션 평균). 60초 런에서는 캐시가 정상 작동해도 효과가 드러날 수 없는 구조다.

검산해 보면 더 분명하다. 미스 비용을 mysql 런의 평균(268.84ms), 히트 비용을 nbase 런의 평균(19.39ms)으로 두고 히트율 6.5%를 섞으면 평균 252.6ms, 6 vuser 기준 23.8 TPS가 나온다. 리포트의 arcus 값은 249.83ms / 24.0 TPS. 즉 arcus 숫자는 "MySQL 조회에 캐시 쓰기 비용이 조금 붙은 것" 이상도 이하도 아니다. 20점짜리 항목의 결론이 여기에 달려 있다.

검산results/*.png 의 값만 사용
# 미스=mysql 평균, 히트=nbase 평균, 히트율=시뮬레이션 6.5%
t = 0.935 * 268.84 + 0.065 * 19.39        # = 252.63 ms
TPS = 6 / (t/1000)                        # = 23.8

리포트 실측 arcus : 249.83 ms / 24.0 TPS
오차              : 평균 1.1% / TPS 1.0%
치명적

testset에 인덱스가 없어 "캐시 없는 MySQL"의 기준선이 풀 테이블 스캔이다

테이블은 create table testset ( id int, data int );로 만들어진다. PRIMARY KEY도 인덱스도 없다. 그런데 조회는 전부 where id=...이므로 1만 행 풀 스캔이 매번 일어난다. 로컬 MySQL 단건 조회가 268ms까지 느려진 이유가 이것이다.

캐시를 붙이기 전에 id에 인덱스부터 거는 것이 정상적인 순서이고, 그렇게 했다면 "캐시 대비 이득"이 훨씬 작게 나왔을 것이다. 기준선을 불리하게 만들어 놓고 비교한 셈인데, 의도적이라는 증거는 없고 단순히 놓친 것으로 보인다. 어느 쪽이든 20점 항목의 숫자를 부풀린다.

api/app.py:36, :44–48
cursor.execute('create table testset ( id int, data int );')   ← 인덱스 없음
...
def select(record_id):
    query = 'select * from testset where id=%s' % record_id     ← 매번 full scan
    cursor.execute(query)
중대

부하 생성기, 웹 서버, DB, 캐시가 모두 한 노트북의 127.0.0.1에 있다

세 리포트 모두 헤더가 Report : Test for 127.0.0.1이고, results/docker process.png의 프롬프트는 maybe@MayMac Book: ~/Workspace/ITE3068다. nGrinder 컨트롤러/에이전트(JVM 2 프로세스 3 스레드), Flask, MySQL, memcached 3대, nBase-ARC가 같은 CPU를 나눠 쓴다. 이 상태에서 측정한 TPS 차이는 네트워크·CPU 경합과 분리되지 않는다.

덧붙여 세 런은 23:46:51 → 23:48:56 → 23:50:42로 2분 간격 연속 실행이다. 워밍업 구간을 버리는 설정(Sample Ignore)이 0이므로 앞 런이 남긴 InnoDB 버퍼 풀 상태가 뒤 런에 그대로 섞인다. 순서도 nbase → arcus → mysql로, 가장 불리한 조건인 mysql이 맨 마지막(=버퍼 풀이 가장 따뜻한 시점)에 실행됐다.

중대

측정을 뒷받침할 nGrinder 스크립트가 레포에 없다

전체 히스토리(모든 브랜치)를 뒤져도 .groovy나 jython 테스트 스크립트, CSV 원시 데이터가 없다. 남은 것은 PNG 스크린샷 3장뿐이다. nGrinder 리포트 화면에는 Download CSV 버튼이 찍혀 있는데 그 CSV도 커밋되지 않았다.

결과적으로 "어떤 URL에, 어떤 파라미터로, 몇 초 램프업으로 요청했는가"를 레포에서 확인할 방법이 없다. 위에서 히트율을 추정할 때도 "에러 0건"이라는 간접 증거로 스크립트가 id를 안 보냈다고 역추론해야 했다. 성능 비교가 핵심인 프로젝트에서 측정 스크립트 미제출은 그 자체로 감점 사유다.

경미

settings.jsontestsize와 코드의 testsize가 10배 다르다

settings.json:45"testsize": 100000, api/app.py:9testsize = 10000이다. 그리고 api/app.pysettings.json을 읽지 않으므로 전자는 아무 효과가 없는 장식이다. README:32의 "All settings are stored in settings.json"과 어긋난다.

오케스트레이션 — app.py

README는 python app.py start 한 줄이면 된다고 쓴다(README:95–99). 99줄짜리 app.pysettings.json을 읽어 docker run 명령 문자열을 조립하고 Popen으로 실행한다. 설정으로 컨테이너 토폴로지를 기술한다는 발상 자체는 괜찮다.

치명적

부트스트랩 실행부 전체가 제출 직전 커밋에서 주석 처리됐다

2017-12-15 23:52 커밋 f636918 "Update application and depricated"가 컨테이너 내부 install.sh 실행 블록과 기동 대기 루프를 통째로 주석으로 바꿨다(18줄 추가 / 17줄 삭제, 전부 # 붙이기). 남은 것은 docker rundocker cp뿐이다.

따라서 app.py start는 스크립트 파일을 컨테이너 /opt에 복사만 하고 실행하지 않는다. arcus zookeeper 앙상블 구성도, Hubblemon 수집기 기동도, nbase-arc 초기화도 일어나지 않는다. results/docker process.png에서 memcached 컨테이너들이 이미지 기본 COMMAND인 /usr/bin/supervisord로 떠 있는 것도 이와 일치한다. README가 약속한 "just run application"과 코드가 하는 일이 다르다.

app.py:61–68
    # Depricated docker exec block original scripts
    # run command
    # print ('Docker commands executing ...')
    # for container in setting:
    #     for cmd in container.get('commands', list()):
    #         execute("docker exec -it {} {}".format(container['name'], cmd), False)

    # execute('docker exec -it nbase-arc /bin/bash -c /root/install.sh')
중대

settings.json의 네 개 키가 코드에서 한 번도 읽히지 않는다

app.pydocker_run()이 소비하는 키는 name, ports, env, docker 넷이고 volumndocker cp에 쓰인다. 설정 파일이 선언한 나머지 commands, wait, host, testsize는 소비처가 없다. commands/wait는 위 주석 처리의 부산물이지만, hosttestsize는 애초에 배선된 적이 없다.

실제로 설정을 그대로 넣어 docker_run()을 돌려 보면 host 값("arcus", "memcached-1" 등)은 생성된 명령 어디에도 나타나지 않는다.

docker_run() 실제 출력settings.json 을 입력으로 재현
docker run -d --net ite3068 --name=arcus-memcached-1 -p 11211:11211 \
  -p 4586:4586 --env HUBBLEMON=4586 ruo91/arcus:memcached
docker run -d --net ite3068 --name=arcus-memcached-2 ruo91/arcus:memcached
docker run -d --net ite3068 --name=arcus-memcached-3 ruo91/arcus:memcached
↑ 2·3번 노드에는 -p 가 없다 → 호스트에서 접근 불가

읽히지 않는 키: ['commands', 'host', 'testsize', 'wait']
중대

execute()가 stderr 유무로 성공/실패를 판정한다

app.py:23return False if stderr else stdout.decode('utf-8')[:-1]이다. 종료 코드가 아니라 stderr가 비었는지로 판단하므로, docker가 경고 한 줄만 뱉어도 성공한 컨테이너가 실패로 집계된다. 반대로 종료 코드가 0이 아니어도 stderr가 비면 성공으로 본다.

파장이 있다. app.py:48–49는 모든 컨테이너가 "성공"일 때만 containers.p를 저장하는데, 이미 ite3068 네트워크가 있는 상태에서 재실행하면 docker network create가 에러를 내고, 이후 app.py stopcontainers.p가 없어 FileNotFoundError로 죽는다. 두 번째 실행부터 정리가 불가능해진다.

경미

최종 트리에 Dockerfile도 docker-compose도 없다

히스토리에는 Dockerfile, app/dockerfile, arcus/dockerfile, docker-compose.yml이 있었으나 모두 삭제됐다. 남은 docker-compose.yml의 마지막 내용(731fc2e)은 Docker 공식 튜토리얼의 web+redis 예제 그대로였고, 6일 뒤 삭제됐다. README:71은 compose를 쓰지 못한 이유를 설명하는데, 이유 자체는 타당하다(컨테이너 기동 후 설정이 필요하다). 다만 results/docker process.pngdocker images에는 로컬 빌드된 arcus, ite3068/arcus 이미지가 찍혀 있다 — 레포에서 재현할 수 없는 이미지로 실험했다는 뜻이다.

잘한 것

arcus/install.sh:8–19의 bash가 실제로 맞다. IFS=";"MEMCACHED 환경변수를 쪼개고 getent hosts로 컨테이너 IP를 역조회해 1.2.3.4:2181,5.6.7.8:2181 형태를 만든 뒤 후행 쉼표를 ${REPLACE: :-1}로 잘라 arcus.shsed로 주입한다. 직접 실행해 보니 후행 세미콜론이 빈 토큰을 만들지 않고 잘림도 의도대로 동작한다. :31–33에서 memcached -Ememcached -u memcached -E로 바꾸는 패치는 root 실행 거부 문제를 겪고 직접 고친 흔적으로 보인다.

보안 — 공개 레포 기준

이 레포는 GitHub에 공개돼 있다. 아래 항목은 값을 적지 않고 위치와 종류만 기록한다. 학부 과제용 로컬 환경이라 실질 피해는 낮지만, 습관의 문제로 전부 지적 대상이다.

치명적

사용자 입력을 문자열 포매팅으로 SQL에 붙인다

request.args.get('id', ...)의 결과가 % 포매팅으로 쿼리에 삽입된다. PyMySQL은 cursor.execute(sql, params) 형태의 파라미터 바인딩을 지원하는데 쓰지 않았다. /mysql?id=1 OR 1=1이면 전체 행이 대상이 되고, PyMySQL 기본 설정에서 다중 문장은 막히지만 UNION 기반 추출은 그대로 열린다.

api/app.py:39의 INSERT도 같은 % 포매팅이지만 여기는 값이 서버 생성 정수라 실질 위험은 없다. 문제는 같은 파일에서 안전한 쓰기 방식을 쓰지 않는 습관이 일관되게 나타난다는 점이다.

api/app.py:44–48, :52
def select(record_id):
    query = 'select * from testset where id=%s' % record_id
    cursor.execute(query)
...
record_id = request.args.get('id', random.randint(1, testsize))

# 실제 생성되는 쿼리:
#   select * from testset where id=1 OR 1=1
치명적

Flask 디버그 모드를 0.0.0.0에 바인딩

debug=True는 Werkzeug 대화형 디버거를 켠다. 예외가 나는 경로에 도달하면 브라우저에서 임의 파이썬 코드를 실행할 수 있고, host='0.0.0.0'이므로 같은 네트워크 어디서나 접근 가능하다. 그리고 예외가 나는 경로가 실제로 있다 — api/app.py:63:74res[1]은 존재하지 않는 id가 오면 resNone이 되어 TypeError를 던진다. /arcus?id=99999 한 번이면 디버거 화면이 열린다.

덤으로 성능 측정에도 영향을 준다. 디버그 모드는 리로더와 예외 추적 오버헤드를 붙이고, Flask 개발 서버는 프로덕션 WSGI가 아니다. nbase 런의 291.5 TPS가 이 서버의 사실상 상한이며, mysql/arcus 런의 22–24 TPS는 그 상한에 한참 못 미치므로 여기서는 병목이 아니지만, "캐시를 붙이면 얼마나 빨라지나"의 천장을 개발 서버가 정해 버린 것은 맞다.

api/app.py:77–78
if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True, port=5000)
치명적

하드코딩된 자격증명이 여러 파일에 남아 있다

애플리케이션 설정과 설치 스크립트에 DB 접속 정보와 SSH 비밀번호가 평문으로 들어 있다. 설치 스크립트는 StrictHostKeyChecking=no까지 함께 쓴다. 위치는 api/app.py, settings.json, README.md, arcus/install.sh이다. 조치는 값을 환경변수로 옮기는 것이 아니라 먼저 해당 자격증명을 폐기·교체하는 것이다 — 이미 커밋된 순간 유출이며, 삭제 커밋으로는 되돌아가지 않는다.

중대

인증 없는 GET 요청 하나가 테이블을 drop한다

/initmethods=['GET']이고 첫 줄이 drop table if exists testset이다. 인증도 확인도 없다. GET은 안전해야 한다는 HTTP 규약을 정면으로 어기므로 브라우저 프리페치나 크롤러 한 번으로 데이터가 날아간다. 실습 환경이라 실해는 없지만, 부하 테스트 도중 누가 /init을 눌렀는지 여부를 확인할 방법도 없다는 뜻이기도 하다.

중대

모듈 전역 connection/cursor 하나를 모든 요청이 공유한다

api/app.py:11–27에서 커넥션과 커서를 모듈 로드 시점에 한 번 만들고, 이후 모든 라우트가 그 전역을 쓴다. PyMySQL 커넥션은 스레드 안전하지 않다. Flask 개발 서버가 스레드 모드로 뜨면 여러 요청이 같은 소켓에 인터리브되어 응답이 섞이거나 InterfaceError가 나고, 스레드 모드가 아니면 모든 요청이 직렬화된다.

nGrinder는 vuser 6으로 동시 요청을 보냈으므로 둘 중 하나가 반드시 일어난다. 어느 쪽이든 측정값의 의미가 흐려진다. 커넥션 풀이나 요청 스코프 커넥션(flask.g)이 정석이다. 덧붙여 :25의 bare except:KeyboardInterrupt까지 삼켜서 기동 대기 루프를 Ctrl-C로 못 빠져나오게 만든다.

arcus · nBase-ARC · Hubblemon

치명적

arcus를 arcus로 쓰지 않았다 — 클라이언트는 평범한 memcached 두 주소를 본다

arcus의 핵심은 ZooKeeper를 통한 클러스터 멤버십 발견과 그것을 구현한 arcus 클라이언트다. 그런데 코드는 python-memcached로 고정된 주소 두 개를 직접 잡는다. ZooKeeper(2181)는 results/docker process.png 기준 arcus-admin에서 호스트로 게시조차 되지 않았다(22/tcp만 노출).

더 나쁜 것은 두 주소 중 하나가 존재하지 않는다는 점이다. settings.json:17–22arcus-memcached-1에 11211만 게시하고 11212는 게시하지 않는다. docker ps 캡처가 이를 확인해 준다: arcus-memcached-1의 PORTS에 11212/tcp는 있지만 0.0.0.0:11212-> 매핑이 없다. 그리고 README가 10점을 주장하는 "multi-node"의 나머지 두 노드 arcus-memcached-2, -3은 게시 포트가 아예 없어 Flask에서 도달 불가다. 3노드 클러스터가 아니라 도달 가능한 1노드다.

레포의 오픈소스 기여 항목은 arcus-python-client에 대한 이슈 2건과 PR 1건인데, 정작 애플리케이션은 그 클라이언트를 쓰지 않는다. 기여와 구현이 따로 논다.

api/app.py:18–20
nbase = redis.StrictRedis(host='127.0.0.1', port=6000)
nbase.get(None)
arcus = memcache.Client(["127.0.0.1:11211", "127.0.0.1:11212"])
                                            ↑ 이 포트는 호스트에 게시되지 않았다
중대

제출된 유일한 Hubblemon 캡처는 arcus가 get을 한 번도 받지 않았음을 보여준다

results/hubblemon.pngd6c48d84e00a/arcus_11211 패널을 그대로 읽으면: cmd_get 그래프는 0에 붙어 있고, curr_items는 1.00 고정, hit_ratio 패널은 데이터가 없어 비어 있으며, cmd_set만 1–2 사이를 규칙적으로 오간다(수집기 자체의 주기적 활동으로 보인다). evictions 0, reclaimed 0.

그리고 캡처의 time range는 2017-12-15 13:11 – 13:41인데, nGrinder 런은 같은 날 23:46–23:51이다. 10시간 차이가 난다. 즉 이 대시보드는 부하 테스트 구간을 보여주는 것이 아니다. 결론은 둘 중 하나다 — 모니터링 증거가 부하 테스트를 전혀 뒷받침하지 못하거나, 아니면 캐시에 아이템이 1개뿐이고 get이 0인 상태가 실제 arcus의 정상 상태였거나. 어느 쪽도 "arcus로 성능 비교를 했다"는 주장에 도움이 되지 않는다.

또한 Hubblemon 상단 내비게이션에는 system / arcus_stat / arcus_list만 있다. mysql·nbase 대시보드 캡처는 제출되지 않았다.

중대

nbase용 Hubblemon 수집기는 NameError로 뜨지 않는다

hubblemon/nbase/collect_client.run_client.py:23에서 psutil_stat import를 주석 처리했는데, :79에서 그 이름을 그대로 호출한다. 모듈 최상위 코드이므로 import 즉시 NameError다. AST로 모듈 수준 호출 이름과 바인딩을 대조해 확인했다 — 세 파일 중 nbase만 미정의 호출이 나온다.

같은 디렉터리의 arcus/mysql/ 버전은 둘 다 from client_psutil_plugin import psutil_stat을 살려 두었다. 저자 본인의 다른 두 파일이 이 이름이 별도 import를 필요로 한다는 증거다. 즉 hubblemon/install.sh:21nohup python3 collect_client/run_client.py &는 nbase 컨테이너에서 즉시 죽고, nohup이라 에러는 nohup.out에만 남는다.

23: # from client_psutil_plugin import psutil_stat
...
79: ps = psutil_stat()
80: c.plugins.append(ps)

AST 검사 결과 -> nbase: undefined calls [(79, 'psutil_stat')]
             arcus: none   /   mysql: none
경미

캐시된 값이 0이면 영원히 미스로 취급된다

api/app.py:59if res:로 히트를 판정한다. 저장되는 값은 :38random.randint(0, testsize)라 0을 포함하고, python-memcached는 정수를 그대로 돌려주므로 0은 falsy가 되어 히트인데도 미스 경로로 간다. 1만 개 중 평균 1개꼴이라 성능 영향은 없지만, is None이 아니라 truthiness로 캐시 히트를 판정하는 것은 캐시 코드에서 가장 흔한 오류 중 하나다.

참고로 nbase(:70)는 redis가 b'0'을 반환해 truthy라 우연히 영향을 받지 않는다. 같은 버그가 한쪽에서만 드러나는 형태다.

api/app.py:55–64
res = arcus.get(str(record_id))
if res:
    return 'Cache Hit: ' + str(res)
else:
    res = select(record_id)
    arcus.set(str(record_id), res[1])   ← res is None 이면 TypeError
    return 'Cache Miss: ' + str(res)
경미

캐시 무효화 전략이 없다 — 다만 무효화할 쓰기 경로도 없다

API는 읽기 전용(/mysql, /arcus, /nbase)이고 /init만 쓴다. 그런데 /init은 MySQL 테이블을 drop하고 nbase를 덮어쓰면서 arcus는 건드리지 않으므로, 이미 채워진 arcus 항목은 이전 데이터셋의 값으로 남는다. TTL도 없다(arcus.set/nbase.set 모두 만료 인자 없음). 두 번째 /init 이후 /arcus는 조용히 옛날 값을 반환한다.

실습 범위에서 치명적이진 않지만, "캐시를 도입했다"고 주장하려면 최소한 쓰기 경로와 무효화 규칙 한 줄은 있어야 한다. 현재 상태는 캐시 계층이 아니라 일회성 조회 가속기다.

잘한 것

Hubblemon 설정 세 벌(hubblemon/{arcus,mysql,nbase}/common.settings.py)은 복붙이 아니라 타깃별로 실제 분화돼 있다. :68–70main_link가 각각 arcus_stat+arcus_list / mysql_stat / redis_stat으로 다르고, arcus 쪽만 :73arcus_zk_addrs =[address + ':2181']을 추가했다. run_client.py 쪽도 플러그인 import와 삼중따옴표 블록 위치를 타깃별로 옮겨 놓았다. 업스트림 예제 파일의 구조를 이해하고 손댔다는 증거다.

레포 위생

중대

레포 296MB 중 98MB가 서드파티 바이너리다

ngrinder-controller-3.4.1.war 68MB 하나, ngrinder-agent/lib/의 jar 39개 약 30MB(jython-standalone 14MB, groovy-all 6.3MB, hibernate-core 4.4MB 등). 모두 nGrinder 공식 배포판을 그대로 커밋한 것으로, ef1d349 "Update docker scripts"(2017-12-06) 한 커밋에 들어왔다. .git만 192MB다.

저자가 직접 쓴 코드는 app.py(99) + api/app.py(79) + settings.json(89) + arcus/install.sh(35) + hubblemon/install.sh(26) 합쳐 약 330줄이다. 코드 1줄당 300KB의 바이너리가 붙어 있는 셈이고, 클론할 때마다 전송된다. nGrinder는 릴리스 URL과 버전만 적어 두면 충분했다.

부수적으로 버전도 안 맞는다. 커밋된 것은 3.4.1인데 results/docker process.pngdocker images에는 ngrinder/controller:3.3ngrinder/agent:3.3이 찍혀 있다. 실제 측정에 쓴 것과 커밋한 것이 다르다.

경미

브랜치 세 개가 서로 다른 프로젝트를 담고 있고, README가 이를 설명하지 않는다

master는 인프라 프로젝트(45 커밋), origin/gh-pages는 billboard.js 과제인 Jekyll 사이트(35 커밋, _posts/ 4편 + _data/), origin/dev는 master보다 뒤처진 상태다(results/*.png 3장이 없고 19개 파일 차이). README:5의 "Assignment: Using billboard.js" 체크는 gh-pages를 가리키는데, 링크는 사내 hconnect 주소라 외부에서 열리지 않는다.

.gitignore는 4줄뿐이고 마지막 줄 containers.p에 개행이 없다. _site/, .sass-cache/는 gh-pages용 항목이 master에 섞여 남은 것이다.

반복되는 패턴

  1. 측정의 대상이 뒤바뀐다. 한쪽만 워밍업된 캐시(api/app.py:40), 인덱스 없는 기준선(:36), 60초 안에 채워질 수 없는 키 공간(:57). 세 가지가 겹쳐서, 제출된 arcus 수치는 "캐시 효과 0" 모델로 오차 1% 안에 재현된다. 숫자를 만들어 낸 것이 아니라 실제로 측정했지만 무엇을 측정하는지 확인하지 않은 경우다.
  2. 문서가 코드보다 앞서간다. README는 배점 7개 항목에 전부 [x]를 찍었지만, 실행 진입점(app.py:61–84)은 주석 처리돼 있고, multi-node 3대 중 2대는 도달 불가하며, nbase Hubblemon은 NameError로 뜨지 않는다. README:32의 "All settings are stored in settings.json"도 네 개 키가 죽어 있어 사실이 아니다.
  3. 이름만 통합하고 프로토콜은 대체한다. arcus를 쓴다면서 ZooKeeper 발견 없이 memcached 주소를 직접 박고(api/app.py:20), nBase-ARC를 쓴다면서 게이트웨이 설정 없이 redis 클라이언트로 6000번 포트만 잡는다(:18). 두 제품 모두 "동작은 한다"에서 멈췄고, 그 제품을 선택할 이유가 되는 기능은 건드리지 않았다.
  4. 올바른 방법을 알면서 한 곳에서만 안 지킨다. hubblemon/mysql/collect_client.run_client.py:60environ[...]으로 자격증명을 주입하는데 hubblemon/nbase/...:59는 리터럴이다. 세 Hubblemon 설정 중 둘은 import가 맞고 하나만 틀리다. 지식의 문제가 아니라 마감 직전 복붙의 문제다.
  5. 증거는 남기지만 재현 경로는 남기지 않는다. 스크린샷 5장은 있는데 nGrinder 스크립트도, CSV도, 실험 조건 메모도 없다. Hubblemon 캡처는 부하 테스트와 10시간 떨어진 구간이다. 결과를 보여주려는 의지는 있으나 "제3자가 다시 돌려 볼 수 있는가"라는 기준이 적용되지 않았다.

지금 손본다면

  1. /init에서 arcus도 같이 채우거나, 셋 다 비운 채로 시작하라. 한 줄 추가(arcus.set(str(i+1), v))면 최소한 arcus와 nbase가 같은 출발선에 선다. 지금 구조에서 20점 항목을 유효하게 만드는 가장 싼 수정이다.
  2. 키 공간을 testsize가 아니라 요청 수에 맞춰라. 60초에 1,343건이면 1만 개 키를 도는 것은 의미가 없다. 키 범위를 200~500으로 줄이거나 Zipf 분포를 쓰면 캐시 히트율이 실제 서비스처럼 올라가고, 그때 비로소 "캐시가 얼마나 이득인가"가 측정된다.
  3. testset.id에 인덱스를 걸고 그 상태의 MySQL 기준선을 다시 재라. create table testset (id int primary key, data int) 한 줄. 캐시의 이득이 줄어들겠지만 그 숫자가 정직한 숫자다. 두 기준선을 모두 보고하면 오히려 분석의 깊이가 생긴다.
  4. app.py:61–84의 주석을 되살리든지, README에서 "just run application"을 빼든지 하나를 골라라. 지금은 문서와 코드가 서로 다른 말을 한다. 되살릴 수 없는 이유가 있었다면(docker exec -it의 TTY 문제로 보인다) -i를 떼고 -d로 돌리면 된다.
  5. 자격증명을 환경변수로 빼고, debug=True를 지우고, 쿼리를 파라미터 바인딩으로 바꿔라. cursor.execute('select * from testset where id=%s', (record_id,)) — 튜플 하나 차이다. 세 수정 모두 5분이면 끝나고, 공개 레포에서 가장 먼저 눈에 띄는 항목이다.
  6. nGrinder war와 jar 98MB를 히스토리에서 들어내라. git filter-repo --path ngrinder-controller-3.4.1.war --path ngrinder-agent/lib --invert-paths. 대신 사용한 버전과 다운로드 URL, 그리고 테스트 스크립트를 커밋하라. 지금 없는 것은 바이너리가 아니라 스크립트다.