# 第 3 章 向世界发布你的代码 在前面一章,我们已经在本地部署好LNMP开发环境,并且也完成了我们第一个小网站的开发。这一章,我们将会进行激动人心的操作——向世界发布我们的代码。我们先来看下为了完成正常发布需要做哪些工作,然后学习完整的生产环境有哪些注意事项,最后我将分享哪些糟糕的实践会阻碍我们追求完美的项目。 ## 3.1 从零到一搭建发布系统 互联网大型公司是如何做到每天发布成千上万次却依然保持有条不紊的?创业团队又该如何实现敏捷发布进行稳定持续的交付?小团队项目时又该如何发布?更重要的是,针对不同规模的团队和项目,分别如何搭建相应的发布流程;又应怎样实现平滑切换,不断演进发布系统?对于这些问题,我们将会在这一节探索它的解空间。 在开始继续往下阅读之前,读者不妨想一下,当前所在的公司是如何发布的?并且思考一下,它的整个发布流程是如何实现的,存在哪些问题,它的优势与特点是什么,是否与当前的发展吻合? ### 3.1.1 不推荐的发布方式 俗话说:幸福的人都是一样的,不幸的人各有各的不幸。同样地,优秀的发布都是一样的,糟糕的发布各有各的不同。 但对于一些糟糕的发布,有些人却持有不同的意见,因为他们正在使用它而不觉得有什么问题,并且他们已经通过这样发布方式工作了好多年。“存在即合理”,他们这样想,哪怕是糟糕的发布方式,既然它已经存在了那么多年,就自然有它存在的道理。我为什么还要做出改变呢? 这样想是无可厚非的,因为他们已经形成了一种发布文化,在他们的公司内形成了一种局部社会认同感。公司其他团队,团队内其他成员都是这样做的,那么他们也会这样做,以致不会显得突兀、不合群。持有优秀发布实践的人很难理解为什么还有那么多人愿意忍受这些糟糕的发布方式,另一方面,“沉醉”于糟糕发布方式的人也难以明白如何切换到优秀发布,甚至为什么要做出改变。 我们先来看下常见糟糕的发布方式有哪些。这些糟糕的做法虽然有一定的价值,但综合考虑权衡的话,是弊大于利的。特别从长期角度考虑,从顺应不确定未来和满足多变市场的角度来看,更是如此。 #### 随时发布 对于年轻的软件开发领域,很多方面都还没形成公认的行业标准和规范,其中发布流程更是如此。每个公司都有自己的发布方式,每个技术人员又有自己不同的做法。而公司的发布流程在很大程度上决定于最初负责发布人员的实现方式,不同技术人员由于背景不同,经历的项目经验不同,其做法各有千秋。但最糟糕的莫过于,这位负责最初发布的同学,恰好又是之前从未接触过正式商业项目发布的,这时他很可能就会倾向于一种随意的发布意识。也就是我们接下来要谈到的——随时发布。 随时发布,可以说是一种不负责任的发布方式。它完全不考虑版本的概念,也没有所谓的迭代周期,对于线上生产系统的稳定性、健壮性以及项目的质量都置于九宵云外而不顾。 不管什么时候,开发人员代码一完成后,就同步更新到生产环境。这非常容易造成有问题就改,有故障就查的局面,从而使得技术开发人员疲于奔命,四处“救火”。注意,这里所说的完成,只是技术开发人员的“一厢情愿”,从他们自己的技术开发角度认为是完成了业务功能需求的开发,而没有经过测试团队的功能测试和回归测试,更别说通过测试了。而且这里需要特别阐明的是,对于这时的完成,大部分开发人员(尤其是绝大部分的初级开发人员)更多的定义是他们只完成了快乐路径的功能开发和自测,而对于失败路径、异常路径、边缘路径、整个业务流程则没有考虑进内,也没对应的代码实现。 举个例子,我们现在有一个共享平台系统,在上面人们可以通过共享的方式向平台通过极低的费用租到价格高昂的商品,例如只需要100元就能租用价值5000元的单反数码相机并使用一周,但需要额外支付一定的押金。一切都运行良好,我们的网站受到了广大消费者的喜好,满足了人们短期内对高端消费品的诉求。随着项目的发展壮大,现在需要接入一家新的第三方支付系统,并且对方允许给消费者一定的支付折扣。负责这块的技术开发人员很快就完成了功能的开发并发布上线,一切看起来很顺利。但过一段时间后,我们发现出问题了。这是因为用户的押金需要退还,而当第三方支付系统识别到用户有退费时,不仅会原路还回,还会把之前的折扣也回收!最终导致了消费者在享受支付折扣后,拿到归还的押金却少了!因为技术开发人员只考虑到支付这条快乐路径,而没考虑到押金退还这一点,使得公司在面临众多用户投诉的同时,也增加了应对这一故障的处理成本。 随时发布这种方式好吗? 也许好,因为需求方可以“很快”就看到他们的需求上线了,技术开发人员可以“很快”就看到他们的代码在生产环境运行了。但是!以此随时发布、快速上线换来的代价是,发布到生产环境的代码都是脆弱的,未经全面测试,兼容性极差的代码。如果说随时发布在竞争激烈、节奏飞快的当下,相当于在高速公路上换轮胎,那么很容易让我们无法慢下来思考如何做到更好、更出色。说到底,我们随时发布的不是优质的代码,取而代之的是不断往臃肿的系统再塞进一些熵,再堆积一些代码异味。慢慢地,越往后,项目越臃肿,越失控,越糟糕。 在软件开发过程中,我们应当时刻意识到,对于同一件事情,不同的做事方式,所产生的直接影响、间接影响都是不同的。在只有一位技术开发人员时,他可以完全根据自己的喜好和便利性来操作,但一旦形成了团队、涉及多方沟通以及项目发展到一定规模时,则需要一些约定,以保证可以创造一个容易催生高效团队协作和沟通的环境。 对于随时发布,我个人觉得是过于随意,缺少严谨性,所以我是不推荐的。由随时发布而产生的间接负面影响,要远大于它的直接正面收获。看似非常快速的发布方式,实际上却让我们不停在原地打转。所以,我不推荐初学者一开始就钟情并习惯于这种发布方式,更不推荐高级资深开发同学固守这种发布方式。如果你当前已经是随时发布,请不要妥协。 #### 通过FTP上传发布代码 比随时发布稍微好一点的是,在时间上,在版本上有一定的规划,或者说有一定的发布频率、发布节奏或发布窗口。按照不成文的约定,通常在每周五是不会进行发布的。因为接下来就是周六、周日,如果一旦因新功能发布上线而出现问题,需要找到技术人员、产品人员、测试人员等项目干系人及时响应和处理是非常困难的。除此之外,沟通成本也会变得非常大,平时在工作时间一起紧密沟通的团队,到了周末就分散在各个城区甚至各个城市,通过远程方式进行不太顺畅的沟通。 那其他工作日期间,应该以什么节奏来发布呢?是每天N次,还是每周M次?这个时间节点可根据项目的情况具体而定。下面我们来看下另一个糟糕的发布方式。 时到今日,我们很遗憾地看到,仍然有不少公司未使用版本控制系统对项目源代码进行版本控制。由此带来的间接影响是他们很多时候都是通过FTP方式上传待发布的项目代码,然后发布上线。不仅如此,那些即使是使用了类似Git,SVN的团队,出于历史遗留原因也在使用FTP方式更新线上环境的代码。 这种发布方式,最明显的问题,它是严重依赖人工来操作的。它需要人为地来选择需要更新哪些代码目录、哪些代码文件,重复性的人工操作,都蕴藏着一个魔鬼——低效且容易出错。此外,还有一个很大的问题是,缺少发布回滚机制。 我只在最初做项目时,而且还只是在大学期间从事校内网站项目开发时才用过FTP方式上传发布代码。毕业工作后就再也没接触过这种发布方式。所以现在当我听到还有公司使用FTP来发布代码,在惊讶的同时感觉到了我们在软件开发领域的薄弱。这种发布方式太业余了,在倡导快速交付的当下还会大大拖慢我们的发布节奏。 #### 生产环境即开发环境 随时发布缺乏对发布时间的统一控制,通过FTP发布是缺乏对发布方式实现自动化,但更为糟糕的是,还有一种发布方式是既忽略了发布时间也忽略了发布方式,那就是开发环境与生产环境共用,技术开发人员直接在生产环境修改代码。生产环境即开发环境,开发环境即生产环境。 这种方式的危害是相当大的,因为它往往也没有对代码进行版本控制。假设一不小心删除了某个源代码文件,就只能自求多福了。而且,代码一修改完,马上生效,这导致了开发调试期间,线上环境的系统功能基本是完全不可用的。因为或多或少会出现一些语法错误、运行时异常导致系统异常中断和退出。而更深远的影响莫过于通过错误的处理方式对数据进行了持久化,对系统产生了脏数据。 不得不承认,这种方式是有好处的。它节省了部署成本,也降低了技术人员的要求。因为不需要区分开发环境和生产环境,只需要部署一套环境就可以了,也不需要再去折腾Git或SVN这类的版本控制系统。技术人员也可以不用学习那么多工具、系统和软件,直接在生产环境修改代码,直到调试完毕就可以了。但是,软件开发是一门专业的工程学科,它需要全面、专业的技艺。如果什么都只图方便,不考量架构合理性,不研究相关技术和原理,那只是小白的表现。 软件开发从来都不是一件容易的事,如果你觉得容易,要么你是还没入门的小白,要么你已经是资深专家级人物。生产环境即开发环境这种做法,虽然节省了发布这一环节,看似高效,实际风险巨大。除了前面说的缺少版本控制,缺少系统稳定性外,它会催生一种野草式的开发模式。迫于压力,为了让系统能快速恢复、正常工作,技术人员都是以当前能想到的方式,以最快的速度进行开发和修复,而不管这种做法是临时的、糟糕的、还是本身就是有问题的。这导致,很多功能性的开发,很多技术决策都是临时性的、突发性的、未加全面考虑的。软件开发有点类似建筑行业,需要提前规划蓝图,有计划地进行实施,而不是像路边的野草放而任之,随心而行。 我曾经也有过使用这种方式的项目开发经验。当时有一个项目也是开发环境与生产环境共用一套环境,技术人员平时在上面直接修改代码,改完后立马生效。当时我在生产环境上修改这个项目系统的代码时,也是如履薄冰,生怕删错源代码文件。但越担心什么,就越会发生什么。有一次,我还真的删错文件了,Linux的rm命令及其背后的文化让我无从恢复刚才删错的文件 。幸好,另外一个团队成员在本地刚好有备份,才得以恢复,虚惊一场。另外比较有趣的是,一旦公司的老板需要对外演示此系统,或者有重要商业合作展览时,我们技术人员就要停止对代码的修改,这时不时间接阻碍了技术开发的进度。 ### 3.1.2 简单的发布 接下来,我们将开始从零到一搭建发布系统,这是一种更为正式、更为正统的发布方式。可能在具体实施过程中,不同团队会有不同的具体做法,但在实现方式、关键流程和核心概念上是类似的。 对于简单的项目,以及初级的项目,我们可以进行简单的发布。这里,只需要三个关键的要素就可以了。它们分别是: + **开发环境** 即本地开发环境,可以参考前一章,搭建本地LNMP开发环境,让技术开发人员可以在本地安全地进行开发、调试和自测。 + **托管仓库** 不管项目有多小,都应尽量纳入源代码版本控制系统,将项目的源代码托管到远程服务器仓库。即需要在远程服务器存有项目代码的最新代码,便于团队内部共享,也保障了个人电脑硬盘损坏后代码仍能幸存于世。 + **生产环境** 系统最终的运行环境,并开放给最终用户人群访问和使用。 不难看出,简单的发布流程是,先在本地开发环境进行代码的编写,然后更新提交到远程代码托管仓库,最后发布到生产环境。 ![图像说明文字](/api/storage/getbykey/screenshow?key=1803b5080f7827e64d44) 图3-1 简单的发布流程 当技术人员在开发环境完成需求开发,进行充分测试后,便可将代码提交到远程代码托管仓库。这时,开发环境通常都是位于个人开发电脑上,并且在公司局域网内。托管仓库,可以是公司的内部服务器,也可以使用第三方云服务器。当全部待发布的功能点都经过功能测试和回归测试后,就可以执行发布操作,将最新版本的代码发布更新到外网生产环境。这里,在外网生产环境也是类似平时在开发环境上更新代码一样,例如Git下的git pull命令,SVN下的更新操作。如果需要回滚,可以通过源代码控制系统进行特定版本的回滚。 这种做法,在项目初期,对于简单的项目,对于单台服务器是适用的。若线上的服务器是多台,或者是一个集群,又怎样才能保证代码在同一时间进行更新,我们又该如何快速、便捷、统一控制呢?下面我们来看下更为专业的发布方式。 ### 3.1.3 实现一键发布 鉴于我们部署的系统环境是LNMP,故而可以使用一些基本的shell命令快速搭建一键发布,实现发布流程化、自动化。这些shell命令主要有打包解压命令tar、远程文件传输命令scp、远程登录和操作命令ssh。 不过在实现一键发布前,我们需要进行一些约定,以便能更好地进行整体的把控。主要有以下几个方面:统一LNMP环境的版本和配置、统一项目路径、统一系统账号。 #### 统一LNMP环境的版本和配置 对于Linux、Nginx、MySQL和PHP,不管选择哪个版本,为生产环境的服务器集群安装同样版本的操作系统和软件是大有裨益的。因为我们可以实现无差异批量化的操作,方便统一管理。 假设选择的是PHP 7.2.3,那么就全部服务器都安装PHP 7.2.3,尽量保持版本上的一致性。不要服务器A安装了PHP 7.2.3,而服务器B则安装PHP 7.1.15;也不要部分服务器运行的是PHP 7,而另外一部分服务器则运行PHP 5。因为不同版本下的软件,都会或多或少存在一些差异。这些差异体现在配置、语法、和安装方式、使用方式上。 除了安装相同版本的环境外,还要进行相同的配置。假设在PHP的配置文件/etc/php.ini内修改variables_order的配置项为EGPCS,如: ``` variables_order = "EGPCS" ``` 那么就应该同步此配置到其他服务器,保证在其他服务器上通过PHP也能正常读取到环境变量$_ENV。此时,你可以选择类似puppet这样的自动化运维工具。 #### 统一项目路径 上面是对LNMP环境的要求,对于项目运行时的部署也应尽量做到一致。具体包括项目源代码的部署路径、日志路径和计划任务等。 例如,我们前面的网站项目www.examples.com,假设源代码的部署路径为:/home/apps/projects/www.examples.com,那么其他服务器也应安装在这一目录下。如果项目的日志存放路径为:/var/logs/apps/projects/www.examples.com,那么其他服务器也要保持一致。 #### 统一系统账号 细心的读者已经留意到,前面在Linux系统上我们主要使用了apps这位用户来部署系统项目。由此不难推断,我们同样建议全部服务器都应使用apps账号来统一操作。此外,引申到启动Nginx和MySQL等,也应该为其分配相同的账号。因为不同的账号在Linux系统上有不同的权限。保持操作系统账号的一致性,可以保证权限的一致性,而不会发生在这台机可以读取执行,到另外一台机却不行。 关于Linux的权限,已经有大量相关书籍。这里不再重复详细展开,但我们可以通过一个例子稍微回顾一下,温故而知新。例如对于前面的projects目录,它的权限如下: ``` drwxrwxr-x. 8 apps apps 4096 Mar 16 23:42 projects ``` 则最前面的d表明projects是一个目录,中间的两个apps,分别表示属于apps用户和表示属于apps这个用户组。对于前面的权限,r表示可读,w表示可写,x表示可执行,分别对应二进制的4、2、1。也就是说,如果权限是rwx,则对应是7,如果权限是r-x,则对应是5。权限有三组,依次是用户自己的、相同用户组和其他用户组。例如常见的755则表示用户本人有全部权限,相同用户组和其他用户组只能读取和执行,不能修改。 当LNMP环境、项目路径和系统账号都统一约定好后,我们就可以进行下一步了。 为实现一键发布,构建完整的发布流程,我们需要引入一些新的要素和设定不同的角色。先来看下新的发布流程。 ![图像说明文字](/api/storage/getbykey/screenshow?key=180347d7cdfff0449608) 图3-2 一键发布 相比前面的简单发布,这里额外引入了两个要素: + **测试环境** 测试环境是用于进行功能测试和回归测试的环境,主要使用对象是QA测试团队。和开发环境一样,通常部署在公司内网,可以安全地进行各种集成测试,甚至可以把整个数据库清空再重建,以满足不同场景下的测试要求。 + **预发布环境** 预发布环境类似仿真环境,和生产环境一样部署在外网,使用的也是和生产环境同样的数据库。稍微不同的是,预发布环境是只开放给公司内部人员使用的,并且使用独立的缓存机制。也就是说,项目代码在正式发布上线前,会先灰度发布到预发布进行更贴近真实环境的测试。 简单做个小总结,我们的项目代码会先在开发环境进行编写和调试,自测通过后提交到远程代码仓库。测试团队会将开发完成的代码部署到测试环境,进行冒烟测试、功能测试、集成测试和系统测试等。全部验收通过后,在正式发布前,会灰度发布到预发布环境进行回归测试。最后,一切准备就绪后,更新发布到生产环境。从开发环境,到测试环境,再到预发布环境,最后到生产环境,就构成了完整的一个发布流程。 在这个过程中,针对不同的阶段,针对不同专业领域的要求,我们还需要设定不同的角色,以进行良好的团队协作。通过集体智慧,完成这一系列的发布操作。前面有说到开发人员、测试人员,此外为了进行发布,可再设立发布人员,专门负责进行发布操作。 下面,我们将重点详细讲解如何构建一键发布。一键发布主要用于将项目代码从开发环境发布到预发布环境,当回归测试通过后,再将项目代码从开发环境发布到生产环境。发布到预发布和生产环境是类似的,只是发布的目的地不同。关键的核心操作环节有: + 1、准备待发布的版本代码 + 2、传输待发布的代码到各服务器 + 3、解压待发布的代码并完成发布的收尾工作 上面的三个核心环节,分别涉及了发布前、发布中和发布后。在发布前,我们需要把待发布的代码进行打包,如果需要进行前端的构建,或者需要把静态的资源文件同步到CDN的话,也可在这一环节完成。尤其对于代码有版本号,或者需要进行一些前置性的工作,都可在这时固化下来。可以说,这一环节非常关键,任何一处缺失都有可能会导致上线后故障的发生。 在我曾经任职过的一个游戏公司里,每次发布前,都需要将根据数据字典自动生成PHP代码,根据XML配置刷新内存中的项目配置,需要提前将对应的Flash资源上传到外网CDN,需要进行必要的数据库变更以及准备新增的Redis实例。当这些都准备妥当之后,我们才可以开始打包待发布的代码。 为方便理解,我们结合shell发布脚本来一起讲解。截至当前,我们可以快速写出下面这样的shell脚本,命名为publish.sh并保存到项目合适的目录下。 ``` #!/bin/bash # 一键发布 # @author dogstar 20180318 # Step 1. 选择发往的环境 if [ $# -lt 1 ]; then echo "Usage: $0 <pre|prod>" echo " - $0 pre # publish to preview environment" echo " - $0 prod # publish to product environment" exit 1 fi ``` 首先,让发布人员选择是发往哪个环境,pre表示预发布环境,prod表示生产环境。接下来,选择需要发布的代码并进行打包操作。注意,这里可选择发布的范围,并不是项目下的全部文件都要发布,只有需要才发布。例如对于日志目录就不需要发布,又例如这里排除了单元测试目录。这时用到了第一个shell命令,即tar打包命令。 ``` # Step 2. 打包待发布的文件 tar -czf ./examples.tar.gz \ ./config \ ./src \ --exclude ./tests \ ./public \ ./statics \ ./data ``` 这里待打包的目录和文件,需要根据你的项目情况进行调整,你可追加需要发布的目录和文件,也可以排除不需要用到的目录和文件。 再接下来,就是进行发布中的操作,即传输待发布的代码到各服务器。这里的IP地址都是虚拟的,可以根据自己的情况进行修改。分别是预发布的IP地址,和生产环境服务器集群的IP地址。这时需要使用到scp命令。 ``` # Step 3. 传输待发布的代码到各服务器 API_ROOT="/home/apps/projects/www.examples.com" USER="apps" IPS=("192.168.1.1" "192.168.1.2" "192.168.1.3") # 生产环境 if [ "$1" == "pre" ]; then IPS=("192.168.1.100") # 预发布环境 fi for ip in ${IPS[*]} do echo -e "[$ip] start to scp ..." scp ./examples.tar.gz $USER@$ip:$API_ROOT done ``` 当全部的发布包都传输完毕后,我们就可以执行批量解压,让PHP代码几乎同时生效啦。注意,是全部传输再一同解压,而不是传输完一台服务器就解压一台服务器。这样可以尽量缩短代码同时生效的时间间隔。这里使用了最后一个命令,ssh。 ``` # Step 4. 解压待发布的代码并完成发布的收尾工作 cmd="tar -xzf $API_ROOT/examples.tar.gz -C $API_ROOT" for ip in ${IPS[*]} do echo "[$ip] start to run: $cmd ..." ssh -t $USER@$ip "$cmd" done ``` 将以上shell代码片段连起来,并在最前面加入备份操作,以及发布后进行相关操作外,我们就初步搭建好了一键发布的自动化流程。合成后的代码如下: ``` #!/bin/bash # 一键发布 # @author dogstar 20180318 # Step 0. 备份 cp ./examples.tar.gz ./examples.bak.tar.gz # Step 1. 选择发往的环境 if [ $# -lt 1 ]; then echo "Usage: $0 <pre|prod>" echo " - $0 pre # publish to preview environment" echo " - $0 prod # publish to product environment" exit 1 fi # Step 2. 打包待发布的文件 tar -czf ./examples.tar.gz \ ./config \ ./src \ --exclude ./tests \ ./public \ ./statics \ ./data # Step 3. 传输待发布的代码到各服务器 API_ROOT="/home/apps/projects/www.examples.com" USER="apps" IPS=("192.168.1.1" "192.168.1.2" "192.168.1.3") # 生产环境 if [ "$1" == "pre" ]; then IPS=("192.168.1.100") # 预发布环境 fi for ip in ${IPS[*]} do echo "[$ip] start to scp ..." scp ./examples.tar.gz $USER@$ip:$API_ROOT done # Step 4. 解压待发布的代码并完成发布的收尾工作 cmd="tar -xzf $API_ROOT/examples.tar.gz -C $API_ROOT" for ip in ${IPS[*]} do echo "[$ip] start to run: $cmd ..." ssh -t $USER@$ip "$cmd" done # Step 5. TODO: 发布后的操作 echo "Finish!" ``` 关于上面的发布操作,我们在背后还隐藏了一些其他的细节。例如,为了能免密码远程操作,我们需要在各远程服务器上的/home/apps/.ssh/authorized_keys文件内,添加操作发布的电脑的RSA公钥。但只要大家能理解整个发布过程的主要环节,至于如何具体实现,则可以灵活进行调整和扩展。 在一键发布时,我们特意考虑到了需要发布回滚的操作,故此提前做好版本备份的工作。对于如何进行发布回滚,其实和正常的发布是类似的,这里不再赘述。感兴趣的同学可当作练习,自己亲自实践一下。 这里描述了一键发布的基本轮廓,大家可以在实际应用中进行细化。在这基础上,我们就可以在后续实现更快速、更稳定地进行发布操作。特别当需要增加新的服务器时,只需要稍加配置,追加到上面的IPS配置,就可以实现对新服务器的发布。同样地,当需要摘除某台服务器时,也只需要在IPS变量中删除相应的服务器IP即可。这是一个好的开始。 ### 3.1.4 建立更完善的发布流程 当项目发展到更大规模时,对线上环境的发布操作会移交给专门的团队来负责。这时需要更完善的发布流程,而这些流程化的操作则会通过更友好的界面,集成度更广的发布系统来体现。将上面的一键发布整合并升级为完整的发布系统,又将是项目成长的一大里程碑。 虽然要完成的工作还有很多,但我们可以更方便地进行整合和集成。界面化的操作,最终都可以归为在Linux上的命令操作。当底层的脚本命令已经基本形成时,在上面再加上界面化就轻而易举了。 ## 3.2 生产环境注意事项 我们从简单的发布开始,演进到一键发布,最后通过发布系统建立了更完善的发布流程。这是否意味着发布就此结束了呢? 不,恰好相反,这只是一个开始。因为当代码发布到生产环境之后,正是一系列操作和工作的开始。为保障系统的安全性、稳定性、健壮性,我们还需要完成以下短期性或长期性的工作。 + 更改默认端口 + 开启错误日记 + 开启慢日志 + 进行服务器监控 + 对数据库进行备份 下面分别简单说明。 ### 3.2.1 更改默认端口 在当前环境下,网络安全一直都是个严峻的问题。即便没有专业的安全专家,我们也可以通过简单的方式来加强我们服务器对外办的防范。其中之一就是更改默认端口。例如更改SSH登录下默认的22端口,修改MySQL数据库默认的3306端口,以及Redis和Memcache的默认端口,以增加外界暴力破解的难度。 ### 3.2.2 开启错误日记 开启错误日记,能方便我们遇到问题时快速定位到具体的原因。这里可以开启Nginx的错误日志,例如前面配置的: ``` error_log /var/log/nginx/www.examples.com.error_log; ``` 此外,还可以开启PHP-FPM的错误日志,例如/etc/php-fpm.conf配置里的: ``` error_log = /var/log/php-fpm/error.log ``` ### 3.2.3 开启慢日志 除了要通过错误日志关注异常情况外,还要通过慢日志来关注系统的响应速度。例如开启PHP-FPM的慢日志,可以修改/etc/php-fpm.d/www.conf配置文件中的: ``` slowlog = /var/log/php-fpm/www-slow.log request_slowlog_timeout = 2s ``` 当PHP请求的响应时间超过2秒时,将会在/var/log/php-fpm/www-slow.log日志文件内产生一条纪录。 例如,我们可以编写一个慢日志的测试脚本test_slow.php,并放置以下代码。 ``` // $ vim ./public/test_slow.php <?php echo "start to run ..."; sleep(1); $arr = range(0, 10); sleep(1); $arr = range(0, 10); sleep(1); $arr = range(0, 10); echo "finish to run ..."; ``` 在这里,我们故意分别睡眠了三次,每次1秒。所以通过浏览器访问后,我们可以看到类似这样的慢日志。 ``` [18-Mar-2018 20:38:42] [pool www] pid 3535 script_filename = /home/apps/www.examles.com/public/test_slow.php [0x00007f4ff8213120] sleep() /home/apps/www.examles.com/public/test_slow.php:11 ``` 特别对于数据库,更需要开启慢查询,以便及时对数据库的慢查询语句进行针对性的优化。修改/etc/my.cnf配置文件,并往里面添加以下配置,可以开启MySQL数据库的慢查询日志。 ``` slow_query_log = 1 slow_query_log_file = /var/log/mysql_slow_query.log long_query_time = 2 ``` 同样地,当数据库查询语句执行时间超过2秒时,将会产生一条慢日志。 ### 3.2.4 服务器监控 除了对PHP、Nginx和MySQL开启错误日志和慢查询日志外,我们还需要对网站赖以生存的系统环境进行监控,以便及时发现服务器在CPU、内存、硬盘空间和网络IO等方面的指标性能。例如你可以选择使用Zabbix进行全方位的监控。 ### 3.2.5 对数据库进行备份 最后,还有一个建议是非常重要的,这个建议很有可能在日后为你的公司挽回数十万的经济损失。那就是,定时对数据库进行备份。通过在不同的服务器物理机上,对当前系统使用的数据库进行自动化数据备份,以应对人为错误操作、机房服务器被外界因素毁坏等不可控的风险。 例如,对于MySQL数据库,可以使用automysqlbackup进行备份。简单配置后,再结合crontab计划任务,我们就可以实现定时自动化数据库备份了。 ``` # 数据库备份 30 3 * * * /usr/bin/automysqlbackup /etc/automysqlbackup/automysqlbackup.conf ``` 如上面的crontab计划任务配置,就可以实现了在每天凌晨3点半时,根据automysqlbackup.conf配置文件进行MySQL数据库的备份。 成功备份后,可以看到在CONFIG_backup_dir配置的备份目录下,有类似如下目录,分别存放了每日、每周、每月备份的数据。更多使用说明,可参考automysqlbackup的相关文档介绍。 ``` . |-- daily | |-- mysql | |-- performance_schema | `-- test | `-- daily_test_2018-03-19_21h54m_Monday.sql.gz |-- fullschema |-- latest |-- monthly |-- status |-- tmp `-- weekly `-- test `-- weekly_test_2018-03-19_21h54m_12.sql.gz ``` ## 3.3 入侵式设计 通过前面介绍的一键发布脚本或者更为强大的发布系统,我们已经向世界发布了我们的代码。但是除了发布以外,在项目部署和架构搭建上,如果方式不对,会产生入侵式的设计。我曾见过因入侵式设计而苦苦挣扎在其中的团队,下面看下哪些是不合理、会导致破坏原有体系的做法。 ### 3.3.1 触电式架构 在编程世界里,对于类、文件和模块之前的依赖,一直都有着类似的原则。例如尽量使用单向依赖而不是双向循环依赖,高层模块不应该依赖于底层模块。 在C++中,若两个类需要相互依赖,那么在代码编写上需要一点技巧,也会引入一些复杂性。如果相互间的依赖过于复杂,就会容易产生接二连三的问题,正所谓“剪不断,理还乱”。DLL地狱就是这类问题的史证。 那这些和使用PHP开发的网站项目有什么关系呢?重点来了,由于PHP是动态脚本,是解释性编程语言,不需要编译就能直接运行,并且在文件引入和类引入上更为灵活。灵活滋生混乱。在编写C++程序时,程序员需要严格遵循规范和标准,使得编写的代码符合要求,通过编译。比较明显的要求就是你在实例化某个类之前,这个类需要先存在,并且将要调用的类成员函数也是确定的。而在编写PHP程序时,你可以动态实例化某个类,也可以动态调用某个类成员函数,甚至传递的参数也是动态变化的。这些灵活的语法特性,在带来了编程开发便利性的同时也会容易被人滥用,从而产生不能事先确定、非线性逻辑的代码。顺便提及一下,也许PHP语言还不是最为复杂的,因为还有更为灵活、通过代码生成代码的元编程语言,例如Ruby。 我们暂时不进行过多的发散,下面回归正题。我们先通过一些代码片段示例,看下PHP都有哪些灵活性。再来看下在这基础上,为何会容易构建出触电式架构。 “一般的开源框架,是怎样做到根据指定URL执行相应PHP代码的?你能具体说明一下吗?” 在面试中,有时面试官或问到这样的问题。对于这个问题,回答的核心正是PHP如何动态执行某个类的某个成员函数。假设客户端访问的URL是: ``` http://localhost/?c=Site&a=Index ``` 其中,参数c表示Controller控制器(即:类名),a表示Action动作(即:方法名)。最终对应执行SiteController类的actionIndex方法,如下: ``` <?php class SiteController { public function actionIndex() { echo "Hello World!"; } } ``` 那怎么实现这一动态执行的过程呢? 为突出如何动态执行这一关注点,我们暂时不考虑路由,也不考虑文件引入。在PHP中,需要动态执行类的某个方法,是比较简单的,并且实现方式可以有多种。简单地,可以直接这样: ``` <?php $controller = $_GET['c'] . 'Controller'; $action = 'action' . $_GET['a']; $instance = new $controller(); $instance->$action(); ``` 为简单起见,这里也省略了对参数的校验以及异常情况的处理。 另外,我们也可以通过call_user_func()函数来实现。目前这里不需要传递参数列表,结合恰当的回调类型的表示方式,还可以这样实现: ``` <?php $controller = $_GET['c'] . 'Controller'; $action = 'action' . $_GET['a']; $instance = new $controller(); // 注意最后这一行,执行回调函数 call_user_func(array($instance, $action)); ``` 你还可以使用call_user_func_array()函数来实现调用,不过其用法和call_user_func()函数类似,不同的是可以把参数列表作为一个数组动态传入。 对于动态执行某个函数,其做法也是类似的。那这样会容易带来什么问题呢?这里有两个要点需要关注的,第一点是PHP是解释性语言,不需要经过编译就能执行,这意味着即便存在语法错误的PHP文件,项目也能正常工作,前提是不触发执行这些有问题的PHP文件即可。更直白来说,哪怕你往网站项目中故意放入了一些有问题的PHP代码,或者添加其他项目的代码也没什么大不了的,只要别踩到这些“地雷”。第二点是,PHP是动态执行的,你将要执行的类静态方法、类成员函数和普通函数,事先是不确定的。你可能找得到它们,也可以找到了但无法执行,比如试图调用protected级别的成员函数,或者传递的参数列表不符。 而这两点,不需要全部符合语法规范以及不确定的动态执行,在项目级别和系统架构级别造就了更大的灵活性。随着公司业务的发展,最初只有一个PHP网站项目,然后PHP项目数量增长到2个、3个、5个、12个……原来只是项目内的依赖关系,现在为贪图方便、或者是缺少考虑、或者是其他历史原因,慢慢扩散到不同项目之间。即项目A,严重依赖于项目B的代码;项目B直接依赖于项目C的底层代码;项目C又需要调用项目A的函数获取最新商品数据。这就慢慢形成了触电式的架构,一旦某个项目的函数出问题了,就会影响其他项目的正常运行。 触电式架构是一种反模式,我觉得应当坚决抵制。触电式架构为线上系统带来了极其脆弱性和不稳定性,项目与项目之间就像手拉手的一群人,只要其中一个人触电了,其他全部人都会跟着受累。而且,由此而引入的复杂性也是高得吓人。深陷在触电式架构内的项目,它的复杂度不是在于它自身业务规则的复杂度,也不在于内部模块与模块之间错乱的调用关系,而是在于它与外部项目的直接依赖关系!由于PHP代码在逻辑上(类与类、函数与函数间的调用)以及物理上(文件与文件之间的include/require引用),最终也决定了这些有依赖的不同项目,都需要部署在同一台服务器上,从而产生了更大层面的依赖。如果某个网站项目因为访问量过高而导致服务器性能负载过高,就会直接影响其他网站项目的正常运行。这种整机部署的策略,暗含了巨大的不确定性和脆弱性。但也许,这还不是最糟糕的。除了在代码上,在服务器部署上有依赖外,根据破窗理论,在触电式架构这一大背景下,开发团队也就“顺其自然”地在数据这一领域上也产生了严重的依赖。他们通常会联表跨库查询和操作,因为既然代码都“在一起”了,数据库也应该“在一起”。 从选择了PHP这门编程语言开发网站开始,得益于PHP的灵活性,我们能快速进行迭代,但如果控制不好也会因其灵活性而产生不恰当的依赖关系。当类与类之间不恰当的依赖关系,蔓延到模块与模块之间,再蔓延到项目与项目之间,就会发生由量变到质变的转换。随之而来的,就是服务器部署上的依赖,和数据库上的依赖。慢慢地,就编织了一张更大的网、更大的坑。 ![图像说明文字](/api/storage/getbykey/screenshow?key=18030d67056d45899c91) 图3-3 触电式架构示例 形成触电式架构的关键决策点在于,第二个项目诞生时如何约束依赖关系。新项目如果需要使用底层的公共函数和配置,该如何处理?新项目如果需要调用最初项目的某个模块完成特定业务功能时,又该如何设计?新项目在不同的业务场景上需要依赖第一个项目的数据库数据时,又该如何应对?如果不加考虑,为了贪图方便,或者迫于上线时间压力,一旦把第二个项目和第一个项目直接部署在一起,并且通过PHP代码直接相互调用,还允许操作其他项目的数据库,那么后来的新项目也会陆续效仿这一做法。如果不加以控制、调整和约束,假以时日,触电式架构就会形成,牵一发而动全身。 通俗来说,触电式架构就是“你中有我,我中有你”,说得动情一点就是共赴患难,有难一起当。但是这种做法是不可取的,除非有特殊的原因(实际上,我也想不出有任何充足的理由),不要采用这种做法。在部署发布第二个项目时,一定要回过头考察评估是否有类似触电式架构这般强依赖的关系网的存在。如果有,请说不! 关于触电式架构,我们暂时说到这里。下面再来看下压缩环境。 ### 3.3.2 压缩环境 平时我们都会吃到压缩饼干,这些饼干将小麦粉、糖、油脂、乳制品等主要原料压缩成饼干,方便食用。但如果追求更健康的生活,我们不能长期只吃压缩饼干,还要补充维生素、微量元素、蛋白质等,均衡饮食。同样,在开发网站项目过程中,我们可能也会用到压缩环境。通常而言,部署的环境按项目的不同阶段和不同使用人员,可以分为:开发环境,测试环境,回归环境,预发布环境,生产环境。但小团队或者小公司,出于低成本,都喜欢使用压缩环境。例如只使用开发环境和生产环境,而没有测试、回归、预发布这些环境。更有甚者,如前面所说,只使用生产环境,即开发环境与生产环境使用同一个。这些就是我们所说的压缩环境。 在触电式架构下,别人发布了新的代码,有可能会导致你的项目首页无法访问,间接无辜受到影响,而且这种影响是不可控、不可预见的。对于使用压缩环境而言,类似地,如果开发环境与生产环境共用一套,就会容易造成因功能开发调试过程影响线上环境的稳定性。如果开发环境与测试环境是同一个,那么开发人员和测试人员就会容易产生冲突。测试人员正在进行功能验收的代码,却被开发人员修改或者切换了;而开发人员正在进行测试的数据,又会被测试人员用于进行功能验收。由于关注点不同,参与的角色越多,集成的系统越多时,压缩环境就会越容易产生更频繁的冲突。 当然,我们需要做到平衡,实现均衡发展。一开始就搭建全部环境也是不现实的,但当确实需要那么多不同的环境却依然使用压缩环境时也是不应该的。需要注意的是,在不同的环境上,应尽量保持一致的部署方式,通过前面搭建的一键发布,我们可以很容易实现这点。 ### 3.3.3 不同环境,不同域名 即便已经根据需要,采用了不同的环境。但如果对于域名的分配和使用方式不对的话,也会产生另一种入侵式的设计。甚至是使用压缩环境,也会有这个问题。这种反模式就是:不同环境,不同域名。尤其体现在测试环境的域名与正式线上环境的域名不同。 实现这种反模式可以有几种做法,分别是: + **通过不同顶级域名区分不同环境** 例如,假设生产环境域名是www.examples.com,那么测试环境则使用.cn域名,即测试环境域名是www.examples.cn。由于使用了不同域名,除了不能共享session会话外,也不能共享客户端COOKIE,这会对开发造成一定的困惑。此外,还会因为不同域名而产生跨域问题,例如前端Javascript在发起Ajax请求时,如果未在不同环境时进行相应切换,就会发生在测试环境跨域请求了生产环境,或者在生产环境跨域请求了测试环境。前者的影响只是会让开发人员产生困惑,而后者一旦发布上线就会直接导致故障的发生。 + **通过添加前缀区分不同环境** 另一种区分的方式是,使用相同的顶级域名,但使用不同的域名前缀来区分。例如,生产环境域名是www.examples.com,测试环境域名是test.www.examples.com。这在一定程度上可以缓解或解决COOKIE共享、Ajax跨域和静态资源引用的问题,因为可以使用相对路径而非绝对路径来实现。但当需要和外部系统进行交互或者集成时就会遇到问题。例如在测试环境分享了一个链接,到外网却打不开。 + **通过动态开发分支名切换不同环境** 最后,还有一种方式是使用分层存储,动态根据开发时的分支名称,切换到不同环境。例如master分支的访问域名是www_master.examples.com,dev分支的访问域名是www_dev.examples.com,依此类推。默认的则是生产环境的域名www.examples.com。这种方式通常是应用在压缩环境。 不管是通过何种方式,一旦不同环境所用的域名不一样,就会产生入侵式的设计。首当其冲就是对代码编写上的影响。网站开发人员在编写PHP代码时,经常要根据不同的环境手动或自动切换到不同的域名,并且还要时刻记得在测试通过、上线发布前把域名恢复为正式域名,否则就会产生故障。不同环境,不同域名,不仅为开发阶段带来了额外的负担,还为测试阶段引入了不必要的复杂性,更让人不能接受的是在线上环境会容易滋生故障。例如发布后,线上环境引用了测试环境的静态资源,导致因缺少Javascript文件而报错,或因缺少CSS样式文件而布局错乱。又或者,因为前端Javascript或者后端PHP内部系统在生产环境调用访问了测试环境的接口链接,无法获取或获取了错误的数据信息。 不同环境使用不同域名,这种做法造成的影响是直接且深远的。对于前端开发员,他们要注意Ajax接口请求、静态资源文件引用上的差异;后端开发人员需要时刻关注根据不同环境进行切换和调整才能保证内部接口调用和系统之间的正确访问;测试人员则要在回归测试时警惕发现不同环境下有没相互穿越错乱的现象,并及时知会技术人员进行修正;产品人员则时不时要担心会不会在线上环境再次发布类似的故障。 ## 本章小结 发布是一件可大可小的事情。说它小,是因为你可以直接通过很简单的方式就能实现线上代码的更新和同步;说它大,是因为如果想做到完整全面,需要考虑的问题会很多,除了要搭建自动化一键部署外,还需要配套集成度更高的发布系统。建立起完善的发布流程,实现项目的快速发布,将会帮助你在项目迭代过程中实现小步快跑。 生产环境是暴露给世界访问的,它处于现实世界的残酷和危险之中。为了最大化保护我们的网站、我们的系统和我们的服务器,需要注意以下事项: + 更改默认端口 + 开启错误日记 + 开启慢日志 + 进行服务器监控 + 对数据库进行备份 除了在生产环境做足了准备外,还需要防范入侵式的设计,例如:触电式架构、压缩环境和不同环境不同域名。任何一种入侵式设计,都会对今后的项目迭代产生深远的负面影响。 发布代码到线上环境,只是一个开始。除了要留意注意事项和防范入侵式设计,我们还会陆续迎接到更大的问题、更严峻的挑战。不过不要急于马上投入到实际商业产品、项目或系统的开发中,先来回顾一下PHP开发相关的知识,看下我们是否遗漏或忽略了某些重要信息。